From c95315d2ce365dd7a31aa67c8c13f4a99f209ced Mon Sep 17 00:00:00 2001 From: Liu Jie Date: Wed, 14 Oct 2026 01:04:33 +1300 Subject: [PATCH 01/92] fix(cli): reject unterminated output filter slices (#2315) --- src/cliOutput.ts | 32 +++++++++- tests/boundary/cli/cliOutput.test.ts | 89 ++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/src/cliOutput.ts b/src/cliOutput.ts index 5119eebc3..ccfaf6b62 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -9,7 +9,7 @@ export type CliOutputArgumentValidation = | { readonly ok: false; readonly format: StructuredOutputFormat; - readonly code: "UNSUPPORTED_OUTPUT_COMBINATION"; + readonly code: "UNSUPPORTED_OUTPUT_COMBINATION" | "INVALID_OUTPUT_FILTER"; readonly message: string; }; @@ -89,13 +89,39 @@ export const parseCliOutputArguments = ( return parsed(false); }; -/** Reject text-window controls that would corrupt a structured document. */ +/** Reject slices that cannot advance within Incur's comma-delimited paths. */ +const hasUnclosedFilterSlice = (expression: string): boolean => { + let depth = 0; + let unclosed = false; + for (const character of expression) { + if (character === "[") { + depth += 1; + unclosed = true; + } else if (character === "]") { + depth -= 1; + // Incur consumes everything through the next closing bracket, including + // nested opening brackets. Do not impose a different filter grammar. + unclosed = false; + } else if (character === "," && depth === 0 && unclosed) return true; + } + return unclosed; +}; + +/** Reject output controls that cannot safely produce a complete result. */ export const validateCliOutputArguments = ( arguments_: readonly string[], ): CliOutputArgumentValidation => { - const { format, parseError, tokenWindow } = + const { filterOutput, format, parseError, tokenWindow } = parseCliOutputArguments(arguments_); if (parseError) return { ok: true }; + if (filterOutput !== undefined && hasUnclosedFilterSlice(filterOutput)) + return { + ok: false, + format: format === "yaml" || format === "jsonl" ? format : "json", + code: "INVALID_OUTPUT_FILTER", + message: + "The --filter-output expression has a slice without a closing ]. Close the slice or remove --filter-output.", + }; if ( tokenWindow && (format === "json" || format === "jsonl" || format === "yaml") diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 43d517e16..44b3ac234 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -58,6 +58,95 @@ describe("CLI output argument boundary", () => { }); }); +describe("unclosed CLI output filter slices", () => { + it.each([ + "providers[", + "providers[0,1].name[", + "name,providers[", + "][,]", + "providers],name[0,1]", + ])("rejects %s before the filter parser runs", (filter) => { + expect( + validateCliOutputArguments([ + "providers", + "--json", + "--filter-output", + filter, + ]), + ).toMatchObject({ ok: false, code: "INVALID_OUTPUT_FILTER" }); + }); + + it("preserves effective-filter and earlier flag-error precedence", () => { + for (const arguments_ of [ + [ + "--filter-output", + "providers[", + "--filter-output", + "capabilities[0,1].provider.id,open", + ], + ["--filter-output", "capabilities[-1,].provider.id"], + ["--filter-output", "capabilities[[0,1]"], + ["--format", "invalid", "--filter-output", "providers["], + ["--token-limit", "invalid", "--filter-output", "providers["], + ]) + expect(validateCliOutputArguments(arguments_)).toEqual({ ok: true }); + }); + + workspaceCliTest( + "fails promptly across output formats without invoking the command", + async ({ cli }) => { + for (const format of ["json", "jsonl", "yaml", "toon", "md"]) { + const result = await cli.run({ + arguments: [ + "analyze", + "/absent/target", + "--format", + format, + "--filter-output", + "result[0,1].name[", + ], + timeoutMs: 3000, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("INVALID_OUTPUT_FILTER"); + expect(result.stdout).toContain("closing ]"); + expect(result.stderr).toBe(""); + } + const splitPath = await cli.run({ + arguments: ["providers", "--json", "--filter-output", "][,]"], + timeoutMs: 3000, + }); + expect(splitPath.exitCode).toBe(1); + expect(splitPath.json).toMatchObject({ + ok: false, + error: { code: "INVALID_OUTPUT_FILTER" }, + }); + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); + + workspaceCliTest( + "retains closed slices and multiple filter paths", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "providers", + "--json", + "--filter-output", + "capabilities[0,1].provider.id,open", + ], + timeoutMs: 3000, + }); + expect(result.exitCode).toBe(0); + expect(result.json).toEqual({ + capabilities: [{ provider: { id: expect.any(String) } }], + open: false, + }); + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); +}); + describe("CLI output valued-flag parsing", () => { workspaceCliTest( "preserves complete JSON errors when native builtin flags follow unusual arguments", From 1dd6c2dff9252c27838589590e07f809a9b1ff42 Mon Sep 17 00:00:00 2001 From: Guangqi Zhang <2065586050@qq.com> Date: Tue, 13 Oct 2026 20:05:31 +0800 Subject: [PATCH 02/92] fix(setup): resolve DeepSeek Harness home overrides (#2461) --- docs/client-configuration.md | 11 +- src/application/SupportedClients.ts | 23 +++- tests/acceptance/setup/dshClientSetup.test.ts | 107 +++++++++++++++++- .../filesystem/dshClientConfiguration.test.ts | 60 +++++++++- 4 files changed, 190 insertions(+), 11 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 00e83e179..e72ca191f 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -209,8 +209,15 @@ replaces) the REA entry in the home patch `$DSH_HOME/cordis.patch.yml` patches and comments. `rea doctor --client dsh` checks that same entry id. `rea uninstall` removes it by scanning supported clients; uninstall takes no `--client` flag. The home patch applies to every Harness profile, so the -registration is written once and no per-profile edit is needed. The written -entry is: +registration is written once and no per-profile edit is needed. + +Setup, doctor and uninstall resolve `DSH_HOME` and `DSH_AGENTS_HOME` as Harness +does: unset, empty or whitespace-only values use `~/.dsh` and `~/.agents`, +respectively. Bare `~`, `~/` and `~\` expand to the user home; other relative +values resolve against the command's working directory. Nonblank values retain +their original spaces, and the agents root is independent of the Harness home. +Run REA and Harness from the same working directory when using relative overrides. +The written entry is: ```yaml - insert: diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 488dda49d..5839409d4 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -157,8 +157,27 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { const grokDirectory = ({ home, env }: ClientPathContext): string => env.GROK_HOME || join(home, ".grok"); +/** Match Harness home-paths for both independent roots; trim only to detect blanks. */ +const dshDirectory = ( + home: string, + override: string | undefined, + fallback: string, +): string => { + const selected = + override !== undefined && override.trim().length > 0 + ? override + : join(home, fallback); + const expanded = + selected === "~" + ? home + : selected.startsWith("~/") || selected.startsWith("~\\") + ? join(home, selected.slice(2)) + : selected; + return resolve(expanded); +}; + const dshHomeDirectory = ({ home, env }: ClientPathContext): string => - env.DSH_HOME ?? join(home, ".dsh"); + dshDirectory(home, env.DSH_HOME, ".dsh"); const dshPatchDirectory = (context: ClientPathContext): string => join(dshHomeDirectory(context), "cordis.patch.yml"); @@ -555,7 +574,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ // The home Cordis patch applies to every profile; the skill directory is // shared agent storage Harness discovers. skillPath: ({ home, env }: ClientPathContext) => - join(env.DSH_AGENTS_HOME ?? join(home, ".agents"), "skills"), + join(dshDirectory(home, env.DSH_AGENTS_HOME, ".agents"), "skills"), configPath: dshPatchDirectory, markerPath: dshHomeDirectory, format: "dsh", diff --git a/tests/acceptance/setup/dshClientSetup.test.ts b/tests/acceptance/setup/dshClientSetup.test.ts index 4010369cd..b544c5816 100644 --- a/tests/acceptance/setup/dshClientSetup.test.ts +++ b/tests/acceptance/setup/dshClientSetup.test.ts @@ -1,5 +1,5 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { expect } from "vitest"; @@ -8,6 +8,111 @@ import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +cliTest.for([ + { + label: "blank defaults", + harness: "", + agents: " \t ", + dshRoot: "home/.dsh", + agentsRoot: "home/.agents", + }, + { + label: "tilde roots", + harness: "~/selected harness", + agents: "~\\shared agents", + dshRoot: "home/selected harness", + agentsRoot: "home/shared agents", + }, + { + label: "relative roots", + harness: "./selected harness", + agents: "../shared agents", + dshRoot: "workspace/selected harness", + agentsRoot: "shared agents", + }, +])( + "DSH CLI uses Harness path semantics through setup, doctor and uninstall for $label", + async ({ harness, agents, dshRoot, agentsRoot }, { cli }) => { + const root = await createTestTempDirectory("rea-dsh-paths-"); + const home = join(root, "home"); + const cwd = join(root, "workspace"); + const config = join(root, dshRoot, "cordis.patch.yml"); + const skillRoot = join( + root, + agentsRoot, + "skills", + "reverse-engineer-anything", + ); + const skill = join(skillRoot, "SKILL.md"); + await mkdir(home); + await mkdir(cwd); + await mkdir(dirname(config), { recursive: true }); + const original = + "# caller patch\n- insert: [{id: caller, name: caller-plugin}]\n"; + await writeFile(config, original); + const run = (args: readonly string[]) => + cli.run({ + arguments: [...args, "--json"], + cwd, + environment: { + HOME: home, + USERPROFILE: home, + DSH_HOME: harness, + DSH_AGENTS_HOME: agents, + }, + }); + const planned = await run(["setup", "--client", "dsh", "--dry-run"]); + expect(planned.exitCode).toBe(0); + expect(planned.json).toMatchObject({ + status: "planned", + appliedActions: [], + plannedActions: expect.arrayContaining([ + expect.objectContaining({ id: "configure_client:dsh", target: config }), + expect.objectContaining({ kind: "install_skill", target: skillRoot }), + ]), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(readFile(skill)).rejects.toMatchObject({ code: "ENOENT" }); + const configured = await run(["setup", "--client", "dsh", "--yes"]); + expect(configured.exitCode).toBe(0); + expect(configured.json).toMatchObject({ + status: "ready", + clients: { dsh: { status: "configured" } }, + }); + const text = await readFile(config, "utf8"); + expect(text).toContain("# caller patch"); + expect(text).toContain("rea-mcp"); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + expect(await readFile(skill, "utf8")).toContain( + "name: reverse-engineer-anything", + ); + const doctor = await run(["doctor", "--client", "dsh", "--skill"]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ healthy: true }); + const repeated = await run(["setup", "--client", "dsh", "--yes"]); + expect(repeated.exitCode).toBe(0); + expect(repeated.json).toMatchObject({ + status: "ready", + plannedActions: [], + appliedActions: [], + }); + expect(await readFile(config, "utf8")).toBe(text); + const removed = await run(["uninstall"]); + expect(removed.exitCode).toBe(0); + expect(removed.json).toMatchObject({ + status: "complete", + items: expect.arrayContaining([ + expect.objectContaining({ name: "dsh", status: "removed" }), + ]), + }); + expect(parse(await readFile(config, "utf8"))).toEqual([ + { insert: [{ id: "caller", name: "caller-plugin" }] }, + ]); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + await expect(readFile(skill)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + cliTest( "DSH CLI previews, approves, repairs, launches MCP, repeats and uninstalls while preserving caller YAML", async ({ cli }) => { diff --git a/tests/boundary/filesystem/dshClientConfiguration.test.ts b/tests/boundary/filesystem/dshClientConfiguration.test.ts index 72dbded9e..c0315f67b 100644 --- a/tests/boundary/filesystem/dshClientConfiguration.test.ts +++ b/tests/boundary/filesystem/dshClientConfiguration.test.ts @@ -1,5 +1,5 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -37,28 +37,76 @@ const dshClient = ( env: Parameters[2], platform: NodeJS.Platform = "linux", ) => - supportedClients(join("/home/a"), platform, env).find( + supportedClients(resolve("/home/a"), platform, env).find( ({ name }) => name === "dsh", ); describe("DeepSeek Harness configuration paths", () => { + it.each([ + [undefined, ".dsh", ".agents", "shared"], + ["", ".dsh", ".agents", "shared"], + [" \t ", ".dsh", ".agents", "shared"], + ["~", "", "", "dsh"], + ["~/selected home", "selected home", "selected home", "dsh"], + ["~\\selected home", "selected home", "selected home", "dsh"], + ] as const)( + "resolves Harness and agents roots for %j", + (override, dshRoot, agentsRoot, client) => { + const home = resolve("/home/a"); + const environment = { DSH_HOME: override, DSH_AGENTS_HOME: override }; + expect( + supportedClients(home, process.platform, environment).find( + ({ name }) => name === "dsh", + ), + ).toMatchObject({ + configPath: join(home, dshRoot, "cordis.patch.yml"), + markerPath: join(home, dshRoot), + }); + expect(clientSkillDirectories(home, ["dsh"], environment)).toEqual([ + { client, directory: join(home, agentsRoot, "skills") }, + ]); + }, + ); + + it.each(["relative home", " literal home ", "~someone"])( + "resolves a literal root %j relative to cwd without trimming it", + (override) => { + const home = resolve("/home/a"); + const root = resolve(override); + const environment = { DSH_HOME: override, DSH_AGENTS_HOME: override }; + expect( + supportedClients(home, process.platform, environment).find( + ({ name }) => name === "dsh", + ), + ).toMatchObject({ + configPath: join(root, "cordis.patch.yml"), + markerPath: root, + }); + expect(clientSkillDirectories(home, ["dsh"], environment)).toEqual([ + { client: "dsh", directory: join(root, "skills") }, + ]); + }, + ); + it("resolves the home Cordis patch under the Harness home", () => { expect(dshClient({})).toEqual({ name: "dsh", displayName: "DeepSeek Harness", - configPath: join("/home/a", ".dsh", "cordis.patch.yml"), - markerPath: join("/home/a", ".dsh"), + configPath: resolve("/home/a", ".dsh", "cordis.patch.yml"), + markerPath: resolve("/home/a", ".dsh"), format: "dsh", }); // The home patch applies to every profile; DSH_HOME relocates it. expect(dshClient({ DSH_HOME: "/custom/harness" })?.configPath).toBe( - join("/custom/harness", "cordis.patch.yml"), + resolve("/custom/harness", "cordis.patch.yml"), ); expect( clientSkillDirectories(join("/home/a"), ["dsh"], { DSH_AGENTS_HOME: "/custom/agents", }), - ).toEqual([{ client: "dsh", directory: join("/custom/agents", "skills") }]); + ).toEqual([ + { client: "dsh", directory: resolve("/custom/agents", "skills") }, + ]); }); }); From 68ecdcd7a130806a03067fbfcce5ee90c70b968f Mon Sep 17 00:00:00 2001 From: Shenrui Ma <115921185+Shenrui-Ma@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:06:19 +0800 Subject: [PATCH 03/92] fix(reference): preserve directory intent when resolving source imports (#2770) Keep directory requests from becoming unresolved or pointing at sibling files when constructing reference-source dependency graphs. Canonicalize index candidates and preserve directory intent while retaining ordinary lookup rules. Constraint: Preserve current TypeScript candidate objects and exact-before-Unicode lookup Rejected: Reorder all file probes before index probes | Changes ordinary CommonJS candidate priority Confidence: high Scope-risk: narrow Tested: Public CLI regression fails on base; focused reference and resolver suites pass; npm run check Not-tested: Full suite on refreshed base; earlier full run had 12 baseline-reproduced failures --- .../ReferenceSourceImportEntries.ts | 18 +-- .../referenceSourceDirectoryImports.test.ts | 118 ++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 tests/boundary/cli/referenceSourceDirectoryImports.test.ts diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 36dae9949..49c6f82f5 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -97,18 +97,22 @@ const resolveInternalSpecifier = ( ); if (logicalPathEscapesRoot(normalized)) return { to: normalized, resolution: "unresolved" }; + // Preserve directory intent before normalization removes terminal dot segments. + const directoryReference = + specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier)); const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; const candidates = suffixes.flatMap((suffix) => [ - `${normalized}${suffix}`, - `${normalized}/index${suffix}`, + ...(directoryReference ? [] : [`${normalized}${suffix}`]), + posix.join(normalized, `index${suffix}`), ]); // Retain present runtime files first; substitute only a missing counterpart. // Counterparts cover explicit filenames, not directory/index resolution. - candidates.push( - ...typescriptEmittedSourceCandidates(fromPath, normalized).map( - ({ path }) => path, - ), - ); + if (!directoryReference) + candidates.push( + ...typescriptEmittedSourceCandidates(fromPath, normalized).map( + ({ path }) => path, + ), + ); const match = lookupPath.resolveCandidates(candidates); return match === undefined ? { to: normalized, resolution: "unresolved" } diff --git a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts new file mode 100644 index 000000000..6270855cb --- /dev/null +++ b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts @@ -0,0 +1,118 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +import { describe, expect } from "vitest"; + +import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +// Reference-source reads require no-follow opens; Windows reports unsupported. +describe.skipIf(process.platform === "win32")( + "compiled reference-source directory imports", + () => { + cliTest( + "resolves directory spellings without selecting similarly named files", + async ({ cli }) => { + const workspace = await createTestTempDirectory( + "rea-reference-directory-imports-", + ); + const root = join(workspace, "source"); + const imports = [ + ["slash.cjs", "./lib/", "lib/index.js"], + ["dot.cjs", "./lib/.", "lib/index.js"], + ["self.cjs", ".", "index.js"], + ["self-slash.cjs", "./", "index.js"], + ["nested/parent.cjs", "..", "index.js"], + ["nested/parent-slash.cjs", "../", "index.js"], + ["direct.cjs", "./lib.js", "lib.js"], + ["fallback.cjs", "./fallback", "fallback/index.js"], + ["legacy-order.cjs", "./dep", "dep/index.js"], + ] as const; + const unresolved = [ + ["missing.cjs", "./missing/", "missing/"], + ["escape.cjs", "../outside/", "../outside/"], + ["main.ts", "./folder.js/.", "folder.js"], + ] as const; + const files = new Map([ + ["index.js", 'module.exports = "root index";\n'], + ["lib/index.js", 'module.exports = "library index";\n'], + ["lib.js", 'module.exports = "sibling JavaScript file";\n'], + ["lib.ts", 'export const value = "sibling TypeScript file";\n'], + ["fallback/index.js", 'module.exports = "fallback index";\n'], + ["dep/index.js", 'module.exports = "existing index priority";\n'], + ["dep.cjs", 'module.exports = "later CommonJS candidate";\n'], + [ + "folder.ts", + 'export const value = "emitted filename counterpart";\n', + ], + ["missing.js", 'module.exports = "not a directory";\n'], + ...[...imports, ...unresolved].map( + ([path, specifier]) => + [path, `require(${JSON.stringify(specifier)});\n`] as const, + ), + ]); + await Promise.all( + [...files].map(async ([path, contents]) => { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents); + }), + ); + const outside = join(workspace, "outside", "index.js"); + await mkdir(dirname(outside), { recursive: true }); + await writeFile(outside, 'module.exports = "outside import root";\n'); + + // Node's loader establishes each fixture's meaning independently. + for (const [path, specifier, target] of imports) + expect(createRequire(join(root, path)).resolve(specifier)).toBe( + join(root, target), + ); + expect(() => + createRequire(join(root, "missing.cjs")).resolve("./missing/"), + ).toThrow(); + expect(() => + createRequire(join(root, "main.ts")).resolve("./folder.js/."), + ).toThrow(); + expect( + createRequire(join(root, "escape.cjs")).resolve("../outside/"), + ).toBe(outside); + + const result = await cli.run({ + arguments: ["import-reference-source", root, "--json"], + environment: { + HOME: workspace, + XDG_CONFIG_HOME: workspace, + XDG_CACHE_HOME: workspace, + }, + }); + expect(result.exitCode).toBe(0); + const graph = historicalSourceGraphSchema.parse(result.json); + expect(graph.parse_failures).toEqual([]); + expect(graph.relationships).toHaveLength( + imports.length + unresolved.length, + ); + for (const [from, , to] of imports) + expect.soft(graph.relationships, from).toContainEqual({ + from_path: from, + to, + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }); + for (const [from, , to] of unresolved) + expect.soft(graph.relationships, from).toContainEqual({ + from_path: from, + to, + kind: "requires", + resolution: "unresolved", + parse_state: "parsed", + }); + expect( + graph.entries.some((entry) => entry.path.includes("outside")), + ).toBe(false); + }, + ); + }, +); From f90382756d4625d2a50b0cf6a4c30de038289b9a Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:06:50 +0800 Subject: [PATCH 04/92] fix(native): keep bounded trace edges and depth consistent (#2758) --- .../native/nativeInvestigationGraph.test.ts | 55 +++++++++++++++++++ src/domain/native/nativeInvestigationGraph.ts | 12 ++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 015458319..684b2a02e 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -83,6 +83,61 @@ const graph = nativeInvestigationGraphSchema.parse({ }); describe("native investigation graph traces", () => { + it.each(["forward", "backward"] as const)( + "keeps both endpoints of retained edges within the %s node budget", + (direction) => { + const trace = traceNativeInvestigationGraph(graph, { + start: direction === "forward" ? "a" : "c", + direction, + limits: { max_nodes: 1 }, + }); + expect(trace.nodes).toHaveLength(1); + expect(trace.edges).toEqual([]); + expect(trace).toMatchObject({ + truncated: true, + reason: "max_nodes_reached", + reached_depth: 0, + }); + }, + ); + + it.each([{ max_nodes: 2 }, { max_edges: 1 }])( + "reports retained node depth when a sibling hits the budget %j", + (limits) => { + const branching = { + ...graph, + edges: graph.edges + .filter((edge) => edge.resolution !== "unresolved") + .map((edge) => ({ ...edge, from: "a" })), + }; + const trace = traceNativeInvestigationGraph(branching, { + start: "a", + limits, + }); + expect(trace.nodes.map(({ id }) => id)).toEqual(["a", "b"]); + expect(trace.edges.map(({ id }) => id)).toEqual(["1"]); + expect(trace.reached_depth).toBe(1); + expect(trace.truncated).toBe(true); + }, + ); + + it("retains cycle edges to admitted nodes when the node budget is full", () => { + const cycle = { + ...graph, + nodes: graph.nodes.slice(0, 2), + edges: [ + graph.edges[0], + { ...graph.edges[0], id: "back", from: "b", to: "a" }, + ], + }; + const trace = traceNativeInvestigationGraph( + nativeInvestigationGraphSchema.parse(cycle), + { start: "a", limits: { max_nodes: 2 } }, + ); + expect(trace.edges.map(({ id }) => id)).toEqual(["1", "back"]); + expect(trace).toMatchObject({ reached_depth: 1, truncated: false }); + }); + it("retains observed and inferred route edges plus unresolved dispatch", () => { const trace = traceNativeInvestigationGraph(graph, { start: "a" }); expect(trace.nodes.map(({ id }) => id)).toEqual(["a", "b", "c"]); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index 0dd0dbe8b..9b9efc412 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -207,7 +207,6 @@ export const traceNativeInvestigationGraph = ( while (queue.length > 0) { const current = queue.shift(); if (current === undefined) break; - reachedDepth = Math.max(reachedDepth, current.depth); const adjacent = adjacency.get(current.id) ?? []; for (const edge of adjacent) { @@ -227,19 +226,24 @@ export const traceNativeInvestigationGraph = ( reason ??= "max_depth_reached"; continue; } - selectedEdges.set(edge.id, edge); - if (alreadyVisited) continue; - if (visited.size >= limits.max_nodes) { + // Check destination capacity before retaining its edge. Otherwise the node + // bound leaves a resolved edge pointing outside the returned trace. + if (!alreadyVisited && visited.size >= limits.max_nodes) { truncated = true; reason = "max_nodes_reached"; break; } + selectedEdges.set(edge.id, edge); + if (alreadyVisited) continue; if (!nodeById.has(nextId)) { truncated = true; reason ??= "edge_target_missing_from_graph"; continue; } visited.add(nextId); + // A retained node has already been reached even if a later sibling + // exhausts a budget before this node is dequeued for expansion. + reachedDepth = Math.max(reachedDepth, current.depth + 1); queue.push({ id: nextId, depth: current.depth + 1 }); } if (reason === "max_edges_reached" || reason === "max_nodes_reached") break; From cd2ad1f24084e68910e4ceab4a33e1b88998ebe7 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:07:28 -0700 Subject: [PATCH 05/92] fix(artifacts): report malformed ZIP archives as format failures (#2683) * fix(artifacts): report malformed ZIP archives as format failures zip.js signals archive faults (missing end-of-central-directory record, unrecognized format, overlapping members, corrupt member data, unsupported compression) as plain Errors. ZipArtifactReader passed them through untyped, so the artifact provider's fallback reported every one as `io` with no detail and told the caller to "check file access". Type these faults at the ZIP reader boundary: structural and data corruption become `format` failures and unsupported archive features become `unavailable`, each carrying the zip.js reason as detail. This covers enumeration, the getData rejection, and the WritableStream abort path that delivers corrupt-data errors first. Co-Authored-By: Claude Opus 5.5 * test(artifacts): share the ZIP inventory failure assertion Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/artifacts/ZipArtifactReader.ts | 85 +++++++++++++++++-- .../filesystem/artifactZipIntegrity.test.ts | 66 +++++++++----- 2 files changed, 124 insertions(+), 27 deletions(-) diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index dfbee8b27..066b005b8 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -2,7 +2,29 @@ import type { FileHandle } from "node:fs/promises"; import { PassThrough, type Readable } from "node:stream"; import { once } from "node:events"; -import { Reader, ZipReader, type Entry, type FileEntry } from "@zip.js/zip.js"; +import { + ERR_AMBIGUOUS_ARCHIVE, + ERR_BAD_FORMAT, + ERR_CENTRAL_DIRECTORY_NOT_FOUND, + ERR_ENCRYPTED_CENTRAL_DIRECTORY, + ERR_ENTRY_DATA_OUT_OF_BOUNDS, + ERR_EOCDR_LOCATOR_ZIP64_NOT_FOUND, + ERR_EOCDR_NOT_FOUND, + ERR_EXTRAFIELD_ZIP64_NOT_FOUND, + ERR_INVALID_COMPRESSED_DATA, + ERR_INVALID_CRC32, + ERR_INVALID_UNCOMPRESSED_SIZE, + ERR_LOCAL_FILE_HEADER_NOT_FOUND, + ERR_OVERLAPPING_ENTRY, + ERR_SPLIT_ZIP_FILE, + ERR_UNSUPPORTED_COMPRESSION, + ERR_UNSUPPORTED_ENCRYPTION, + ERR_UNSUPPORTED_UINT64, + Reader, + ZipReader, + type Entry, + type FileEntry, +} from "@zip.js/zip.js"; import { ArtifactReaderFailure, @@ -159,9 +181,9 @@ export class ZipArtifactReader implements ArtifactReader { let index = 0; // A traversal owns its accounting, including overlapping or repeated passes. let declaredFileBytes = 0; - for await (const entry of this.#reader.getEntriesGenerator({ - decodeText: decodeZipEntryText, - })) { + for await (const entry of typedZipFailures( + this.#reader.getEntriesGenerator({ decodeText: decodeZipEntryText }), + )) { abortIfNeeded(signal); // ZIP member identity is its directory occurrence, not its filename. // Logical path uniqueness belongs to the artifact inventory boundary. @@ -327,7 +349,7 @@ const extractStream = (entry: FileEntry, signal?: AbortSignal): Readable => { output.end(); }, abort: (reason) => { - output.destroy(toError(reason)); + output.destroy(toError(typedZipFailure(reason))); }, }); void entry @@ -337,10 +359,61 @@ const extractStream = (entry: FileEntry, signal?: AbortSignal): Readable => { checkOverlappingEntry: true, onprogress: () => abortIfNeeded(signal), }) - .catch((cause: unknown) => output.destroy(toError(cause))); + .catch((cause: unknown) => output.destroy(toError(typedZipFailure(cause)))); return output; }; +/** zip.js reports archive faults as plain errors identified by message. */ +const ZIP_FAILURE_REASONS: ReadonlyMap = + new Map([ + ...[ + ERR_AMBIGUOUS_ARCHIVE, + ERR_BAD_FORMAT, + ERR_CENTRAL_DIRECTORY_NOT_FOUND, + ERR_ENTRY_DATA_OUT_OF_BOUNDS, + ERR_EOCDR_LOCATOR_ZIP64_NOT_FOUND, + ERR_EOCDR_NOT_FOUND, + ERR_EXTRAFIELD_ZIP64_NOT_FOUND, + ERR_INVALID_COMPRESSED_DATA, + ERR_INVALID_CRC32, + ERR_INVALID_UNCOMPRESSED_SIZE, + ERR_LOCAL_FILE_HEADER_NOT_FOUND, + ERR_OVERLAPPING_ENTRY, + ERR_UNSUPPORTED_UINT64, + ].map((message) => [message, "format"] as const), + ...[ + ERR_ENCRYPTED_CENTRAL_DIRECTORY, + ERR_SPLIT_ZIP_FILE, + ERR_UNSUPPORTED_COMPRESSION, + ERR_UNSUPPORTED_ENCRYPTION, + ].map((message) => [message, "unavailable"] as const), + ]); + +/** Type a zip.js archive fault so consumers do not report it as file I/O. */ +const typedZipFailure = (cause: unknown): unknown => { + if (!(cause instanceof Error) || cause instanceof ArtifactReaderFailure) + return cause; + const reason = ZIP_FAILURE_REASONS.get(cause.message); + if (reason === undefined) return cause; + return new ArtifactReaderFailure( + reason, + reason === "format" + ? `ZIP archive is malformed: ${cause.message}` + : `ZIP archive uses an unsupported feature: ${cause.message}`, + { cause }, + ); +}; + +async function* typedZipFailures( + source: AsyncIterable, +): AsyncIterable { + try { + yield* source; + } catch (cause: unknown) { + throw typedZipFailure(cause); + } +} + const isSymlink = (entry: Entry): boolean => entry.unixMode !== undefined && (entry.unixMode & 0o170000) === 0o120000; diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 41397c520..cf7dcd595 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -366,16 +366,11 @@ it("rejects overlapping ZIP member data ranges through artifact inventory", asyn bytes.writeUInt32LE(firstLocalOffset, second + 42); await writeFile(path, bytes); - const target = await parseBinaryTarget(path); - if (!target.ok) throw target.error; - const result = await withArtifactClient(target.value, (client) => - client.execute("inventory_artifact", {}), + await expectInventoryFailure( + path, + "format", + "ZIP archive is malformed: Overlapping entry found", ); - - expect(result).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "io" }, - }); }); it("preserves encrypted-member and corrupt-CRC observations", async () => { @@ -438,16 +433,11 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => { bytes[dataOffset] = firstByte ^ 1; await writeFile(corruptPath, bytes); - const corruptTarget = await parseBinaryTarget(corruptPath); - if (!corruptTarget.ok) throw corruptTarget.error; - const corruptResult = await withArtifactClient( - corruptTarget.value, - (client) => client.execute("inventory_artifact", {}), + await expectInventoryFailure( + corruptPath, + "format", + "ZIP archive is malformed: Invalid compressed data", ); - expect(corruptResult).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "io" }, - }); }); it("rejects a truncated ZIP through artifact inventory", async () => { @@ -455,17 +445,51 @@ it("rejects a truncated ZIP through artifact inventory", async () => { const path = join(root, "truncated.zip"); await writeFile(path, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + await expectInventoryFailure( + path, + "format", + "ZIP archive is malformed: File format is not recognized", + ); +}); + +it("reports an unsupported ZIP compression method as unavailable", async () => { + const root = await createTestTempDirectory("rea-zip-method-"); + const path = join(root, "lzma.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("data.txt", new TextReader("member bytes"), { level: 0 }); + const bytes = Buffer.from(await writer.close()); + // Declare LZMA (14) in both headers; REA reads only stored and Deflate data. + const localHeader = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04])); + const centralHeader = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); + if (localHeader < 0 || centralHeader < 0) + throw new Error("Expected ZIP local and central headers"); + bytes.writeUInt16LE(14, localHeader + 8); + bytes.writeUInt16LE(14, centralHeader + 10); + await writeFile(path, bytes); + + await expectInventoryFailure( + path, + "unavailable", + "ZIP archive uses an unsupported feature: Compression method not supported", + ); +}); + +/** Inventory one archive through the provider and expect a typed failure. */ +const expectInventoryFailure = async ( + path: string, + reason: string, + detail: string, +): Promise => { const target = await parseBinaryTarget(path); if (!target.ok) throw target.error; const result = await withArtifactClient(target.value, (client) => client.execute("inventory_artifact", {}), ); - expect(result).toMatchObject({ ok: false, - error: { _tag: "ArtifactOperationError", reason: "io" }, + error: { _tag: "ArtifactOperationError", reason, detail }, }); -}); +}; const writeSingleEntryZip = async ( path: string, From f974353c329ebfa58ba5f268078da30952a82ec0 Mon Sep 17 00:00:00 2001 From: Chaoqi <136579807+Chaoqi31@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:10:10 -0400 Subject: [PATCH 06/92] fix(javascript): keep paths after __dirname inside the source directory (#2366) Node's path.join does not reset at an absolute segment, so path.join(__dirname, '/preload.js') is /preload.js. The static path projection skipped the __dirname anchor and joined the remaining parts, which turned that common spelling into the root path /preload.js. In an app whose main script lives in src/, the BrowserWindow preload was then reported not-found, or resolved to an unrelated preload.js at the artifact root, and the utilityProcess.fork target was never resolved. String concatenation had the same defect: path.dirname(__filename) + '/preload.js' projected to /preload.js, and __dirname + '/preload.js' stayed unknown. A source directory now contributes "." to a join, so path.join(__dirname) projects to "." like path.resolve(__dirname). A concatenation that starts with __dirname or the source file's dirname keeps text that begins with a separator inside that directory; other text extends the directory's own name and stays unknown. The empty-string projection of path.dirname(__filename), which produced the wrong concatenation, is removed, so a bare path.dirname(__filename) is unknown like a bare __dirname. --- .../javascriptStaticAnalysisHelpers.ts | 60 ++++++++++++++----- .../javascriptStaticPathResolution.test.ts | 17 +++++- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 71562e6ca..f2045a1eb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -181,18 +181,37 @@ export const staticPath = (node: t.Node): string | undefined => { if (t.isStringLiteral(node)) return node.value; if (t.isTemplateLiteral(node) && node.expressions.length === 0) return stringValue(node); - if (t.isBinaryExpression(node, { operator: "+" })) { - const left = staticPath(node.left); - const right = staticPath(node.right); - return left === undefined || right === undefined - ? undefined - : `${left}${right}`; - } + if (t.isBinaryExpression(node, { operator: "+" })) + return staticConcatenation(node); if (t.isCallExpression(node) || t.isNewExpression(node)) return staticCallPath(node); return undefined; }; +/** + * Concatenate inert text. A leading source directory contributes no text: + * `__dirname + "/preload.js"` names a file inside it, while text without a + * leading separator extends the directory's own name and stays unknown. + */ +const staticConcatenation = (node: t.BinaryExpression): string | undefined => { + const operands: t.Node[] = []; + let current: t.Node = node; + while (t.isBinaryExpression(current, { operator: "+" })) { + operands.push(current.right); + current = current.left; + } + const anchored = isDirectoryPath(current); + if (!anchored) operands.push(current); + let text = ""; + for (const operand of operands.reverse()) { + const value = staticPath(operand); + if (value === undefined) return undefined; + text += value; + } + if (!anchored) return text; + return text.startsWith("/") ? posix.join(".", text) : undefined; +}; + const staticCallPath = ( node: t.CallExpression | t.NewExpression, ): string | undefined => { @@ -204,11 +223,6 @@ const staticCallPath = ( if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) { return staticFileUrlPath(argumentNode(node.arguments[0])); } - if ( - (name === "dirname" || name.endsWith(".dirname")) && - isFileIdentity(argumentNode(node.arguments[0])) - ) - return ""; if (name.endsWith(".resolve")) return name === "win32.resolve" || name.endsWith(".win32.resolve") ? undefined @@ -216,7 +230,12 @@ const staticCallPath = ( if (!name.endsWith(".join") && name !== "join") return undefined; const parts: string[] = []; for (const argument of node.arguments) { - if (isDirectoryIdentity(argumentNode(argument))) continue; + if (isDirectoryIdentity(argumentNode(argument))) { + // join never resets at an absolute segment, so `/preload.js` after the + // source directory stays inside it. + parts.push("."); + continue; + } const value = t.isNode(argument) ? staticPath(argument) : undefined; if (value === undefined) return undefined; parts.push(value); @@ -331,11 +350,20 @@ const isFilesystemPathExpression = (node: t.Node): boolean => { }; const isDirectoryIdentity = (node: t.Node | undefined): boolean => { - if (t.isIdentifier(node, { name: "__dirname" })) return true; + if (isDirectoryPath(node)) return true; if (!t.isCallExpression(node) && !t.isNewExpression(node)) return false; const name = calleeName(node.callee); - if (name === "URL" || name.endsWith(".URL")) - return isFileIdentity(argumentNode(node.arguments[1])); + return ( + (name === "URL" || name.endsWith(".URL")) && + isFileIdentity(argumentNode(node.arguments[1])) + ); +}; + +/** `__dirname` or the dirname of the source file, as a string. */ +const isDirectoryPath = (node: t.Node | undefined): boolean => { + if (t.isIdentifier(node, { name: "__dirname" })) return true; + if (!t.isCallExpression(node)) return false; + const name = calleeName(node.callee); return ( (name === "dirname" || name.endsWith(".dirname")) && isFileIdentity(argumentNode(node.arguments[0])) diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index e54122440..30bbc317f 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -38,6 +38,9 @@ const anchoredExpressions = [ ["path.resolve(__dirname)", "."], ['path.posix.resolve(`/app`, `/shared`, "preload.js")', "/shared/preload.js"], ['path.posix.resolve("" + "/app", "preload.js")', "/app/preload.js"], + ['__dirname + "/preload.js"', "preload.js"], + ['__dirname + "/" + "preload.js"', "preload.js"], + ['path.dirname(__filename) + "/lib" + "/preload.js"', "lib/preload.js"], ] as const; const unknownExpressions = [ @@ -58,6 +61,8 @@ const unknownExpressions = [ 'path.win32.resolve(__dirname, "preload.js")', 'win32.resolve("/app", "preload.js")', 'resolve("/app", "preload.js")', + '__dirname + "preload.js"', + 'path.dirname(__filename) + "preload.js"', ]; describe("inert JavaScript path resolution", () => { @@ -100,6 +105,16 @@ describe("inert JavaScript path resolution", () => { "preload.js", "filesystem-expression", ], + [ + 'path.join(__dirname, "/preload.js")', + "preload.js", + "filesystem-expression", + ], + [ + 'path.join(dirname(fileURLToPath(import.meta.url)), "/lib", "/preload.js")', + "lib/preload.js", + "filesystem-expression", + ], [ 'path.join("ignored", __dirname, "preload.js")', "ignored/preload.js", @@ -111,6 +126,7 @@ describe("inert JavaScript path resolution", () => { "filesystem-expression", ], ['join("sub", "preload.js")', "sub/preload.js", "module-specifier"], + ["path.join(__dirname)", ".", "filesystem-expression"], ] as const)("preserves join behavior in %s", (expression, path, context) => { assertPathProjection(expression, path, context); }); @@ -182,7 +198,6 @@ describe("inert JavaScript path resolution", () => { it.each([ "path.join()", - "path.join(__dirname)", 'path.join("/app", runtimeFile)', "path.join(...parts)", ])("preserves unresolved join syntax in %s", (expression) => From eeeb4ca379a948109542e66a8d0adb3998f6f60d Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:10:39 +0800 Subject: [PATCH 07/92] fix(javascript): preserve container ownership for getter mutations (#2765) --- docs/javascript-application-workflows.md | 7 ++ ...tSemanticAnalysis.getter_receivers.test.ts | 70 +++++++++++++++++- .../javascriptSemanticMemberMutations.ts | 49 +++++++++++-- .../javascriptSemanticObjectReferences.ts | 62 +++++++++++++--- .../getterReceiverMutationsCli.test.ts | 73 +++++++++++++++++-- 5 files changed, 233 insertions(+), 28 deletions(-) diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 1e9e7fe19..041015b9c 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -208,6 +208,13 @@ arrays, and later explicit data properties and methods replace earlier object spread references. A known array prefix does not expose values from a following spread. Accessors, custom or uncertain iteration, unknown computed keys, and unknown spread lengths retain conservative uncertainty. +Writes through an object getter's returned receiver retain that uncertainty +when the object is an array element, nested inside mixed object/array +containers, or assigned to a previously declared identifier. Saving the getter +result in an alias does not make its old field values immutable. Known array +indices include holes; an element after a spread has only a minimum possible +index. Unrelated properties and known array prefixes remain precise, and a +getter read without a write does not invalidate the receiver's fields. Nested callable returns are not assigned to their parent callable. Projected graph observations carry source ranges but never source text. diff --git a/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts index 3b83bf482..c3b38842a 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts @@ -33,6 +33,38 @@ const getterReceivers = [ "nested owner", "const outer = { box: { x: 1, get self() { return this; } } }; outer.box.self.x = 2; return outer.box.x;", ], + [ + "array element", + "const arr = [{ x: 1, get self() { return this; } }]; arr[0].self.x = 2; return arr[0].x;", + ], + [ + "array element after a hole", + "const arr = [, { x: 1, get self() { return this; } }]; arr[1].self.x = 2; return arr[1].x;", + ], + [ + "mixed object and array owners", + "const outer = { rows: [{ box: { x: 1, get self() { return this; } } }] }; outer.rows[0].box.self.x = 2; return outer.rows[0].box.x;", + ], + [ + "assigned owner", + "let box; box = { x: 1, get self() { return this; } }; box.self.x = 2; return box.x;", + ], + [ + "saved array receiver alias", + "const arr = [{ x: 1, get self() { return this; } }]; const saved = arr[0].self; saved.x = 2; return arr[0].x;", + ], + [ + "old receiver captured inside its replacement initializer", + "const old = { x: 1 }; let box = old; box = { old: box, get self() { return this; } }; box.self.old.x = 2; return old.x;", + ], + [ + "array receiver after an unknown spread", + "const arr = [...items, { x: 1, get self() { return this; } }]; arr[index].self.x = 2; return arr[index].x;", + ], + [ + "saved receiver before its array alias is reassigned", + "const original = [{ x: 1, get self() { return this; } }]; let arr = original; const saved = arr[0].self; arr = [{ x: 1 }]; saved.x = 2; return original[0].x;", + ], [ "inherited receiver field", "const shared = { x: 1 }; class A { get v() { return this.s; } } class B extends A { s = shared; } new B().v.x = 2; return shared.x;", @@ -75,7 +107,7 @@ const getterReceivers = [ ], ] as const; -describe("getter receiver mutation aliases (#1665)", () => { +describe("getter receiver mutation aliases (#1665, #2654)", () => { it.each(getterReceivers)( "does not report a stale literal through %s", (_shape, body) => { @@ -103,6 +135,42 @@ describe("getter receiver mutation aliases (#1665)", () => { "unrelated sibling", "const box = { x: 1, y: 1, get self() { return this; } }; box.self.y = 2; return box.x;", ], + [ + "array receiver read without write", + "const arr = [{ x: 1, get self() { return this; } }]; const saved = arr[0].self; return arr[0].x;", + ], + [ + "assigned receiver read without write", + "let box; box = { x: 1, get self() { return this; } }; const saved = box.self; return box.x;", + ], + [ + "unrelated array receiver property", + "const arr = [{ x: 1, y: 1, get self() { return this; } }]; arr[0].self.y = 2; return arr[0].x;", + ], + [ + "unrelated array element", + "const arr = [{ x: 1, get self() { return this; } }, { x: 1 }]; arr[0].self.x = 2; return arr[1].x;", + ], + [ + "array replacement after a receiver was captured", + "const original = [{ x: 1, get self() { return this; } }]; const replacement = [{ x: 1 }]; let arr = original; const saved = arr[0].self; arr = replacement; saved.x = 2; return replacement[0].x;", + ], + [ + "known array prefix before a spread receiver", + "const arr = [{ x: 1 }, ...items, { x: 1, get self() { return this; } }]; arr[1].self.x = 2; return arr[0].x;", + ], + [ + "outer binding shadowed by an assigned receiver", + "let box; box = { x: 1 }; { let box; box = { x: 1, get self() { return this; } }; box.self.x = 2; } return box.x;", + ], + [ + "old object before an unconditional receiver assignment", + "const old = { x: 1 }; let box = old; box = { x: 1, get self() { return this; } }; box.self.x = 2; return old.x;", + ], + [ + "unmodified old receiver captured inside its replacement initializer", + "const old = { x: 1 }; let box = old; box = { x: 1, old: box, get self() { return this; } }; box.self.x = 2; return old.x;", + ], [ "replace getter", "const shared = { x: 1 }; const box = { s: shared, get v() { return this.s; } }; box.v = {}; return shared.x;", diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index c2f7efba3..35b63c815 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -18,6 +18,7 @@ import { semanticClassCandidates, semanticClassPropertyReferences, semanticObjectPropertyReferences, + type JavaScriptSemanticObjectReference, } from "./javascriptSemanticObjectReferences.js"; import { semanticIterationSources } from "./javascriptSemanticIterationSources.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; @@ -98,8 +99,20 @@ export function* collectSemanticMemberMutationsSteps( effect: ValueEffect = "write", mutation?: t.Node, originAt: t.Node | undefined = mutation, + ownerInitializer?: t.Node, ): void => { - const pending = [{ node, path, bindings, originAt }]; + const pending: (ReferencedValue & { + readonly bindings: ReadonlySet; + readonly originAt: t.Node | undefined; + })[] = [ + { + node, + path, + bindings, + originAt, + ...(ownerInitializer === undefined ? {} : { ownerInitializer }), + }, + ]; // Visit shallower alias traversals first so their exact prefixes suppress // longer loop paths before those paths enumerate every assignment order. for (let cursor = 0; cursor < pending.length; cursor++) { @@ -132,6 +145,8 @@ export function* collectSemanticMemberMutationsSteps( binding.bindingId, current.originAt?.start, current.originAt?.end, + current.ownerInitializer?.start, + current.ownerInitializer?.end, ]; const lifetime = JSON.stringify([...capture, current.path]); if (current.bindings.has(lifetime)) continue; @@ -165,6 +180,7 @@ export function* collectSemanticMemberMutationsSteps( "escape", mutation, current.originAt, + current.ownerInitializer, ); continue; } @@ -195,6 +211,8 @@ export function* collectSemanticMemberMutationsSteps( mutation?.end, current.originAt?.start, current.originAt?.end, + current.ownerInitializer?.start, + current.ownerInitializer?.end, ]); const previous = selections.get(selectionIdentity); const excludedKeys = @@ -220,6 +238,8 @@ export function* collectSemanticMemberMutationsSteps( mutation?.end, current.originAt?.start, current.originAt?.end, + current.ownerInitializer?.start, + current.ownerInitializer?.end, ]); let covering = coveringPaths.get(pathState); if (covering === undefined) { @@ -235,6 +255,8 @@ export function* collectSemanticMemberMutationsSteps( mutation?.end, current.originAt?.start, current.originAt?.end, + current.ownerInitializer?.start, + current.ownerInitializer?.end, ]); if (recordedEffects.has(identity)) continue; // A recorded write invalidates the binding to a non-primitive value, so @@ -248,10 +270,23 @@ export function* collectSemanticMemberMutationsSteps( value?.status === "literal" || value?.status === "union"; recordedEffects.add(identity); const nested = new Set([...current.bindings, lifetime, captureKey]); + // Returning a literal's receiver reaches that allocation, not the + // binding's older values read while evaluating its initializer. const origins = - current.originAt === undefined - ? binding - : semanticMutationInitializers(binding, current.originAt, parents); + current.ownerInitializer !== undefined + ? { + initializers: binding.initializers.filter( + ({ node }) => node === current.ownerInitializer, + ), + referenceInitializers: [], + } + : current.originAt === undefined + ? binding + : semanticMutationInitializers( + binding, + current.originAt, + parents, + ); if (!primitiveWrite) { if (effect === "escape") binding.escapedPaths.push({ path, node: mutation }); @@ -367,6 +402,8 @@ export function* collectSemanticMemberMutationsSteps( binding === undefined ? null : binding.bindingId, binding === undefined ? node.name : null, pathKey, + value.ownerInitializer?.start, + value.ownerInitializer?.end, ]); if (leafIdentities.has(identity)) continue; leafIdentities.add(identity); @@ -551,9 +588,7 @@ export function* collectSemanticMemberMutationsSteps( clearSemanticValueCaches(state); } -interface ReferencedValue { - readonly node: t.Node; - readonly path: PropertyPath; +interface ReferencedValue extends JavaScriptSemanticObjectReference { readonly iterableFallbackPath?: PropertyPath; } diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 387ba09d2..4d1bf3e76 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -14,9 +14,11 @@ import { } from "./javascriptSemanticPropertyPaths.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; -interface ReferencedValue { +export interface JavaScriptSemanticObjectReference { readonly node: t.Node; readonly path: PropertyPath; + /** The allocation owned by this binding, rather than a read of its prior value. */ + readonly ownerInitializer?: t.Node; } export const semanticClassCandidates = ( @@ -72,10 +74,10 @@ const instanceFieldReferences = ( path: PropertyPath, state: JavaScriptSemanticAnalysisState, ): { - readonly references: readonly ReferencedValue[]; + readonly references: readonly JavaScriptSemanticObjectReference[]; readonly definiteNames: ReadonlySet; } => { - const references: ReferencedValue[] = []; + const references: JavaScriptSemanticObjectReference[] = []; const pending = [{ klass: root, excluded: new Set() }]; const selections = new Map>(); let definiteNames: ReadonlySet | undefined; @@ -208,7 +210,7 @@ export const semanticClassPropertyReferences = ( selection: PropertyPath, state: JavaScriptSemanticAnalysisState, activeGetters: ReadonlyMap = new Map(), -): readonly ReferencedValue[] => { +): readonly JavaScriptSemanticObjectReference[] => { const root = lookup.owner; const receiver = lookup.receiver ?? root; const [key, ...path] = selection; @@ -216,7 +218,7 @@ export const semanticClassPropertyReferences = ( key == null || typeof key === "object" ? null : String(key); const pending: (t.ClassDeclaration | t.ClassExpression)[] = [root]; const seen = new Set(); - const possibleResults: ReferencedValue[] = []; + const possibleResults: JavaScriptSemanticObjectReference[] = []; // Instance fields are own properties, including fields initialized by base // constructors. They take precedence over every prototype descriptor. let instanceFields: ReadonlySet = new Set(); @@ -267,11 +269,11 @@ const objectReceiverReference = ( object: t.ObjectExpression, path: PropertyPath, state: JavaScriptSemanticAnalysisState, -): readonly ReferencedValue[] => { +): readonly JavaScriptSemanticObjectReference[] => { // A literal's enclosing binding owns its slots. Returning the literal // itself would bypass invalidation of that binding's observed properties. let current: t.Node = object; - const projection: (string | number | null)[] = []; + const projection: PropertyPath[number][] = []; while (true) { const parent = state.parentsByNode.get(current); if ( @@ -279,7 +281,43 @@ const objectReceiverReference = ( parent.init === current && t.isIdentifier(parent.id) ) - return [{ node: parent.id, path: [...projection, ...path] }]; + return [ + { + node: parent.id, + path: [...projection, ...path], + ownerInitializer: current, + }, + ]; + if ( + t.isAssignmentExpression(parent, { operator: "=" }) && + parent.right === current && + t.isIdentifier(parent.left) + ) + return [ + { + node: parent.left, + path: [...projection, ...path], + ownerInitializer: current, + }, + ]; + if (t.isArrayExpression(parent)) { + let minimumIndex = 0; + let uncertainIndex = false; + for (const element of parent.elements) { + if (element === current) break; + if (t.isSpreadElement(element)) uncertainIndex = true; + else minimumIndex++; + } + // A spread contributes an unknown number of slots. Retain the known + // prefix without treating the element's syntax position as its index. + projection.unshift( + uncertainIndex + ? { excludedKeys: [], startIndex: minimumIndex } + : minimumIndex, + ); + current = parent; + continue; + } if (t.isObjectProperty(parent) && parent.value === current) { projection.unshift( semanticStaticPropertyKey(parent.key, parent.computed), @@ -312,7 +350,7 @@ const getterResultReferences = ( path: PropertyPath, state: JavaScriptSemanticAnalysisState, activeGetters: ReadonlyMap = new Map(), -): readonly ReferencedValue[] => { +): readonly JavaScriptSemanticObjectReference[] => { const { getter, owner, receiver = owner } = invocation; const previousLength = activeGetters.get(getter); if (previousLength !== undefined && previousLength <= path.length) return []; @@ -323,7 +361,7 @@ const getterResultReferences = ( const resolve = ( node: t.Node, remaining: PropertyPath, - ): readonly ReferencedValue[] => { + ): readonly JavaScriptSemanticObjectReference[] => { const expression = unwrapJavaScriptExpression(node).node; if (t.isThisExpression(expression)) { if (t.isClass(receiver)) { @@ -405,9 +443,9 @@ export const semanticObjectPropertyReferences = ( node: t.ObjectExpression, path: PropertyPath, state: JavaScriptSemanticAnalysisState, -): readonly ReferencedValue[] => { +): readonly JavaScriptSemanticObjectReference[] => { const [key, ...remaining] = path; - const references: ReferencedValue[] = []; + const references: JavaScriptSemanticObjectReference[] = []; const overwritten = new Set(); const dataOverwritten = new Set(); const accessorNames = new Set(); diff --git a/tests/acceptance/applications/getterReceiverMutationsCli.test.ts b/tests/acceptance/applications/getterReceiverMutationsCli.test.ts index a5ba0bfeb..95b6e4862 100644 --- a/tests/acceptance/applications/getterReceiverMutationsCli.test.ts +++ b/tests/acceptance/applications/getterReceiverMutationsCli.test.ts @@ -39,6 +39,47 @@ export function superclass() { new B().v.x = 2; return shared.x; } +export function arrayReceiver() { + const arr = [{ x: 1, get self() { return this; } }]; + arr[0].self.x = 2; + return arr[0].x; +} +export function nestedArrayReceiver() { + const outer = { arr: [{ x: 1, get self() { return this; } }] }; + outer.arr[0].self.x = 2; + return outer.arr[0].x; +} +export function assignedReceiver() { + let box; + box = { x: 1, get self() { return this; } }; + box.self.x = 2; + return box.x; +} +export function savedArrayReceiver() { + const arr = [{ x: 1, get self() { return this; } }]; + const saved = arr[0].self; + saved.x = 2; + return arr[0].x; +} +export function arrayReadOnly() { + const arr = [{ x: 1, get self() { return this; } }]; + const saved = arr[0].self; + return arr[0].x; +} +export function replacementKeepsOldObject() { + const old = { x: 1 }; + let box = old; + box = { x: 1, old: box, get self() { return this; } }; + box.self.x = 2; + return old.x; +} +export function replacementMutatesCapturedObject() { + const old = { x: 1 }; + let box = old; + box = { old: box, get self() { return this; } }; + box.self.old.x = 2; + return old.x; +} export function untouched() { const box = { x: 1, y: 1, get self() { return this; } }; box.self.y = 2; @@ -66,13 +107,20 @@ export function untouched() { const { graph } = z .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) .parse(document).normalized_result; - for (const name of [ - "self", - "property", - "privateField", - "superclass", - "untouched", - ]) { + for (const [name, status] of [ + ["self", "unknown"], + ["property", "unknown"], + ["privateField", "unknown"], + ["superclass", "unknown"], + ["arrayReceiver", "unknown"], + ["nestedArrayReceiver", "unknown"], + ["assignedReceiver", "unknown"], + ["savedArrayReceiver", "unknown"], + ["arrayReadOnly", "literal"], + ["replacementKeepsOldObject", "literal"], + ["replacementMutatesCapturedObject", "unknown"], + ["untouched", "literal"], + ] as const) { const projection = graph.nodes .flatMap(({ observations }) => observations) .find( @@ -82,7 +130,16 @@ export function untouched() { ); expect(projection, name).toBeDefined(); expect(projection?.properties.static_return_shapes).toMatchObject([ - { value_status: name === "untouched" ? "literal" : "unknown" }, + { + value_status: status, + fields: [ + { + path: "", + state: status, + ...(status === "literal" ? { value: 1 } : {}), + }, + ], + }, ]); } }, From a8b50ea147ab3aaa2870160481e09403c3310ea0 Mon Sep 17 00:00:00 2001 From: Shreyansh Jain <136733716+ShreyanshJain105@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:55:58 +0530 Subject: [PATCH 08/92] refactor(firmware): split verification into staged functions (#2713) Refactor firmware verifier into staged functions Restructure scripts/verify/firmware/analysis.mjs for readability without changing behavior. - Move prerequisite checks into checkPrerequisites() - Split the main flow into stageCli, stageMcp, stageExt4 and stageGhidra - Extract timeouts and size limits into named constants - Rename cli/call helpers to runCli/callTool Checks, order of operations, error messages and env vars are unchanged. Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- scripts/verify/firmware/analysis.mjs | 504 ++++++++++++++++----------- 1 file changed, 299 insertions(+), 205 deletions(-) diff --git a/scripts/verify/firmware/analysis.mjs b/scripts/verify/firmware/analysis.mjs index 1f84681c9..804e8fffa 100644 --- a/scripts/verify/firmware/analysis.mjs +++ b/scripts/verify/firmware/analysis.mjs @@ -1,15 +1,8 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { - access, - mkdir, - mkdtemp, - readFile, - rm, - symlink, -} from "node:fs/promises"; +import * as fs from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import * as nodePath from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; @@ -18,247 +11,348 @@ import { firmwareResultSchemas } from "../../../dist/domain/firmware/firmwareAna import { parseEvidence } from "../../../dist/domain/evidence.js"; import { hashFirmwareFile } from "../../../dist/firmware/FirmwareFiles.js"; -const exec = promisify(execFile); -const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); -const entrypoint = resolve( +const execFileAsync = promisify(execFile); +const { join, resolve, dirname } = nodePath; + +const CALL_TIMEOUT_MS = 150_000; +const CONNECT_TIMEOUT_MS = 30_000; +const SEVENZIP_TIMEOUT_MS = 10_000; +const MAX_STDOUT_BYTES = 16 * 1024 * 1024; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const ENTRYPOINT = resolve( process.env.REA_FIRMWARE_TEST_ENTRYPOINT ?? - join(repository, "scripts/rea.mjs"), + join(REPO_ROOT, "scripts/rea.mjs"), ); -if (process.platform !== "linux") - throw new Error( - "verify:firmware requires Linux; other hosts are not verified", - ); -for (const variable of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) { - const path = process.env[variable]; - if (path === undefined) - throw new Error( - `verify:firmware requires ${variable}; see docs/firmware-analysis.md`, - ); - await access(path); -} -try { - await exec("7z", ["i"], { timeout: 10_000 }); -} catch (cause) { - throw new Error( - "verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH", - { cause }, - ); -} -const fixtureRoot = resolve( +const FIXTURE_ROOT = resolve( process.env.REA_FIRMWARE_FIXTURE_ROOT ?? - join(repository, "_reference/firmware-integration/generated"), + join(REPO_ROOT, "_reference/firmware-integration/generated"), ); -await access(join(fixtureRoot, "oracle.json")).catch((cause) => { - throw new Error( - "verify:firmware requires generated fixtures; run npm run fixtures:firmware", - { cause }, - ); -}); + +// --------------------------------------------------------------------------- +// Prerequisite checks +// --------------------------------------------------------------------------- + +async function checkPrerequisites() { + if (process.platform !== "linux") { + throw new Error( + "verify:firmware requires Linux; other hosts are not verified", + ); + } + + for (const name of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) { + const value = process.env[name]; + if (value === undefined) { + throw new Error( + `verify:firmware requires ${name}; see docs/firmware-analysis.md`, + ); + } + await fs.access(value); + } + + try { + await execFileAsync("7z", ["i"], { timeout: SEVENZIP_TIMEOUT_MS }); + } catch (cause) { + throw new Error( + "verify:firmware prerequisite unavailable: Unblob's gzip/USTAR fixture extraction requires 7z on PATH", + { cause }, + ); + } + + try { + await fs.access(join(FIXTURE_ROOT, "oracle.json")); + } catch (cause) { + throw new Error( + "verify:firmware requires generated fixtures; run npm run fixtures:firmware", + { cause }, + ); + } +} + +await checkPrerequisites(); + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + const oracle = JSON.parse( - await readFile(join(fixtureRoot, "oracle.json"), "utf8"), + await fs.readFile(join(FIXTURE_ROOT, "oracle.json"), "utf8"), ); -const path = join(fixtureRoot, "firmware.bin"); -assert.equal(await hashFirmwareFile(path), oracle.sha256); -const root = await mkdtemp(join(tmpdir(), "rea-real-firmware-")); -const environment = Object.fromEntries( - Object.entries(process.env).filter(([, value]) => value !== undefined), +const firmwarePath = join(FIXTURE_ROOT, "firmware.bin"); +assert.equal(await hashFirmwareFile(firmwarePath), oracle.sha256); + +const workDir = await fs.mkdtemp(join(tmpdir(), "rea-real-firmware-")); +const baseEnv = Object.fromEntries( + Object.entries(process.env).filter(([, v]) => v !== undefined), ); -const client = new Client({ name: "rea-real-firmware-verifier", version: "1" }); -const cli = async (args, env = environment) => { - const response = await exec( +const mcpClient = new Client({ + name: "rea-real-firmware-verifier", + version: "1", +}); + +// --------------------------------------------------------------------------- +// Invocation helpers +// --------------------------------------------------------------------------- + +async function runCli(args, env = baseEnv) { + const { stdout } = await execFileAsync( process.execPath, - [entrypoint, ...args, "--format", "json"], + [ENTRYPOINT, ...args, "--format", "json"], { env, - cwd: repository, - timeout: 150_000, - maxBuffer: 16 * 1024 * 1024, + cwd: REPO_ROOT, + timeout: CALL_TIMEOUT_MS, + maxBuffer: MAX_STDOUT_BYTES, }, ); - return parseEvidence(JSON.parse(response.stdout)); -}; -const call = async (name, args) => { - const response = await client.callTool( + return parseEvidence(JSON.parse(stdout)); +} + +async function callTool(name, args) { + const result = await mcpClient.callTool( { name, arguments: args }, - { timeout: 150_000 }, + { timeout: CALL_TIMEOUT_MS }, ); - assert.notEqual(response.isError, true, JSON.stringify(response)); - assert.ok(response.structuredContent !== undefined); - return response.structuredContent; -}; -const verifyRegions = (evidence) => { + assert.notEqual(result.isError, true, JSON.stringify(result)); + assert.ok(result.structuredContent !== undefined); + return result.structuredContent; +} + +// --------------------------------------------------------------------------- +// Result verifiers +// --------------------------------------------------------------------------- + +function verifyRegions(evidence) { assert.equal(evidence.subject.digest.sha256, oracle.sha256); - const result = firmwareResultSchemas.inspect_firmware_regions.parse( + + const parsed = firmwareResultSchemas.inspect_firmware_regions.parse( evidence.normalized_result, ); - const gzip = result.regions.find( - (region) => region.signature === "gzip" && region.offset === oracle.offset, + + const gzipRegion = parsed.regions.find( + (r) => r.signature === "gzip" && r.offset === oracle.offset, ); assert.ok( - gzip !== undefined, + gzipRegion !== undefined, "Binwalk must find the independently generated gzip offset", ); - assert.equal(gzip.reported_size, oracle.length); - assert.equal(gzip.size_basis, "provider_reported_validation_unknown"); - return result.regions.map( - ({ provider_id: _provider_id, ...region }) => region, - ); -}; -const verifyFiles = async (evidence) => { - const result = firmwareResultSchemas.extract_firmware.parse( + assert.equal(gzipRegion.reported_size, oracle.length); + assert.equal(gzipRegion.size_basis, "provider_reported_validation_unknown"); + + // Strip provider-specific ids so CLI and MCP results compare equal. + return parsed.regions.map((region) => { + const { provider_id: _ignored, ...rest } = region; + return rest; + }); +} + +async function verifyFiles(evidence) { + const parsed = firmwareResultSchemas.extract_firmware.parse( evidence.normalized_result, ); - assert.ok(result.chunks.some((chunk) => chunk.handler === "gzip")); + + assert.ok(parsed.chunks.some((c) => c.handler === "gzip")); + for (const [suffix, expected] of Object.entries(oracle.files)) { - const file = result.files.find((candidate) => - candidate.relative_path.endsWith(`/${suffix}`), + const entry = parsed.files.find((f) => + f.relative_path.endsWith(`/${suffix}`), ); - assert.ok(file !== undefined, `Missing extracted ${suffix}`); - assert.equal(file.sha256, expected.sha256); - assert.equal(file.size, expected.size); - assert.equal(await hashFirmwareFile(file.path), expected.sha256); - assert.equal(file.original_file_range, null); - assert.equal(file.runtime_address, null); + assert.ok(entry !== undefined, `Missing extracted ${suffix}`); + assert.equal(entry.sha256, expected.sha256); + assert.equal(entry.size, expected.size); + assert.equal(await hashFirmwareFile(entry.path), expected.sha256); + assert.equal(entry.original_file_range, null); + assert.equal(entry.runtime_address, null); } - return result; -}; -try { - const inspected = await cli(["inspect-firmware-regions", path]); - const cliRegions = verifyRegions(inspected); - const extracted = await cli([ - "extract-firmware", - path, - join(root, "cli-output"), + + return parsed; +} + +const toContentIdentity = (parsed) => + parsed.files.map(({ relative_path, sha256, size }) => ({ + relative_path, + sha256, + size, + })); + +// --------------------------------------------------------------------------- +// Verification stages +// --------------------------------------------------------------------------- + +async function stageCli() { + const regionsEvidence = await runCli([ + "inspect-firmware-regions", + firmwarePath, ]); - const cliFiles = await verifyFiles(extracted); - assert.equal(cliFiles.coverage, "partial"); + const regions = verifyRegions(regionsEvidence); + + const extractEvidence = await runCli([ + "extract-firmware", + firmwarePath, + join(workDir, "cli-output"), + ]); + const files = await verifyFiles(extractEvidence); + + assert.equal(files.coverage, "partial"); assert.ok( - cliFiles.chunks.some( - (chunk) => - chunk.handler === null && - chunk.range.offset === 0 && - chunk.range.length === 64, + files.chunks.some( + (c) => + c.handler === null && c.range.offset === 0 && c.range.length === 64, ), ); - await client.connect( - new StdioClientTransport({ - command: process.execPath, - args: [entrypoint, "mcp"], - cwd: repository, - env: environment, - stderr: "pipe", - }), - { timeout: 30_000 }, - ); - const inspectedMcp = await call("inspect_firmware_regions", { path }); - const mcpEvidence = parseEvidence(inspectedMcp); - assert.deepEqual(verifyRegions(mcpEvidence), cliRegions); - const extractedMcp = await call("extract_firmware", { - path, - output_directory: join(root, "mcp-output"), + + return { regions, files }; +} + +async function connectMcp() { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [ENTRYPOINT, "mcp"], + cwd: REPO_ROOT, + env: baseEnv, + stderr: "pipe", }); - const mcpFiles = await verifyFiles(parseEvidence(extractedMcp)); - const contentIdentity = (result) => - result.files.map(({ relative_path, sha256, size }) => ({ - relative_path, - sha256, - size, - })); - assert.deepEqual(contentIdentity(mcpFiles), contentIdentity(cliFiles)); - const selected = await call("extract_firmware", { - path, - output_directory: join(root, "selected-output"), + await mcpClient.connect(transport, { timeout: CONNECT_TIMEOUT_MS }); +} + +async function stageMcp(cli) { + const regionsRaw = await callTool("inspect_firmware_regions", { + path: firmwarePath, + }); + assert.deepEqual(verifyRegions(parseEvidence(regionsRaw)), cli.regions); + + const extractRaw = await callTool("extract_firmware", { + path: firmwarePath, + output_directory: join(workDir, "mcp-output"), + }); + const mcpFiles = await verifyFiles(parseEvidence(extractRaw)); + assert.deepEqual(toContentIdentity(mcpFiles), toContentIdentity(cli.files)); + + const rangedRaw = await callTool("extract_firmware", { + path: firmwarePath, + output_directory: join(workDir, "selected-output"), range: { offset: oracle.offset, length: oracle.length }, }); - const selectedFiles = await verifyFiles(parseEvidence(selected)); - assert.equal(selectedFiles.selection.offset, oracle.offset); - assert.equal(selectedFiles.selection.length, oracle.length); - const depth = await call("extract_firmware", { - path, - output_directory: join(root, "depth-output"), + const ranged = await verifyFiles(parseEvidence(rangedRaw)); + assert.equal(ranged.selection.offset, oracle.offset); + assert.equal(ranged.selection.length, oracle.length); + + const depthRaw = await callTool("extract_firmware", { + path: firmwarePath, + output_directory: join(workDir, "depth-output"), max_depth: 1, }); - const depthResult = firmwareResultSchemas.extract_firmware.parse( - depth.normalized_result, + const depthParsed = firmwareResultSchemas.extract_firmware.parse( + depthRaw.normalized_result, ); - assert.equal(depthResult.coverage, "partial"); - assert.ok(depthResult.depth_limited_paths.length > 0); + assert.equal(depthParsed.coverage, "partial"); + assert.ok(depthParsed.depth_limited_paths.length > 0); + console.log( "PASS real Binwalk/Unblob: CLI, MCP, independent offsets/digests, range extraction, unknown chunks and depth limits", ); - if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") { - const ext4 = join(fixtureRoot, "ext4.bin"); - await access(ext4); - const response = await call("extract_firmware", { - path: ext4, - output_directory: join(root, "ext4-output"), - }); - const result = firmwareResultSchemas.extract_firmware.parse( - response.normalized_result, + + return mcpFiles; +} + +async function stageExt4() { + const ext4Path = join(FIXTURE_ROOT, "ext4.bin"); + await fs.access(ext4Path); + + const raw = await callTool("extract_firmware", { + path: ext4Path, + output_directory: join(workDir, "ext4-output"), + }); + const parsed = firmwareResultSchemas.extract_firmware.parse( + raw.normalized_result, + ); + assert.ok(parsed.chunks.some((c) => c.handler === "extfs")); + assert.equal(parsed.coverage, "complete"); + + for (const [suffix, expected] of Object.entries(oracle.files)) { + const match = parsed.files.find((f) => + f.relative_path.endsWith(`/${suffix}`), ); - assert.ok(result.chunks.some((chunk) => chunk.handler === "extfs")); - assert.equal(result.coverage, "complete"); - for (const [suffix, expected] of Object.entries(oracle.files)) - assert.equal( - result.files.find((file) => file.relative_path.endsWith(`/${suffix}`)) - ?.sha256, - expected.sha256, - ); - console.log("PASS real ext4 extraction with debugfs; no mounts"); - // Preserve the process-ownership utility while withholding all extractors. - const withoutExtractors = join(root, "without-extractors"); - await access("/bin/ps"); - await mkdir(withoutExtractors); - await symlink("/bin/ps", join(withoutExtractors, "ps")); - const missing = await cli( - ["extract-firmware", ext4, join(root, "missing-extractor-output")], - { ...environment, PATH: withoutExtractors }, - ); - const partial = firmwareResultSchemas.extract_firmware.parse( - missing.normalized_result, - ); - assert.equal(partial.coverage, "partial"); - assert.ok(JSON.stringify(partial.diagnostics).includes("debugfs")); - console.log( - "PASS real missing-extractor diagnostics retained despite Unblob exit 1", - ); - } - if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1") { - if (process.env.GHIDRA_INSTALL_DIR === undefined) - throw new Error( - "Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md", - ); - const child = mcpFiles.files.find((file) => - file.relative_path.endsWith("/usr/bin/probe"), - ); - assert.ok(child !== undefined); - await call("open_binary", { path: child.path, provider_id: "ghidra" }); - const dossier = await call("analyze_function", { - procedure: "firmware_probe", - }); - const evidence = parseEvidence(dossier); - assert.equal( - evidence.subject.digest.sha256, - oracle.files["usr/bin/probe"].sha256, - ); - const pseudocode = await call("procedure_pseudo_code", { - procedure: "firmware_probe", - }); - assert.equal(parseEvidence(pseudocode).provider.id, "ghidra"); - assert.ok( - JSON.stringify(evidence.normalized_result).includes("firmware_probe"), - ); - await call("close_binary", {}); - console.log( - "PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest", + assert.equal(match?.sha256, expected.sha256); + } + console.log("PASS real ext4 extraction with debugfs; no mounts"); + + // Keep the process-ownership utility available but hide every extractor. + const emptyBin = join(workDir, "without-extractors"); + await fs.access("/bin/ps"); + await fs.mkdir(emptyBin); + await fs.symlink("/bin/ps", join(emptyBin, "ps")); + + const degraded = await runCli( + ["extract-firmware", ext4Path, join(workDir, "missing-extractor-output")], + { ...baseEnv, PATH: emptyBin }, + ); + const degradedParsed = firmwareResultSchemas.extract_firmware.parse( + degraded.normalized_result, + ); + assert.equal(degradedParsed.coverage, "partial"); + assert.ok(JSON.stringify(degradedParsed.diagnostics).includes("debugfs")); + console.log( + "PASS real missing-extractor diagnostics retained despite Unblob exit 1", + ); +} + +async function stageGhidra(mcpFiles) { + if (process.env.GHIDRA_INSTALL_DIR === undefined) { + throw new Error( + "Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md", ); } + + const probe = mcpFiles.files.find((f) => + f.relative_path.endsWith("/usr/bin/probe"), + ); + assert.ok(probe !== undefined); + + await callTool("open_binary", { path: probe.path, provider_id: "ghidra" }); + + const dossierRaw = await callTool("analyze_function", { + procedure: "firmware_probe", + }); + const dossier = parseEvidence(dossierRaw); assert.equal( - await hashFirmwareFile(path), + dossier.subject.digest.sha256, + oracle.files["usr/bin/probe"].sha256, + ); + + const pseudoRaw = await callTool("procedure_pseudo_code", { + procedure: "firmware_probe", + }); + assert.equal(parseEvidence(pseudoRaw).provider.id, "ghidra"); + assert.ok( + JSON.stringify(dossier.normalized_result).includes("firmware_probe"), + ); + + await callTool("close_binary", {}); + console.log( + "PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest", + ); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +try { + const cli = await stageCli(); + await connectMcp(); + const mcpFiles = await stageMcp(cli); + + if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") await stageExt4(); + if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1") + await stageGhidra(mcpFiles); + + assert.equal( + await hashFirmwareFile(firmwarePath), oracle.sha256, "Original firmware bytes must remain unchanged", ); } finally { - await client.close(); - await rm(root, { recursive: true, force: true }); + await mcpClient.close(); + await fs.rm(workDir, { recursive: true, force: true }); } From fcfa5b7e88e2251359950d3fcca8a1ae61dc6814 Mon Sep 17 00:00:00 2001 From: Jeongwan Jeon Date: Tue, 13 Oct 2026 21:27:38 +0900 Subject: [PATCH 09/92] fix(artifacts): distinguish JVM classes from universal Mach-O files (#2327) * test(artifacts): reproduce JVM class inventory misclassification * fix(artifacts): validate FAT declarations before classifying executables --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../ArtifactGraphConstruction.test.ts | 103 ++++++++++++++++ src/domain/apple/machoContainer.ts | 5 + .../acceptance/artifactClassification.test.ts | 111 ++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 src/artifacts/inventory/ArtifactGraphConstruction.test.ts create mode 100644 tests/acceptance/artifactClassification.test.ts diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts new file mode 100644 index 000000000..925d3543a --- /dev/null +++ b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { CPU, fatImage, machoImage } from "../apple/MachoImage.fixture.js"; +import { classifyArtifactContent } from "./ArtifactGraphConstruction.js"; + +const fat = (wide = false): Buffer => + Buffer.from(fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }], wide)); + +describe("bounded FAT inventory classification", () => { + it("requires the complete table but not slice bytes in the retained prefix", () => { + const bytes = fat(); + for (const length of [4, 7, 8, 27]) { + expect( + classifyArtifactContent( + "native", + bytes.subarray(0, length), + bytes.length, + ), + ).toEqual({ kind: "unknown", format: "unknown" }); + } + expect( + classifyArtifactContent( + "native.class", + bytes.subarray(0, 28), + bytes.length, + ), + ).toEqual({ kind: "executable", format: "mach-o-universal" }); + }); + + it.each([ + ["empty table", (bytes: Buffer) => bytes.writeUInt32BE(0, 4)], + ["slice overlapping table", (bytes: Buffer) => bytes.writeUInt32BE(0, 16)], + ["empty slice", (bytes: Buffer) => bytes.writeUInt32BE(0, 20)], + [ + "slice beyond file", + (bytes: Buffer) => bytes.writeUInt32BE(bytes.length, 20), + ], + ["unaligned slice", (bytes: Buffer) => bytes.writeUInt32BE(4097, 16)], + ["unsupported alignment", (bytes: Buffer) => bytes.writeUInt32BE(53, 24)], + ] as const)("keeps %s unknown", (_label, mutate) => { + const bytes = fat(); + mutate(bytes); + expect(classifyArtifactContent("native", bytes)).toEqual({ + kind: "unknown", + format: "unknown", + }); + }); + + it("does not infer Mach-O from an out-of-bounds architecture count", () => { + const bytes = fat(); + bytes.writeUInt32BE(0xffffffff, 4); + expect(classifyArtifactContent("native", bytes)).toEqual({ + kind: "resource", + format: "file", + }); + }); + + it("does not lose unsafe FAT64 range values to number conversion", () => { + const bytes = fat(true); + bytes.writeBigUInt64BE(BigInt(Number.MAX_SAFE_INTEGER) + 1n, 16); + expect(classifyArtifactContent("native", bytes)).toEqual({ + kind: "unknown", + format: "unknown", + }); + }); + + it("requires valid ranges for every declared slice", () => { + const bytes = Buffer.from( + fatImage([ + { cpu: CPU.arm64, bytes: machoImage({}) }, + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + ]), + ); + bytes.writeUInt32BE(bytes.length, 40); + expect(classifyArtifactContent("native", bytes)).toEqual({ + kind: "unknown", + format: "unknown", + }); + }); + + it("requires observed slice magic for an unknown CPU type", () => { + const cpu = { type: 0x12345678, subtype: 0 }; + const bytes = Buffer.from(fatImage([{ cpu, bytes: machoImage({ cpu }) }])); + expect( + classifyArtifactContent("native", bytes.subarray(0, 28), bytes.length), + ).toEqual({ kind: "unknown", format: "unknown" }); + expect(classifyArtifactContent("native", bytes)).toEqual({ + kind: "executable", + format: "mach-o-universal", + }); + }); + + it.each([NaN, Infinity, -1, 8.5, Number.MAX_SAFE_INTEGER + 1, 27])( + "keeps invalid or contradictory file size %s unknown", + (size) => { + const bytes = fat(); + expect(classifyArtifactContent("native", bytes, size)).toEqual({ + kind: "unknown", + format: "unknown", + }); + }, + ); +}); diff --git a/src/domain/apple/machoContainer.ts b/src/domain/apple/machoContainer.ts index 7b552be64..c23cf7f64 100644 --- a/src/domain/apple/machoContainer.ts +++ b/src/domain/apple/machoContainer.ts @@ -63,6 +63,11 @@ export const readFatContainerHeader = ( const magic = view.getUint32(0, false); if (![FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) return { state: "not-fat" }; + if (!Number.isSafeInteger(fileSize) || fileSize < bytes.byteLength) + return { + state: "malformed", + reason: "FAT file size is invalid or contradicts retained bytes", + }; if (bytes.byteLength < 8) { return fileSize < 8 ? { state: "malformed", reason: "FAT header is truncated" } diff --git a/tests/acceptance/artifactClassification.test.ts b/tests/acceptance/artifactClassification.test.ts new file mode 100644 index 000000000..7436a4967 --- /dev/null +++ b/tests/acceptance/artifactClassification.test.ts @@ -0,0 +1,111 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { + CPU, + fatImage, + machoImage, +} from "../../src/artifacts/apple/MachoImage.fixture.js"; +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest.for(["jar", "zip"])( + "distinguishes JVM classes from universal Mach-O files in %s inventory", + async (extension, { cli }) => { + const root = await createTestTempDirectory("rea-class-inventory-"); + const path = join(root, `mixed.${extension}`); + // The large class can hold the entire 65-entry table suggested by its + // version bytes. Checking only the table length does not distinguish it. + const classes = [ + ["Hello.class", javaClass()], + ["Big.class", javaClass("x".repeat(4_000))], + ["renamed.bin", javaClass()], + ] as const; + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const [name, bytes] of classes) + await writer.add(name, new Uint8ArrayReader(bytes)); + const nativePaths: string[] = []; + for (const wide of [false, true]) { + for (const littleEndian of [false, true]) { + const name = `fat${wide ? 64 : 32}-${littleEndian ? "le" : "be"}`; + nativePaths.push(name); + await writer.add( + name, + new Uint8ArrayReader( + fatImage( + [{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }], + wide, + littleEndian, + ), + ), + ); + } + } + await writeFile(path, await writer.close()); + + const reply = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + }); + expect(reply.exitCode, reply.stdout + reply.stderr).toBe(0); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(reply.json).normalized_result, + ); + const inventory = artifactInventoryResultSchema.parse( + inspection.substeps[0]?.evidence.normalized_result, + ); + for (const name of nativePaths) { + expect(inventory.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: name, + artifact_kind: "executable", + artifact_format: "mach-o-universal", + limitations: [], + }), + ); + } + for (const [name, bytes] of classes) { + const occurrence = inventory.occurrences.find( + (item) => item.logical_path === name, + ); + expect(occurrence).toBeDefined(); + expect.soft(occurrence?.artifact_kind, name).not.toBe("executable"); + expect.soft(occurrence?.artifact_format, name).not.toMatch(/^mach-o/u); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const node = inventory.nodes.find((item) => item.sha256 === sha256); + expect(node).toBeDefined(); + expect.soft(node?.format, name).not.toMatch(/^mach-o/u); + } + }, +); + +/** Minimal valid Java 21 class, with an optional unused UTF-8 constant. */ +const javaClass = (extra?: string): Buffer => { + const utf8 = (value: string): Buffer => { + const text = Buffer.from(value); + const header = Buffer.alloc(3); + header[0] = 1; + header.writeUInt16BE(text.length, 1); + return Buffer.concat([header, text]); + }; + const constants = [ + Buffer.from([7, 0, 2]), + utf8("Hello"), + Buffer.from([7, 0, 4]), + utf8("java/lang/Object"), + ...(extra === undefined ? [] : [utf8(extra)]), + ]; + const header = Buffer.from("cafebabe000000410000", "hex"); + header.writeUInt16BE(constants.length + 1, 8); + return Buffer.concat([ + header, + ...constants, + Buffer.from("0021000100030000000000000000", "hex"), + ]); +}; From d85374496965a8a997e1d13e98a27ff5e5ca1077 Mon Sep 17 00:00:00 2001 From: Jeongwan Jeon Date: Tue, 13 Oct 2026 21:28:31 +0900 Subject: [PATCH 10/92] fix: preserve process identities in pwntools cleanup errors (#2284) Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/native/pwntools/PwntoolsDecoder.ts | 23 ++++++++-------- src/process/OwnedCommand.ts | 19 +++++++++----- tests/boundary/process/ownedCommand.test.ts | 8 +++--- .../pwntoolsStageRetirement.test.ts | 26 ++++++++++++------- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 169f25601..6aa456631 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -32,7 +32,10 @@ import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorClean import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { err, ok, type Result } from "../../domain/result.js"; -import { runOwnedCommand } from "../../process/OwnedCommand.js"; +import { + ownedCommandResources, + runOwnedCommand, +} from "../../process/OwnedCommand.js"; import type { ProviderProcessSupervisor, OwnedProviderProcessSpawnOptions, @@ -562,20 +565,16 @@ export class PwntoolsDecoder< const pending = this.#pendingCleanup; if (pending?.process === undefined) return undefined; const stopped = await pending.process.stop(); + const ownership = pending.process.launch.ownership; if (stopped.status === "incomplete") return new ProviderCleanupError( this.identity.id, - [ - pending.process.launch.ownership?.runId ?? "owned-decoder", - ...(pending.root === undefined ? [] : [pending.root.path]), - // The retained group may still be live; name it as OwnedCommand does. - ...(pending.process.launch.ownership === undefined - ? [] - : [ - `pid:${pending.process.launch.ownership.leaderPid}`, - `process-group:${pending.process.launch.ownership.processGroupId}`, - ]), - ], + ownership === undefined + ? [ + "owned-decoder", + ...(pending.root === undefined ? [] : [pending.root.path]), + ] + : ownedCommandResources(ownership, pending.root?.path), { reason: stopped.reason, captured_output: { diff --git a/src/process/OwnedCommand.ts b/src/process/OwnedCommand.ts index 02fb4defc..d375971ae 100644 --- a/src/process/OwnedCommand.ts +++ b/src/process/OwnedCommand.ts @@ -9,6 +9,7 @@ import { } from "./ProviderProcess.js"; import { cleanupOwnedProcessGroup, + type OwnedProcessGroup, type ProcessCleanupFailure, } from "./ProcessOwnership.js"; import { @@ -34,6 +35,17 @@ export class OwnedCommandFailure extends Error { } } +/** Report recorded launch identities; these labels do not authorize signaling. */ +export const ownedCommandResources = ( + ownership: OwnedProcessGroup, + directory?: string, +): readonly string[] => [ + ownership.runId, + ...(directory === undefined ? [] : [directory]), + `pid:${ownership.leaderPid}`, + `process-group:${ownership.processGroupId}`, +]; + /** Execute a single owned process with independent cleanup and bounded diagnostic retention. */ export const runOwnedCommand = async ( spawn: OwnedProviderProcessSpawnOptions, @@ -208,12 +220,7 @@ const collectOwnedCommand = async ( ? {} : { cleanupResult: stopped.cleanupResult }), }, - [ - spawn.runId, - ...(spawn.cwd === undefined ? [] : [spawn.cwd]), - `pid:${launched.ownership.leaderPid}`, - `process-group:${launched.ownership.processGroupId}`, - ], + ownedCommandResources(launched.ownership, spawn.cwd), supervisor, ); if (failure !== undefined) diff --git a/tests/boundary/process/ownedCommand.test.ts b/tests/boundary/process/ownedCommand.test.ts index 34205af3d..ca0cb2db6 100644 --- a/tests/boundary/process/ownedCommand.test.ts +++ b/tests/boundary/process/ownedCommand.test.ts @@ -257,9 +257,11 @@ it.skipIf(process.platform === "win32").each(["cancelled", "timeout"] as const)( failures: [expect.objectContaining({ reason: "run-token-mismatch" })], }); expect(failure.cleanupResult).toEqual(cleanup); - expect(failure.resources).toEqual( - expect.arrayContaining([request.runId]), - ); + expect(failure.resources).toEqual([ + request.runId, + `pid:${launched.ownership.leaderPid}`, + `process-group:${launched.ownership.processGroupId}`, + ]); // An unresolved member does not grant authority to signal the shared group. const acquiredPid = launched.ownership.leaderPid; expect(() => process.kill(acquiredPid, 0)).not.toThrow(); diff --git a/tests/process-global/pwntoolsStageRetirement.test.ts b/tests/process-global/pwntoolsStageRetirement.test.ts index 6ec062535..7cdf0b48a 100644 --- a/tests/process-global/pwntoolsStageRetirement.test.ts +++ b/tests/process-global/pwntoolsStageRetirement.test.ts @@ -18,7 +18,10 @@ import { PWNTOOLS_MEMORY_FAILURE_EXIT, PWNTOOLS_PROVIDER_IDENTITY, } from "../../src/native/pwntools/PwntoolsRelease.js"; -import { cleanupOwnedProcessGroup } from "../../src/process/ProcessOwnership.js"; +import { + cleanupOwnedProcessGroup, + type OwnedProcessGroup, +} from "../../src/process/ProcessOwnership.js"; import { prepareProcessOwnershipInspection } from "../../src/process/ProcessOwnershipObservation.js"; import { spawnOwnedProviderProcess } from "../../src/process/ProviderProcess.js"; import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js"; @@ -232,12 +235,13 @@ it.runIf(supportedHost).each([ const { path, root, environment, basic } = await fixture(); let launches = 0; let cleanupAllowed = false; + let retainedOwnership: OwnedProcessGroup | undefined; const read = vi.spyOn(artifacts, "readStableArtifact"); const provider = new PwntoolsRecordedCrashProvider( environment, async (spawn) => { if (stage === "debugger" && ++launches === 1) return basic(spawn); - return changingWorker( + const launched = await changingWorker( spawn, join(spawn.cwd ?? "", "resource.failure"), { @@ -247,6 +251,8 @@ it.runIf(supportedHost).each([ }, () => cleanupAllowed, ); + retainedOwnership = launched.ownership; + return launched; }, async () => root, ); @@ -261,14 +267,14 @@ it.runIf(supportedHost).each([ if (result.ok) throw new Error("Expected unresolved process ownership"); const projected = projectAnalysisError(result.error); expect(projected.code).toBe("cleanup_incomplete"); - expect(projected).toMatchObject({ - details: { - resources: expect.arrayContaining([ - expect.stringMatching(/^pid:\d+$/u), - expect.stringMatching(/^process-group:\d+$/u), - ]), - }, - }); + if (retainedOwnership === undefined) + throw new Error("Expected retained process ownership"); + expect(projected.details?.resources).toEqual([ + retainedOwnership.runId, + root.path, + `pid:${retainedOwnership.leaderPid}`, + `process-group:${retainedOwnership.processGroupId}`, + ]); if (stage === "debugger") expect(projected).toMatchObject({ details: { From 2f3dee3fcf65813d6daf7c3209d921451c160ddb Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:37:48 +0800 Subject: [PATCH 11/92] test(javascript): verify Windows path case and load scope (#2635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select the Windows curated lane for edits to the shared runtime path matcher and its workflow suite. Strengthen the existing POSIX/Windows mapping controls with matching basis, coherent target/script coordinates, caller spelling and static load-state assertions. Follow-up to #2422; thanks to @Aurantium-47 for identifying the recorded-path grammar issue. Verified with actual Windows 11 x64, official Node 24.18.0 and production CLI/MCP workflows using published npm 11.16.0 module Evidence. Four exact/mismatched implicit/explicit mapping cases pass with unchanged normalized hashes. The capture provider was Node Inspector; no IDA, Ghidra or Electron GUI verification is claimed. 230 focused tests, all eight CI static tasks, architecture guards and documentation checks pass after rebasing onto main fb28ec153. The unrelated release-checkpoint and dyld fixture corrections were integrated from upstream; no patches to those files are included here. Final Windows MCP replay at e6e86946d preserves all four normalized hashes, with no owned Node processes remaining. Resource probes ran serially at BelowNormal priority with one CPU and 512 MiB Node heaps; calls took 251–620 ms and the sampled process-family working-set sum peaked at 499,380,224 bytes. Source pins, provenance and measurement limits are recorded in experiments/analysis-efficiency/windows-posix-reconciliation.md. All 34 jobs in current-head CI run 38391921571 passed, including the Windows capability/package lane, all six portable test shards, JavaScript recovery and the required aggregate. Current-head Codex and Greptile reviews are clean; both prior scope-selection findings were fixed and resolved. --- .github/workflows/ci.yml | 1 + .../windows-posix-reconciliation.md | 128 +++++++++++++++ scripts/ci/scopes.mjs | 2 + .../boundary/filesystem/ciChangeScope.test.ts | 4 + .../javascriptApplicationWorkflows.test.ts | 154 ++++++++++-------- 5 files changed, 224 insertions(+), 65 deletions(-) create mode 100644 experiments/analysis-efficiency/windows-posix-reconciliation.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0516da85..d2cc59493 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -597,6 +597,7 @@ jobs: tests/boundary/filesystem/setupUnavailableProvider.test.ts tests/boundary/filesystem/windowsNpxRegistration.test.ts tests/acceptance/setup/homeDirectoryFallback.test.ts + tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts tests/boundary/filesystem/webScriptManifestPaths.test.ts tests/boundary/process/ProcessEvidence.host.test.ts tests/acceptance/webModuleCli.test.ts diff --git a/experiments/analysis-efficiency/windows-posix-reconciliation.md b/experiments/analysis-efficiency/windows-posix-reconciliation.md new file mode 100644 index 000000000..0df06ac3e --- /dev/null +++ b/experiments/analysis-efficiency/windows-posix-reconciliation.md @@ -0,0 +1,128 @@ +# Windows reconciliation of recorded POSIX paths + +Follow-up to [#2422](https://github.com/morluto/rea/pull/2422), including +@Aurantium-47's diagnosis of the host-dependent relative-path operation. + +## Source pins and real data + +The initial reproduction and passive producer used main +fa4b8288655188426222773ead2ccbdaf76334fd. Before branching, main was updated +to 8b181716aca2cb467be499cba226abfb5ae19141. The owning matcher and its +affected reconciliation consumers were unchanged between those revisions. +The newer baseline again reproduced both errors on the real Windows host. + +Input: npm 11.16.0's published lib/utils/validate-lockfile.js, 1,023 bytes, +SHA-256 2295e2dac1c5816e1eae2d417804960ebbef17b64a0a44db4f3483ce6e6d9526. +The installed copy was independently checked against the exact module in the +[official registry package](https://registry.npmjs.org/npm/11.16.0). +The 2,945,850-byte tarball matched its published SHA-512 SRI before reading +that member; no archive extraction or installation occurred, and the tarball +was not retained. + +Two actual Linux filesystem directories, Runtime/App and Runtime/app, +held byte-identical module copies and had distinct directory identities. +A controlled Node 24.18.0 process loaded the public module and ran its real +function against 587 locked dependency records: compatible trees returned zero +errors, and one controlled version mismatch returned one error. A passive REA +Inspector capture recorded 114 loaded scripts, including the selected module. +The provider did not evaluate, pause, resume or read source. The owned process +exited normally. Function execution is established by the harness receipt; +REA's passive script metadata establishes loaded status. + +Production static analysis inspected both directories plus an independent +Static/public-module copy. The latter prevents implicit root matching from +hiding the explicit mapping result. Complete static/runtime Evidence was then +consumed by the actual **Windows** CLI and SDK 2.3.1 stdio MCP server, using +Windows 11 10.0.22631.0 and official Windows x64 Node 24.18.0. +No Electron GUI, IDA or Ghidra engine was used. + +## Correctness and provenance + +| Windows reconciliation | Baseline | Candidate | +| -------------------------------------- | ------------------------------ | ----------------------------------- | +| Exact POSIX input root | Matched, artifact path | Same complete normalized result | +| Case-mismatched POSIX input root | Incorrectly matched and loaded | Unknown; static load states unknown | +| Exact explicit POSIX mapping | Matched, operator file mapping | Same complete normalized result | +| Case-mismatched explicit POSIX mapping | Incorrectly matched and loaded | Unknown; static load states unknown | + +All four CLI and same-connection MCP normalized results agree. The input +Evidence, recorded runtime facts and caller-selected root spellings are +preserved. Rejected location membership does not establish that a module was +absent or unexecuted. The operation remains offline and performs no target or +filesystem inspection. + +The shared matcher now chooses posix.relative after identifying POSIX input; +Windows coordinates continue to use win32.relative. Both location matching +and active-layer scope use this owner. The obsolete host-default import is +removed; no downstream repair or new path abstraction is introduced. + +The related suite passed 102 tests: 15 workflow tests, 85 CI scope tests and +two curated-path checks. Four simulated Electron Evidence controls cover +POSIX mismatch/exact matching, Windows case-insensitive matching, the resulting +asset load state and retained mapping spelling. They are service regressions, +not real Electron-provider verification. The existing workflow test file is +registered in both the Windows execution lane and its package-change scope. + +## Delivery and resource observations + +Probes ran serially at BelowNormal priority with one CPU and 512 MiB Node heap +settings for CLI/server/client work. Heap settings do not bound aggregate memory. +Four corrected CLI exit-time observations reported 7.343–15.827 seconds CPU and +322,308–324,280 KiB peak working set, excluding the probe parent. Wall times +ranged from 7.785 to 17.601 seconds. There is no paired resource baseline. + +One complete MCP trial connected in 18,296 ms and discovered 189 tools in +875 ms (2,588,336 serialized bytes). The four subsequent same-connection calls +took 879, 320, 353 and 422 ms. Their serialized replies were 918,133 / 913,765 / +918,571 / 914,171 bytes; inputs totalled 681,312 bytes. Discovery plus these +inputs and replies totalled 6,934,288 bytes, excluding initialization, framing +and notifications. This is delivery volume, not model token usage. Actual +model context and total model task cost were not measured. + +A process-generation sampler recorded 233 observations over 25,671 ms: +15,797 ms aggregate observed CPU as a lower bound and a peak sampled working-set +sum of 491,597,824 bytes (about 469 MiB). Exited-process tails can be missed; +shared pages can be counted twice. This excludes the observer and is not an +exact physical-memory peak. The server wrote four structured completion records +to stderr; tool replies and normalized results passed the recorded checks. + +Cold/warm caches and observer differences were not normalized. No latency, +CPU, memory or model-token improvement is claimed. The fix adds no lookup I/O, +engine invocation, retry or cache. Complete default replies remain available; +only compact selected findings and hashes were sent to the investigating agent. + +## Main integration replay + +Runtime cb0d8a9fef2b907cff2ba27f1e017493e1173eb2, rebased onto main +73dd8d6ed, again passed the 101 related tests and all four real Windows CLI/MCP +cases using the unchanged older producer Evidence. Positive results and original +runtime/layer facts remain identical. Typecheck, lint, dependency and metadata +checks passed; all five changed files passed formatting independently. +The full format gate at that main revision failed on the unchanged upstream +GhidraDoctor.test.ts and GhidraSnapshotCache.test.ts files. The PR does not +repair those unrelated baseline files. Resource figures above refer to the +earlier candidate; the integration replay does not establish a speedup. + +Review found that owning only the Windows test path would still skip that lane +on a later implementation-only edit. The matcher implementation is now also +registered in package scope. A source-only classifier case verifies that it +selects both Windows package and JavaScript recovery lanes; 85 scope checks and +two curated-path checks pass. Runtime code and the real-data checks are unchanged. + +The original production fix merged as +[1a1b67c88](https://github.com/morluto/rea/commit/1a1b67c88) while this follow-up +was open. The follow-up was then rebased onto main fb28ec153 and contains only +the stronger workflow assertions, Windows lane ownership, scope classification +and this verification record. The updated 131 related tests, two representative +Ghidra boundary tests, seven IDA input-thinning tests, 24 semantic-structure +tests, 16 required-lane checks, 39 release-checkpoint tests and 11 dyld CLI +tests passed after the rebase (230 total). +Upstream a0317cf73 corrected the invalid FAT fixture and consolidated unrelated +duplicated helpers; e05d3399f aligned the older parameter-provenance assertion +with the upstream semantics. Those changes, the threshold change and the new +CI qualification tiers are part of main, outside this follow-up's diff. +Upstream 747e72991 restored release preflight coverage for reusable workflow +jobs. The independently reviewed, fully green #2755 fixture correction merged +as fb28ec153 and restored real x64 dyld CLI admission without altering the +original same-named arm64e assertions. +No production source is duplicated by the follow-up. diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index a8679726c..ffe15cb3d 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -56,6 +56,8 @@ export const SCOPES = { "tests/boundary/filesystem/setupUnavailableProvider.test.ts", "tests/boundary/filesystem/windowsNpxRegistration.test.ts", "tests/acceptance/setup/homeDirectoryFallback.test.ts", + "tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts", + "src/domain/javascript/javascriptRuntimeStaticCandidates.ts", "tests/boundary/filesystem/webScriptManifestPaths.test.ts", "tests/boundary/process/ProcessEvidence.host.test.ts", "tests/acceptance/webModuleCli.test.ts", diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 163219c39..321160a36 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -515,6 +515,10 @@ it.each([ "src/windows/Example.ts", { code: true, runtime: true, package: true, apple: false, evm: false }, ], + [ + "src/domain/javascript/javascriptRuntimeStaticCandidates.ts", + { code: true, runtime: true, package: true, javascript_recovery: true }, + ], ["install.sh", { code: true, runtime: true, package: true, evm: false }], [ ".agents/skills/reverse-engineer-anything/SKILL.md", diff --git a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts index b79407821..8393bad54 100644 --- a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts @@ -444,73 +444,97 @@ describe("JavaScript application workflow error diagnostics", () => { }); }); +const RUNTIME_PATH_CASES = [ + { + name: "case-sensitive POSIX input root", + root: null, + path: "/applications/Example.app/Contents/Resources/app/renderer.js", + status: "unknown", + basis: "none", + }, + { + name: "case-sensitive POSIX mapping root", + root: "/runtime/App", + path: "/runtime/app/renderer.js", + status: "unknown", + basis: "none", + }, + { + name: "matching POSIX mapping root", + root: "/runtime/App", + path: "/runtime/App/renderer.js", + status: "matched", + basis: "operator-file-mapping", + }, + { + name: "case-insensitive Windows mapping root", + root: String.raw`C:\runtime\App`, + path: String.raw`c:\runtime\app\renderer.js`, + status: "matched", + basis: "operator-file-mapping", + }, +] as const; + describe("JavaScript runtime reconciliation local paths", () => { - it.each([ - { - name: "case-sensitive POSIX input root", - root: null, - path: "/applications/Example.app/Contents/Resources/app/renderer.js", - status: "unknown", - }, - { - name: "case-sensitive POSIX mapping root", - root: "/runtime/App", - path: "/runtime/app/renderer.js", - status: "unknown", - }, - { - name: "matching POSIX mapping root", - root: "/runtime/App", - path: "/runtime/App/renderer.js", - status: "matched", - }, - { - name: "case-insensitive Windows mapping root", - root: String.raw`C:\runtime\App`, - path: String.raw`c:\runtime\app\renderer.js`, - status: "matched", - }, - ])("uses $name on every host", ({ root, path, status }) => { - const base = JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE; - const original = base.runtime_observations[0]!; - const inspection = electronPageInspectionSchema.parse( - original.normalized_result, - ); - const runtime = createEvidence(undefined, original.provider, { - predicateType: original.predicate_type, - operation: original.operation, - parameters: { ...original.parameters, include_script_sources: false }, - result: { - ...inspection, - scripts: { - ...inspection.scripts, - items: inspection.scripts.items.map((script) => ({ - ...script, - file_path: path, - source: { included: false, reason: "not-requested" }, - })), + it.each(RUNTIME_PATH_CASES)( + "uses $name on every host", + ({ root, path, status, basis }) => { + const base = JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE; + const original = base.runtime_observations[0]!; + const inspection = electronPageInspectionSchema.parse( + original.normalized_result, + ); + // Keep target and script coordinates consistent so an unrelated target + // cannot activate the static layer after its script mapping was rejected. + const runtime = createEvidence(undefined, original.provider, { + predicateType: original.predicate_type, + operation: original.operation, + parameters: { ...original.parameters, include_script_sources: false }, + result: { + ...inspection, + target: { ...inspection.target, file_path: path }, + scripts: { + ...inspection.scripts, + items: inspection.scripts.items.map((script) => ({ + ...script, + file_path: path, + source: { included: false, reason: "not-requested" }, + })), + }, }, - }, - confidence: original.confidence, - authority: original.authority, - }); - const reconciled = reconcileJavaScriptRuntimeEvidence({ - static_layers: base.static_layers.map((layer) => ({ - ...layer, - runtime_mappings: root === null ? [] : [{ kind: "file-root", root }], - })), - runtime_observations: [runtime], - }); - if (!reconciled.ok) throw reconciled.error; - const result = javascriptRuntimeReconciliationResultSchema.parse( - reconciled.value.normalized_result, - ); - expect( - result.reconciliations.find( - ({ entity_kind }) => entity_kind === "script", - ), - ).toMatchObject({ status }); - }); + confidence: original.confidence, + authority: original.authority, + }); + const reconciled = reconcileJavaScriptRuntimeEvidence({ + static_layers: base.static_layers.map((layer) => ({ + ...layer, + runtime_mappings: root === null ? [] : [{ kind: "file-root", root }], + })), + runtime_observations: [runtime], + }); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.normalized_result, + ); + expect( + result.reconciliations.find( + ({ entity_kind }) => entity_kind === "script", + ), + ).toMatchObject({ status, basis }); + expect( + result.static_load_states.find( + ({ kind }) => kind === "javascript-asset", + ), + ).toMatchObject( + status === "matched" + ? { status: "loaded", reason: "runtime-script-correspondence" } + : { status: "unknown", reason: "layer-outside-runtime-scope" }, + ); + expect(result.static_layers[0]?.runtime_mappings).toEqual( + root === null ? [] : [{ kind: "file-root", root, artifact_prefix: "" }], + ); + }, + ); it("resolves relative file mapping roots before deriving result Evidence", () => { const absoluteRoot = resolve(process.cwd(), "runtime-cache-fixture"); From 8823c55b0ce325cf9edb7d2f9aadaf39db4e8f31 Mon Sep 17 00:00:00 2001 From: Omisakin Joshua <117924167+thefolahan@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:41:10 +0100 Subject: [PATCH 12/92] feat(ghidra): decode Swift String literals in function dossiers (#2049) * feat(ghidra): decode Swift String literals in function dossiers Swift passes a String as two words. Small literals keep their UTF-8 bytes inside those words, and immortal large literals keep a count plus a pointer to their bytes biased by 32. Ghidra creates no data reference for either, so analyze_function reported no strings for Swift code. On AArch64 programs that contain Swift metadata, the Ghidra bridge now runs constant propagation over the function and decodes register pairs where Swift consumes a String: consecutive argument registers at calls and tail calls, x0 and x1 at returns, and both registers of one STP. Large literals must resolve to terminated bytes inside one initialized block that is not executable. A pair whose registers both come from conditional selects is skipped, because Ghidra forks each select without correlating the condition and could otherwise pair words from different cases. Decoded values are a derivation from the ABI layout rather than observed references, so they appear in a new optional swift_string_literals field with their source instruction, registers and raw words, and in a matching inspect_analysis_view facet. referenced_strings is unchanged. Refs #2030 * fix(ghidra): bound Swift literal reads and compare decoded strings Large immortal literals are measured against the remaining heap before allocation, and a failed fit stays visible as a limitation. The canonical empty string is kept. Small and large literals now require matching address nullability, and function comparison treats decoded literals as string evidence without equating a missing record to an empty one. * fix(ghidra): budget Swift literal decoding and tighten use sites Large literals now draw on one heap budget per dossier, measured once before decoding, instead of a separate check per literal. A literal that does not fit is listed in a new swift_string_literal_omissions field with its address, byte length and reason, so consumers read omissions from structured data rather than from limitation text. strings_names comparison checks referenced strings and names first and reports a change there even when Swift literal coverage differs; it returns unknown only when those facts match and literal coverage is absent on one side or partial. A computed jump counts as a tail call only when it has no resolved target inside the function, so jump tables no longer qualify, and a return requires a RETURN p-code operation, so traps no longer qualify. Decoded literals keep control characters, which Swift strings may contain and JSON escapes. The Swift verifier builds the literal fixture for arm64 on any Mac host and now also covers the empty string and a literal containing a tab. * fix(ghidra): charge escaped JSON and follow copied selects The Swift literal budget now charges the worst case per UTF-8 byte: UTF-16 text, six escaped JSON characters held in Gson's growing buffer and in the returned String, and the UTF-8 bytes written to the socket. Literals made of control characters can no longer exceed the reserved heap. A jump counts as a tail call only when every resolved target lies outside the function. The conditional select check follows register copies back to their definition, so selects copied into argument registers are recognized like selects written there directly. strings_names reports a change when both dossiers decoded different literals at the same use site, even when either side has omissions; it returns unknown only when no such use site disagrees. * fix(ghidra): bound literal records and trace selects across branches Every Swift literal record now pays a fixed cost against the dossier budget in addition to its text, so many empty or small literals are bounded as well. The first record that does not fit is kept as an omission and decoding stops there, which keeps the omission record itself bounded; omissions now describe small or large literals. The conditional select check searches every predecessor path for the reaching definitions, following register copies, so selects made before a branch into the use block are recognized. A dossier records Swift literals and their omissions together. Coverage reads omissions first, and strings_names compares every use site that neither side omitted: a decoded literal is known where it was recorded, and an absence is known only in a dossier whose decoding examined every use site. * fix(ghidra): stop every flow at the literal budget and route omissions Ending one flow from the evaluator leaves other queued flows running, so the budget now records a shared stop that every flow checks before it examines another instruction. A record that exceeds the budget is routed by that capacity result rather than by whether text was decoded, so a small literal over budget becomes an omission instead of a literal. When coverage is partial, the per site comparison keeps each literal's representation as well as its value, matching the full strings_names projection. * fix(ghidra): keep unresolved Swift tail-call sites unknown --------- Co-authored-by: Cursor Agent Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- bridge/ghidra/ReaGhidraBridge.java | 380 ++++++++++++++++++ scripts/verify-real-ghidra-swift.mjs | 68 ++++ src/domain/analysisView/analysisView.test.ts | 20 +- src/domain/analysisView/analysisView.ts | 2 + src/domain/analysisView/nativeFunctionView.ts | 5 +- src/domain/functionComparison.test.ts | 230 +++++++++++ src/domain/functionComparisonDimensions.ts | 42 +- src/domain/functionComparisonNormalization.ts | 79 ++++ src/domain/hopperValues.ts | 76 ++++ src/ghidra/GhidraFunctionValues.test.ts | 98 +++++ src/ghidra/GhidraFunctionValues.ts | 17 + tests/conformance/swift/string-literals.swift | 23 ++ 12 files changed, 1035 insertions(+), 5 deletions(-) create mode 100644 tests/conformance/swift/string-literals.swift diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 02fc0f148..70132eeae 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -13,6 +13,8 @@ import java.net.UnixDomainSocketAddress; import java.nio.channels.Channels; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; @@ -22,7 +24,9 @@ import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.PosixFilePermissions; import java.security.MessageDigest; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.HashSet; import java.util.IdentityHashMap; @@ -82,6 +86,7 @@ import ghidra.program.model.address.SegmentedAddress; import ghidra.program.model.reloc.Relocation; import java.io.ByteArrayOutputStream; import ghidra.program.model.symbol.Namespace; +import ghidra.program.model.symbol.FlowType; import ghidra.program.model.symbol.RefType; import ghidra.program.model.symbol.Reference; import ghidra.program.model.symbol.ReferenceIterator; @@ -100,6 +105,9 @@ import ghidra.program.model.pcode.PcodeBlock; import ghidra.program.model.pcode.PcodeBlockBasic; import ghidra.program.model.pcode.Varnode; import ghidra.program.model.pcode.VarnodeAST; +import ghidra.program.util.ContextEvaluatorAdapter; +import ghidra.program.util.SymbolicPropogator; +import ghidra.program.util.VarnodeContext; public final class ReaGhidraBridge extends HeadlessScript { private static final int MAX_HIGH_PCODE_OPS = 3000; @@ -1319,6 +1327,13 @@ public final class ReaGhidraBridge extends HeadlessScript { result.add("outgoing_references", outgoing); result.add("referenced_strings", referencedStrings); result.add("referenced_names", referencedNames); + boolean swift = swiftProgram(); + boolean aarch64 = currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:"); + SwiftStringLiteralDecode swiftLiterals = swift && aarch64 ? swiftStringLiterals(function) : null; + if (swiftLiterals != null) { + result.add("swift_string_literals", swiftLiterals.literals()); + result.add("swift_string_literal_omissions", swiftLiterals.omissions()); + } result.add("basic_blocks", blocks); result.add( "native_api", @@ -1338,6 +1353,20 @@ public final class ReaGhidraBridge extends HeadlessScript { limitations.add( "Synthetic Ghidra entry-point references without actionable memory sources are omitted." ); + if (swift && aarch64) { + limitations.add( + "Swift String literals are decoded from constant register pairs at calls, returns and paired stores; literals stored one word at a time, chosen by conditional selects, passed through unresolved jumps, or built at runtime are not reported." + ); + if (swiftLiterals.omissions().size() > 0) { + limitations.add( + "Swift String literal records listed in swift_string_literal_omissions could not be retained or decoded within this dossier's remaining heap budget; later symbolic flows remain unexamined." + ); + } + } else if (swift) { + limitations.add( + "Swift String literal decoding is implemented for AArch64 register pairs only; no Swift literals were decoded for this architecture." + ); + } List boundaryLimitations = noReturnCallLimitations(scan.instructions); for (String limitation : boundaryLimitations) { limitations.add(limitation); @@ -3391,6 +3420,349 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private boolean swiftProgram() { + for (MemoryBlock block : currentProgram.getMemory().getBlocks()) { + String name = block.getName(); + if (name.startsWith("__swift5_") || name.startsWith("swift5_")) return true; + } + return false; + } + + /** + * Swift passes a String as two words: small literals carry their bytes in the words and + * immortal large literals carry their count and a biased pointer to the bytes. Ghidra keeps + * neither as a data reference, so decode constant pairs where Swift consumes them: argument + * and return registers at calls, tail calls and returns, and both registers of one STP. + * The canonical empty string is a small literal with a zero count. + */ + private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception { + Register[] registers = new Register[31]; + for (int index = 0; index < registers.length; index++) { + registers[index] = currentProgram.getRegister("x" + index); + } + TreeMap observed = new TreeMap<>(); + TreeMap omitted = new TreeMap<>(); + SwiftLiteralBudget budget = new SwiftLiteralBudget(); + Map selectedValues = new HashMap<>(); + SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false); + propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() { + @Override + public boolean evaluateContextBefore(VarnodeContext context, Instruction instruction) { + // Returning true ends only the current flow, so every queued flow checks the shared stop. + if (budget.stopped()) return true; + for (Register[] pair : swiftStringPairs(function, instruction, registers)) { + Long word = registerConstant(context, pair[0]); + Long object = registerConstant(context, pair[1]); + if (word == null || object == null) continue; + SwiftLiteralLayout layout = swiftLiteralLayout(word, object); + if (layout == null) continue; + // A selected word can combine with a path-dependent ordinary move. Cache the + // provenance question per site/register, charging retained cache state to the + // same dossier budget before allocating it. + boolean selected = false; + boolean provenanceOverCapacity = false; + for (Register register : pair) { + String provenanceKey = canonicalAddress(instruction.getAddress()) + "\u0000" + + register.getBaseRegister().getName(); + Boolean value = selectedValues.get(provenanceKey); + if (value == null) { + if (!budget.takeState()) { + provenanceOverCapacity = true; + break; + } + value = selectedValue(function, instruction, register); + selectedValues.put(provenanceKey, value); + } + if (value) { + selected = true; + break; + } + } + if (selected) continue; + // Forks can reach one site with different constants, so the words are part of the key. + String key = canonicalAddress(instruction.getAddress()) + "\u0000" + pair[0].getName() + + "\u0000" + pair[1].getName() + "\u0000" + Long.toUnsignedString(word, 16) + + "\u0000" + Long.toUnsignedString(object, 16); + if (observed.containsKey(key) || omitted.containsKey(key)) continue; + // Every record draws on the budget; the first one that does not fit is kept as an + // omission and decoding stops, so later sites are never examined. + String value = layout.smallValue(); + boolean overCapacity = provenanceOverCapacity || !budget.take(layout.byteLength()); + if (!overCapacity && value == null) { + try { + value = swiftLargeLiteralText(layout); + } catch (OutOfMemoryError exhausted) { + overCapacity = true; + } + } + if (value == null && !overCapacity) continue; + JsonObject item = overCapacity + ? swiftStringOmission(layout, word, object) + : swiftStringItem(value, layout, word, object); + item.addProperty("source_address", canonicalAddress(instruction.getAddress())); + JsonArray pairRegisters = new JsonArray(); + pairRegisters.add(pair[0].getName()); + pairRegisters.add(pair[1].getName()); + item.add("registers", pairRegisters); + (overCapacity ? omitted : observed).put(key, item); + if (overCapacity) { + budget.stop(); + return true; + } + } + return false; + } + }, false, monitor); + JsonArray literals = new JsonArray(); + observed.values().forEach(literals::add); + JsonArray omissions = new JsonArray(); + omitted.values().forEach(omissions::add); + return new SwiftStringLiteralDecode(literals, omissions); + } + + private static List swiftStringPairs( + Function function, + Instruction instruction, + Register[] registers + ) { + List pairs = new ArrayList<>(); + if (instruction.getMnemonicString().equals("stp")) { + Register first = instruction.getRegister(0); + Register second = instruction.getRegister(1); + if (first != null && second != null && first.getBitLength() == 64 && second.getBitLength() == 64) { + pairs.add(new Register[] {first, second}); + } + return pairs; + } + FlowType flow = instruction.getFlowType(); + boolean returns = false; + for (PcodeOp operation : instruction.getPcode()) { + if (operation.getOpcode() == PcodeOp.RETURN) returns = true; + } + // A jump is a proven tail call only when it has resolved targets and all leave the + // body. A targetless computed jump can still dispatch inside the function. + Address[] targets = instruction.getFlows(); + boolean tailCall = flow.isJump() && !flow.isConditional() && targets.length > 0 && + Arrays.stream(targets).allMatch(target -> !function.getBody().contains(target)); + if (!flow.isCall() && !returns && !tailCall) return pairs; + // A String occupies two consecutive registers among x0-x7, or x0 and x1 when returned. + int lastFirstRegister = returns ? 0 : 6; + for (int index = 0; index <= lastFirstRegister; index++) { + pairs.add(new Register[] {registers[index], registers[index + 1]}); + } + return pairs; + } + + private static Long registerConstant(VarnodeContext context, Register register) { + Varnode value = context.getRegisterVarnodeValue(register); + return value != null && context.isConstant(value) ? value.getOffset() : null; + } + + // Search every predecessor path for the reaching definitions, following register copies, + // since a select copied into an argument register or defined before a branch forks just as + // independently as one written there directly. + private boolean selectedValue(Function function, Instruction use, Register register) { + ArrayDeque instructions = new ArrayDeque<>(); + ArrayDeque targets = new ArrayDeque<>(); + Set visited = new HashSet<>(); + for (Instruction predecessor : predecessors(function, use)) { + instructions.push(predecessor); + targets.push(register.getBaseRegister()); + } + while (!instructions.isEmpty()) { + Instruction instruction = instructions.pop(); + Register target = targets.pop(); + if (!visited.add(canonicalAddress(instruction.getAddress()) + "\u0000" + target.getName())) continue; + boolean defines = false; + for (Object result : instruction.getResultObjects()) { + if (result instanceof Register written && written.getBaseRegister().equals(target)) defines = true; + } + Register next = target; + if (defines) { + PcodeOp[] operations = instruction.getPcode(); + for (PcodeOp operation : operations) { + if (operation.getOpcode() == PcodeOp.CBRANCH && operation.getInput(0).isConstant()) return true; + } + Register source = copiedRegister(operations); + if (source == null) continue; + next = source.getBaseRegister(); + } + for (Instruction predecessor : predecessors(function, instruction)) { + instructions.push(predecessor); + targets.push(next); + } + } + return false; + } + + private List predecessors(Function function, Instruction instruction) { + List result = new ArrayList<>(); + Address from = instruction.getFallFrom(); + if (from != null) { + Instruction previous = currentProgram.getListing().getInstructionAt(from); + if (previous != null) result.add(previous); + } + for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(instruction.getAddress())) { + if (!reference.getReferenceType().isFlow() || reference.getReferenceType().isCall()) continue; + if (!function.getBody().contains(reference.getFromAddress())) continue; + Instruction source = currentProgram.getListing().getInstructionAt(reference.getFromAddress()); + if (source != null) result.add(source); + } + return result; + } + + private Register copiedRegister(PcodeOp[] operations) { + if (operations.length != 1) return null; + int opcode = operations[0].getOpcode(); + Varnode input = operations[0].getInput(0); + if ((opcode != PcodeOp.COPY && opcode != PcodeOp.INT_ZEXT) || input == null || !input.isRegister()) return null; + return currentProgram.getRegister(input); + } + + /** Validate the ABI layout of a pair; a small literal is decoded here, a large one only located. */ + private SwiftLiteralLayout swiftLiteralLayout(long word, long object) { + int discriminator = (int) (object >>> 60); + if (discriminator == 0xE || discriminator == 0xA) { + int count = (int) ((object >>> 56) & 0xF); + byte[] bytes = new byte[15]; + for (int index = 0; index < 8; index++) bytes[index] = (byte) (word >>> (8 * index)); + for (int index = 0; index < 7; index++) bytes[8 + index] = (byte) (object >>> (8 * index)); + for (int index = count; index < bytes.length; index++) { + if (bytes[index] != 0) return null; + } + // Count zero is the canonical empty string (object 0xE000000000000000, word 0). + String value = swiftStringText(bytes, count, discriminator == 0xE); + return value == null ? null : new SwiftLiteralLayout("small", null, count, true, value); + } + int flags = (int) (word >>> 60); + long count = word & 0xFFFFFFFFFFFFL; + // Immortal literals: ASCII and NFC or neither, tail allocated, with a pointer biased by 32. + if ((object >>> 56) != 0x80 || (flags != 0xD && flags != 0x1) || ((word >>> 48) & 0xFFF) != 0) { + return null; + } + try { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress((object & 0x00FFFFFFFFFFFFFFL) + 0x20); + MemoryBlock block = currentProgram.getMemory().getBlock(address); + // The bytes and their terminator must lie in one initialized, non-executable block, + // and Java arrays bound one read. + if (block == null || !block.isInitialized() || block.isExecute() || + count >= block.getEnd().subtract(address) + 1 || count >= Integer.MAX_VALUE) { + return null; + } + return new SwiftLiteralLayout("large", address, count, flags == 0xD, null); + } catch (Exception unaddressable) { + return null; + } + } + + private String swiftLargeLiteralText(SwiftLiteralLayout layout) { + int count = (int) layout.byteLength(); + byte[] bytes = new byte[count + 1]; + try { + if (currentProgram.getMemory().getBytes(layout.address(), bytes) != bytes.length) return null; + } catch (Exception unreadable) { + return null; + } + return bytes[count] == 0 ? swiftStringText(bytes, count, layout.ascii()) : null; + } + + /** + * Every literal record of one dossier draws on half of the heap that was free when decoding + * began. Text is charged at the worst case per UTF-8 byte: two bytes of UTF-16 text, then up + * to six escaped JSON characters ("\\u0000") held as UTF-16 in Gson's growing writer buffer + * (up to twice its length), again in the returned String, and once more as UTF-8 on the + * socket. Each record also pays a fixed cost for its map key, JSON members, addresses, words + * and their serialized form, so many empty or small literals are bounded too. Retained + * provenance-cache entries draw on that same fixed-state allowance. + */ + private static final class SwiftLiteralBudget { + private static final long BYTES_PER_LITERAL_BYTE = 2 + 6 * 2 * 2 + 6 * 2 + 6; + private static final long BYTES_PER_RECORD = 4096; + private long remaining; + private boolean stopped; + + SwiftLiteralBudget() { + Runtime runtime = Runtime.getRuntime(); + remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2); + } + + boolean takeState() { + if (remaining < BYTES_PER_RECORD) return false; + remaining -= BYTES_PER_RECORD; + return true; + } + + boolean take(long bytes) { + if (remaining < BYTES_PER_RECORD || + bytes > (remaining - BYTES_PER_RECORD) / BYTES_PER_LITERAL_BYTE) { + return false; + } + remaining -= BYTES_PER_RECORD + bytes * BYTES_PER_LITERAL_BYTE; + return true; + } + + void stop() { + stopped = true; + } + + boolean stopped() { + return stopped; + } + } + + // Swift String values may contain any scalar, including controls; JSON escapes them. + private static String swiftStringText(byte[] bytes, int length, boolean ascii) { + if (length < 0 || length > bytes.length) return null; + for (int index = 0; index < length; index++) { + if (ascii && bytes[index] < 0) return null; + } + try { + return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes, 0, length)).toString(); + } catch (CharacterCodingException invalid) { + return null; + } + } + + private static JsonObject swiftStringOmission(SwiftLiteralLayout layout, long word, long object) { + JsonObject item = new JsonObject(); + item.addProperty("representation", layout.representation()); + if (layout.address() == null) { + item.add("address", JsonNull.INSTANCE); + } else { + item.addProperty("address", canonicalAddress(layout.address())); + } + item.addProperty("byte_length", layout.byteLength()); + item.addProperty("reason", "capacity"); + item.add("words", swiftStringWords(word, object)); + return item; + } + + private static JsonObject swiftStringItem( + String value, + SwiftLiteralLayout layout, + long word, + long object + ) { + JsonObject item = new JsonObject(); + item.addProperty("value", value); + item.addProperty("representation", layout.representation()); + if (layout.address() == null) { + item.add("address", JsonNull.INSTANCE); + } else { + item.addProperty("address", canonicalAddress(layout.address())); + } + item.add("words", swiftStringWords(word, object)); + return item; + } + + private static JsonArray swiftStringWords(long word, long object) { + JsonArray words = new JsonArray(); + words.add("0x" + Long.toUnsignedString(word, 16)); + words.add("0x" + Long.toUnsignedString(object, 16)); + return words; + } + private static String exclusiveAddress(Address inclusive) { Address next = inclusive.next(); if (next != null && next.getAddressSpace().equals(inclusive.getAddressSpace())) { @@ -3930,6 +4302,14 @@ public final class ReaGhidraBridge extends HeadlessScript { private record AddressNameRequest(String inputAddress, Address address, String name) {} private record FunctionEntry(Function function, InventoryItem item) {} private record FunctionAnalysisObservation(JsonObject dossier, List limitations) {} + private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {} + private record SwiftLiteralLayout( + String representation, + Address address, + long byteLength, + boolean ascii, + String smallValue + ) {} private record InstructionScan(List instructions, boolean truncated) {} private record SessionDescriptor( String transport, diff --git a/scripts/verify-real-ghidra-swift.mjs b/scripts/verify-real-ghidra-swift.mjs index 0593408b0..0aa58e2f0 100644 --- a/scripts/verify-real-ghidra-swift.mjs +++ b/scripts/verify-real-ghidra-swift.mjs @@ -45,8 +45,13 @@ const transport = new StdioClientTransport({ const source = fileURLToPath( new URL("../tests/conformance/swift/type-inventory.swift", import.meta.url), ); +const literalSource = fileURLToPath( + new URL("../tests/conformance/swift/string-literals.swift", import.meta.url), +); const binary = join(root, "swift-inventory"); +const literalBinary = join(root, "swift-literals"); const reports = []; +let literalReport; try { await execute("swiftc", ["-O", "-g", source, "-o", binary], { timeout: 60_000, @@ -54,6 +59,19 @@ try { await mkdir(join(root, "copied")); const copied = join(root, "copied", "swift-inventory"); await copyFile(binary, copied); + // Decoding covers AArch64 register pairs, so build that slice on any Mac host. + await execute( + "swiftc", + [ + "-O", + "-target", + "arm64-apple-macosx13.0", + literalSource, + "-o", + literalBinary, + ], + { timeout: 60_000 }, + ); await client.connect(transport); for (const [variant, path] of [ ["debug", binary], @@ -132,6 +150,7 @@ try { await call("close_binary", {}); } } + literalReport = await verifyStringLiterals(); } finally { await client.close(); await transport.close(); @@ -143,6 +162,7 @@ console.log( ok: true, provider: "ghidra", fixtures: reports, + swift_string_literals: literalReport, verifier_run: await completeVerifierRun(run), }, null, @@ -159,3 +179,51 @@ async function call(name, arguments_) { toolContract(name).outputSchema.parse(response.structuredContent); return requireMcpOperationResult(response, name); } + +// Literal text lives only in register constants, so it must come from the decoded pairs. +async function verifyStringLiterals() { + await call("open_binary", { path: literalBinary, provider_id: "ghidra" }); + try { + const procedures = await call("list_procedures", {}); + const expected = { + smallLiteral: [["SleepDisabled", "small"]], + largeLiteral: [["The power setting could not be changed.", "large"]], + unicodeLiterals: [ + ["café", "small"], + ["Résumé of the naïve décor, written twice", "large"], + ], + emptyAndControlLiterals: [ + ["", "small"], + ["tab\there", "small"], + ], + returnedLiteral: [["Holding awake", "small"]], + }; + const decoded = {}; + for (const [name, literals] of Object.entries(expected)) { + const procedure = procedures.find(({ value }) => value.includes(name)); + assert(procedure, `${name} is missing from the procedure inventory`); + const dossier = await call("analyze_function", { + procedure: procedure.address, + }); + const observed = dossier.swift_string_literals; + assert(Array.isArray(observed), `${name} recorded no Swift literals`); + assert.deepEqual(dossier.swift_string_literal_omissions, []); + assert.deepEqual( + observed.map(({ value, representation }) => [value, representation]), + literals, + ); + for (const literal of observed) { + assert.deepEqual(literal.registers, ["x0", "x1"]); + assert.equal( + literal.address === null, + literal.representation === "small", + `${literal.value} storage does not match its representation`, + ); + } + decoded[name] = observed.length; + } + return decoded; + } finally { + await call("close_binary", {}); + } +} diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index f1aa3e829..07e6909c3 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -169,7 +169,8 @@ it("pages every retained dossier collection and the native API record", () => { | "comments" | "unresolved_calls" | "referenced_strings" - | "referenced_names", + | "referenced_names" + | "swift_string_literals", result: unknown = original, ) => { const view = projectAnalysisView(nativeParent(result), { @@ -195,6 +196,23 @@ it("pages every retained dossier collection and the native API record", () => { }), ).toMatchObject({ item: [{ text: "entry" }] }); expect(page("referenced_names")).toMatchObject({ coverage: { total: 0 } }); + const literal = { + source_address: "0x401004", + value: "SleepDisabled", + representation: "small", + address: null, + registers: ["x0", "x1"], + words: ["0x7369447065656c53", "0xed000064656c6261"], + }; + expect( + page("swift_string_literals", { + ...original, + swift_string_literals: [literal], + }), + ).toMatchObject({ item: [literal], coverage: { total: 1 } }); + expect(page("swift_string_literals")).toMatchObject({ + item: { available: false, reason: "not recorded" }, + }); const { unresolved_calls: _omitted, ...withoutUnresolved } = original; expect( page("unresolved_calls", { diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 7f985f349..b08001e35 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -160,6 +160,8 @@ const nativeViewSchema = z.strictObject({ "unresolved_calls", "referenced_strings", "referenced_names", + "swift_string_literals", + "swift_string_literal_omissions", "native_api", "value_flow_summary", "value_flow_operations", diff --git a/src/domain/analysisView/nativeFunctionView.ts b/src/domain/analysisView/nativeFunctionView.ts index f5a29e0e1..b56de24a1 100644 --- a/src/domain/analysisView/nativeFunctionView.ts +++ b/src/domain/analysisView/nativeFunctionView.ts @@ -79,6 +79,8 @@ const nativeRows = ( unresolved_calls: dossier.unresolved_calls, referenced_strings: dossier.referenced_strings, referenced_names: dossier.referenced_names, + swift_string_literals: dossier.swift_string_literals, + swift_string_literal_omissions: dossier.swift_string_literal_omissions, value_flow_operations: flow?.operations, value_flow_def_use: flow?.def_use, value_flow_effects: flow?.effects, @@ -163,7 +165,8 @@ export const projectNativeFunctionView = ( singleton({ available: false, // Only value-flow facets inherit the value-flow failure reason; an - // older dossier simply has no unresolved_calls record. + // older dossier simply has no unresolved_calls record, and a target + // whose provider does not decode Swift literals has no such record. reason: view.facet.startsWith("value_flow_") && flow?.available === false ? flow.reason diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index e03a27748..677fdc0cc 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -716,6 +716,236 @@ describe("function comparison identity and malformed CFG", () => { }); }); +const withLiterals = (base: "0x1000" | "0x2000", values: readonly string[]) => + functionDossierSchema.parse({ + ...dossier("return 0;", base), + swift_string_literals: values.map((value) => ({ + source_address: base, + value, + representation: "small" as const, + address: null, + registers: ["x0", "x1"] as const, + words: ["0x0", "0xe"] as const, + })), + }); +const stringsDimension = ( + leftDigit: string, + left: ReturnType | ReturnType, + rightDigit: string, + right: ReturnType | ReturnType, +) => + compareFunctions( + observe(leftDigit, left), + observe(rightDigit, right), + ).dimensions.find(({ dimension }) => dimension === "strings_names"); + +describe("function comparison Swift string literals", () => { + it("treats differing decoded literals as a strings and names change", () => { + expect( + stringsDimension( + "1", + withLiterals("0x1000", ["SleepDisabled"]), + "2", + withLiterals("0x2000", ["Holding awake"]), + ), + ).toMatchObject({ status: "changed" }); + }); + + it("matches the same decoded literal after the function is relocated", () => { + expect( + stringsDimension( + "3", + withLiterals("0x1000", ["SleepDisabled"]), + "4", + withLiterals("0x2000", ["SleepDisabled"]), + ), + ).toMatchObject({ status: "unchanged" }); + }); + + it("keeps strings and names unknown when only one dossier recorded Swift literals", () => { + expect( + stringsDimension( + "5", + withLiterals("0x1000", []), + "6", + dossier("return 0;", "0x2000"), + ), + ).toMatchObject({ + status: "unknown", + limitations: [ + "Swift String literal coverage differs or is partial; referenced strings and names match, and complete coverage does not establish a differing literal.", + ], + }); + }); + + it("reports a referenced name change even when Swift literal coverage differs", () => { + const named = functionDossierSchema.parse({ + ...dossier("return 0;", "0x2000"), + referenced_names: [ + { address: "0x3000", value: "helper", source_address: "0x2000" }, + ], + }); + expect( + stringsDimension("7", withLiterals("0x1000", []), "8", named), + ).toMatchObject({ status: "changed" }); + }); +}); + +describe("function comparison partial Swift literal coverage", () => { + it("does not equate a partial literal record with a complete one", () => { + const partial = functionDossierSchema.parse({ + ...withLiterals("0x2000", ["SleepDisabled"]), + swift_string_literal_omissions: [ + { + source_address: "0x2000", + representation: "large", + address: "0x3000", + byte_length: 64, + reason: "capacity", + registers: ["x0", "x1"], + words: ["0xd000000000000040", "0x8000000000002fe0"], + }, + ], + }); + expect( + stringsDimension( + "9", + withLiterals("0x1000", ["SleepDisabled"]), + "a", + partial, + ), + ).toMatchObject({ status: "unknown" }); + }); + + it("reports a literal that a complete dossier shows absent at a site the partial one decoded", () => { + const partial = functionDossierSchema.parse({ + ...withLiterals("0x2000", ["SleepDisabled"]), + swift_string_literal_omissions: [ + { + source_address: "0x2004", + representation: "small", + address: null, + byte_length: 4, + reason: "capacity", + registers: ["x0", "x1"], + words: ["0x732f4220", "0xe400000000000000"], + }, + ], + }); + expect( + stringsDimension("d", withLiterals("0x1000", []), "e", partial), + ).toMatchObject({ status: "changed" }); + }); + + it("does not interpret an unexamined path as a missing literal at a retained site", () => { + const partial = functionDossierSchema.parse({ + ...withLiterals("0x2000", ["A"]), + swift_string_literal_omissions: [ + { + source_address: "0x2004", + representation: "small", + address: null, + byte_length: 0, + reason: "capacity", + registers: ["x0", "x1"], + words: ["0x0", "0xe000000000000000"], + }, + ], + }); + const complete = withLiterals("0x1000", ["A", "B"]); + expect(stringsDimension("1", complete, "2", partial)).toMatchObject({ + status: "unknown", + }); + expect(stringsDimension("3", partial, "4", complete)).toMatchObject({ + status: "unknown", + }); + expect( + stringsDimension("5", withLiterals("0x1000", []), "6", partial), + ).toMatchObject({ status: "changed" }); + }); + + it("treats an omission record without decoded literals as partial coverage", () => { + const omissionOnly = functionDossierSchema.parse({ + ...dossier("return 0;", "0x2000"), + swift_string_literal_omissions: [ + { + source_address: "0x2004", + representation: "small", + address: null, + byte_length: 4, + reason: "capacity", + registers: ["x0", "x1"], + words: ["0x732f4220", "0xe400000000000000"], + }, + ], + }); + expect( + stringsDimension("f", dossier("return 0;", "0x1000"), "0", omissionOnly), + ).toMatchObject({ status: "unknown" }); + }); + + it("keeps different retained representations unknown when both dossiers are partial", () => { + const omission = { + source_address: "0x1004", + representation: "small", + address: null, + byte_length: 4, + reason: "capacity", + registers: ["x0", "x1"], + words: ["0x732f4220", "0xe400000000000000"], + } as const; + const small = functionDossierSchema.parse({ + ...withLiterals("0x1000", ["SleepDisabled"]), + swift_string_literal_omissions: [omission], + }); + const large = functionDossierSchema.parse({ + ...dossier("return 0;", "0x2000"), + swift_string_literals: [ + { + source_address: "0x2000", + value: "SleepDisabled", + representation: "large", + address: "0x3000", + registers: ["x0", "x1"], + words: ["0xd00000000000000d", "0x8000000000002fe0"], + }, + ], + swift_string_literal_omissions: [ + { ...omission, source_address: "0x2004" }, + ], + }); + expect(stringsDimension("1", small, "2", large)).toMatchObject({ + status: "unknown", + }); + }); + + it("keeps different retained literals unknown when both dossiers are partial", () => { + const partial = (base: "0x1000" | "0x2000", value: string) => + functionDossierSchema.parse({ + ...withLiterals(base, [value]), + swift_string_literal_omissions: [ + { + source_address: base === "0x1000" ? "0x1004" : "0x2004", + representation: "large", + address: "0x3000", + byte_length: 64, + reason: "capacity", + registers: ["x0", "x1"], + words: ["0xd000000000000040", "0x8000000000002fe0"], + }, + ], + }); + expect( + stringsDimension( + "b", + partial("0x1000", "SleepDisabled"), + "c", + partial("0x2000", "Holding awake"), + ), + ).toMatchObject({ status: "unknown" }); + }); +}); + const expectInlineComparisonEvidence = ( result: ReturnType, ): void => { diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index 27bb7b176..c5a0f373f 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -11,6 +11,7 @@ import { referenceProjection, sorted, stringAndNameProjection, + swiftLiteralSitesConflict, } from "./functionComparisonNormalization.js"; import type { DimensionName, @@ -139,8 +140,12 @@ const compareStringsAndNames = ( right: FunctionSnapshot, context: ComparisonContext, ): FunctionDimension => { - const leftValues = stringAndNameProjection(left); - const rightValues = stringAndNameProjection(right); + const complete = + swiftLiteralCoverage(left) === swiftLiteralCoverage(right) && + swiftLiteralCoverage(left) !== "partial"; + // Facts both dossiers record decide a change before Swift literal coverage can. + const leftValues = stringAndNameProjection(left, complete); + const rightValues = stringAndNameProjection(right, complete); if (leftValues === null || rightValues === null) return unresolvedDimension({ dimension: "strings_names", @@ -151,9 +156,40 @@ const compareStringsAndNames = ( "Reference source locations could not be normalized to function offsets.", ], }); - return compareValues("strings_names", leftValues, rightValues, context); + const compared = compareValues( + "strings_names", + leftValues, + rightValues, + context, + ); + if (complete || compared.status === "changed") return compared; + // A retained literal differs only when the opposite dossier establishes its absence. + if (swiftLiteralSitesConflict(left, right)) { + const leftAll = stringAndNameProjection(left); + const rightAll = stringAndNameProjection(right); + if (leftAll !== null && rightAll !== null) + return compareValues("strings_names", leftAll, rightAll, context); + } + return unresolvedDimension({ + dimension: "strings_names", + links: context.links, + leftCount: null, + rightCount: null, + limitations: [ + "Swift String literal coverage differs or is partial; referenced strings and names match, and complete coverage does not establish a differing literal.", + ], + }); }; +const swiftLiteralCoverage = ( + snapshot: FunctionSnapshot, +): "absent" | "complete" | "partial" => + (snapshot.dossier.swift_string_literal_omissions ?? []).length > 0 + ? "partial" + : snapshot.dossier.swift_string_literals === undefined + ? "absent" + : "complete"; + const compareReferences = ( left: FunctionSnapshot, right: FunctionSnapshot, diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts index 5282272e1..f32220b0b 100644 --- a/src/domain/functionComparisonNormalization.ts +++ b/src/domain/functionComparisonNormalization.ts @@ -142,6 +142,7 @@ export const commentProjection = ( export const stringAndNameProjection = ( snapshot: FunctionSnapshot, + includeSwiftLiterals = true, ): readonly unknown[] | null => { const values = [ ...snapshot.dossier.referenced_strings.map( @@ -164,12 +165,90 @@ export const stringAndNameProjection = ( value, }), ), + ...(includeSwiftLiterals + ? (snapshot.dossier.swift_string_literals ?? []) + : [] + ).map( + ({ + source_address: sourceAddress, + value, + representation, + registers, + }) => ({ + kind: "swift_string", + source_offset: relativeAddress( + sourceAddress, + snapshot.dossier.procedure.address, + ), + value, + representation, + registers, + }), + ), ]; return values.some(({ source_offset: offset }) => offset === null) ? null : sorted(values); }; +/** + * A retained literal proves presence, not that every path through its site was examined. + * A differing retained fact proves a change only when the opposite dossier is complete + * and therefore establishes its absence. + */ +export const swiftLiteralSitesConflict = ( + left: FunctionSnapshot, + right: FunctionSnapshot, +): boolean => { + const leftSites = swiftLiteralSites(left); + const rightSites = swiftLiteralSites(right); + for (const site of new Set([ + ...leftSites.values.keys(), + ...rightSites.values.keys(), + ])) { + const leftValues = + leftSites.values.get(site) ?? (leftSites.complete ? [] : undefined); + const rightValues = + rightSites.values.get(site) ?? (rightSites.complete ? [] : undefined); + if (leftValues === undefined || rightValues === undefined) continue; + const leftFacts = new Set(leftValues); + const rightFacts = new Set(rightValues); + if ( + (rightSites.complete && + leftValues.some((value) => !rightFacts.has(value))) || + (leftSites.complete && rightValues.some((value) => !leftFacts.has(value))) + ) + return true; + } + return false; +}; + +const swiftLiteralSites = (snapshot: FunctionSnapshot) => { + const base = snapshot.dossier.procedure.address; + const omissions = snapshot.dossier.swift_string_literal_omissions ?? []; + const site = (sourceAddress: string, registers: readonly string[]) => { + const offset = relativeAddress(sourceAddress, base); + return offset === null ? null : canonicalJson([offset, registers]); + }; + // Keep the same literal facts the full strings_names projection compares. + const values = new Map(); + for (const literal of snapshot.dossier.swift_string_literals ?? []) { + const key = site(literal.source_address, literal.registers); + if (key === null) continue; + values.set( + key, + [ + ...(values.get(key) ?? []), + canonicalJson([literal.value, literal.representation]), + ].sort(compareUnicodeCodePoints), + ); + } + const complete = + snapshot.dossier.swift_string_literals !== undefined && + omissions.length === 0; + return { values, complete }; +}; + export const sorted = (values: readonly unknown[]): readonly unknown[] => values .map((value) => ({ diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 769d0f040..d6344ff3e 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -290,6 +290,70 @@ export const unresolvedCallSchema = z.strictObject({ reason: z.string(), }); +const swiftStringLiteralFields = { + source_address: z + .string() + .describe( + "Instruction that consumes both words: a call, tail call, return, or paired store.", + ), + value: z + .string() + .describe("Decoded UTF-8 text, including the canonical empty string."), + registers: z.tuple([z.string(), z.string()]), + words: z + .tuple([z.string(), z.string()]) + .describe("Observed constant register values, in register order."), +}; + +/** A Swift String literal decoded from the two words of its ABI value, not a data reference. */ +export const swiftStringLiteralSchema = z.discriminatedUnion("representation", [ + z.strictObject({ + ...swiftStringLiteralFields, + representation: z + .literal("small") + .describe("The UTF-8 bytes are inside the two words."), + address: z.null().describe("A small literal has no storage of its own."), + }), + z.strictObject({ + ...swiftStringLiteralFields, + representation: z + .literal("large") + .describe( + "An immortal literal whose words hold its count and a pointer to its bytes.", + ), + address: z.string().describe("Address of a large literal's bytes."), + }), +]); + +const swiftStringLiteralOmissionFields = { + source_address: swiftStringLiteralFields.source_address, + byte_length: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + reason: z + .literal("capacity") + .describe( + "The record did not fit the provider's memory budget for this dossier; decoding stopped here, so later use sites were not examined.", + ), + registers: swiftStringLiteralFields.registers, + words: swiftStringLiteralFields.words, +}; + +/** A Swift literal whose layout matched but whose record did not fit; decoding stopped there. */ +export const swiftStringLiteralOmissionSchema = z.discriminatedUnion( + "representation", + [ + z.strictObject({ + ...swiftStringLiteralOmissionFields, + representation: z.literal("small"), + address: z.null(), + }), + z.strictObject({ + ...swiftStringLiteralOmissionFields, + representation: z.literal("large"), + address: z.string().describe("Address of the literal's bytes."), + }), + ], +); + /** Observed endpoints with complete or partial reference classification evidence. */ export const referenceEdgeSchema = z .object({ @@ -341,6 +405,18 @@ export const functionDossierSchema = z }) .strict(), ), + swift_string_literals: z + .array(swiftStringLiteralSchema) + .exactOptional() + .describe( + "Swift String literals decoded from constant register values. Absent when the provider does not decode them for this target.", + ), + swift_string_literal_omissions: z + .array(swiftStringLiteralOmissionSchema) + .exactOptional() + .describe( + "The literal at which decoding stopped for capacity. Present exactly when swift_string_literals is present; empty when decoding examined every use site.", + ), basic_blocks: z.array( z .object({ diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index 75d59c97f..333d2c913 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -195,6 +195,51 @@ describe("Ghidra function-analysis result values", () => { }); }); +describe("Ghidra Swift String literals", () => { + it("keeps a small Swift literal without storage apart from a large literal's bytes", () => { + const dossier = ghidraFunctionDossier(); + if ( + typeof dossier !== "object" || + dossier === null || + Array.isArray(dossier) + ) + throw new TypeError("Ghidra dossier fixture is invalid"); + const literals = [ + { + source_address: "0x401004", + value: "The user name ", + representation: "small", + address: null, + registers: ["x0", "x1"], + words: ["0x7265737520656854", "0xee0020656d616e20"], + }, + { + source_address: "0x401006", + value: "", + representation: "small", + address: null, + registers: ["x0", "x1"], + words: ["0x0", "0xe000000000000000"], + }, + { + source_address: "0x401008", + value: " cannot be written into a sudoers rule safely.", + representation: "large", + address: "0x402270", + registers: ["x0", "x1"], + words: ["0xd00000000000002e", "0x8000000000402250"], + }, + ]; + expect( + parseFunctionValueFixture("analyze_function", { + ...dossier, + swift_string_literals: literals, + swift_string_literal_omissions: [], + }), + ).toMatchObject({ ok: true, value: { swift_string_literals: literals } }); + }); +}); + describe("Ghidra compact reference provenance", () => { it("distinguishes incoming references, examined emptiness and unavailable coverage at data addresses", () => { const instruction = { @@ -423,6 +468,34 @@ describe("Ghidra function-analysis malformed results", () => { ); }); +const malformedSwiftStringLiterals = ( + dossier: Record, +): Array<[string, GhidraFunctionOperation, JsonValue]> => + ( + [ + ["non-canonical Swift literal address", "large", "402240"], + ["small Swift literal with storage", "small", "0x402270"], + ["large Swift literal without storage", "large", null], + ] as const + ).map(([name, representation, address]) => [ + name, + "analyze_function", + { + ...dossier, + swift_string_literal_omissions: [], + swift_string_literals: [ + { + source_address: "0x401004", + value: "The power setting could not be changed.", + representation, + address, + registers: ["x0", "x1"], + words: ["0xd000000000000027", "0x8000000000402220"], + }, + ], + }, + ]); + const malformedOutputs = (): Array< [string, GhidraFunctionOperation, JsonValue] > => { @@ -437,6 +510,11 @@ const malformedOutputs = (): Array< throw new TypeError("Ghidra native API fixture is invalid"); return [ ["non-canonical address", "procedure_callers", ["401000"]], + [ + "Swift literals without their omission record", + "analyze_function", + { ...dossier, swift_string_literals: [] }, + ], [ "missing classification", "procedure_info", @@ -467,6 +545,26 @@ const malformedOutputs = (): Array< Object.entries(dossier).filter(([key]) => key !== "native_api"), ), ], + ...malformedSwiftStringLiterals(dossier), + [ + "non-canonical Swift literal omission address", + "analyze_function", + { + ...dossier, + swift_string_literals: [], + swift_string_literal_omissions: [ + { + source_address: "0x401004", + representation: "large", + address: "402240", + byte_length: 39, + reason: "capacity", + registers: ["x0", "x1"], + words: ["0xd000000000000027", "0x8000000000402220"], + }, + ], + }, + ], [ "non-canonical native API target", "analyze_function", diff --git a/src/ghidra/GhidraFunctionValues.ts b/src/ghidra/GhidraFunctionValues.ts index d45e41ba9..4e4c5054a 100644 --- a/src/ghidra/GhidraFunctionValues.ts +++ b/src/ghidra/GhidraFunctionValues.ts @@ -293,6 +293,15 @@ const ghidraFunctionDossier = functionDossierSchema }) .strict() .superRefine((value, context) => { + if ( + (value.swift_string_literals === undefined) !== + (value.swift_string_literal_omissions === undefined) + ) + context.addIssue({ + code: "custom", + message: + "Ghidra dossier must record Swift literals and their omissions together", + }); const addresses = [ value.procedure.address, ...value.comments.map(({ address }) => address), @@ -308,6 +317,14 @@ const ghidraFunctionDossier = functionDossierSchema address, source_address, ]), + ...(value.swift_string_literals ?? []).flatMap( + ({ address, source_address }) => + address === null ? [source_address] : [address, source_address], + ), + ...(value.swift_string_literal_omissions ?? []).flatMap( + ({ address, source_address }) => + address === null ? [source_address] : [address, source_address], + ), ...value.basic_blocks.flatMap(({ start, end, successors }) => [ start, end, diff --git a/tests/conformance/swift/string-literals.swift b/tests/conformance/swift/string-literals.swift new file mode 100644 index 000000000..345c4cf74 --- /dev/null +++ b/tests/conformance/swift/string-literals.swift @@ -0,0 +1,23 @@ +@inline(never) +func consume(_ value: String) { print(value) } +@inline(never) +func smallLiteral() { consume("SleepDisabled") } +@inline(never) +func largeLiteral() { consume("The power setting could not be changed.") } +@inline(never) +func unicodeLiterals() { + consume("café") + consume("Résumé of the naïve décor, written twice") +} +@inline(never) +func emptyAndControlLiterals() { + consume("") + consume("tab\there") +} +@inline(never) +func returnedLiteral() -> String { "Holding awake" } +smallLiteral() +largeLiteral() +unicodeLiterals() +emptyAndControlLiterals() +print(returnedLiteral(), CommandLine.arguments.count) From ec4f4ddc588a564b75d69249b4ebbe4d4051c792 Mon Sep 17 00:00:00 2001 From: yasshmittal Date: Tue, 13 Oct 2026 18:11:28 +0530 Subject: [PATCH 13/92] fix(ghidra): read UTF-8 signed language definitions (#2772) Buffer#toString kept a leading UTF-8 byte order mark as U+FEFF, so the strict XML parser rejected a valid .ldefs file as content outside the root element and aborted the whole language catalog read. XML 1.0 section 4.3.3 defines the mark as an encoding signature; decode with TextDecoder, which consumes it. Malformed XML is still rejected. Fixes #2533 Co-authored-by: Claude Opus 5.5 --- src/ghidra/GhidraLanguageCatalog.ts | 4 +++- src/ghidra/GhidraLanguageOverride.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/ghidra/GhidraLanguageCatalog.ts b/src/ghidra/GhidraLanguageCatalog.ts index 5da5943dc..6373f187f 100644 --- a/src/ghidra/GhidraLanguageCatalog.ts +++ b/src/ghidra/GhidraLanguageCatalog.ts @@ -75,8 +75,10 @@ export const readGhidraLanguageCatalog = async ( if (!entry.endsWith(".ldefs")) continue; const bytes = await readFile(join(directory, entry)); if (bytes.length > MAX_LDEFS_BYTES) continue; + // XML 1.0 section 4.3.3: a leading UTF-8 BOM is an encoding signature, + // not document content; TextDecoder consumes it, Buffer#toString does not. definitions.push( - ...parseGhidraLanguageDefinitions(bytes.toString("utf8")), + ...parseGhidraLanguageDefinitions(new TextDecoder().decode(bytes)), ); } } diff --git a/src/ghidra/GhidraLanguageOverride.test.ts b/src/ghidra/GhidraLanguageOverride.test.ts index 5f90a1454..02c5bf245 100644 --- a/src/ghidra/GhidraLanguageOverride.test.ts +++ b/src/ghidra/GhidraLanguageOverride.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -190,6 +191,28 @@ describe("Ghidra language catalog", () => { ); }); + it("reads a UTF-8 signed definition beside an unsigned sibling", async () => { + const root = await createTestTempDirectory("rea-ghidra-bom-"); + const module = (name: string) => + join(root, "Ghidra/Processors", name, "data/languages"); + await mkdir(module("probe"), { recursive: true }); + await mkdir(module("x86"), { recursive: true }); + await writeFile( + join(module("probe"), "probe.ldefs"), + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from( + '\n', + ), + ]), + ); + await writeFile(join(module("x86"), "x86.ldefs"), X86_LDEFS); + expect(await readGhidraLanguageCatalog(root)).toEqual([ + { id: "probe:LE:32:default", compilerSpecIds: [] }, + ...parseGhidraLanguageDefinitions(X86_LDEFS), + ]); + }); + it("admits only languages and compiler specs the installation defines", () => { const catalog = parseGhidraLanguageDefinitions(X86_LDEFS); expect( From 84cf7791a881e5b16a0288600fbc8d48d494ef01 Mon Sep 17 00:00:00 2001 From: Saim Shafique Date: Tue, 13 Oct 2026 17:42:40 +0500 Subject: [PATCH 14/92] fix(call-path): keep caller-only unknown coverage from blocking not_found (#2371) * fix(call-path): keep caller-only unknown coverage from blocking not_found build_call_path expands only callee edges, so relationship coverage that is unknown solely because callers are unobservable (the modern headless IDA profile) no longer marks the searched callee closure incomplete. The caller caveat stays visible in limitations, and callee-side unknowns still yield unknown. Fixes #2322. * test(call-path): cover IDA-capped callees beside unknown callers Greptile review noted the poll-timing coverage only exercised unknown callees, leaving the partial callees IDA returns at its 500-result cap untested. Add the combined unknown-callers/partial-callees case: isCallerOnlyUnknownCoverage must stay false and buildCallPath must keep search_scope.exhaustive false, so a future change cannot silently report not_found when IDA has omitted callees. --- src/domain/callPath.test.ts | 83 +++++++++++++++++++++++++ src/domain/callPath.ts | 7 ++- src/domain/functionCallCoverage.test.ts | 64 +++++++++++++++++++ src/domain/functionCallCoverage.ts | 22 ++++++- 4 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 src/domain/functionCallCoverage.test.ts diff --git a/src/domain/callPath.test.ts b/src/domain/callPath.test.ts index e0f343fca..e78a5f936 100644 --- a/src/domain/callPath.test.ts +++ b/src/domain/callPath.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildCallPath } from "./callPath.js"; +import { combineFunctionRelationshipCoverage } from "./functionCallCoverage.js"; import { callPathResultSchema, type CallPathInput } from "./callPathSchemas.js"; import { createEvidence, type Evidence } from "./evidence.js"; import type { JsonValue } from "./jsonValue.js"; @@ -396,6 +397,60 @@ describe("explicit call relationship coverage", () => { expect(result.limitations).toEqual([reason]); }); + it("establishes not_found when only caller coverage is unknown", () => { + const reason = + "Callers: Upstream xrefs do not establish direct caller relationships."; + const result = run([ + observe("0x1000", ["0x2000"], "rea-workflow", { + relationshipCoverage: { status: "unknown", reason }, + }), + observe("0x2000", [], "rea-workflow", { + relationshipCoverage: { status: "unknown", reason }, + }), + ]); + expect(result).toMatchObject({ + status: "not_found", + search_scope: { exhaustive: true }, + }); + expect(result.limitations).toContain(reason); + }); + + it("stays unknown when callee coverage is unknown", () => { + const reason = + "Callees: Upstream callees capped results at 500; additional callees remain unknown."; + const result = run([ + observe("0x1000", ["0x2000"], "rea-workflow", { + relationshipCoverage: { status: "unknown", reason }, + }), + observe("0x2000", [], "rea-workflow", { + relationshipCoverage: { status: "unknown", reason }, + }), + ]); + expect(result).toMatchObject({ + status: "unknown", + search_scope: { exhaustive: false }, + }); + expect(result.limitations).toContain(reason); + }); + + it("stays unknown when both caller and callee coverage are unknown", () => { + const reason = + "Callers: Upstream xrefs do not establish direct caller relationships. Callees: Upstream callees are not observed."; + const result = run([ + observe("0x1000", ["0x2000"], "rea-workflow", { + relationshipCoverage: { status: "unknown", reason }, + }), + observe("0x2000", [], "rea-workflow", { + relationshipCoverage: { status: "unknown", reason }, + }), + ]); + expect(result).toMatchObject({ + status: "unknown", + search_scope: { exhaustive: false }, + }); + expect(result.limitations).toContain(reason); + }); + it("ignores incomplete relationship coverage outside the reachable search closure", () => { const reason = "An unrelated function's callees were not observed."; const result = run([ @@ -415,6 +470,34 @@ describe("explicit call relationship coverage", () => { }); }); +describe("IDA-capped callee coverage beside unknown callers", () => { + it("keeps the search non-exhaustive for the combined coverage value", () => { + const combined = combineFunctionRelationshipCoverage( + { + status: "unknown", + reason: "Upstream xrefs do not establish direct caller relationships.", + }, + { + status: "partial", + limitations: [ + "Upstream callees capped results at 500; additional callees remain unknown.", + ], + }, + ); + expect(combined.status).toBe("unknown"); + const result = run([ + observe("0x1000", [], "rea-workflow", { relationshipCoverage: combined }), + observe("0x4000", []), + ]); + expect(result).toMatchObject({ + status: "unknown", + search_scope: { exhaustive: false }, + shortest_hops: null, + paths: [], + }); + }); +}); + describe("call path evidence admission", () => { it("normalizes hex addresses and rejects duplicates and mixed providers", () => { expect( diff --git a/src/domain/callPath.ts b/src/domain/callPath.ts index 2186c8010..0bd5e88e1 100644 --- a/src/domain/callPath.ts +++ b/src/domain/callPath.ts @@ -10,6 +10,7 @@ import { parseFunctionEvidence, type FunctionSnapshot, } from "./functionDossierEvidence.js"; +import { isCallerOnlyUnknownCoverage } from "./functionCallCoverage.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; /** Minimal directed caller-to-callee adjacency for call-path search. */ @@ -255,10 +256,14 @@ const inspectSearch = ({ // Explicit, Evidence-bound relationship coverage owns completeness; // generic provider/dossier limitations remain contextual. Legacy Evidence // without that metadata retains its conservative limitation-based rule. + // The search only expands callee edges, so coverage that is unknown + // solely because callers are unobservable does not make the searched + // callee closure incomplete; the caller caveat stays in limitations. if ( coverage === null ? snapshot.limitations.length > 0 - : coverage.status !== "complete" + : coverage.status !== "complete" && + !isCallerOnlyUnknownCoverage(coverage) ) incompleteExpandedCoverage = true; } else if (node === goal) { diff --git a/src/domain/functionCallCoverage.test.ts b/src/domain/functionCallCoverage.test.ts new file mode 100644 index 000000000..6fd3fb045 --- /dev/null +++ b/src/domain/functionCallCoverage.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + combineFunctionRelationshipCoverage, + isCallerOnlyUnknownCoverage, +} from "./functionCallCoverage.js"; +import type { RelationshipCoverage } from "./relationshipCoverage.js"; + +const unknownCallers: RelationshipCoverage = { + status: "unknown", + reason: "Upstream xrefs do not establish direct caller relationships.", +}; +const unknownCallees: RelationshipCoverage = { + status: "unknown", + reason: "Upstream callees are not observed in this profile.", +}; +const cappedCallees: RelationshipCoverage = { + status: "partial", + limitations: [ + "Upstream callees capped results at 500; additional callees remain unknown.", + ], +}; +const complete: RelationshipCoverage = { status: "complete" }; + +describe("isCallerOnlyUnknownCoverage", () => { + it("detects unknown callers beside complete callees", () => { + const combined = combineFunctionRelationshipCoverage( + unknownCallers, + complete, + ); + expect(combined.status).toBe("unknown"); + expect(isCallerOnlyUnknownCoverage(combined)).toBe(true); + }); + + it("rejects unknown callees beside complete callers", () => { + const combined = combineFunctionRelationshipCoverage( + complete, + unknownCallees, + ); + expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); + }); + + it("rejects unknown coverage on both sides", () => { + const combined = combineFunctionRelationshipCoverage( + unknownCallers, + unknownCallees, + ); + expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); + }); + + it("rejects unknown callers beside IDA-capped callees", () => { + const combined = combineFunctionRelationshipCoverage( + unknownCallers, + cappedCallees, + ); + expect(combined.status).toBe("unknown"); + expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); + }); + + it("rejects complete coverage", () => { + const combined = combineFunctionRelationshipCoverage(complete, complete); + expect(isCallerOnlyUnknownCoverage(combined)).toBe(false); + }); +}); diff --git a/src/domain/functionCallCoverage.ts b/src/domain/functionCallCoverage.ts index eb2c05a13..b64e78cd6 100644 --- a/src/domain/functionCallCoverage.ts +++ b/src/domain/functionCallCoverage.ts @@ -2,6 +2,9 @@ import type { RelationshipCoverage } from "./relationshipCoverage.js"; import type { FunctionDossier } from "./hopperValues.js"; import { uniqueSorted } from "./canonicalOrdering.js"; +const callerCoveragePrefix = "Callers: "; +const calleeCoveragePrefix = "Callees: "; + /** Combine caller and callee read outcomes without losing either explanation. */ export const combineFunctionRelationshipCoverage = ( callers: RelationshipCoverage | undefined, @@ -20,7 +23,8 @@ export const combineFunctionRelationshipCoverage = ( ); const details = diagnostics.length > 0 ? { diagnostics } : {}; const reasons = reads.flatMap(({ direction, coverage }) => { - const label = direction === "callers" ? "Callers" : "Callees"; + const label = + direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix; const limitations = coverage === undefined ? ["Provider did not report relationship coverage."] @@ -29,7 +33,7 @@ export const combineFunctionRelationshipCoverage = ( : coverage.status === "partial" ? coverage.limitations : []; - return limitations.map((reason) => `${label}: ${reason}`); + return limitations.map((reason) => `${label}${reason}`); }); if ( reads.some( @@ -50,6 +54,20 @@ export const combineFunctionRelationshipCoverage = ( return { status: "complete", ...details }; }; +/** + * Whether combined relationship coverage is unknown only on the caller side. + * The combine helper above prefixes each direction's reasons, so a reason + * that carries the caller prefix and no callee prefix records observed + * callees with unobservable callers. Forward (callee-edge) searches stay + * exhaustive under that caveat; the caller reason remains a limitation. + */ +export const isCallerOnlyUnknownCoverage = ( + coverage: RelationshipCoverage, +): boolean => + coverage.status === "unknown" && + coverage.reason.startsWith(callerCoveragePrefix) && + !coverage.reason.includes(calleeCoveragePrefix); + /** Explain why two resolved call sets cannot establish call-set equality. */ export const functionCallCoverageLimitations = ( coverage: RelationshipCoverage | null, From 1ca5fd38b4833ac3fe6213a55e5abfd426228266 Mon Sep 17 00:00:00 2001 From: Jack Liang <130693642+Jackmeson1@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:43:20 -0700 Subject: [PATCH 15/92] fix(cli)!: keep a selected JSON null in --filter-output output (#2333) createCliResultOutput rendered an explicit null selected by --filter-output as the empty document {}, the same output it uses when the selection has no value at all. On the streamed JSON/JSONL path, a consumer could not distinguish a selected null from a missing field. Write a selected null as null in streamed JSON and JSONL. This also preserves an unfiltered top-level null. The {} fallback stays for undefined, functions and symbols. The --full-output envelope already carried data: null for a selected null. The --token-count path still hands results that fit the formatted-string limit back to Incur and prints 0 for a selected null. Other formats and Incur-owned result paths are unchanged. Consumers must accept JSON null for a present null and distinguish it from a missing selection. Use --full-output if an object envelope is required. Keeping the old shared {} representation would continue hiding this distinction. Claude-Session: https://claude.ai/code/session_019TTGAv6fe859ebbCLLfLHP Co-authored-by: Claude Opus 5.5 --- src/cli/cliResultOutput.ts | 5 +++-- tests/boundary/cli/jsonOutput.test.ts | 32 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index 67e745b0b..5efde8b33 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -216,8 +216,9 @@ export const createCliResultOutput = ( : filtered === undefined ? { ok: true, meta } : { ok: true, data: filtered, meta } - : filtered === undefined || - filtered === null || + : // A selected null is a value; only a selection with no JSON value + // falls back to the empty document. + filtered === undefined || typeof filtered === "function" || typeof filtered === "symbol" ? {} diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index 5e8e86e7f..0d1ee5a59 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -561,3 +561,35 @@ describe("CLI result output surface", () => { }); }, 60_000); }); + +describe("streamed CLI filtered selections", () => { + it.each([ + ["raw_result", "json", "null\n"], + ["raw_result", "jsonl", "null\n"], + ["nested.value", "json", '{"nested":{"value":null}}\n'], + ["missing", "json", "{}\n"], + ["count", "json", "0\n"], + ["enabled", "json", "false\n"], + ] as const)( + "keeps the selected value of --filter-output %s in %s", + async (filter, format, expected) => { + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const output = createCliResultOutput( + ["--format", format, "--filter-output", filter], + destination, + ); + if (output === undefined) throw new Error("Missing JSON output surface"); + await output.write( + { raw_result: null, nested: { value: null }, count: 0, enabled: false }, + { command: "fixture", duration: "1ms", format }, + ); + expect(Buffer.concat(chunks).toString("utf8")).toBe(expected); + }, + ); +}); From 36132d3e1770c8b69f86c86821d2c872234e370a Mon Sep 17 00:00:00 2001 From: Jin Date: Tue, 13 Oct 2026 08:43:39 -0400 Subject: [PATCH 16/92] fix(managed): classify 32-bit-preferred assemblies as platform neutral (#2342) The CLI header's 32BITREQUIRED and 32BITPREFERRED bits are one two-bit field. Reading 32BITREQUIRED on its own reported Prefer32Bit assemblies (flags 0x00020003) as x86-only with requires_32bit true, and left anycpu-prefer-32 reachable only through the reserved combination. Co-authored-by: Claude Opus 5.5 --- src/dotnet/ManagedArtifactInspector.test.ts | 24 +++++++++++++++++++++ src/dotnet/ManagedArtifactInspector.ts | 7 +++--- src/dotnet/ManagedNativeBoundaryHelpers.ts | 7 ++++-- src/dotnet/ManagedPeReader.ts | 14 ++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/dotnet/ManagedArtifactInspector.test.ts b/src/dotnet/ManagedArtifactInspector.test.ts index 2d30bf580..3b734c176 100644 --- a/src/dotnet/ManagedArtifactInspector.test.ts +++ b/src/dotnet/ManagedArtifactInspector.test.ts @@ -292,6 +292,30 @@ describe("managed artifact inventory", () => { ]); expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); }); + + // dotnet/runtime corhdr.h: 32BITREQUIRED and 32BITPREFERRED form one + // two-bit field, so neither bit can be read on its own. + it.each([ + ["neither bit", 0x0000_0001, "anycpu", false], + ["32BITREQUIRED alone", 0x0000_0003, "x86", true], + ["both bits", 0x0002_0003, "anycpu-prefer-32", false], + ["the reserved 32BITPREFERRED alone", 0x0002_0001, "anycpu", false], + ] as const)( + "reads %s as one 32-bit requirement", + (_label, cliFlags, architecture, requires32Bit) => { + const bytes = buildManagedPeFixture({ cliFlags }); + const target = managedPeFixtureTarget(bytes); + + expect( + inspectManagedArtifactBytes(bytes, target).classification + .managed_architecture, + ).toBe(architecture); + expect( + inspectManagedNativeBoundariesBytes(bytes, target).cli_native + ?.requires_32bit, + ).toBe(requires32Bit); + }, + ); }); describe("managed artifact coded indexes", () => { diff --git a/src/dotnet/ManagedArtifactInspector.ts b/src/dotnet/ManagedArtifactInspector.ts index 7df8aae83..7e7acb087 100644 --- a/src/dotnet/ManagedArtifactInspector.ts +++ b/src/dotnet/ManagedArtifactInspector.ts @@ -9,6 +9,7 @@ import { type ManagedParseIssue, } from "../domain/managed/managedArtifact.js"; import { + managedCli32BitRequirement, readManagedPeLayout, type ManagedCliHeader, type ManagedPeLayout, @@ -249,9 +250,9 @@ const managedArchitectureFor = ( ): ManagedArtifactInspection["classification"]["managed_architecture"] => { if (layout.architecture !== "x86" || (cli.flags & 1) === 0) return layout.architecture; - if ((cli.flags & 0x0000_0002) !== 0) return "x86"; - if ((cli.flags & 0x0002_0000) !== 0) return "anycpu-prefer-32"; - return "anycpu"; + const requirement = managedCli32BitRequirement(cli.flags); + if (requirement === "required") return "x86"; + return requirement === "preferred" ? "anycpu-prefer-32" : "anycpu"; }; const runtimeClassification = ({ diff --git a/src/dotnet/ManagedNativeBoundaryHelpers.ts b/src/dotnet/ManagedNativeBoundaryHelpers.ts index fb81e1d28..e4b1a5960 100644 --- a/src/dotnet/ManagedNativeBoundaryHelpers.ts +++ b/src/dotnet/ManagedNativeBoundaryHelpers.ts @@ -18,7 +18,10 @@ import { } from "./ManagedMetadataHeaps.js"; import { managedTableRowCounts } from "./ManagedMetadataInventory.js"; import type { ManagedMetadataInventory } from "./ManagedMetadataInventory.js"; -import type { ManagedPeLayout } from "./ManagedPeReader.js"; +import { + managedCli32BitRequirement, + type ManagedPeLayout, +} from "./ManagedPeReader.js"; import { readManagedValue } from "./ManagedReaderFailure.js"; type ModuleRef = ManagedNativeBoundaryInspection["module_refs"][number]; @@ -380,7 +383,7 @@ export const cliNative = ( if (pe.cli === null) return null; return { il_only: (pe.cli.flags & 0x0000_0001) !== 0, - requires_32bit: (pe.cli.flags & 0x0000_0002) !== 0, + requires_32bit: managedCli32BitRequirement(pe.cli.flags) === "required", strong_name_signed: (pe.cli.flags & 0x0000_0008) !== 0, native_entry_point: (pe.cli.flags & 0x0000_0010) !== 0, ready_to_run_signature: pe.cli.readyToRunSignature, diff --git a/src/dotnet/ManagedPeReader.ts b/src/dotnet/ManagedPeReader.ts index 2f9bc3151..9a30c007b 100644 --- a/src/dotnet/ManagedPeReader.ts +++ b/src/dotnet/ManagedPeReader.ts @@ -31,6 +31,20 @@ export interface ManagedCliHeader { readonly readyToRunSignature: boolean; } +/** + * How the CLI header constrains process bitness. 32BITREQUIRED and + * 32BITPREFERRED are one two-bit field: required alone is x86-only, both set + * is platform-neutral code that prefers a 32-bit process, and preferred alone + * is reserved and constrains nothing. + */ +export const managedCli32BitRequirement = ( + flags: number, +): "required" | "preferred" | "none" => { + const pair = flags & 0x0002_0002; + if (pair === 0x0000_0002) return "required"; + return pair === 0x0002_0002 ? "preferred" : "none"; +}; + export interface ManagedPeLayout { readonly machine: number; readonly architecture: "x86" | "x86_64" | "arm" | "arm64"; From d3a55720fc4aac8f9d2c5f5a4c9f20cdf5594d8e Mon Sep 17 00:00:00 2001 From: Chaoqi <136579807+Chaoqi31@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:44:17 -0400 Subject: [PATCH 17/92] fix(setup): keep Hermes config numbers as written (#2368) Setup and uninstall re-printed every number in an existing Hermes config.yaml from a JavaScript number. Hermes reads the file as YAML 1.1 with exact integers, so a Discord channel ID lost its low digits, an octal mode such as 0600 became 600, and 0.0000001 became 1e-7, which YAML 1.1 does not define as a float. Two IDs that round to the same double were also rejected as duplicate keys, so setup could not register at all. Hermes YAML parsing and editing move to HermesConfigurationYaml. A parsed number is written with its source spelling while that spelling still reads as the node's value, the rule yaml's own boolean tag follows, and two keys are duplicates only when both their value and their spelling match. Parsed values stay JavaScript numbers. --- .../ClientConfigurationDocument.ts | 26 ++++--- .../HermesConfigurationYaml.test.ts | 21 ++++++ src/application/HermesConfigurationYaml.ts | 74 +++++++++++++++++++ .../hermesClientConfiguration.test.ts | 32 ++++++++ 4 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 src/application/HermesConfigurationYaml.test.ts create mode 100644 src/application/HermesConfigurationYaml.ts diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index b32df3fd0..1b241a98b 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -1,9 +1,5 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; -import { - parse as parseYaml, - parseDocument as parseYamlDocument, - stringify as stringifyYaml, -} from "yaml"; +import { stringify as stringifyYaml } from "yaml"; import { isDeepStrictEqual } from "node:util"; import { @@ -12,6 +8,10 @@ import { editDshDocument, dshClientRegistrationEntry, } from "./DshCordisPatch.js"; +import { + editHermesConfigurationYaml, + parseHermesConfigurationYaml, +} from "./HermesConfigurationYaml.js"; import { applyEdits, findNodeAtLocation, @@ -118,7 +118,10 @@ const parseDocument = ( if (isEmptyClientConfigurationText(text)) return {}; // Hermes stores MCP servers in `mcp_servers` inside a YAML document. if (format === "hermes") - return requireObject(parseYaml(text), "The configuration root"); + return requireObject( + parseHermesConfigurationYaml(text), + "The configuration root", + ); // ZCode loads nonempty native files with strict JSON.parse. if (format === "zcode") return requireObject(JSON.parse(text), "The configuration root"); @@ -1419,12 +1422,11 @@ const serializeHermesConfiguration = ( typeof servers === "object" && servers !== null ? (servers as Record)[PRODUCT_IDENTITY.mcpServerKey] : undefined; - const yamlDocument = parseYamlDocument(originalText); - if (entry === undefined) - yamlDocument.deleteIn([serversKey, PRODUCT_IDENTITY.mcpServerKey]); - else yamlDocument.setIn([serversKey, PRODUCT_IDENTITY.mcpServerKey], entry); - // Editing REA must not reflow unrelated user prompts or other YAML scalars. - return yamlDocument.toString({ lineWidth: 0 }); + return editHermesConfigurationYaml( + originalText, + [serversKey, PRODUCT_IDENTITY.mcpServerKey], + entry, + ); }; /** Build the stdio entry shape expected by one client's configuration dialect. */ diff --git a/src/application/HermesConfigurationYaml.test.ts b/src/application/HermesConfigurationYaml.test.ts new file mode 100644 index 000000000..41b74fcc9 --- /dev/null +++ b/src/application/HermesConfigurationYaml.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { + editHermesConfigurationYaml, + parseHermesConfigurationYaml, +} from "./HermesConfigurationYaml.js"; + +describe("Hermes configuration YAML", () => { + it("writes an edited number from its new value", () => { + expect( + editHermesConfigurationYaml("mode: 0600\nmask: 0x1F\n", ["mode"], 384), + ).toBe("mode: 384\nmask: 0x1F\n"); + }); + + it("accepts keys Hermes reads as distinct and rejects a repeated key", () => { + expect(() => + parseHermesConfigurationYaml("0600: octal\n600: decimal\n"), + ).not.toThrow(); + expect(() => parseHermesConfigurationYaml('a: 1\n"a": 2\n')).toThrow(); + }); +}); diff --git a/src/application/HermesConfigurationYaml.ts b/src/application/HermesConfigurationYaml.ts new file mode 100644 index 000000000..8d245da37 --- /dev/null +++ b/src/application/HermesConfigurationYaml.ts @@ -0,0 +1,74 @@ +import { + isScalar, + parse, + parseDocument, + type ParsedNode, + type ScalarTag, + type Tags, +} from "yaml"; + +const NUMBER_TAGS = new Set([ + "tag:yaml.org,2002:int", + "tag:yaml.org,2002:float", +]); + +/** + * Write a parsed number with its source spelling while that spelling still + * reads as the node's value. Hermes reads config.yaml as YAML 1.1 with exact + * integers, so re-printing a JavaScript number changes what it reads: a + * Discord channel ID loses its low digits, `0600` (octal) becomes `600`, and + * `0.0000001` becomes `1e-7`, which YAML 1.1 does not define as a float. + */ +const keepNumberSource = (tag: Tags[number]): Tags[number] => { + if (typeof tag === "string" || tag.collection !== undefined) return tag; + const { stringify } = tag; + if (!NUMBER_TAGS.has(tag.tag) || stringify === undefined) return tag; + return { + ...tag, + stringify: (item, context, ...rest) => { + const { source } = item; + if (source === undefined) return stringify(item, context, ...rest); + const reader = context.doc.schema.tags.find( + (candidate): candidate is ScalarTag => + candidate.default === true && candidate.test?.test(source) === true, + ); + const read = reader?.resolve( + source, + () => undefined, + context.doc.options, + ); + return Object.is(isScalar(read) ? read.value : read, item.value) + ? source + : stringify(item, context, ...rest); + }, + }; +}; + +const HERMES_YAML_OPTIONS = { + customTags: (tags: Tags) => tags.map(keepNumberSource), + // Hermes keeps integers exact, so IDs that round to one double, or `0600` + // and `600`, are distinct keys. + uniqueKeys: (a: ParsedNode, b: ParsedNode) => + a === b || + (isScalar(a) && + isScalar(b) && + a.value === b.value && + a.source === b.source), +}; + +/** Parse a Hermes `config.yaml`. */ +export const parseHermesConfigurationYaml = (text: string): unknown => + parse(text, HERMES_YAML_OPTIONS); + +/** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */ +export const editHermesConfigurationYaml = ( + text: string, + path: readonly string[], + value: unknown, +): string => { + const document = parseDocument(text, HERMES_YAML_OPTIONS); + if (value === undefined) document.deleteIn(path); + else document.setIn(path, value); + // Editing REA must not reflow unrelated user prompts or other YAML scalars. + return document.toString({ lineWidth: 0 }); +}; diff --git a/tests/boundary/filesystem/hermesClientConfiguration.test.ts b/tests/boundary/filesystem/hermesClientConfiguration.test.ts index 43a5f841c..218573d5c 100644 --- a/tests/boundary/filesystem/hermesClientConfiguration.test.ts +++ b/tests/boundary/filesystem/hermesClientConfiguration.test.ts @@ -237,6 +237,38 @@ mcp_servers: expect(await readFile(client.configPath, "utf8")).toBe(original); }); + it("keeps unrelated numbers as written, including Discord IDs", async () => { + const { home, client } = await hermesClient(); + // Hermes reads these exactly: two IDs one apart, an octal mode, a float. + const original = `discord: + free_response_channels: + - 1187654321098765432 + channel_prompts: + 1187654321098765433: Build questions. + 1187654321098765434: Release questions. + mode: 0600 + min_score: 0.0000001 +mcp_servers: + other: + command: other-server +`; + await writeFile(client.configPath, original); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text.startsWith(original)).toBe(true); + expect(parseYaml(text, { intAsBigInt: true })).toMatchObject({ + mcp_servers: { + rea: { command: command[0], args: command.slice(1), enabled: true }, + }, + }); + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(await readFile(client.configPath, "utf8")).toBe(original); + }); + it("creates config.yaml when Hermes has no configuration yet", async () => { const { client } = await hermesClient(); expect( From f63674cac0329aa3ed76234b31cf8a3536ac07d0 Mon Sep 17 00:00:00 2001 From: Danish Rafie Ekaputra <155859586+0xRafie@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:44:41 +0700 Subject: [PATCH 18/92] docs(browser): clarify attach coverage and Chromium setup (#2287) Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/browser-observation.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 2f73f4261..dca1f994b 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -80,6 +80,27 @@ google-chrome \ REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface. +Other Chromium-family browsers, such as Chromium or Brave, work the same way +with the same two flags. On macOS, run the executable inside the application +bundle, for example +`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"` or +`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"`. Fedora installs +Chromium as `chromium-browser`. + +REA needs the browser's HTTP discovery endpoint on a loopback port. A browser +driven over `--remote-debugging-pipe` or a private channel, for example by an +automation SDK, exposes no such endpoint, so `list_browser_targets` has nothing +to attach to. Connection failures report `endpoint_unreachable`; a reachable +HTTP service that returns a non-200 or non-JSON discovery response reports +`invalid_endpoint_response`. +Reproduce that flow in a Chromium-family browser started with +`--remote-debugging-port`. + +In a minimal Linux container, headed Chromium under Xvfb can keep running while +its DevTools port never opens. Install the GLES, EGL, and Mesa DRI libraries +(on Fedora: `libglvnd-gles`, `mesa-libEGL`, and `mesa-dri-drivers`), then +restart the browser. + On Linux, a reachable but locked desktop keyring can stall Chrome before its first navigation. For a dedicated investigation profile, add `--password-store=basic` to bypass that keyring. Empty or placeholder frame URLs @@ -207,7 +228,7 @@ commands fail closed. A correlated reply's session ID must match the command's selected session. An optional method is unavailable only when the browser rejects it as unknown; other command rejections retain their reported failure. -Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete. +Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Scripts are different: scripts the page parsed before REA attached are still reported under `scripts`. A page that loaded before attach can therefore list its scripts while `network.requests` lacks the requests that fetched them. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete. Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Each fetch operation has a 30-second deadline and a 64 MiB aggregate streamed-response budget across redirects. A deadline or byte-limit failure remains explicitly unavailable and makes bundle completeness partial. Source-map decoding also bounds raw resolved identities to 64 MiB and aggregate inline evidence to 32 MiB. Failed maps preserve their failure context when it fits; if another context cannot fit, collection stops with the full requested count, the returned processed count, and an explicit unknown-results limitation. Retained mappings and original sources are returned inline. See [source-location limits](web-source-location.md) for the structural decoder bounds. Original source artifacts with `.ts`, `.tsx`, `.mts`, or `.cts` suffixes carry `text/typescript`; other source names retain `text/javascript`. From 0ca70e121c43db41d02c5afb6439e15cbf335289 Mon Sep 17 00:00:00 2001 From: AbdulAzizWang <95555196+AbdulAzizWang@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:51:18 +0800 Subject: [PATCH 19/92] fix(ghidra): pre-create the launch-support config directory (#2470) * fix(ghidra): pre-create the launch-support config directory Ghidra's launch support resolves its JDK before the headless run starts and persists that choice under `/ghidra/`. Inside REA's private runtime a child JVM cannot create those directories, so the first Windows Ghidra query aborted at ghidra.launch.AppConfig.saveJavaHome with: java.io.IOException: Failed to create directory: ...\config\ghidra\ghidra_12.1.4_PUBLIC at ghidra.launch.AppConfig.saveJavaHome(AppConfig.java:145) at LaunchSupport.findJavaHome(LaunchSupport.java:210) LaunchSupport then fell back to its interactive prompt, read EOF, and reported "JDK 21+ (64-bit) could not be found and must be manually chosen!", so the provider failed with "Ghidra exited before bridge startup" even though `rea doctor` reported a valid Ghidra installation and JDK. Create the release directory while this process still owns the runtime, leaving Ghidra only a file write inside it. The release coordinate comes from the installation's Ghidra/application.properties (application.version plus application.release.name) with the extraction directory name as a fallback. On Windows each level goes through the native private-runtime owner, which opens every existing component and creates only the final one. Verified on Windows x64 with Ghidra 12.1.4, a 64-bit Temurin JDK 21, and REA's packaged native addon: `rea inspect --provider ghidra` and the equivalent MCP `binary_overview` call both complete and report 21 segments, 1436 procedures, and 2761 strings. Windows Ghidra remains read-only P0; this change does not extend that boundary. * style(ghidra): format launch configuration regressions * fix(ghidra): reuse Java Properties parsing for launch release identity --------- Co-authored-by: AbdulAzizShanShi <441650742@QQ.COM> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/ghidra/GhidraLauncher.ts | 50 +++++++++++++- .../providers/ghidra/ghidraLauncher.test.ts | 67 ++++++++++++++++++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 59c555606..e1527ea81 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -1,3 +1,4 @@ +import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js"; import { GHIDRA_SEED_SCRIPT, snapshotGhidraAnalysisSeeds, @@ -5,7 +6,7 @@ import { } from "./GhidraAnalysisSeeds.js"; import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; -import { mkdir, realpath } from "node:fs/promises"; +import { mkdir, readFile, realpath } from "node:fs/promises"; import { basename, dirname, join, posix, win32 } from "node:path"; import writeFileAtomic from "write-file-atomic"; @@ -219,7 +220,11 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { "Installed Allegrex content changed since profile resolution; reopen the target to resolve a fresh profile.", ); } - await createGhidraRuntimeDirectories(paths, platform); + await createGhidraRuntimeDirectories( + paths, + platform, + await ghidraConfigReleaseDirectory(this.options.analyzeHeadlessPath), + ); const seedPath = this.options.analysisSeeds === undefined ? undefined @@ -721,9 +726,44 @@ const ghidraRuntimePaths = (runtimeRoot: string) => ({ scriptLogPath: join(runtimeRoot, "script.log"), }); +/** + * Ghidra's launch support resolves its JDK before the headless run and then + * persists that choice under `/ghidra/`. Inside + * REA's Windows private runtime a child JVM cannot create those directories + * itself, so startup aborted with "Failed to create directory" and reported + * that no supported JDK was found. Create the release directory while this + * process still owns the runtime, leaving Ghidra only a file write inside it. + */ +const ghidraConfigReleaseDirectory = async ( + analyzeHeadlessPath: string, +): Promise => { + const installRoot = dirname(dirname(analyzeHeadlessPath)); + try { + const properties = await readFile( + join(installRoot, "Ghidra", "application.properties"), + "latin1", + ); + const parsed = parseGhidraJavaProperties(properties); + if (!parsed.ok) return basename(installRoot); + const version = parsed.value.get("application.version"); + const release = parsed.value.get("application.release.name"); + return version === undefined || + release === undefined || + release.length === 0 + ? basename(installRoot) + : `ghidra_${version}_${release}`; + } catch (cause: unknown) { + // An unreadable properties file leaves the release coordinate unknown; the + // conventional extraction directory carries the same name. + void cause; + return basename(installRoot); + } +}; + const createGhidraRuntimeDirectories = async ( paths: ReturnType, platform: NodeJS.Platform, + configReleaseDirectory: string, ): Promise => { if (platform === "win32") { const runtime = windowsPrivateRuntime(dirname(paths.projectRoot)); @@ -736,6 +776,11 @@ const createGhidraRuntimeDirectories = async ( paths.dataRoot, ]) runtime.mkdir(basename(path)); + // Each level needs its parent first: the native owner opens every existing + // component and creates only the final one. + const configRoot = basename(paths.configRoot); + runtime.mkdir(win32.join(configRoot, "ghidra")); + runtime.mkdir(win32.join(configRoot, "ghidra", configReleaseDirectory)); return; } await Promise.all( @@ -746,6 +791,7 @@ const createGhidraRuntimeDirectories = async ( paths.cacheRoot, paths.configRoot, paths.dataRoot, + join(paths.configRoot, "ghidra", configReleaseDirectory), ].map((path) => mkdir(path, { recursive: true, mode: 0o700 })), ); }; diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 270bf02b3..ea8fb964c 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -1,4 +1,11 @@ -import { access, chmod, readFile, stat } from "node:fs/promises"; +import { + access, + chmod, + mkdir, + readFile, + stat, + writeFile, +} from "node:fs/promises"; import { join, posix, win32 } from "node:path"; import { fileURLToPath } from "node:url"; @@ -480,6 +487,64 @@ describe("Ghidra Windows launcher", () => { ).toThrow(/metacharacters/u); }); + it.skipIf(process.platform !== "win32").each([ + { + properties: + "application.version=12.1.4\napplication.release.name=PUBLIC\n", + release: "ghidra_12.1.4_PUBLIC", + }, + { properties: undefined, release: "renamed-install" }, + { properties: "application.version=12.1.4\n", release: "renamed-install" }, + ])( + "prepares the release directory through the Windows runtime ($release)", + async ({ properties, release }) => { + const parent = await createTestTempDirectory("rea-launch-config-"); + const installRoot = join(parent, "renamed-install"); + if (properties !== undefined) { + await mkdir(join(installRoot, "Ghidra"), { recursive: true }); + await writeFile( + join(installRoot, "Ghidra", "application.properties"), + properties, + ); + } + const runtime = await createGhidraTestRuntime(parent); + runtimes.push(runtime); + const spawnProcess = vi.fn(async () => { + throw new Error("fixture stops before process creation"); + }); + const launcher = new GhidraHeadlessLauncher({ + environment: process.env, + platform: "win32", + analyzeHeadlessPath: join( + installRoot, + "support", + "analyzeHeadless.bat", + ), + javaHome: "C:\\Java\\jdk-21", + bridgeScriptPath: join(parent, "ReaGhidraBridge.java"), + spawnProcess, + }); + const launched = await launcher.launch({ + runtimeRoot: runtime.path, + transport: "unix-socket", + endpointPath: join(runtime.path, "bridge.sock"), + token: "fixture-token", + runId: "d6fcbb66-e829-4ff6-a535-0035aec63139", + targetPath: join(parent, "fixture.exe"), + targetSha256: "b".repeat(64), + providerVersion: "12.1.4", + profileDigest: "a".repeat(64), + }); + expect(launched.ok).toBe(false); + expect(spawnProcess).toHaveBeenCalledOnce(); + expect( + ( + await stat(join(runtime.path, "config", "ghidra", release)) + ).isDirectory(), + ).toBe(true); + }, + ); + it.skipIf(process.platform !== "win32")( "keeps authority in a private descriptor and isolates Ghidra state through the batch launcher", async () => { From 03ab521db3330493372dd47ab858fc142feea5b9 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:51:44 -0400 Subject: [PATCH 20/92] fix(process): resolve POSIX backslash basenames through PATH (#2471) Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../ProcessScenarioRuntimeValidation.test.ts | 22 ++++++++++++++ .../ProcessScenarioRuntimeValidation.ts | 6 ++-- .../processCaptureNormalization.test.ts | 29 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts index 1277b753f..7e5cb7e30 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts @@ -17,6 +17,28 @@ import { resolveProcessScenarioRuntimePaths, } from "./ProcessScenarioRuntimeValidation.js"; import { parseProcessScenario } from "../../domain/process/processScenario.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform === "win32")( + "resolves a literal backslash in a POSIX executable basename through PATH", + async () => { + const root = await createTestTempDirectory("rea-process-posix-basename-"); + const bin = join(root, "bin"); + await mkdir(bin); + const command = "selected\\node"; + const selected = join(bin, command); + await symlink(process.execPath, selected); + await expect(resolveExecutable(command, root, bin)).resolves.toBe(selected); + const resolved = await resolveProcessScenarioRuntimePaths( + parseProcessScenario({ + executable: command, + working_directory: root, + environment: { PATH: bin }, + }), + ); + expect(resolved.executable).toBe(selected); + }, +); it("preserves the caller-selected executable symlink for process invocation", async () => { const root = await mkdtemp(join(tmpdir(), "rea-process-executable-alias-")); diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.ts b/src/process/capture/ProcessScenarioRuntimeValidation.ts index 1ac9d2228..6d2978b5b 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.ts @@ -1,6 +1,6 @@ import { constants as fsConstants } from "node:fs"; import { access, realpath, stat } from "node:fs/promises"; -import { delimiter, isAbsolute, join, resolve } from "node:path"; +import { delimiter, isAbsolute, join, resolve, sep } from "node:path"; import type { ProcessScenario } from "../../domain/process/processScenario.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; @@ -71,7 +71,9 @@ export const resolveExecutable = async ( : resolve(workingDirectory, directory), ); const candidates = - isAbsolute(command) || command.includes("/") || command.includes("\\") + isAbsolute(command) || + command.includes("/") || + (sep === "\\" && command.includes("\\")) ? [resolve(workingDirectory, command)] : directories.map((directory) => join(directory, command)); const failures: string[] = []; diff --git a/tests/boundary/process/processCaptureNormalization.test.ts b/tests/boundary/process/processCaptureNormalization.test.ts index 2837ccf40..159f84c93 100644 --- a/tests/boundary/process/processCaptureNormalization.test.ts +++ b/tests/boundary/process/processCaptureNormalization.test.ts @@ -28,6 +28,35 @@ const identitySchema = z.object({ const itWithLinuxCaptureCapability = process.platform === "linux" ? itWithCaptureCapability : it.skip; +itWithCaptureCapability( + "captures a POSIX PATH executable with a literal backslash in its basename", + async () => { + const root = await createTestTempDirectory("rea-process-backslash-name-"); + const bin = join(root, "bin"); + await mkdir(bin); + const command = "selected\\node"; + const selected = join(bin, command); + await symlink(process.execPath, selected); + const scenario = parseProcessScenario({ + executable: command, + working_directory: root, + environment: { PATH: bin }, + arguments: ["-e", 'process.stdout.write("selected executable")'], + settle_ms: 100, + }); + const result = await captureProcessScenario(scenario); + if (!result.ok) throw result.error; + expect(result.value.frames.map(({ data }) => data).join("")).toBe( + "selected executable", + ); + expect(result.value.exit).toMatchObject({ code: 0, reason: "exited" }); + expect(result.value.cleanup).toMatchObject({ + owned_process_group: "verified", + temporary_root: "removed", + }); + }, +); + itWithCaptureCapability( "preserves numeric reports and original normalized PTY text in Evidence", async () => { From 43738bec579fc69bab084bffa964916d43d43ed1 Mon Sep 17 00:00:00 2001 From: Aurantium <3550271410@qq.com> Date: Tue, 13 Oct 2026 05:52:17 -0700 Subject: [PATCH 21/92] fix(targets): retain unsupported PE machine in diagnostics (#2448) Assisted-by: OpenAI GPT-6 Co-authored-by: Aurantium-47 <280805072+Aurantium-47@users.noreply.github.com> --- src/domain/binaryTarget.ts | 8 ++++-- .../filesystem/targetDiagnostics.test.ts | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 48da65960..99bc6dd24 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -278,8 +278,12 @@ export const parsePeRecord = ( record.toString("binary", 0, 4) !== "PE\u0000\u0000" ) return err("invalid or truncated PE header"); - const machine = decodePeMachine(record.readUInt16LE(4)); - if (machine === undefined) return err("unsupported PE architecture"); + const machineType = record.readUInt16LE(4); + const machine = decodePeMachine(machineType); + if (machine === undefined) + return err( + `unsupported PE architecture (COFF machine 0x${machineType.toString(16).padStart(4, "0")})`, + ); const { architecture } = machine; const optionalHeaderSize = record.readUInt16LE(20); if (optionalHeaderSize > 4096 || record.length < 24 + optionalHeaderSize) diff --git a/tests/boundary/filesystem/targetDiagnostics.test.ts b/tests/boundary/filesystem/targetDiagnostics.test.ts index 5d1953760..dacd21687 100644 --- a/tests/boundary/filesystem/targetDiagnostics.test.ts +++ b/tests/boundary/filesystem/targetDiagnostics.test.ts @@ -4,9 +4,35 @@ import { describe, expect, it } from "vitest"; import { systemDoctorHost } from "../../../src/application/Doctor.js"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { pe } from "../../../src/domain/binaryTarget.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("executable format refusal diagnostics", () => { + it.each([ + [0x01a6, "0x01a6"], + [0x0000, "0x0000"], + [0xffff, "0xffff"], + ] as const)( + "retains unsupported PE machine %i in the refusal", + async (machine, expectedMachine) => { + const directory = await createTestTempDirectory("rea-target-pe-machine-"); + const path = join(directory, "unsupported.exe"); + const bytes = pe(0x014c); + bytes.writeUInt16LE(machine, 0x44); + await writeFile(path, bytes); + + const result = await parseBinaryTarget(path); + if (result.ok) throw new Error("unsupported PE target was admitted"); + const reason = `unsupported PE architecture (COFF machine ${expectedMachine})`; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "target_unavailable", + category: "unsupported_target", + message: `Cannot open artifact: ${reason}.`, + details: { reason, constraint: "file_format_rejected" }, + }); + }, + ); + it.each(["NE", "LE", "LX"] as const)( "reports unsupported %s executables without path advice", async (signature) => { From 8e6f37e2a0873ac21ebcc889c27839bfcb7552ca Mon Sep 17 00:00:00 2001 From: Lucas LSMO Date: Tue, 13 Oct 2026 09:54:37 -0300 Subject: [PATCH 22/92] fix(formats): preserve high bits in remaining magic-byte checks (#2384) * fix(formats): preserve high bits in remaining magic-byte checks Node's "ascii" decoder masks the high bit, so byte-for-byte signature comparisons accepted lookalikes such as CD DA ("MZ" | 0x80) or a high-bit "bplist00"/"NIBArchive" header. ac57c88ef fixed this for the shared DOS/MZ reader; apply the same latin1 decoding to the remaining signature checks: NativeAOT and Windows native loader MZ/PE, binary plist and NIBArchive detection, and PNG IHDR checks. Display-name decodes (section and metadata stream names) are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * style(windows): format signature admission regressions --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- scripts/verify-windows-native-artifact.mjs | 4 ++-- src/domain/native/nativeAotPe.test.ts | 19 +++++++++++++++++++ src/domain/native/nativeAotPe.ts | 4 ++-- src/windows/WindowsNativeLoader.test.ts | 16 ++++++++++++++++ src/windows/WindowsNativeLoader.ts | 4 ++-- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/scripts/verify-windows-native-artifact.mjs b/scripts/verify-windows-native-artifact.mjs index 1a6777322..4db66e0ee 100644 --- a/scripts/verify-windows-native-artifact.mjs +++ b/scripts/verify-windows-native-artifact.mjs @@ -27,9 +27,9 @@ if ( ); const offset = bytes.length >= 64 ? bytes.readUInt32LE(0x3c) : bytes.length; if ( - bytes.toString("ascii", 0, 2) !== "MZ" || + bytes.toString("latin1", 0, 2) !== "MZ" || offset > bytes.length - 26 || - bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" || + bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" || bytes.readUInt16LE(offset + 4) !== 0x8664 || bytes.readUInt16LE(offset + 24) !== 0x20b ) diff --git a/src/domain/native/nativeAotPe.test.ts b/src/domain/native/nativeAotPe.test.ts index 8ae624d44..ab031cc85 100644 --- a/src/domain/native/nativeAotPe.test.ts +++ b/src/domain/native/nativeAotPe.test.ts @@ -159,3 +159,22 @@ describe("read-only NativeAOT PE parser", () => { }); }); }); + +describe("NativeAOT PE signature admission", () => { + it.each([ + ["MZ", "Not a PE image"], + ["PE", "Invalid PE signature"], + ])( + "rejects a high-bit lookalike of the %s signature", + (signature, reason) => { + const bytes = nativeAotPeFixture(); + const offset = signature === "MZ" ? 0 : bytes.readUInt32LE(0x3c); + for (const position of [offset, offset + 1]) + bytes.writeUInt8(bytes.readUInt8(position) | 0x80, position); + expect( + parseNativeAotPe(bytes, nativeAotPeDigest(bytes), SUMMARY_BUDGET) + .summary, + ).toMatchObject({ status: "not_applicable", reason }); + }, + ); +}); diff --git a/src/domain/native/nativeAotPe.ts b/src/domain/native/nativeAotPe.ts index b67a58398..7f9c5eb55 100644 --- a/src/domain/native/nativeAotPe.ts +++ b/src/domain/native/nativeAotPe.ts @@ -384,11 +384,11 @@ class PeImage { constructor(bytes: Buffer) { this.#bytes = bytes; - if (bytes.length < 0x40 || bytes.toString("ascii", 0, 2) !== "MZ") + if (bytes.length < 0x40 || bytes.toString("latin1", 0, 2) !== "MZ") throw new Error("Not a PE image"); const peOffset = this.#u32(0x3c); this.#range(peOffset, 24); - if (bytes.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") + if (bytes.toString("latin1", peOffset, peOffset + 4) !== "PE\0\0") throw new Error("Invalid PE signature"); this.#machine = this.#u16(peOffset + 4); const count = this.#u16(peOffset + 6); diff --git a/src/windows/WindowsNativeLoader.test.ts b/src/windows/WindowsNativeLoader.test.ts index 1aa8d7375..c0fee8273 100644 --- a/src/windows/WindowsNativeLoader.test.ts +++ b/src/windows/WindowsNativeLoader.test.ts @@ -143,6 +143,22 @@ describe("packaged Windows native admission", () => { }, ); + it.each([ + { signature: "MZ", offset: 0 }, + { signature: "PE", offset: 64 }, + ])( + "rejects digest-valid high-bit $signature lookalikes before loading", + ({ offset }) => { + const value = fixture(); + value.bytes[offset] = (value.bytes[offset] ?? 0) | 0x80; + value.manifest.artifactSha256 = createHash("sha256") + .update(value.bytes) + .digest("hex"); + expect(loadWindowsNativeAuthority(value.host).available).toBe(false); + expect(value.loads()).toBe(0); + }, + ); + it("rejects a digest-valid artifact for another machine", () => { const value = fixture(); value.bytes.writeUInt16LE(0xaa64, 68); diff --git a/src/windows/WindowsNativeLoader.ts b/src/windows/WindowsNativeLoader.ts index 7719a0a85..2ab08e1ed 100644 --- a/src/windows/WindowsNativeLoader.ts +++ b/src/windows/WindowsNativeLoader.ts @@ -132,12 +132,12 @@ export const loadWindowsNativeAuthority = ( }; const assertWindowsX64Artifact = (bytes: Buffer): void => { - if (bytes.length < 64 || bytes.toString("ascii", 0, 2) !== "MZ") + if (bytes.length < 64 || bytes.toString("latin1", 0, 2) !== "MZ") throw new Error("Windows native artifact is not a PE image"); const offset = bytes.readUInt32LE(0x3c); if ( offset > bytes.length - 26 || - bytes.toString("ascii", offset, offset + 4) !== "PE\0\0" || + bytes.toString("latin1", offset, offset + 4) !== "PE\0\0" || bytes.readUInt16LE(offset + 4) !== 0x8664 || bytes.readUInt16LE(offset + 24) !== 0x20b ) From e0bd31c18c84fc406ad1c1fe412bc9018ede44ec Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:55:40 +0700 Subject: [PATCH 23/92] fix(ghidra): bound the Java version probe output (#2361) The Java version probe captured its output with maxBuffer set to Infinity, so a chatty or hostile JVM selected as the Ghidra launcher could grow the captured stdout/stderr without limit and exhaust REA's memory during installation discovery. Bound the capture to 1 MiB, matching the budget already used for other Java listings. A probe that exceeds the budget yields no observation and is reported through the existing failed-probe path, the same one the Java check already uses whenever the probe cannot produce an observation. Co-authored-by: Claude Code --- src/ghidra/GhidraInstallation.ts | 11 ++++- .../filesystem/ghidraJavaProbe.test.ts | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index c6ca5212d..9675d133f 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -72,6 +72,15 @@ const NATIVE_ARCHITECTURES: readonly { /** Bound installation discovery reads; larger headers remain unknown. */ const EXECUTABLE_HEADER_PROBE_BYTES = 4096; +/** + * Bound the Java version probe so a chatty or hostile JVM cannot force an + * unbounded in-memory capture. Well-behaved JVMs print a few kilobytes for + * `-XshowSettings:properties`; the same 1 MiB budget used for other Java + * listings leaves ample headroom. A probe that exceeds the budget yields no + * observation and is reported through the existing failed-probe path. + */ +const JAVA_PROBE_MAX_OUTPUT_BYTES = 1024 * 1024; + /** Compatibility of one present native decompiler with the inspected host. */ type NativeDecompilerCompatibility = | { @@ -763,7 +772,7 @@ const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ encoding: "utf8", env: environment, timeout: 5_000, - maxBuffer: Number.POSITIVE_INFINITY, + maxBuffer: JAVA_PROBE_MAX_OUTPUT_BYTES, windowsHide: true, }, ); diff --git a/tests/boundary/filesystem/ghidraJavaProbe.test.ts b/tests/boundary/filesystem/ghidraJavaProbe.test.ts index c0a6974d5..415fbf3dd 100644 --- a/tests/boundary/filesystem/ghidraJavaProbe.test.ts +++ b/tests/boundary/filesystem/ghidraJavaProbe.test.ts @@ -87,3 +87,43 @@ it.each([ ); }, ); + +it("bounds the Java version probe and reports an over-budget probe as a failed Java check", async () => { + const root = await createTestTempDirectory("rea-java-probe-bound-"); + const configuredHome = join(root, "selected-jdk"); + const installDir = join(root, "ghidra"); + await mkdir(join(installDir, "Ghidra"), { recursive: true }); + await writeFile( + join(installDir, "Ghidra", "application.properties"), + `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`, + ); + // A chatty or hostile JVM that overruns the capture budget: spawnSync reports + // ENOBUFS, which the probe already treats as a failed observation. + mockSpawnSync.mockReturnValue({ + stdout: "", + stderr: "java.version = 21.0.11\n", + status: null, + error: Object.assign(new Error("spawnSync /java ENOBUFS"), { + code: "ENOBUFS", + }), + }); + + const inspection = inspectGhidraInstallation({ + environment: {}, + installDir, + javaHome: configuredHome, + }); + + const [, , options] = mockSpawnSync.mock.calls[0] as [ + string, + readonly string[], + { readonly maxBuffer?: number }, + ]; + expect(options.maxBuffer).toBe(1024 * 1024); + expect(inspection.javaVersion).toBeNull(); + expect(inspection.javaHome).toBe(configuredHome); + expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({ + status: "failed", + code: "runtime_missing", + }); +}); From 5f8af4ee5f615e97f343d70d91a74568d0ec9fbd Mon Sep 17 00:00:00 2001 From: Mykola Dementiev Date: Tue, 13 Oct 2026 14:56:20 +0200 Subject: [PATCH 24/92] feat(ida): resolve containing procedures and report the admitted program as the document (#2374) * test(mcp): expect trace_feature to report references the provider truncated A provider that reports a capped xrefs read makes the trace truncated with a residual unknown for that target. An explicit complete report and a provider that reports nothing leave the trace as before. * fix(mcp): report references a provider truncated in trace_feature Executions gain an optional flag for a capped xrefs read, set by the IDA adapter from the upstream cap. trace_feature marks the trace truncated with a residual unknown for that target only when the provider reports a truncation; a provider that reports nothing keeps its previous result. The trace_feature workflow profile is revised so earlier results are not replayed. * refactor(ida): report reference truncation whenever the runner observed an xrefs cap * test(mcp): cover session forwarding, per-target state, native reports and failures of the trace coverage * test(mcp): format the session forwarding test * test(ida): expect headless IDA to resolve containing procedures from lookups and segment facts Cover positive and negative containment, segment boundaries including addresses above 2^53, failures of the lookup and of the segment survey, cancellation, input validation and the admitted program as the only document. * test(ida): expect feature tracing and address context to work on headless IDA Run trace_feature, including a capped reference read, and inspect_address_context with and without an explicit document through the IDA session client. * test(mcp): expect trace_feature availability to follow the search operations it calls * fix(mcp): derive trace_feature availability from the search operations it calls * feat(ida): resolve containing procedures and report the admitted program as the document The modern headless profile resolves an address through the function lookup and, when no function contains it, tells a mapped address from an unmapped one using the database-scoped minimal survey. A survey that is missing, truncated or inconsistent fails the read. list_documents reports the single admitted program. The attached profiles do not declare either operation. * docs(ida): describe containing procedures, the admitted document and truncated feature traces * test(ida): verify containing procedures, the admitted document and feature tracing on real IDA * docs(testing): list the headless IDA workflows the real lane verifies * test(ida): make each survey failure case isolate a single defect * test(ida): pin large addresses, segment order, document identity, attached refusals and each trace_feature prerequisite * test(mcp): expect a repeated reference source to be resolved once in trace_feature * fix(mcp): resolve the containing procedure of a repeated reference source once * test(ida): trace the fixture procedure and derive probe addresses from its facts in the real lane * test(process): format processCaptureLifecycle.test.ts after the merge * test(ida): declare the alignment each synthetic universal slice offset satisfies * refactor(ida): move the real lane's feature trace assertions into a helper * test(ida): expect the real lane's feature trace check to accept references to every matched address * fix(ida): check each trace reference against its own matched address in the real lane * test(ida): pin that the new operations reject a document other than the admitted program * refactor(ida): move the real lane's mapped non-function probe into a helper * test(ida): expect the real lane's mapped data probe to skip strings inside functions * fix(ida): try the target's strings in order for the mapped non-function probe * test(ida): declare the real lane helper types for the verifier tests * test(ida): expect the real lane to reject a listed string that resolves as unmapped * fix(ida): fail the real lane when a listed string resolves as unmapped --- docs/ida-provider.md | 2 + docs/testing.md | 4 +- scripts/lib/ida-feature-trace.d.mts | 9 + scripts/lib/ida-feature-trace.mjs | 52 ++ scripts/verify-real-ida.mjs | 61 +- src/application/AnalysisProvider.ts | 11 + src/application/CapabilityInventory.test.ts | 68 ++ src/application/CapabilityInventory.ts | 4 +- src/application/EnhancedLiteralTracing.ts | 64 +- src/application/EnhancedTools.ts | 2 + src/application/InvestigationProviders.ts | 4 +- .../binary/AnalysisSnapshotCache.test.ts | 49 ++ .../binary/AnalysisSnapshotCache.ts | 3 + src/ida/IdaContainment.test.ts | 623 ++++++++++++++++++ src/ida/IdaNativeProtocol.test.ts | 30 + src/ida/IdaOperationRunner.ts | 106 ++- src/ida/IdaProtocolValues.ts | 50 ++ src/ida/IdaProviderCapabilities.ts | 79 ++- src/ida/IdaReferenceCoverage.test.ts | 96 +++ src/ida/IdaSessionClient.ts | 19 + ...enhancedTools.traceFeatureCoverage.test.ts | 249 +++++++ .../process/processCaptureLifecycle.test.ts | 6 +- .../providers/ida/containment.test.ts | 144 ++++ .../ida/verifierFeatureTrace.test.ts | 212 ++++++ .../analysis-sessions/binarySession.test.ts | 99 +++ .../conformance/ida/providerContracts.test.ts | 16 +- tests/fixtures/idaMcp.ts | 1 + 27 files changed, 1984 insertions(+), 79 deletions(-) create mode 100644 scripts/lib/ida-feature-trace.d.mts create mode 100644 scripts/lib/ida-feature-trace.mjs create mode 100644 src/ida/IdaContainment.test.ts create mode 100644 src/ida/IdaReferenceCoverage.test.ts create mode 100644 tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts create mode 100644 tests/boundary/providers/ida/containment.test.ts create mode 100644 tests/boundary/providers/ida/verifierFeatureTrace.test.ts diff --git a/docs/ida-provider.md b/docs/ida-provider.md index b22a35cce..50040425b 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -126,6 +126,8 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client Promise, +): Promise; diff --git a/scripts/lib/ida-feature-trace.mjs b/scripts/lib/ida-feature-trace.mjs new file mode 100644 index 000000000..3c4778d25 --- /dev/null +++ b/scripts/lib/ida-feature-trace.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; + +const NEGATIVE_REASONS = new Set(["not_in_procedure", "outside_segments"]); + +/** + * Assert the `trace_feature` result of the real IDA lane for one selected + * procedure. The query is a substring search, so the trace may also match other + * procedures and strings; each reference is checked against its own matched + * target, and a source outside every function (a data reference) is valid. + */ +export function assertFeatureTrace(trace, procedure) { + assert.ok( + trace.matches.some( + ({ type, address }) => + type === "procedure" && address === procedure.address, + ), + "The trace omitted the selected procedure's match.", + ); + assert.equal(typeof trace.truncated, "boolean"); + const matched = new Set(trace.matches.map(({ address }) => address)); + for (const reference of trace.references) { + assert.ok( + matched.has(reference.target_address), + `A reference targets ${reference.target_address}, which is not a match.`, + ); + const { containing_procedure: containing } = reference; + if (containing.found) + assert.equal(typeof containing.procedure.address, "string"); + else assert.ok(NEGATIVE_REASONS.has(containing.reason)); + } +} + +/** + * Find a string that IDA maps outside every function. A string inside a + * function chunk (inline data in code) is valid and skipped, so the strings are + * tried in order. Any other answer must be "not in a procedure": IDA lists only + * mapped strings, so an unmapped answer is a containment defect and fails the + * lane. Returns the address, or undefined when every string is inside a function. + */ +export async function findMappedNonFunction(strings, resolveAt) { + for (const { address } of strings) { + const resolved = await resolveAt(address); + if (resolved.found) continue; + assert.equal( + resolved.reason, + "not_in_procedure", + `The listed string at ${address} resolved as ${resolved.reason}.`, + ); + return address; + } + return undefined; +} diff --git a/scripts/verify-real-ida.mjs b/scripts/verify-real-ida.mjs index cedbf5076..bed0519a9 100644 --- a/scripts/verify-real-ida.mjs +++ b/scripts/verify-real-ida.mjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +import { + assertFeatureTrace, + findMappedNonFunction, +} from "./lib/ida-feature-trace.mjs"; import { requireMcpToolError } from "./lib/mcp-verifier-results.mjs"; import assert from "node:assert/strict"; @@ -6,7 +10,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { access, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs, promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; @@ -261,6 +265,61 @@ const mcp = async (cliDossier) => { break; } } + if (config.mode === "headless") { + const documents = observe(await call("list_documents", {})); + assert.equal(documents.length, 1); + assert.equal(basename(documents[0]), basename(target)); + assert.ok( + !workspaces.has(dirname(documents[0])), + "list_documents reported the private workspace copy.", + ); + const resolveAt = async (address) => + observe(await call("resolve_containing_procedure", { address })); + const instructionAddresses = dossier.assembly + .map((line) => /^(0x[0-9a-f]+)/iu.exec(line)?.[1]) + .filter((address) => address !== undefined); + const queries = [ + dossier.procedure.address, + ...instructionAddresses.slice(1, 2), + ]; + for (const queried of queries) { + const containing = await resolveAt(queried); + assert.equal(containing.found, true); + assert.equal(containing.procedure.address, dossier.procedure.address); + assert.equal(containing.procedure.name, dossier.procedure.name); + } + const unmapped = await resolveAt("0xffffffffffffff00"); + assert.equal(unmapped.found, false); + assert.equal(unmapped.reason, "outside_segments"); + const strings = observe(await call("list_strings", {})); + const mappedString = await findMappedNonFunction(strings, resolveAt); + if (mappedString === undefined) + report.unverified.push( + "Mapped non-function containment: no string of the target is mapped outside every function.", + ); + const context = await client.callTool({ + name: "inspect_address_context", + arguments: { address: dossier.procedure.address }, + }); + assert.notEqual(context.isError, true); + const contextResult = context.structuredContent.result; + assert.equal(contextResult.document, documents[0]); + assert.equal(contextResult.procedure.state, "available"); + assert.equal( + contextResult.procedure.value.procedure.address, + dossier.procedure.address, + ); + const traced = await client.callTool({ + name: "trace_feature", + arguments: { query: dossier.procedure.name }, + }); + assert.notEqual(traced.isError, true); + const trace = traced.structuredContent.normalized_result; + assertFeatureTrace(trace, dossier.procedure); + report.checks.push( + "headless containing procedures, the admitted program as the document, address context, and the procedure match and references of feature tracing", + ); + } if (values["expected-string"] === undefined) report.unverified.push( "String inventory/search correctness: no independent expected literal supplied.", diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index f3b51257a..376036fa1 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -50,6 +50,13 @@ export interface AnalysisExecution { readonly limitations: readonly string[]; /** Provider-owned completeness assessment for caller/callee observations. */ readonly relationshipCoverage?: RelationshipCoverage; + /** + * Whether the provider capped an `xrefs` read: true when it reported a + * truncation, false when it explicitly reported none. Absent when it reports + * nothing, which is not a completeness claim. Never part of Evidence or tool + * output. + */ + readonly referencesTruncated?: boolean; readonly locations: readonly EvidenceLocation[]; readonly subject: EvidenceSubjectTarget | null; } @@ -59,6 +66,7 @@ interface CreateAnalysisExecutionOptions { readonly analysisProfile?: AnalysisProfileCommitment; readonly limitations?: readonly string[]; readonly relationshipCoverage?: RelationshipCoverage; + readonly referencesTruncated?: boolean; readonly locations?: readonly EvidenceLocation[]; readonly subject?: EvidenceSubjectTarget; } @@ -103,6 +111,9 @@ export function createAnalysisExecution( options.relationshipCoverage, ), }), + ...(options.referencesTruncated === undefined + ? {} + : { referencesTruncated: options.referencesTruncated }), locations: [...(options.locations ?? [])], subject: options.subject ?? null, }; diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 1b8ec6411..4286ff1d0 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -497,3 +497,71 @@ it("reports host denial before target mismatch but keeps composed workflow prere ).reason, ).toBe("target_required"); }); + +describe("trace_feature requirements", () => { + const capabilities = (operations: readonly string[]) => + operations.map((operation) => ({ + operation, + available: true, + reason: null, + })); + + it("needs the search operations the workflow actually calls", () => { + expect( + entry( + "trace_feature", + status({ + open: true, + kind: "executable", + capabilities: capabilities([ + "search_strings", + "search_procedures", + "xrefs", + "resolve_containing_procedure", + ]), + }), + ), + ).toMatchObject({ available: true, reason: "available" }); + }); + + it.each(["search_strings", "search_procedures"])( + "is unavailable without %s", + (missing) => { + const operations = [ + "search_strings", + "search_procedures", + "xrefs", + "resolve_containing_procedure", + ].filter((operation) => operation !== missing); + + expect( + entry( + "trace_feature", + status({ + open: true, + kind: "executable", + capabilities: capabilities(operations), + }), + ), + ).toMatchObject({ available: false, reason: "provider_missing" }); + }, + ); + + it("is unavailable when only the list operations exist", () => { + expect( + entry( + "trace_feature", + status({ + open: true, + kind: "executable", + capabilities: capabilities([ + "list_strings", + "list_procedures", + "xrefs", + "resolve_containing_procedure", + ]), + }), + ), + ).toMatchObject({ available: false, reason: "provider_missing" }); + }); +}); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 6fefb8e20..4e364a1aa 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -121,8 +121,8 @@ const ENHANCED_REQUIREMENTS: Readonly> = { analyze_function: ["analyze_function"], inspect_native_api: ["analyze_function"], trace_feature: [ - "list_strings", - "list_procedures", + "search_strings", + "search_procedures", "xrefs", "resolve_containing_procedure", ], diff --git a/src/application/EnhancedLiteralTracing.ts b/src/application/EnhancedLiteralTracing.ts index 9c86781bc..79684ce0f 100644 --- a/src/application/EnhancedLiteralTracing.ts +++ b/src/application/EnhancedLiteralTracing.ts @@ -1,5 +1,8 @@ import { z } from "zod"; -import type { AnalysisOperation } from "./AnalysisProvider.js"; +import type { + AnalysisExecution, + AnalysisOperation, +} from "./AnalysisProvider.js"; import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -16,6 +19,12 @@ type AnalysisCall = ( signal?: AbortSignal, ) => Promise>; +type ObserveCall = ( + name: AnalysisOperation, + arguments_: Readonly>, + signal?: AbortSignal, +) => Promise>; + type LiteralSearchSource = readonly [ "search_strings" | "search_procedures", "string" | "procedure", @@ -39,6 +48,7 @@ export interface LiteralTraceInput { /** Trace matching strings and procedures through provider xrefs. */ export const traceLiteralFeature = async ( call: AnalysisCall, + observe: ObserveCall, input: LiteralTraceInput, signal?: AbortSignal, ): EnhancedResult => { @@ -49,7 +59,7 @@ export const traceLiteralFeature = async ( signal, ); if (!searched.ok) return searched; - const traced = await traceReferences(call, { + const traced = await traceReferences(call, observe, { matches: searched.value.matches, ...(signal === undefined ? {} : { signal }), }); @@ -59,8 +69,8 @@ export const traceLiteralFeature = async ( search_mode: "literal", matches: searched.value.matches, references: traced.value.references, - truncated: false, - residual_unknowns: [], + truncated: traced.value.residual.length > 0, + residual_unknowns: traced.value.residual, }); }; @@ -126,24 +136,39 @@ const addPageMatches = (input: { const traceReferences = async ( call: AnalysisCall, + observe: ObserveCall, request: ReferenceTraceRequest, -): Promise> => { +): Promise< + Result<{ references: TraceReference[]; residual: string[] }, AnalysisError> +> => { const references: TraceReference[] = []; + const residual: string[] = []; + // A source address is resolved once however many matches reference it. + const containing = new Map(); for (const match of request.matches) { - const xrefs = await call( + const observed = await observe( "xrefs", { address: match.address }, request.signal, ); - if (!xrefs.ok) return xrefs; - if (!Array.isArray(xrefs.value)) + if (!observed.ok) return observed; + // Only an explicit producer report of truncation is a coverage gap; a + // provider that reports nothing keeps its previous, unqualified result. + const unknown = `References to ${match.address} were truncated by the provider; additional references may exist.`; + if ( + observed.value.referencesTruncated === true && + !residual.includes(unknown) + ) + residual.push(unknown); + const sources = observed.value.result; + if (!Array.isArray(sources)) return err( new AnalysisOutputError( "xrefs", "provider returned a non-array result", ), ); - for (const source of xrefs.value) { + for (const source of sources) { if (typeof source !== "string") return err( new AnalysisOutputError( @@ -151,18 +176,23 @@ const traceReferences = async ( "provider returned a non-address value", ), ); - const resolved = await call( - "resolve_containing_procedure", - { address: source }, - request.signal, - ); - if (!resolved.ok) return resolved; + let containingProcedure = containing.get(source); + if (containingProcedure === undefined) { + const resolved = await call( + "resolve_containing_procedure", + { address: source }, + request.signal, + ); + if (!resolved.ok) return resolved; + containingProcedure = resolved.value; + containing.set(source, containingProcedure); + } references.push({ target_address: match.address, source_address: source, - containing_procedure: resolved.value, + containing_procedure: containingProcedure, }); } } - return ok({ references }); + return ok({ references, residual }); }; diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 87d27fc5f..207186ccc 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -163,6 +163,8 @@ export class EnhancedTools { return traceLiteralFeature( (name, arguments_, operationSignal) => this.#call(name, arguments_, operationSignal), + (name, arguments_, operationSignal) => + this.#observe(name, arguments_, operationSignal), call.input, signal, ); diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 5dcb18150..af23463f4 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -20,7 +20,9 @@ export const workflowAnalysisProfile = ( upstream_analysis_profile: upstream, ...(operation === "binary_overview" ? { workflow_revision: 3 } - : operation === "batch_decompile" || operation === "get_call_graph" + : operation === "batch_decompile" || + operation === "get_call_graph" || + operation === "trace_feature" ? { workflow_revision: 2 } : {}), }); diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 48c2a54bd..91b9f8807 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -661,3 +661,52 @@ it("does not replay pre-envelope call graphs under the revised workflow profile" }).success, ).toBe(false); }); + +describe("executions that report truncated references", () => { + const record = (referencesTruncated: boolean | undefined) => { + const cache = new AnalysisSnapshotCache(); + const parameters = { address: "0x1000", document: "fixture" }; + cache.record({ + target: ANALYSIS_SNAPSHOT_TARGET, + profile: ANALYSIS_SNAPSHOT_PROFILE, + operation: "xrefs", + parameters, + execution: createAnalysisExecution( + ["0x2000"], + ANALYSIS_SNAPSHOT_PROVIDER, + { + subject: ANALYSIS_SNAPSHOT_TARGET, + analysisProfile: ANALYSIS_SNAPSHOT_PROFILE, + ...(referencesTruncated === undefined ? {} : { referencesTruncated }), + }, + ), + }); + return cache.lookup( + ANALYSIS_SNAPSHOT_TARGET, + ANALYSIS_SNAPSHOT_PROFILE, + "xrefs", + parameters, + ); + }; + + it("are not replayed, because a replay would lose the truncation", () => { + expect(record(true)).toBeUndefined(); + }); + + it("do not stop an execution that is complete or silent from being replayed", () => { + expect(record(false)?.result).toEqual(["0x2000"]); + expect(record(undefined)?.result).toEqual(["0x2000"]); + }); +}); + +it("revises the trace_feature workflow profile so earlier results are not replayed", () => { + const profile = ANALYSIS_SNAPSHOT_PROFILE; + + expect(workflowAnalysisProfile(profile, "trace_feature")).not.toEqual( + workflowAnalysisProfile(profile), + ); + expect( + workflowAnalysisProfile(profile, "trace_call_path"), + "other workflows keep their profile", + ).toEqual(workflowAnalysisProfile(profile)); +}); diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index bdaa9f68d..7e307baa0 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -337,6 +337,9 @@ export class AnalysisSnapshotCache { }): void { const { target, profile, operation, parameters, execution } = input; this.select(target, profile); + // The snapshot format does not carry reference truncation; a replay would + // present a capped read as complete, so it is not recorded. + if (execution.referencesTruncated === true) return; const queryId = analysisQueryId( snapshotTarget(target), snapshotBinding(profile), diff --git a/src/ida/IdaContainment.test.ts b/src/ida/IdaContainment.test.ts new file mode 100644 index 000000000..069388df8 --- /dev/null +++ b/src/ida/IdaContainment.test.ts @@ -0,0 +1,623 @@ +import { rm } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { + createIdaTarget, + RecordingIdaMcp, +} from "../../tests/fixtures/idaMcp.js"; +import { decodeIdaToolResult } from "./IdaMcpConnection.js"; +import { idaCapabilities } from "./IdaProviderCapabilities.js"; +import { IdaSessionClient } from "./IdaSessionClient.js"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) + await rm(root, { recursive: true, force: true }); +}); + +const segment = (name: string, start: string, end: string) => ({ + name, + start, + end, + size: `0x${(BigInt(end) - BigInt(start)).toString(16)}`, + permissions: "r--", +}); + +/** Adjacent .text and .data, a gap, then a segment above 2^53. */ +const SEGMENTS = [ + segment(".text", "0x1000", "0x2000"), + segment(".data", "0x2000", "0x3000"), + segment(".far", "0x5000", "0x6000"), + segment(".huge", "0x20000000000001", "0x20000000000002"), +]; + +/** The same segments in descending order, which a scan may not rely on. */ +const UNSORTED = [...SEGMENTS].reverse(); + +const survey = (segments: JsonValue = SEGMENTS) => ({ + metadata: { path: "/db/target.i64", module: "target", arch: "metapc" }, + statistics: { total_segments: Array.isArray(segments) ? segments.length : 0 }, + segments, + entrypoints: [], +}); + +const function_ = { addr: "0x1000", name: "main", size: "0x20" }; + +const MODERN_WITHOUT_SURVEY = [ + "idb_open", + "idb_list", + "idb_close", + "server_health", + "list_funcs", + "lookup_funcs", + "find_regex", + "decompile", + "disasm", + "callees", + "xrefs_to", +]; + +interface Upstream { + /** Answer for the lookup of the queried address; default is "Not a function". */ + readonly lookup?: (query: string) => JsonValue; + readonly survey?: JsonValue; + readonly tools?: readonly string[]; + readonly surveyFailure?: "error" | "preview"; + /** A distinct source alias of the admitted target, as for an app bundle. */ + readonly sourcePath?: string; + /** Report a different input from the second health read on. */ + readonly identityChangesAfterOperation?: boolean; +} + +const session = async ( + upstream: Upstream = {}, + mode: "headless" | "attached" = "headless", +) => { + const created = await createIdaTarget("x86_64"); + const { root } = created; + const target = + upstream.sourcePath === undefined + ? created.target + : { ...created.target, sourcePath: upstream.sourcePath }; + roots.push(root); + const producer = new RecordingIdaMcp(target, mode); + if (upstream.tools !== undefined) producer.tools = upstream.tools; + producer.beforeCall = async (name) => { + if (name !== "survey_binary") return; + if (upstream.surveyFailure === "error") + decodeIdaToolResult({ + content: [{ type: "text", text: "survey failed" }], + isError: true, + }); + if (upstream.surveyFailure === "preview") + decodeIdaToolResult({ + content: [{ type: "text", text: "preview" }], + _meta: { ida_mcp: { output_truncated: true } }, + }); + }; + let healthReads = 0; + producer.overrideCall = (name, args) => { + if (name === "server_health" && upstream.identityChangesAfterOperation) { + healthReads += 1; + if (healthReads === 2) + return { + status: "ok", + input_path: "/different/target.elf", + idb_path: "/different/target.elf.i64", + module: "target.elf", + imagebase: "0x1000", + auto_analysis_ready: true, + }; + } + if (name === "lookup_funcs") { + const queries = Array.isArray(args.queries) ? args.queries : []; + return queries.map((query) => + upstream.lookup === undefined + ? { query, fn: null, error: "Not a function" } + : upstream.lookup(String(query)), + ); + } + if (name === "survey_binary") return upstream.survey ?? survey(); + return undefined; + }; + const client = new IdaSessionClient( + { + command: "fixture", + args: [], + env: {}, + mode, + timeoutMs: 1000, + workspaceRoot: root, + }, + target, + producer, + ); + return { client, producer, target }; +}; + +const containing = async ( + client: IdaSessionClient, + address: string, + options?: Parameters[2], +) => client.execute("resolve_containing_procedure", { address }, options); + +const found = (query: string, fn: typeof function_) => ({ + query, + fn, + error: null, +}); + +describe("IDA containment capabilities", () => { + it("declares both operations for the headless profile only", () => { + const declared = (mode: "attached" | "headless", native = false) => + idaCapabilities(mode, false, native) + .map(({ operation }) => operation) + .filter( + (operation) => + operation === "resolve_containing_procedure" || + operation === "list_documents", + ); + + expect( + idaCapabilities("headless", false) + .filter( + ({ operation }) => + operation === "resolve_containing_procedure" || + operation === "list_documents", + ) + .map(({ available }) => available), + "both are declared available, not merely listed", + ).toEqual([true, true]); + expect(declared("headless")).toEqual([ + "resolve_containing_procedure", + "list_documents", + ]); + expect(declared("attached")).toEqual([]); + expect(declared("attached", true)).toEqual([]); + expect( + idaCapabilities("attached", false).map(({ operation }) => operation), + "attached profiles do not even declare them", + ).not.toContain("list_documents"); + }); + + it.each(["resolve_containing_procedure", "list_documents"] as const)( + "refuses %s on the legacy attached profile without calling upstream", + async (operation) => { + const { client, producer } = await session({}, "attached"); + try { + const result = await client.execute( + operation, + operation === "list_documents" ? {} : { address: "0x1000" }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(producer.calls.some(({ name }) => name === "lookup_funcs")).toBe( + false, + ); + } finally { + expect((await client.close()).ok).toBe(true); + } + }, + ); +}); + +describe("headless IDA containing procedure", () => { + it("returns the canonical entry for an interior address without reading segments", async () => { + const { client, producer } = await session({ + lookup: (query) => found(query, function_), + }); + try { + const result = await containing(client, "1ffe"); + + if (!result.ok) throw result.error; + expect(result.value.result).toEqual({ + query_address: "0x1ffe", + found: true, + procedure: { + address: "0x1000", + name: "main", + classification: null, + body: { + available: false, + reason: "IDA MCP does not report complete function body ranges.", + }, + }, + }); + expect( + producer.calls.filter(({ name }) => name === "lookup_funcs"), + "the normalized address is the only query", + ).toMatchObject([{ args: { queries: ["0x1ffe"] } }]); + expect( + producer.calls.some(({ name }) => name === "survey_binary"), + "a positive answer needs no segment facts", + ).toBe(false); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it.each([ + ["inside the first segment", "0x1000", "not_in_procedure"], + ["inside an adjacent segment", "0x2000", "not_in_procedure"], + [ + "at the exclusive end of the last adjacent segment", + "0x3000", + "outside_segments", + ], + ["in the gap between segments", "0x4000", "outside_segments"], + ["one below a segment start", "0xfff", "outside_segments"], + ["the last byte of a segment", "0x5fff", "not_in_procedure"], + ["above every segment", "0x6000", "outside_segments"], + [ + "inside a one-byte segment above 2^53", + "0x20000000000001", + "not_in_procedure", + ], + [ + "one below that segment, which a rounded number cannot tell apart", + "0x20000000000000", + "outside_segments", + ], + [ + "at the exclusive end of that segment", + "0x20000000000002", + "outside_segments", + ], + ] as const)( + "reports an address that is not a function %s as %s", + async (_description, address, reason) => { + const { client, producer } = await session(); + try { + const result = await containing(client, address); + + if (!result.ok) throw result.error; + expect(result.value.result).toEqual({ + query_address: address, + found: false, + procedure: null, + reason, + }); + expect( + producer.calls.find(({ name }) => name === "survey_binary")?.args, + "the minimal survey is the only segment source", + ).toMatchObject({ detail_level: "minimal" }); + expect( + producer.calls.filter(({ name }) => name === "survey_binary"), + ).toHaveLength(1); + } finally { + expect((await client.close()).ok).toBe(true); + } + }, + ); +}); + +describe("headless IDA containment failures", () => { + it.each([ + ["no segments field", { metadata: {}, statistics: {} }], + ["segments that are null", survey(null)], + ["an empty segment list", survey([])], + [ + "a row with a malformed address", + survey([ + { + name: ".x", + start: "0xzz", + end: "0x2000", + size: "0x1000", + permissions: "r--", + }, + ]), + ], + [ + "a reversed segment", + survey([ + { name: ".x", start: "0x2000", end: "0x1000", permissions: "r--" }, + ]), + ], + [ + "a size that disagrees with the bounds", + survey([{ ...segment(".x", "0x1000", "0x2000"), size: "0x10" }]), + ], + [ + "a total that disagrees with the rows", + { ...survey(), statistics: { total_segments: 9 } }, + ], + ["a malformed row after valid ones", survey([...SEGMENTS, { name: ".y" }])], + ] as const)( + "fails instead of guessing a reason when the survey has %s", + async (_description, reply) => { + const { client } = await session({ survey: reply as JsonValue }); + try { + const result = await containing(client, "0x4000"); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); + } finally { + expect((await client.close()).ok).toBe(true); + } + }, + ); + + it.each(["error", "preview"] as const)( + "fails when the survey tool reports %s", + async (surveyFailure) => { + const { client } = await session({ surveyFailure }); + try { + const result = await containing(client, "0x4000"); + + expect(result.ok).toBe(false); + } finally { + expect((await client.close()).ok).toBe(true); + } + }, + ); + + it("fails without a segment source when the server does not advertise the survey", async () => { + const { client, producer } = await session({ + tools: MODERN_WITHOUT_SURVEY, + }); + try { + const negative = await containing(client, "0x4000"); + + expect(negative.ok).toBe(false); + expect( + producer.calls.some(({ name }) => name === "survey_binary"), + "an unadvertised tool is never requested", + ).toBe(false); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("still answers a positive lookup when the server does not advertise the survey", async () => { + const { client } = await session({ + lookup: (query) => found(query, function_), + tools: MODERN_WITHOUT_SURVEY, + }); + try { + const result = await containing(client, "0x1010"); + + expect(result.ok).toBe(true); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); +}); + +describe("headless IDA containment lookup failures", () => { + it.each([ + ["an answer for another query", () => found("0x9999", function_)], + [ + "a function together with an error", + (query: string) => ({ query, fn: function_, error: "Not found" }), + ], + [ + "neither a function nor an error", + (query: string) => ({ query, fn: null, error: null }), + ], + [ + "an unknown error text", + (query: string) => ({ query, fn: null, error: "boom" }), + ], + [ + "a padded missing answer", + (query: string) => ({ query, fn: null, error: " Not a function" }), + ], + ] as const)( + "treats %s as a producer failure and reads no segments", + async (_description, lookup) => { + const { client, producer } = await session({ lookup }); + try { + const result = await containing(client, "0x4000"); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisProtocolError"); + expect( + producer.calls.some(({ name }) => name === "survey_binary"), + ).toBe(false); + } finally { + expect((await client.close()).ok).toBe(true); + } + }, + ); + + it('treats "Not found" for an address like any expected missing answer', async () => { + const { client } = await session({ + lookup: (query) => ({ query, fn: null, error: "Not found" }), + }); + try { + const result = await containing(client, "0x4000"); + + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + found: false, + reason: "outside_segments", + }); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("stops before the survey when the request is cancelled after the lookup", async () => { + const controller = new AbortController(); + const { client, producer } = await session(); + const inner = producer.beforeCall; + producer.beforeCall = async (name, args) => { + await inner?.(name, args); + if (name === "lookup_funcs") controller.abort(); + }; + try { + const result = await containing(client, "0x4000", { + signal: controller.signal, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); + expect( + producer.calls.some(({ name }) => name === "survey_binary"), + "nothing is dispatched after cancellation", + ).toBe(false); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("does not depend on the order of the surveyed segments", async () => { + const { client } = await session({ survey: survey(UNSORTED) }); + try { + const inside = await containing(client, "0x1000"); + const gap = await containing(client, "0x4000"); + + if (!inside.ok) throw inside.error; + if (!gap.ok) throw gap.error; + expect(inside.value.result).toMatchObject({ reason: "not_in_procedure" }); + expect(gap.value.result).toMatchObject({ reason: "outside_segments" }); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("retains the completed replies when the survey is rejected", async () => { + const { client } = await session({ + survey: survey([ + { name: ".x", start: "0x2000", end: "0x1000", permissions: "r--" }, + ]), + }); + try { + const result = await containing(client, "0x4000"); + + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error.partialObservation).toMatchObject({ + normalized_result: { + status: "partial", + observations: [ + expect.objectContaining({ tool: "lookup_funcs" }), + expect.objectContaining({ tool: "survey_binary" }), + ], + }, + }); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("rejects an address that is not hexadecimal and a document that is not the admitted target", async () => { + const { client, producer } = await session(); + try { + const badAddress = await containing(client, "main"); + const badDocument = await client.execute("resolve_containing_procedure", { + address: "0x1000", + document: "/somewhere/else", + }); + + for (const result of [badAddress, badDocument]) { + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisInputError"); + } + expect( + producer.calls.some(({ name }) => name === "lookup_funcs"), + "invalid input never reaches upstream", + ).toBe(false); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); +}); + +describe("headless IDA document identity", () => { + it("accepts an explicit admitted document and rejects an unknown one for the same operations", async () => { + const { client, target, producer } = await session(); + try { + const admitted = await client.execute("resolve_containing_procedure", { + address: "0x4000", + document: target.path, + }); + const unknownContainment = await client.execute( + "resolve_containing_procedure", + { address: "0x4000", document: "/somewhere/else" }, + ); + const unknownListing = await client.execute("list_documents", { + document: "/somewhere/else", + }); + + expect(admitted.ok, "the admitted program is accepted").toBe(true); + for (const rejected of [unknownContainment, unknownListing]) { + expect(rejected.ok).toBe(false); + if (!rejected.ok) + expect(rejected.error._tag).toBe("AnalysisInputError"); + } + expect( + producer.calls.filter(({ name }) => name === "lookup_funcs"), + "only the admitted request reached upstream", + ).toHaveLength(1); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("lists the admitted path, not its source alias or the private copy", async () => { + const { client, target, producer } = await session({ + sourcePath: "/Applications/Fixture.app", + }); + try { + const result = await client.execute("list_documents", {}); + + if (!result.ok) throw result.error; + expect(result.value.result).toEqual([target.path]); + expect( + producer.calls.filter(({ name }) => name === "server_health"), + "the target identity is checked before and after the local answer", + ).toHaveLength(2); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("fails when the target identity changes during the operation", async () => { + const { client } = await session({ identityChangesAfterOperation: true }); + try { + const result = await client.execute("list_documents", {}); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("identity changed"); + } + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("stops without dispatching when the request is already cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + const { client, producer } = await session(); + try { + const result = await client.execute( + "list_documents", + {}, + { signal: controller.signal }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(producer.calls).toEqual([]); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("lists exactly the admitted program", async () => { + const { client, target } = await session(); + try { + const result = await client.execute("list_documents", {}); + + if (!result.ok) throw result.error; + expect(result.value.result).toEqual([target.path]); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); +}); diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index d59f598dd..5da9d40f7 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -758,3 +758,33 @@ describe("native IDA malformed observations", () => { await client.close(); }); }); + +describe("native IDA attached operation admission", () => { + it("forwards the native producer's explicit reference cap flag", async () => { + const { client } = await fixture(); + const result = await client.execute("xrefs", { address: "0x1000" }); + + if (!result.ok) throw result.error; + expect(result.value.referencesTruncated).toBe(false); + await client.close(); + }); + + it("does not admit the headless-only containment and document operations", async () => { + const { client, producer } = await fixture(); + for (const [operation, parameters] of [ + ["resolve_containing_procedure", { address: "0x1000" }], + ["list_documents", {}], + ] as const) { + const result = await client.execute(operation, parameters); + + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); + } + expect( + producer.calls.some(({ name }) => name === "lookup_funcs"), + "nothing is dispatched upstream", + ).toBe(false); + await client.close(); + }); +}); diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index c30ec81bc..25a49c268 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -45,6 +45,7 @@ import { modernStringsSchema, modernXrefsSchema, nextOffset, + surveySegmentsSchema, type IdaDisassembly, type IdaFunction, } from "./IdaProtocolValues.js"; @@ -86,6 +87,8 @@ export class IdaOperationRunner { readonly locations: EvidenceLocation[] = []; readonly limitations: string[] = []; relationshipCoverage: RelationshipCoverage | undefined; + /** The upstream cap flag of the last `xrefs_to` read, when the producer reported one. */ + referencesTruncated: boolean | undefined; constructor( private readonly connection: IdaMcpConnection, private readonly protocol: "legacy" | "modern" | "native", @@ -97,6 +100,10 @@ export class IdaOperationRunner { readonly x86Only?: boolean; /** False when the upstream server does not advertise `callgraph`. */ readonly callGraphAvailable?: boolean; + /** False when the upstream server does not advertise `survey_binary`. */ + readonly surveyAvailable?: boolean; + /** The program identity REA admitted, reported by `list_documents`. */ + readonly admittedDocument?: string; } = {}, ) {} @@ -177,41 +184,89 @@ export class IdaOperationRunner { return entry; } - private lookupFunction( + /** Parse one lookup row: a function, an expected "missing" answer, or a producer failure. */ + private lookupOutcome( query: string, item: z.infer[number] | undefined, - requestedBy?: string, - ): IdaFunction { + ): { readonly fn: IdaFunction } | { readonly missing: string } { if (item?.query !== query) throw new AnalysisProtocolError( `IDA function lookup answered for a different query than ${JSON.stringify(query)}.`, ); if ( - requestedBy !== undefined && item.fn === null && item.error !== null && MISSING_PROCEDURE_ANSWERS.has(item.error) ) - throw new AnalysisInputError( - requestedBy, - { - cause: new AnalysisProtocolError( - `IDA function lookup failed for ${query}: ${item.error}`, - ), - }, - [ - { - path: [], - reason: "invalid_value", - message: `IDA could not resolve procedure ${JSON.stringify(query)}: ${item.error}.`, - }, - ], - ); + return { missing: item.error }; if (item.fn === null || item.error !== null) throw new AnalysisProtocolError( `IDA function lookup failed for ${query}: ${item.error ?? "missing function"}`, ); - return item.fn; + return { fn: item.fn }; + } + + private lookupFunction( + query: string, + item: z.infer[number] | undefined, + requestedBy?: string, + ): IdaFunction { + const outcome = this.lookupOutcome(query, item); + if ("fn" in outcome) return outcome.fn; + if (requestedBy === undefined) + throw new AnalysisProtocolError( + `IDA function lookup failed for ${query}: ${outcome.missing}`, + ); + throw new AnalysisInputError( + requestedBy, + { + cause: new AnalysisProtocolError( + `IDA function lookup failed for ${query}: ${outcome.missing}`, + ), + }, + [ + { + path: [], + reason: "invalid_value", + message: `IDA could not resolve procedure ${JSON.stringify(query)}: ${outcome.missing}.`, + }, + ], + ); + } + + /** The containing function of an address, or why none contains it. */ + private async containingProcedure(address: string): Promise { + const item = modernLookupSchema.parse( + await this.call("lookup_funcs", { queries: [address] }), + )[0]; + const outcome = this.lookupOutcome(address, item); + if ("fn" in outcome) + return { + query_address: address, + found: true, + procedure: identity(outcome.fn), + }; + return { + query_address: address, + found: false, + procedure: null, + reason: (await this.addressInSegment(address)) + ? "not_in_procedure" + : "outside_segments", + }; + } + + /** Segment membership from the database-scoped minimal survey; a missing fact is a failure. */ + private async addressInSegment(address: string): Promise { + if (this.options.surveyAvailable === false) + throw new AnalysisProtocolError( + "The upstream server does not advertise survey_binary, so segment membership cannot be established.", + ); + const { segments } = surveySegmentsSchema.parse( + await this.call("survey_binary", { detail_level: "minimal" }), + ); + const value = BigInt(address); + return segments.some(({ start, end }) => start <= value && value < end); } /** True only for modern callee reads on targets not proven x86-only; x86 reads never reach the graph code. */ @@ -390,6 +445,7 @@ export class IdaOperationRunner { throw new AnalysisProtocolError( `IDA xrefs failed: ${page?.error ?? "missing references"}`, ); + if (page.more !== undefined) this.referencesTruncated = page.more; if (page.more === true) this.limitations.push( "Upstream xrefs_to capped results at 1000 references; additional references remain unknown.", @@ -536,6 +592,16 @@ export class IdaOperationRunner { address === idaAddressSchema.parse(parameters.address), ); } + if (operation === "list_documents") { + if (this.options.admittedDocument === undefined) + throw new AnalysisProtocolError("The admitted IDA program is unknown."); + return [this.options.admittedDocument]; + } + if (operation === "resolve_containing_procedure") { + const queried = idaAddressSchema.parse(parameters.address); + this.locations.push({ kind: "address", address: queried }); + return this.containingProcedure(queried); + } if (operation === "xrefs") { const inspectedAddress = idaAddressSchema.parse(parameters.address); this.locations.push({ kind: "address", address: inspectedAddress }); diff --git a/src/ida/IdaProtocolValues.ts b/src/ida/IdaProtocolValues.ts index 93ddbef88..d4afb92db 100644 --- a/src/ida/IdaProtocolValues.ts +++ b/src/ida/IdaProtocolValues.ts @@ -247,3 +247,53 @@ export const absentDatabaseCloseSchema = (sessionId: string) => z .object({ error: z.literal(`Session not found: ${sessionId}`) }) .passthrough(); + +// Piped, so a malformed coordinate is a schema issue and never reaches BigInt. +const hexCoordinate = z + .string() + .regex(/^0x[0-9a-f]+$/iu) + .pipe(z.transform((value) => BigInt(value))); + +/** + * The minimal `survey_binary` reply, reduced to validated segment intervals + * (exclusive ends). Any malformed, reversed or inconsistent fact rejects the + * whole reply, because a membership answer needs every segment. + */ +export const surveySegmentsSchema = z + .object({ + statistics: z + .object({ total_segments: z.number().int().nonnegative().optional() }) + .passthrough() + .optional(), + segments: z + .array( + z + .object({ + start: hexCoordinate, + end: hexCoordinate, + size: hexCoordinate.optional(), + }) + .passthrough(), + ) + .min(1), + }) + .passthrough() + .superRefine(({ statistics, segments }, context) => { + if ( + statistics?.total_segments !== undefined && + statistics.total_segments !== segments.length + ) + context.addIssue({ + code: "custom", + message: "survey segment count disagrees with its statistics", + }); + for (const { start, end, size } of segments) { + // A malformed row has already reported its own issue; refinements still run. + if (typeof start !== "bigint" || typeof end !== "bigint") continue; + if (start >= end || (size !== undefined && size !== end - start)) + context.addIssue({ + code: "custom", + message: "survey segment bounds are reversed or inconsistent", + }); + } + }); diff --git a/src/ida/IdaProviderCapabilities.ts b/src/ida/IdaProviderCapabilities.ts index d52c949e0..0ccc016d2 100644 --- a/src/ida/IdaProviderCapabilities.ts +++ b/src/ida/IdaProviderCapabilities.ts @@ -24,9 +24,23 @@ export const IDA_OPERATIONS = Object.freeze([ "read_function_instructions", "xrefs", "analyze_function", + "resolve_containing_procedure", + "list_documents", ] as const satisfies readonly Exclude[]); export type IdaOperation = (typeof IDA_OPERATIONS)[number]; +/** Operations only the modern headless profile implements. */ +const HEADLESS_ONLY_OPERATIONS: ReadonlySet = new Set([ + "resolve_containing_procedure", + "list_documents", +]); + +/** Whether the configured profile admits an operation. */ +export const idaProfileAdmits = ( + operation: IdaOperation, + mode: "attached" | "headless", +): boolean => mode === "headless" || !HEADLESS_ONLY_OPERATIONS.has(operation); + /** Shared limitations of observations from an externally mutable analysis database. */ export const IDA_LIMITATIONS = Object.freeze([ "Provider version identifies the REA IDA adapter. IDA engine and upstream distribution versions are unknown unless separately observed; MCP serverInfo is reported independently.", @@ -58,34 +72,37 @@ export const idaCapabilities = ( stdio: boolean, native = false, ): readonly CapabilityDescriptor[] => - IDA_OPERATIONS.map((operation) => ({ - provider: IDA_PROVIDER_IDENTITY, - operation, - ...((mode === "headless" || native) && operation === "procedure_callers" - ? ({ - available: false, - reason: native - ? "The native upstream profile reports code references without proving direct call edges." - : "The modern upstream profile reports code references without proving direct call edges.", - } as const) - : ({ available: true, reason: null } as const)), - cachePolicy: "live", - effects: { - mutatesArtifact: false, - launchesProcess: stdio || mode === "headless", - mayShowUi: - mode === "attached" && - (native - ? operation === "procedure_pseudo_code" || - operation === "analyze_function" - : ATTACHED_GUI_NAVIGATION_OPERATIONS.has(operation)), - mayAccessNetwork: true, - mayWriteFilesystem: stdio || mode === "headless", - changesPermissions: false, - requiresRoot: false, - }, - limitations: - native && (operation === "list_strings" || operation === "search_strings") - ? [...IDA_LIMITATIONS, IDA_NATIVE_STRING_LIMITATION] - : IDA_LIMITATIONS, - })); + IDA_OPERATIONS.filter((operation) => idaProfileAdmits(operation, mode)).map( + (operation) => ({ + provider: IDA_PROVIDER_IDENTITY, + operation, + ...((mode === "headless" || native) && operation === "procedure_callers" + ? ({ + available: false, + reason: native + ? "The native upstream profile reports code references without proving direct call edges." + : "The modern upstream profile reports code references without proving direct call edges.", + } as const) + : ({ available: true, reason: null } as const)), + cachePolicy: "live", + effects: { + mutatesArtifact: false, + launchesProcess: stdio || mode === "headless", + mayShowUi: + mode === "attached" && + (native + ? operation === "procedure_pseudo_code" || + operation === "analyze_function" + : ATTACHED_GUI_NAVIGATION_OPERATIONS.has(operation)), + mayAccessNetwork: true, + mayWriteFilesystem: stdio || mode === "headless", + changesPermissions: false, + requiresRoot: false, + }, + limitations: + native && + (operation === "list_strings" || operation === "search_strings") + ? [...IDA_LIMITATIONS, IDA_NATIVE_STRING_LIMITATION] + : IDA_LIMITATIONS, + }), + ); diff --git a/src/ida/IdaReferenceCoverage.test.ts b/src/ida/IdaReferenceCoverage.test.ts new file mode 100644 index 000000000..930c3a88a --- /dev/null +++ b/src/ida/IdaReferenceCoverage.test.ts @@ -0,0 +1,96 @@ +import { rm } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createIdaTarget, + RecordingIdaMcp, +} from "../../tests/fixtures/idaMcp.js"; +import { + callGraphSession as session, + removeCallGraphSessions, +} from "../../tests/fixtures/idaCallGraph.js"; + +import { IdaSessionClient } from "./IdaSessionClient.js"; + +const attachedRoots: string[] = []; +afterEach(async () => { + await removeCallGraphSessions(); + for (const root of attachedRoots.splice(0)) + await rm(root, { recursive: true, force: true }); +}); + +const xrefs = async (more: boolean | undefined) => { + const { client, producer } = await session({ callees: [] }, "x86_64"); + const inner = producer.overrideCall; + producer.overrideCall = (name, args) => + name === "xrefs_to" + ? [{ xrefs: [], ...(more === undefined ? {} : { more }) }] + : inner?.(name, args); + try { + const result = await client.execute("xrefs", { address: "0x1000" }); + if (!result.ok) throw result.error; + return result.value; + } finally { + expect((await client.close()).ok).toBe(true); + } +}; + +describe("headless IDA reference coverage", () => { + it("reports a capped xrefs read as truncated", async () => { + const execution = await xrefs(true); + + expect(execution.referencesTruncated).toBe(true); + expect( + execution.limitations, + "the diagnostic limitation is still reported", + ).toContain( + "Upstream xrefs_to capped results at 1000 references; additional references remain unknown.", + ); + }); + + it("reports an explicitly uncapped xrefs read as not truncated", async () => { + expect((await xrefs(false)).referencesTruncated).toBe(false); + }); + + it("reports nothing when the producer does not say whether the read was capped", async () => { + expect((await xrefs(undefined)).referencesTruncated).toBeUndefined(); + }); + + it("does not report reference coverage for other operations", async () => { + const { client } = await session({ callees: [] }, "x86_64"); + try { + const result = await client.execute("procedure_callees", { + procedure: "main", + }); + + if (!result.ok) throw result.error; + expect(result.value.referencesTruncated).toBeUndefined(); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("reports nothing for the legacy profile, whose producer has no cap flag", async () => { + const { root, target } = await createIdaTarget("x86_64"); + attachedRoots.push(root); + const client = new IdaSessionClient( + { + command: "fixture", + args: [], + env: {}, + mode: "attached", + timeoutMs: 1000, + workspaceRoot: root, + }, + target, + new RecordingIdaMcp(target, "attached"), + ); + try { + const result = await client.execute("xrefs", { address: "0x1000" }); + + if (!result.ok) throw result.error; + expect(result.value.referencesTruncated).toBeUndefined(); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); +}); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 7d3ec066c..c940e4c6a 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -33,6 +33,7 @@ import { IDA_LIMITATIONS, IDA_OPERATIONS, IDA_PROVIDER_IDENTITY, + idaProfileAdmits, type IdaOperation, } from "./IdaProviderCapabilities.js"; import { @@ -122,6 +123,7 @@ export class IdaSessionClient implements AnalysisClient { #openAttempted = false; #openCompleted = false; #callGraphAdvertised = false; + #surveyAdvertised = false; #initialIdentity: string | undefined; #metadata: JsonValue = null; #lifecycle: JsonValue[] = []; @@ -194,6 +196,17 @@ export class IdaSessionClient implements AnalysisClient { "This integration admits read-only analysis operations only.", ), ); + if ( + operation !== "health" && + !idaProfileAdmits(operation, this.config.mode) + ) + return err( + new AnalysisCapabilityUnavailableError( + "ida", + operation, + "This operation is available for the modern headless profile only.", + ), + ); if ( operation === "procedure_callers" && idaProtocol(this.config) !== "legacy" @@ -269,6 +282,9 @@ export class IdaSessionClient implements AnalysisClient { ...(runner.relationshipCoverage === undefined ? {} : { relationshipCoverage: runner.relationshipCoverage }), + ...(runner.referencesTruncated !== undefined + ? { referencesTruncated: runner.referencesTruncated } + : {}), locations: [ { kind: "artifact-path", path: this.target.path }, ...runner.locations, @@ -293,6 +309,8 @@ export class IdaSessionClient implements AnalysisClient { selectorOrigin: options?.selectorOrigin, x86Only: isX86OnlyTarget(this.target), callGraphAvailable: this.#callGraphAdvertised, + surveyAvailable: this.#surveyAdvertised, + admittedDocument: this.target.path, }, ); } @@ -423,6 +441,7 @@ export class IdaSessionClient implements AnalysisClient { async #initialize(): Promise { const tools = new Set(await this.connection.connect()); this.#callGraphAdvertised = tools.has("callgraph"); + this.#surveyAdvertised = tools.has("survey_binary"); const expected = this.config.protocol === "native" ? nativeTools diff --git a/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts b/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts new file mode 100644 index 000000000..8c94d5de0 --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; +import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; +import { err, ok } from "../../../src/domain/result.js"; + +import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js"; + +afterEach(closeEnhancedToolResources); + +const PROVIDER = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", +} as const; + +type Coverage = boolean | undefined; + +interface Behavior { + /** What the provider reports for the references of each match address. */ + readonly coverage?: Readonly>; + readonly limitations?: readonly string[]; + /** Also report the first string match as a procedure match. */ + readonly duplicateProcedure?: boolean; + /** Fail the references read of this address. */ + readonly failXrefsOf?: string; + readonly calls?: string[]; +} + +const containing = (address: string) => ({ + query_address: address, + found: false, + procedure: null, + reason: "not_in_procedure", +}); + +/** + * A provider with two string matches (0x10 and 0x11), each referenced from + * one address (0x20 and 0x21). + */ +const provider = (behavior: Behavior = {}): AnalysisOperationPort => ({ + execute: async (name, arguments_) => { + const address = String(arguments_.address); + behavior.calls?.push(`${name}:${address}`); + switch (name) { + case "search_strings": + return ok( + createAnalysisExecution( + [ + { address: "0x10", value: "hello" }, + { address: "0x11", value: "hello again" }, + ], + PROVIDER, + ), + ); + case "search_procedures": + return ok( + createAnalysisExecution( + behavior.duplicateProcedure === true + ? [{ address: "0x10", value: "hello" }] + : [], + PROVIDER, + ), + ); + case "xrefs": { + if (address === behavior.failXrefsOf) + return err(new AnalysisOutputError("xrefs", "provider failed")); + const reported = behavior.coverage?.[address]; + return ok( + createAnalysisExecution( + [address === "0x10" ? "0x20" : "0x21"], + PROVIDER, + { + limitations: behavior.limitations ?? [], + ...(reported === undefined + ? {} + : { referencesTruncated: reported }), + }, + ), + ); + } + case "resolve_containing_procedure": + return ok(createAnalysisExecution(containing(address), PROVIDER)); + default: + throw new Error(`Unexpected operation: ${name}`); + } + }, +}); + +const expectedTrace = ( + residual: readonly string[], + extraMatches: readonly object[] = [], +) => ({ + query: "hello", + search_mode: "literal", + matches: [ + { type: "string", address: "0x10", value: "hello" }, + { type: "string", address: "0x11", value: "hello again" }, + ...extraMatches, + ], + references: [ + { + target_address: "0x10", + source_address: "0x20", + containing_procedure: containing("0x20"), + }, + { + target_address: "0x11", + source_address: "0x21", + containing_procedure: containing("0x21"), + }, + ...extraMatches.map(() => ({ + target_address: "0x10", + source_address: "0x20", + containing_procedure: containing("0x20"), + })), + ], + truncated: residual.length > 0, + residual_unknowns: residual, +}); + +const unknownFor = (address: string) => + `References to ${address} were truncated by the provider; additional references may exist.`; + +const trace = async (analysis: AnalysisOperationPort) => { + const client = await connect(analysis); + const result = await client.callTool({ + name: "trace_feature", + arguments: { query: "hello" }, + }); + expect(result.isError).not.toBe(true); + return result.structuredContent as { normalized_result: unknown }; +}; + +describe("trace_feature reference coverage", () => { + it("marks the trace truncated for the target whose references the provider capped", async () => { + const output = await trace( + provider({ coverage: { "0x10": true, "0x11": false } }), + ); + + expect( + output.normalized_result, + "the received references are kept and only the capped target is a residual unknown", + ).toEqual(expectedTrace([unknownFor("0x10")])); + }); + + it("does not carry a truncation over to the next target that reports nothing", async () => { + const output = await trace(provider({ coverage: { "0x10": true } })); + + expect(output.normalized_result).toEqual( + expectedTrace([unknownFor("0x10")]), + ); + }); + + it("reports a capped target once when it is matched twice", async () => { + const output = await trace( + provider({ coverage: { "0x10": true }, duplicateProcedure: true }), + ); + + expect(output.normalized_result).toEqual( + expectedTrace( + [unknownFor("0x10")], + [{ type: "procedure", address: "0x10", value: "hello" }], + ), + ); + }); + + it("resolves the containing procedure of a repeated reference source once", async () => { + const calls: string[] = []; + const output = await trace(provider({ duplicateProcedure: true, calls })); + + expect( + calls.filter((call) => call.startsWith("resolve_containing_procedure:")), + "the source 0x20 is referenced by two matches of the same address and resolved once", + ).toEqual([ + "resolve_containing_procedure:0x20", + "resolve_containing_procedure:0x21", + ]); + expect( + output.normalized_result, + "every reference is still reported with its containing procedure", + ).toEqual( + expectedTrace( + [], + [{ type: "procedure", address: "0x10", value: "hello" }], + ), + ); + }); + + it("keeps the trace complete when the provider explicitly reports no cap", async () => { + const output = await trace( + provider({ coverage: { "0x10": false, "0x11": false } }), + ); + + expect(output.normalized_result).toEqual(expectedTrace([])); + }); + + it("does not change the trace of a provider that reports no coverage", async () => { + const output = await trace( + provider({ + limitations: ["Upstream xrefs_to capped results at 1000 references."], + }), + ); + + expect( + output.normalized_result, + "a limitation text alone never truncates, and an absent field keeps the previous result", + ).toEqual(expectedTrace([])); + }); + + it("never exposes the coverage field in the tool output", async () => { + const output = await trace( + provider({ coverage: { "0x10": true, "0x11": false } }), + ); + + expect(JSON.stringify(output)).not.toMatch( + /referencesTruncated|references_truncated/, + ); + }); + + it("fails the trace when a references read fails and reads nothing further", async () => { + const calls: string[] = []; + const result = await new EnhancedTools( + provider({ failXrefsOf: "0x10", calls }), + ).execute("trace_feature", { query: "hello" }); + + expect(result.ok).toBe(false); + expect( + calls.filter((call) => call.startsWith("xrefs:")), + "the next target is not read after the failure", + ).toEqual(["xrefs:0x10"]); + }); + + it("stops without reading references when the trace is cancelled", async () => { + const calls: string[] = []; + const controller = new AbortController(); + controller.abort(); + const result = await new EnhancedTools(provider({ calls })).execute( + "trace_feature", + { query: "hello" }, + controller.signal, + ); + + expect(result.ok).toBe(false); + expect(calls.filter((call) => call.startsWith("xrefs:"))).toEqual([]); + }); +}); diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index c3dc21542..01d8eba13 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -196,9 +196,9 @@ itWithCaptureCapability( result.value.frames.map(({ at_ms }) => at_ms), "capture-relative frame times stay ordered and nonnegative", ).toEqual( - [...result.value.frames.map(({ at_ms }) => at_ms)].sort( - (left, right) => left - right, - ), + result.value.frames + .map(({ at_ms }) => at_ms) + .sort((left, right) => left - right), ); expect(result.value.frames.every(({ at_ms }) => at_ms >= 0)).toBe(true); expect(result.value.exit.reason).toBe("exited"); diff --git a/tests/boundary/providers/ida/containment.test.ts b/tests/boundary/providers/ida/containment.test.ts new file mode 100644 index 000000000..75eb78f42 --- /dev/null +++ b/tests/boundary/providers/ida/containment.test.ts @@ -0,0 +1,144 @@ +import { rm } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { inspectAddressContext } from "../../../../src/application/AnalysisContextQueries.js"; +import { EnhancedTools } from "../../../../src/application/EnhancedTools.js"; +import { IdaSessionClient } from "../../../../src/ida/IdaSessionClient.js"; +import { createIdaTarget, RecordingIdaMcp } from "../../../fixtures/idaMcp.js"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) + await rm(root, { recursive: true, force: true }); +}); + +const session = async (cappedReferences = false) => { + const { root, target } = await createIdaTarget("arm64"); + roots.push(root); + const producer = new RecordingIdaMcp(target, "headless"); + if (cappedReferences) { + const inner = producer.overrideCall; + producer.overrideCall = (name, args) => + name === "xrefs_to" + ? [ + { + xrefs: [ + { + addr: "0x1004", + type: "code", + fn: { addr: "0x1000", name: "main", size: "0x20" }, + }, + ], + more: true, + }, + ] + : inner?.(name, args); + } + const client = new IdaSessionClient( + { + command: "fixture", + args: [], + env: {}, + mode: "headless", + timeoutMs: 1000, + workspaceRoot: root, + }, + target, + producer, + ); + return { client, target }; +}; + +const MAIN = { + found: true, + procedure: { address: "0x1000", name: "main" }, +}; + +describe("trace_feature on headless IDA", () => { + it("attributes a reference to its containing procedure", async () => { + const { client } = await session(); + try { + const result = await new EnhancedTools(client).execute("trace_feature", { + query: "a.b", + }); + + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + matches: [{ type: "string", address: "0x3000" }], + references: [ + { + target_address: "0x3000", + source_address: "0x1004", + containing_procedure: MAIN, + }, + ], + truncated: false, + residual_unknowns: [], + }); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("keeps an upstream reference cap visible once containment makes the trace run", async () => { + const { client } = await session(true); + try { + const result = await new EnhancedTools(client).execute("trace_feature", { + query: "a.b", + }); + + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + references: [{ source_address: "0x1004", containing_procedure: MAIN }], + truncated: true, + residual_unknowns: [ + "References to 0x3000 were truncated by the provider; additional references may exist.", + ], + }); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); +}); + +describe("inspect_address_context on headless IDA", () => { + it("resolves the admitted program as the document and reports the containing procedure", async () => { + const { client, target } = await session(); + try { + const result = await inspectAddressContext(client, { + address: "0x1004", + }); + + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + address: "0x1004", + document: target.path, + procedure: { state: "available", value: MAIN }, + name: { state: "unavailable" }, + comment: { state: "unavailable" }, + inline_comment: { state: "unavailable" }, + bookmarks: { state: "unavailable" }, + }); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); + + it("answers the same way for an explicit admitted document", async () => { + const { client, target } = await session(); + try { + const implicit = await inspectAddressContext(client, { + address: "0x1004", + }); + const explicit = await inspectAddressContext(client, { + address: "0x1004", + document: target.path, + }); + + if (!implicit.ok) throw implicit.error; + if (!explicit.ok) throw explicit.error; + expect(explicit.value).toEqual(implicit.value); + } finally { + expect((await client.close()).ok).toBe(true); + } + }); +}); diff --git a/tests/boundary/providers/ida/verifierFeatureTrace.test.ts b/tests/boundary/providers/ida/verifierFeatureTrace.test.ts new file mode 100644 index 000000000..77f654ffc --- /dev/null +++ b/tests/boundary/providers/ida/verifierFeatureTrace.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { + assertFeatureTrace, + findMappedNonFunction, +} from "../../../../scripts/lib/ida-feature-trace.mjs"; + +const procedure = { address: "0x1000", name: "main" }; + +const containing = (found: boolean) => + found + ? { + query_address: "0x1004", + found: true, + procedure: { address: "0x1000", name: "main" }, + } + : { + query_address: "0x3010", + found: false, + procedure: null, + reason: "not_in_procedure", + }; + +const trace = ( + matches: readonly { type: string; address: string }[], + references: readonly { + target_address: string; + containing_procedure: unknown; + }[], +) => ({ + query: "main", + search_mode: "literal", + matches, + references, + truncated: false, + residual_unknowns: [], +}); + +const SELECTED = { type: "procedure", address: "0x1000" }; +const SUBSTRING = { type: "procedure", address: "0x2000" }; +const STRING = { type: "string", address: "0x3000" }; + +describe("the real IDA lane's feature trace assertions", () => { + it("accepts references to every matched address, whoever contains the source", () => { + expect(() => + assertFeatureTrace( + trace( + [SELECTED, SUBSTRING, STRING], + [ + { + target_address: "0x1000", + containing_procedure: containing(true), + }, + // A substring match such as main_helper, referenced from code. + { + target_address: "0x2000", + containing_procedure: containing(true), + }, + // A string containing the query, referenced from data outside functions. + { + target_address: "0x3000", + containing_procedure: containing(false), + }, + ], + ), + procedure, + ), + ).not.toThrow(); + }); + + it("accepts a reference to the selected procedure from data outside functions", () => { + expect(() => + assertFeatureTrace( + trace( + [SELECTED], + [ + { + target_address: "0x1000", + containing_procedure: containing(false), + }, + ], + ), + procedure, + ), + ).not.toThrow(); + }); + + it("rejects a reference whose target is not one of the matches", () => { + expect(() => + assertFeatureTrace( + trace( + [SELECTED], + [ + { + target_address: "0x9999", + containing_procedure: containing(true), + }, + ], + ), + procedure, + ), + ).toThrow(); + }); + + it("rejects a trace without the selected procedure's match", () => { + expect(() => assertFeatureTrace(trace([STRING], []), procedure)).toThrow(); + }); + + it("rejects a containment answer that is neither found nor a known reason", () => { + expect(() => + assertFeatureTrace( + trace( + [SELECTED], + [ + { + target_address: "0x1000", + containing_procedure: { + query_address: "0x1004", + found: false, + procedure: null, + reason: "unknown", + }, + }, + ], + ), + procedure, + ), + ).toThrow(); + }); + + it("rejects a trace that does not say whether it was truncated", () => { + expect(() => + assertFeatureTrace( + { ...trace([SELECTED], []), truncated: undefined }, + procedure, + ), + ).toThrow(); + }); +}); + +describe("the real IDA lane's mapped non-function probe", () => { + const inFunction = { + query_address: "0x1100", + found: true, + procedure: { address: "0x1000", name: "main" }, + }; + const data = { + query_address: "0x3000", + found: false, + procedure: null, + reason: "not_in_procedure", + }; + const resolver = + (answers: Readonly>, asked: string[] = []) => + async (address: string) => { + asked.push(address); + return answers[address]; + }; + + it("skips a string inside a function chunk and uses the next one", async () => { + const asked: string[] = []; + + await expect( + findMappedNonFunction( + [{ address: "0x1100" }, { address: "0x3000" }], + resolver({ "0x1100": inFunction, "0x3000": data }, asked), + ), + ).resolves.toBe("0x3000"); + expect(asked, "strings are tried in order until one qualifies").toEqual([ + "0x1100", + "0x3000", + ]); + }); + + it("stops at the first string that qualifies", async () => { + const asked: string[] = []; + + await expect( + findMappedNonFunction( + [{ address: "0x3000" }, { address: "0x3100" }], + resolver({ "0x3000": data, "0x3100": data }, asked), + ), + ).resolves.toBe("0x3000"); + expect(asked).toEqual(["0x3000"]); + }); + + it("reports no qualifying string when every string is inside a function", async () => { + await expect( + findMappedNonFunction( + [{ address: "0x1100" }, { address: "0x1200" }], + resolver({ "0x1100": inFunction, "0x1200": inFunction }), + ), + ).resolves.toBeUndefined(); + }); + + it("reports no qualifying string when the target has no strings", async () => { + await expect( + findMappedNonFunction([], resolver({})), + ).resolves.toBeUndefined(); + }); + + it("rejects a listed string that resolves as unmapped, since IDA cannot list an unmapped address", async () => { + await expect( + findMappedNonFunction( + [{ address: "0x4000" }, { address: "0x3000" }], + resolver({ + "0x4000": { ...data, reason: "outside_segments" }, + "0x3000": data, + }), + ), + ).rejects.toThrow(); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 813b90004..092f869e2 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -1278,3 +1278,102 @@ describe("selector origin forwarding", () => { ).toEqual(["provider", "provider", undefined]); }); }); + +describe("reference truncation forwarding", () => { + const referenceProvider = ( + referencesTruncated: boolean | undefined, + calls: string[], + ): AnalysisProvider => { + const identity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + } as const; + return { + identity: () => identity, + resolveAnalysisProfile: () => + Promise.resolve( + resultOk({ + profile: createAnalysisProfile(identity, { fixture: true }), + }), + ), + capabilities: () => [ + { + provider: identity, + operation: "xrefs", + available: true, + reason: null, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limitations: [], + }, + ], + createClient: () => ({ + execute: (operation) => { + calls.push(operation); + return Promise.resolve( + resultOk( + createAnalysisExecution( + ["0x2000"], + identity, + referencesTruncated === undefined + ? {} + : { referencesTruncated }, + ), + ), + ); + }, + close: () => Promise.resolve(resultOk(null)), + }), + }; + }; + const parameters = { address: "0x1000", document: "fixture" }; + + it("forwards a truncation on both execution paths and never replays it from the snapshot", async () => { + const [first] = await createBinarySessionTargets(); + const calls: string[] = []; + const session = createTestBinarySession(referenceProvider(true, calls)); + expect((await session.open(first)).ok).toBe(true); + calls.length = 0; + + const direct = await session.execute("xrefs", parameters); + const admitted = await session.withAdmittedAnalysis( + "trace_feature", + undefined, + (analysis) => analysis.execute("xrefs", parameters), + ); + + expect(direct.ok && direct.value.referencesTruncated).toBe(true); + if (!admitted.ok) throw admitted.error; + expect(admitted.value.ok && admitted.value.value.referencesTruncated).toBe( + true, + ); + expect( + calls, + "a truncated read is not recorded, so every call reaches the provider", + ).toEqual(["xrefs", "xrefs"]); + }); + + it("keeps an explicit complete report out of the way of snapshot replay", async () => { + const [first] = await createBinarySessionTargets(); + const calls: string[] = []; + const session = createTestBinarySession(referenceProvider(false, calls)); + expect((await session.open(first)).ok).toBe(true); + calls.length = 0; + + const live = await session.execute("xrefs", parameters); + await session.execute("xrefs", parameters); + + expect(live.ok && live.value.referencesTruncated).toBe(false); + expect(calls, "the complete read is recorded and replayed").toEqual([ + "xrefs", + ]); + }); +}); diff --git a/tests/conformance/ida/providerContracts.test.ts b/tests/conformance/ida/providerContracts.test.ts index 39c524637..4e4b04c3b 100644 --- a/tests/conformance/ida/providerContracts.test.ts +++ b/tests/conformance/ida/providerContracts.test.ts @@ -29,15 +29,25 @@ const parametersFor = ( operation: string, ): Readonly> => { if (operation.startsWith("search_")) return { pattern: "a.b" }; - if (operation === "xrefs") return { address: "0x1000" }; + if (operation === "xrefs" || operation === "resolve_containing_procedure") + return { address: "0x1000" }; if (operation.startsWith("list_")) return {}; return { procedure: "main" }; }; +/** Operations only the headless profile declares. */ +const HEADLESS_ONLY: ReadonlySet = new Set([ + "resolve_containing_procedure", + "list_documents", +]); + describe.each(["attached", "headless"] as const)( "IDA %s caller-visible contracts", (mode) => { - it.each(IDA_OPERATIONS)( + const operations = IDA_OPERATIONS.filter( + (operation) => mode === "headless" || !HEADLESS_ONLY.has(operation), + ); + it.each(operations)( "declares %s UI effects matching the upstream tools it reaches", async (operation) => { const { root, target } = await createIdaTarget(); @@ -69,7 +79,7 @@ describe.each(["attached", "headless"] as const)( }, ); - it.each(IDA_OPERATIONS)( + it.each(operations)( "serializes %s against its named advertised output contract", async (operation) => { const { root, target } = await createIdaTarget(); diff --git a/tests/fixtures/idaMcp.ts b/tests/fixtures/idaMcp.ts index 04bdb5f16..21e5e38f7 100644 --- a/tests/fixtures/idaMcp.ts +++ b/tests/fixtures/idaMcp.ts @@ -57,6 +57,7 @@ const modernNames = [ "callees", "xrefs_to", "callgraph", + "survey_binary", ]; /** Stateful producer fixture at the real connection seam; no module mocking. */ From 2a61deafc632079d10c572bc09e99233826516a3 Mon Sep 17 00:00:00 2001 From: Jin Date: Tue, 13 Oct 2026 08:56:41 -0400 Subject: [PATCH 25/92] fix(adb): report the device state when a push cannot probe the path (#2354) A push without overwrite read any unclassified failure of its existence probe as an existing path, so an offline or unauthorized device was reported as "the device path already exists". With overwrite it pushed after a probe that had not established anything. The probe's own refusal is now returned in both cases. Co-authored-by: Claude Opus 5.5 --- src/adb/AdbProvider.test.ts | 30 ++++++++++++++++++++++++++++++ src/adb/AdbProvider.ts | 18 ++++++++---------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index ae81353c7..23645e456 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -810,6 +810,36 @@ exit 1`, expect(result.error.message).toContain("overwrite"); }); +it.each([ + ["offline", false, "error: device offline"], + ["unauthorized", false, "error: device unauthorized"], + ["offline", true, "error: device offline"], +] as const)( + "reports the %s device state when the push existence probe cannot run (overwrite %s)", + async (state, overwrite, stderr) => { + const { binary, root } = await writeAdbStub( + (stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ]; then printf '%s\\n' "$3" >> "${stubRoot}/calls"; printf '%s\\n' "${stderr}" >&2; exit 1; fi`, + ); + const localFile = join(root, "payload.bin"); + await writeFile(localFile, "x"); + const projected = await refusedBy(providerOver(binary), { + operation: "push_adb_file", + input: { + serial: "emulator-5554", + local_path: localFile, + device_path: "/data/local/tmp/payload.bin", + overwrite, + }, + }); + // Existence is unknown, so nothing is claimed about the path and no push runs. + expect(projected).toMatchObject({ code: "unsupported_target" }); + expect(projected.message).toContain(state); + expect(projected.message).not.toContain("already exists"); + expect(await readFile(join(root, "calls"), "utf8")).toBe("shell\n"); + }, +); + it("reports a local push source that does not exist", async () => { const { binary } = await writeAdbStub( () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index b9af6d14f..d26345eba 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -1623,17 +1623,15 @@ export class AdbProvider { { timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 }, signal, ); - let deviceExists: boolean; - if (existsProbe.ok) deviceExists = true; - else if (existsProbe.error instanceof AnalysisProtocolError) - deviceExists = false; - else if (existsProbe.error instanceof AnalysisTimeoutError) + // `test -e` exits nonzero with no adb diagnosis when the path is absent. + // Any other probe failure leaves existence unknown, so it is reported as + // is rather than read as an existing path or followed by a push. + if ( + !existsProbe.ok && + !(existsProbe.error instanceof AnalysisProtocolError) + ) return existsProbe; - else if (existsProbe.error instanceof AnalysisCancelledError) - return existsProbe; - else if (existsProbe.error instanceof AnalysisResourceConstraintError) - return existsProbe; - else deviceExists = true; // authorization/refusal states surface on push instead + const deviceExists = existsProbe.ok; if (deviceExists && !input.overwrite) return err( new AnalysisOutputError( From 4ea24f97670bebf8ca053179ca0dc5009e6f6a28 Mon Sep 17 00:00:00 2001 From: jhuang-tw <77732008+jhuang-tw@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:58:51 +0800 Subject: [PATCH 26/92] fix(ida): retain private input after ambiguous open (#1845) fix(ida): retain private input after unverified open --- docs/ida-provider.md | 2 + src/ida/IdaProtocolValues.ts | 8 +++ src/ida/IdaSessionClient.test.ts | 98 ++++++++++++++++++++++++++------ src/ida/IdaSessionClient.ts | 13 +++++ 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/docs/ida-provider.md b/docs/ida-provider.md index 50040425b..fbf67c8a2 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -88,6 +88,8 @@ REA copies the admitted binary into a private temporary directory, checks its di One REA session owns one database. Stdio registrations default `IDA_MCP_MAX_WORKERS` to `1`; the explicit upstream `--max-workers 1` also bounds a supervisor. Database size and engine memory depend on the target and upstream; REA does not impose an engine memory limit. The original binary is never executed or overwritten. Optional `workspaceRoot` selects an existing absolute temporary-directory parent on the REA host. Ensure it has space for the input copy and IDA working files. +When a completed `idb_open` response reports success or is otherwise ambiguous, but no active owned private worker can be verified, REA retains the private workspace even if `idb_list` appears empty. A later close can retry verification if the original owned worker becomes discoverable. A completed explicit `idb_open` rejection retains the existing no-worker cleanup path. This is a local admission/ownership safeguard and **does not** provide a process-termination receipt or change the legacy reaped-worker protocol. + Headless cleanup uses the upstream database lifecycle. Abruptly terminating REA, its proxy, or the supervisor does not establish worker release. Prefer `close_binary` for MCP sessions; after an interrupted process, inspect upstream session inventory and retained workspace diagnostics before removing working files. This adapter does not claim Windows Job Object containment for the upstream detached workers. ## Connect an agent once diff --git a/src/ida/IdaProtocolValues.ts b/src/ida/IdaProtocolValues.ts index d4afb92db..f18e85ee0 100644 --- a/src/ida/IdaProtocolValues.ts +++ b/src/ida/IdaProtocolValues.ts @@ -218,6 +218,14 @@ export const openDatabaseSchema = z .passthrough(), }) .passthrough(); +/** Only an explicit completed rejection preserves legacy no-worker cleanup. */ +export const rejectedOpenDatabaseSchema = z + .object({ + error: z.string().min(1), + success: z.never().optional(), + }) + .passthrough(); + export const databaseListSchema = z .object({ sessions: z.array( diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index e43f18cf7..b41a3c34a 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -515,7 +515,7 @@ describe("headless IDA transient busy health", () => { }); it.each([ - ["free-form text", "token=abc123 /private/home/user secret"], + ["free-form text", "token=abc123 /private secret"], ["an over-long name", "x".repeat(200)], ])( "reports %s from the worker as unknown instead of echoing it", @@ -899,6 +899,85 @@ describe("headless IDA reaped worker cleanup", () => { }); }); +/** A completed open response is not proof that its private Worker was ever released. */ +describe("headless IDA unresolved successful-open ownership", () => { + it.each(["matching", "foreign-input", "malformed"] as const)( + "retains the private input after %s success with incomplete initial discovery", + async (kind) => { + const { producer, client, root } = await fixture("headless"); + let hidden = true; + let ownedId: string | undefined; + let privateInput: string | undefined; + producer.beforeCall = async (name, args) => { + if (name !== "idb_open") return; + ownedId = z.string().parse(args.preferred_session_id); + privateInput = z.string().parse(args.input_path); + producer.database = ownedId; + producer.inputPath = privateInput; + }; + producer.overrideCall = (name) => { + if (name === "idb_open") { + const originalId = z.string().parse(ownedId); + const originalInput = z.string().parse(privateInput); + if (kind === "foreign-input") + return { + success: true, + session: { + session_id: "foreign-session", + input_path: "/foreign/input", + }, + }; + if (kind === "malformed") + return { success: true, session: { session_id: originalId } }; + return { + success: true, + session: { session_id: originalId, input_path: originalInput }, + }; + } + if (name === "idb_list" && hidden) return { sessions: [] }; + return undefined; + }; + + expect((await client.execute("health", {})).ok).toBe(false); + const incomplete = await client.close(); + expect(incomplete.ok).toBe(false); + if (!incomplete.ok) expect(incomplete.error.cleanupIncomplete).toBe(true); + expect(producer.closes).toBe(0); + expect(producer.database).toBe(ownedId); + expect( + (await readdir(root)).some((name) => name.startsWith("rea-ida-")), + ).toBe(true); + expect(producer.calls.some(({ name }) => name === "idb_close")).toBe( + false, + ); + + // A later positive private ownership record permits ordinary save=false + // cleanup, without ever adopting the foreign open-response identity. + hidden = false; + expect((await client.close()).ok).toBe(true); + expect(producer.calls.filter(({ name }) => name === "idb_close")).toEqual( + [{ name: "idb_close", args: { database: ownedId, save: false } }], + ); + expect(producer.closes).toBe(1); + expect(await readdir(root)).toEqual(["target.elf"]); + }, + ); + + it("preserves cleanup after a completed, explicit idb_open rejection", async () => { + const { producer, client, root } = await fixture("headless"); + producer.overrideCall = (name) => + name === "idb_open" + ? { error: "Input was rejected before creating a session" } + : undefined; + + expect((await client.execute("health", {})).ok).toBe(false); + expect((await client.close()).ok).toBe(true); + expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false); + expect(producer.closes).toBe(1); + expect(await readdir(root)).toEqual(["target.elf"]); + }); +}); + describe("headless IDA cleanup verification", () => { it("retries failed worker release through the retained connection before removing its workspace", async () => { const { producer, client, root } = await fixture("headless"); @@ -943,23 +1022,6 @@ describe("headless IDA cleanup verification", () => { ).toHaveLength(1); expect(await readdir(root)).toEqual(["target.elf"]); }); - it("never closes an unrelated database when startup returns a different input identity", async () => { - const { producer, client, root } = await fixture("headless"); - producer.overrideCall = (name) => - name === "idb_open" - ? { - success: true, - session: { - session_id: "user-database", - input_path: "/unrelated/input", - }, - } - : undefined; - expect((await client.execute("health", {})).ok).toBe(false); - expect((await client.close()).ok).toBe(true); - expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false); - expect(await readdir(root)).toEqual(["target.elf"]); - }); it("releases a privately owned worker after malformed open output using verified session inventory", async () => { const { producer, client, root } = await fixture("headless"); producer.beforeCall = async (name, args) => { diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index c940e4c6a..14b20b107 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -43,6 +43,7 @@ import { legacyMetadataSchema, modernHealthSchema, openDatabaseSchema, + rejectedOpenDatabaseSchema, } from "./IdaProtocolValues.js"; import { idaFileDigest, @@ -122,6 +123,7 @@ export class IdaSessionClient implements AnalysisClient { #databaseCloseCompleted = false; #openAttempted = false; #openCompleted = false; + #openRejected = false; #callGraphAdvertised = false; #surveyAdvertised = false; #initialIdentity: string | undefined; @@ -469,6 +471,9 @@ export class IdaSessionClient implements AnalysisClient { preferred_session_id: this.#workspace.sessionId, }); this.#openCompleted = true; + // A completed but malformed/positive open may still own a live Worker. + // Only an explicit producer rejection keeps the old no-worker branch. + this.#openRejected = rejectedOpenDatabaseSchema.safeParse(raw).success; this.#lifecycle.push(raw); const opened = openDatabaseSchema.parse(raw); if ( @@ -633,6 +638,14 @@ export class IdaSessionClient implements AnalysisClient { throw new AnalysisProtocolError( "An unowned worker still uses the private workspace; it was retained.", ); + if ( + this.#database === undefined && + this.#openCompleted && + !this.#openRejected + ) + throw new AnalysisProtocolError( + "IDA open returned without a verified private worker binding. An empty session inventory does not prove that its Worker exited; the private workspace was retained.", + ); } if (this.#database !== undefined) { if (!this.#databaseCloseCompleted) { From d2e16b2a4998818be5f29219467b97cc15ae8061 Mon Sep 17 00:00:00 2001 From: yasshmittal Date: Tue, 13 Oct 2026 18:33:14 +0530 Subject: [PATCH 28/92] fix(sqlite): attribute unknown column selections to columns (#2774) Every worker "selection" rejection was projected onto the table argument, including an exact column name that does not exist in an existing table. Give column rejections their own worker reason and map each input reason to the caller argument it requires correcting. Table rejections keep the table path. Fixes #2613 Co-authored-by: Claude Opus 5.5 --- src/sqlite/SqliteDatabaseInspection.ts | 2 +- src/sqlite/SqliteDatabaseLimits.ts | 1 + src/sqlite/SqliteDatabaseWorkerExecution.ts | 11 ++++- .../sqlite/sqliteDatabaseProvider.test.ts | 44 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index f5f92bdf4..510fbaafc 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -394,7 +394,7 @@ const readRows = ( for (const name of columns) if (!available.has(name)) throw new SqliteInspectionFailure( - "selection", + "column-selection", `Selected column does not exist with that exact name in table ${input.table}: ${name}`, ); const projection = columns diff --git a/src/sqlite/SqliteDatabaseLimits.ts b/src/sqlite/SqliteDatabaseLimits.ts index 99df7ed9d..408fc4bc3 100644 --- a/src/sqlite/SqliteDatabaseLimits.ts +++ b/src/sqlite/SqliteDatabaseLimits.ts @@ -12,6 +12,7 @@ export class SqliteInspectionFailure extends Error { readonly reason: | "format" | "selection" + | "column-selection" | "unsupported" | "unavailable" | "output-limit" diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts index 9474a6d27..cba180399 100644 --- a/src/sqlite/SqliteDatabaseWorkerExecution.ts +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -36,6 +36,7 @@ const replySchema = z.discriminatedUnion("ok", [ reason: z.enum([ "format", "selection", + "column-selection", "unsupported", "unavailable", "output-limit", @@ -49,6 +50,14 @@ export type SqliteWorkerLauncher = NonNullable< Parameters[2] >["launcher"]; +// Each input rejection names the caller argument that requires correction. +const rejectedArgument = { + format: "path", + unsupported: "path", + selection: "table", + "column-selection": "columns", +} as const; + const rejectedReply = ( reply: z.output & { ok: false }, capturedOutput: AnalysisCapturedOutput, @@ -83,7 +92,7 @@ const rejectedReply = ( ); return new AnalysisInputError(operation, { capturedOutput }, [ { - path: [reply.reason === "selection" ? "table" : "path"], + path: [rejectedArgument[reply.reason]], reason: "invalid_format", message: reply.message, }, diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts index 24dadc128..b5adf8297 100644 --- a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -5,6 +5,7 @@ import { DatabaseSync } from "node:sqlite"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { expect, it as nativeTest, onTestFinished } from "vitest"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; @@ -356,6 +357,49 @@ it("does not execute views when selecting rows", async () => { }); }); +it("attributes table and column selection failures to their own arguments", async () => { + const { database } = await createFixture(); + const provider = createProvider(); + onTestFinished(() => provider.close()); + const projection = async (selection: { + table: string; + columns?: string[]; + }) => { + const result = await provider.inspect({ + path: database.path, + row_limit: 1, + ...selection, + }); + if (result.ok) throw new Error("Expected a rejected selection"); + return projectAnalysisError(result.error); + }; + expect( + await projection({ table: "records", columns: ["nope"] }), + ).toMatchObject({ + details: { + issues: [ + { + path: ["columns"], + reason: "invalid_format", + message: + "Selected column does not exist with that exact name in table records: nope", + }, + ], + }, + }); + expect(await projection({ table: "absent" })).toMatchObject({ + details: { issues: [{ path: ["table"] }] }, + }); + const selected = await provider.inspect({ + path: database.path, + table: "records", + columns: ["id"], + row_limit: 1, + }); + if (!selected.ok) throw selected.error; + expect(selected.value.result).toMatchObject({ rows: { columns: ["id"] } }); +}); + it("rejects malformed databases without altering the selected file", async () => { const workspace = await createTestWorkspace("rea-sqlite-invalid-"); onTestFinished(() => removeTestWorkspace(workspace.root)); From 91830f1e0e68232e991902b0fa7617651d2eee15 Mon Sep 17 00:00:00 2001 From: Nelglor Date: Tue, 13 Oct 2026 09:12:15 -0400 Subject: [PATCH 30/92] feat(managed): name the declaring type of call and field targets (#2163) * feat(managed): name the declaring type of call and field targets Call edges named external targets by member name alone (Join, ToJson, WriteAllText), and method-spec edges had no name at all, so an analyst could not tell which library a method calls or find every use of a type without a decompiler. Field accesses through MemberRefs had no name either. Name each TypeDef, TypeRef, and generic-instantiation TypeSpec once, in the namespace-qualified form TypeDef full_name uses, and add member_refs parent_name, call_edges target_declaring_type, and field_accesses field_declaring_type. A method-spec target reports the generic method it instantiates. Fields, member references, methods, and method specs now resolve through one token-keyed MemberCore lookup instead of three maps. Evidence recorded before these fields reads them as null. Co-Authored-By: Claude Opus 5.5 * fix(managed): validate spec blobs and member kinds before naming targets Address review findings on declaring-type naming: - A generic instantiation must name a TypeDef or TypeRef, so a TypeSpec base is reported as malformed instead of followed. Naming no longer recurses. - TypeSpec and MethodSpec instantiation blobs are decoded with the member-signature grammar. Malformed blobs and out-of-range TypeDef rows are reported against their owning row and make coverage partial. - A MethodSpec reads both columns. An invalid Method coded index is reported; a readable Method keeps its name beside a malformed instantiation. - MemberCore carries its signature kind, so a field operand is named only by a field or field-signature MemberRef. This replaces the token-table guard. Co-Authored-By: Claude Opus 5.5 * fix(managed): validate MethodSpec arity and report unsupported spec blobs - A MethodSpec now checks that its method signature is generic and that the instantiation supplies as many type arguments as it declares. A non-generic target, a field reference, or a count mismatch is reported as invalid-row; the readable method keeps its name. - Unsupported TypeSpec and MethodSpec forms raise an owner-scoped unsupported-signature issue instead of reading as an intentional unknown. - MethodSpec reading moves to ManagedMethodSpecs.ts, and its tests get their own describe block. Co-Authored-By: Claude Opus 5.5 * fix(managed): reject non-method signature headers and invalid TypeRef scopes The method signature header reader now owns the calling-convention rule that only FNPTR applied: FIELD, LOCAL_SIG, PROPERTY, and GENERICINST headers, the reserved bit, and EXPLICITTHIS without HASTHIS are malformed. A MethodSpec whose method has a PROPERTY header is no longer accepted as a generic method, and member signatures with such headers no longer decode as methods. The type-name lookup validates a TypeRef's ResolutionScope coded index and reports an out-of-range scope as invalid-row, keeping the readable name. Co-Authored-By: Claude Opus 5.5 * fix(managed): hold member rows to MethodDefSig and FieldSig headers Method signature headers are now checked in context. MethodDef and MemberRef rows accept only the MethodDefSig/MethodRefSig headers: DEFAULT, VARARG, or GENERIC with DEFAULT. FNPTR keeps the unmanaged conventions compilers emit for function pointers. A field signature must be exactly FIELD, so a flagged FIELD header is malformed and never names a field. Co-Authored-By: Claude Opus 5.5 * fix(managed): accept MethodSpec arity only from a fully decoded signature The arity check read only the target's signature header, so a truncated generic method signature passed. Under type selection a MethodDef target outside the selection is never decoded elsewhere, so coverage stayed complete. The arity now comes from a transient full decode, and only a decoded method signature supplies it. signature() keeps its raw length and digest around the shared decodeSignature. Co-Authored-By: Claude Opus 5.5 * perf(managed): decode each MethodSpec target signature once edgeTargets caches the generic arity, including a failed decode, by target method token, so many instantiations of one generic method share a single signature decode. Unity.InputSystem has 472 distinct MethodSpecs over about 133 target methods. Co-Authored-By: Claude Opus 5.5 * fix(managed): validate spec type operands and transitive reference scopes * fix(managed): retain readable names when a scope row is unreadable * test(managed): pin owner-scoped spec operand and scope-chain checks e59217c83 validates spec type operands inside specSignature, so a malformed generic TypeSpec base is now invalid-row under its owning TypeSpec row; the three stale expectations follow that. New cases cover a TypeSpec argument and a MethodSpec argument that name a missing TypeRef, and a TypeRef whose ResolutionScope names itself. The scope cases move to their own describe block. Co-Authored-By: Claude Opus 5.5 * fix(managed): keep field targets from naming MethodSpecs A MethodSpec over a field-signature MemberRef inherited kind "field", so an ldsfld on that MethodSpec published a field name and declaring type for malformed IL. A MethodSpec now borrows its target's name only when the target is not a field; the arity check still reports it. Co-Authored-By: Claude Opus 5.5 * fix(managed): reuse owner-decoded signatures for MethodSpec arity MemberRef signatures, and MethodDef signatures in the type selection, are already fully decoded, but the MethodSpec arity check decoded them again and admitted a second transient allocation, which could exhaust the budget. MemberCore now carries the owner's genericArity, and only a target nothing decoded, such as an unselected MethodDef, falls back to the raw signature. signatureGenericArity is the one rule for both paths. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/managed-code-analysis.md | 22 ++ src/domain/managed/managedArtifact.ts | 19 +- src/dotnet/ManagedMemberInspector.test.ts | 394 +++++++++++++++++++++- src/dotnet/ManagedMemberInspector.ts | 18 +- src/dotnet/ManagedMemberInspectorCore.ts | 230 ++++++++++--- src/dotnet/ManagedMemberRows.ts | 113 +++++-- src/dotnet/ManagedMethodSpecs.ts | 154 +++++++++ src/dotnet/ManagedPe.fixture.ts | 81 ++++- src/dotnet/ManagedTypeNames.ts | 137 ++++++++ 9 files changed, 1060 insertions(+), 108 deletions(-) create mode 100644 src/dotnet/ManagedMethodSpecs.ts create mode 100644 src/dotnet/ManagedTypeNames.ts diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 060b44bc2..d60603ce3 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -147,6 +147,28 @@ opposite observation's selection unknown, and application graphs report their selected scope as partial. Neither makes an absence claim about the whole assembly. Older member Evidence without `selection` remains readable. +### Edge target declaring types + +`member_refs[].parent_name`, `call_edges[].target_declaring_type`, and +`field_accesses[].field_declaring_type` name the type that declares each +target. Referenced spec signatures validate encoded type coordinates against +the metadata tables, including nested type arguments and custom modifiers. +TypeRef resolution scopes are checked transitively with cycle detection; +readable names survive malformed scopes as partial evidence. +TypeDef and TypeRef names use the namespace-qualified `full_name` +form, and nested types keep bare names. A generic-instantiation TypeSpec +parent names its generic type, such as ``System.Collections.Generic.List`1``. +Other well-formed TypeSpec forms and ModuleRef or MethodDef parents stay +`null`. A `method-spec` call edge reports the name and declaring type of the +generic method it instantiates. A field access is named only by a field or a +field-signature MemberRef. TypeSpec and MethodSpec blobs are decoded with the +member-signature grammar, and a MethodSpec must instantiate a generic method +with as many type arguments as it declares. Malformed or unsupported rows and +blobs, and arity mismatches, make coverage partial. A readable MethodSpec +method column that names a method, or a TypeRef whose resolution scope is +invalid, keeps its name. +Older member Evidence without these fields reads them as `null`: unknown. + ## Analysis objective Start by identifying the artifact with `inspect_managed_artifact`, then inspect diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index 297b60a4e..94eff2398 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -203,10 +203,18 @@ const managedMethodSchema = z.object({ body: managedMethodBodySchema, }); +// Declaring types are namespace-qualified like TypeDef `full_name`, nested +// types keep bare names, and a generic instantiation names its generic type. +// Evidence recorded before declaring types were named reads them as null. +const declaringTypeNameSchema = z.string().nullable().default(null); + const managedMemberReferenceSchema = z.object({ token: tokenSchema, row_offset: offsetSchema, parent_token: tokenSchema.nullable(), + parent_name: declaringTypeNameSchema.describe( + "Name of a TypeDef, TypeRef, or generic-instantiation TypeSpec parent; null for other parents or when unknown", + ), name: z.string(), signature: managedSignatureSchema, }); @@ -281,7 +289,13 @@ const managedCallEdgeSchema = z.object({ opcode: z.string(), target_token: tokenSchema, target_kind: z.enum(["method-def", "member-ref", "method-spec", "unknown"]), - target_name: z.string().nullable(), + target_name: z + .string() + .nullable() + .describe("Method name; a method-spec target reports its generic method"), + target_declaring_type: declaringTypeNameSchema.describe( + "Declaring type of the called method; null when unknown", + ), }); const managedFieldAccessSchema = z.object({ @@ -290,6 +304,9 @@ const managedFieldAccessSchema = z.object({ opcode: z.string(), field_token: tokenSchema, field_name: z.string().nullable(), + field_declaring_type: declaringTypeNameSchema.describe( + "Declaring type of the accessed field; null when unknown", + ), }); const managedParseIssueSchema = z.object({ diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 81d64f4a1..9ada1bf55 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -110,6 +110,8 @@ describe("managed member inspection", () => { expect(result.member_refs).toEqual([ expect.objectContaining({ token: "0x0a000001", + parent_token: "0x01000001", + parent_name: "System.Runtime.Versioning.TargetFrameworkAttribute", name: ".ctor", signature: expect.objectContaining({ kind: "method", @@ -126,6 +128,8 @@ describe("managed member inspection", () => { target_token: "0x0a000001", target_kind: "member-ref", target_name: ".ctor", + target_declaring_type: + "System.Runtime.Versioning.TargetFrameworkAttribute", }, ]); expect(result.field_accesses).toEqual([ @@ -135,12 +139,366 @@ describe("managed member inspection", () => { opcode: "ldfld", field_token: "0x04000001", field_name: "counter", + field_declaring_type: "Fixture.Program", }, ]); expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); }); }); +/** A tiny-header body of token-operand instructions followed by `ret`. */ +const tokenBody = ( + instructions: readonly (readonly [opcode: number, token: number])[], +): Buffer => { + const il = Buffer.concat([ + ...instructions.map(([opcode, token]) => { + const instruction = Buffer.alloc(5); + instruction.writeUInt8(opcode, 0); + instruction.writeUInt32LE(token, 1); + return instruction; + }), + Buffer.from([0x2a]), + ]); + return Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]); +}; + +const attribute = "System.Runtime.Versioning.TargetFrameworkAttribute"; +const inspect = (options: Parameters[0]) => { + const bytes = buildManagedPeFixture(options); + return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes)); +}; +const call = 0x28; +const ldsfld = 0x7e; +const memberRefOnTypeSpec = (signature: Buffer) => + inspect({ + typeSpecSignatures: [signature], + memberRefParentRaw: (1 << 3) | 4, + }); + +// HASTHIS | GENERIC, one generic parameter, no parameters, void. +const genericRef = Buffer.from([0x30, 0x01, 0x00, 0x01]); +// GENERIC, one generic parameter, no parameters, void. +const genericDef = Buffer.from([0x10, 0x01, 0x00, 0x01]); + +describe("managed edge target declaring types", () => { + it("names a generic TypeSpec parent by its generic type and leaves other TypeSpecs unknown", () => { + // GENERICINST CLASS TypeRef(1) + const generic = memberRefOnTypeSpec( + Buffer.from([0x15, 0x12, (1 << 2) | 1, 0x01, 0x0e]), + ); + expect(generic.member_refs[0]?.parent_name).toBe(attribute); + expect(generic.call_edges[0]?.target_declaring_type).toBe(attribute); + + // SZARRAY i4 + const array = memberRefOnTypeSpec(Buffer.from([0x1d, 0x08])); + expect(array.member_refs[0]?.parent_name).toBeNull(); + expect(array.coverage).toEqual({ state: "complete", issues: [] }); + }); + + it.each([ + [ + "names a TypeSpec as its generic type", + [0x15, 0x12, (1 << 2) | 2, 0x01, 0x0e], + "invalid-row", + "metadata.TypeSpec:0x1b000001", + ], + [ + "has no type arguments", + [0x15, 0x12, (1 << 2) | 1], + "invalid-blob", + "metadata.TypeSpec:0x1b000001", + ], + [ + "names a missing TypeRef", + [0x15, 0x12, (9 << 2) | 1, 0x01, 0x0e], + "invalid-row", + "metadata.TypeSpec:0x1b000001", + ], + [ + "names a missing TypeDef", + [0x15, 0x12, (9 << 2) | 0, 0x01, 0x0e], + "invalid-row", + "metadata.TypeSpec:0x1b000001", + ], + [ + "has a type argument naming a missing TypeRef", + [0x15, 0x12, (1 << 2) | 1, 0x01, 0x12, (9 << 2) | 1], + "invalid-row", + "metadata.TypeSpec:0x1b000001", + ], + ])( + "reports a generic TypeSpec parent that %s and leaves it unknown", + (_case, signature, code, scope) => { + const result = memberRefOnTypeSpec(Buffer.from(signature)); + + expect(result.member_refs[0]?.parent_name).toBeNull(); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [{ code, scope }], + }); + }, + ); + + it("reports unsupported TypeSpec and MethodSpec forms instead of treating them as unknown", () => { + const typeSpec = memberRefOnTypeSpec(Buffer.from([0xff])); + const methodSpec = inspect({ + memberRefSignature: genericRef, + methodSpecMethods: [(1 << 1) | 1], + methodSpecInstantiation: Buffer.from([0x0a, 0x01, 0xff]), + ilBody: tokenBody([[call, 0x2b00_0001]]), + }); + + expect(typeSpec.member_refs[0]?.parent_name).toBeNull(); + expect(typeSpec.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "unsupported-signature", + scope: "metadata.TypeSpec:0x1b000001", + }, + ], + }); + expect(methodSpec.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "unsupported-signature", + scope: "metadata.MethodSpec:0x2b000001", + }, + ], + }); + }); + + it("names field MemberRefs as fields and leaves mismatched member kinds unnamed", () => { + const result = inspect({ + memberRefName: "Empty", + // FIELD string + memberRefSignature: Buffer.from([0x06, 0x0e]), + ilBody: tokenBody([ + [ldsfld, 0x0a00_0001], + [call, 0x0400_0001], + ]), + }); + const methodRef = inspect({ ilBody: tokenBody([[ldsfld, 0x0a00_0001]]) }); + const flaggedField = inspect({ + memberRefName: "Empty", + // GENERIC | FIELD string: FieldSig allows no flags. + memberRefSignature: Buffer.from([0x16, 0x0e]), + ilBody: tokenBody([[ldsfld, 0x0a00_0001]]), + }); + + expect(result.field_accesses).toMatchObject([ + { field_name: "Empty", field_declaring_type: attribute }, + ]); + expect(methodRef.field_accesses).toMatchObject([ + { field_name: null, field_declaring_type: null }, + ]); + expect(flaggedField.field_accesses).toMatchObject([ + { field_name: null, field_declaring_type: null }, + ]); + expect(flaggedField.coverage.state).toBe("partial"); + expect(result.call_edges).toMatchObject([ + { + target_kind: "unknown", + target_name: null, + target_declaring_type: null, + }, + ]); + }); + + it("reads Evidence recorded before declaring types were named as unknown", () => { + const current = inspect({}); + const parsed = managedMemberInspectionSchema.parse({ + ...current, + member_refs: current.member_refs.map( + ({ parent_name: _omitted, ...legacy }) => legacy, + ), + call_edges: current.call_edges.map( + ({ target_declaring_type: _omitted, ...legacy }) => legacy, + ), + field_accesses: current.field_accesses.map( + ({ field_declaring_type: _omitted, ...legacy }) => legacy, + ), + }); + + expect(parsed.member_refs[0]?.parent_name).toBeNull(); + expect(parsed.call_edges[0]?.target_declaring_type).toBeNull(); + expect(parsed.field_accesses[0]?.field_declaring_type).toBeNull(); + }); +}); + +describe("managed TypeRef resolution scopes", () => { + it.each([ + // AssemblyRef 9 does not exist. + ["an invalid", (9 << 2) | 2, "is invalid"], + // The TypeRef is its own enclosing type. + ["a cyclic", (1 << 2) | 3, "contains a cycle"], + ])( + "keeps a TypeRef name beside %s resolution scope", + (_case, typeRefScopeRaw, detail) => { + const result = inspect({ typeRefScopeRaw }); + + expect(result.member_refs[0]?.parent_name).toBe(attribute); + expect(result.call_edges[0]?.target_declaring_type).toBe(attribute); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "invalid-row", + scope: "metadata.TypeRef:0x01000001", + detail: expect.stringContaining(detail), + }, + ], + }); + }, + ); +}); + +describe("managed MethodSpec call targets", () => { + it("names generic method calls after the method they instantiate", () => { + const result = inspect({ + memberRefSignature: genericRef, + methodSignature: genericDef, + methodSpecMethods: [(1 << 1) | 1, (1 << 1) | 0], + ilBody: tokenBody([ + [call, 0x2b00_0001], + [call, 0x2b00_0002], + ]), + }); + + expect( + result.call_edges.map((edge) => [ + edge.target_kind, + edge.target_name, + edge.target_declaring_type, + ]), + ).toEqual([ + ["method-spec", ".ctor", attribute], + ["method-spec", "Main", "Fixture.Program"], + ]); + expect(result.coverage).toEqual({ state: "complete", issues: [] }); + }); + + it.each([ + [ + "a non-generic method", + {}, + Buffer.from([0x0a, 0x01, 0x0e]), + "but its method declares 0 generic parameter(s)", + ], + [ + "the wrong number of type arguments", + { memberRefSignature: genericRef }, + Buffer.from([0x0a, 0x02, 0x0e, 0x08]), + "supplies 2 type argument(s), but its method declares 1", + ], + [ + "a field reference", + { memberRefSignature: Buffer.from([0x06, 0x0e]) }, + Buffer.from([0x0a, 0x01, 0x0e]), + "does not have a decodable method signature", + ], + [ + "a property signature", + // PROPERTY | GENERIC, one generic parameter, no parameters, void. + { memberRefSignature: Buffer.from([0x18, 0x01, 0x00, 0x01]) }, + Buffer.from([0x0a, 0x01, 0x0e]), + "does not have a decodable method signature", + ], + [ + "a truncated signature", + // HASTHIS | GENERIC, one generic parameter, no parameters, no return type. + { memberRefSignature: Buffer.from([0x30, 0x01, 0x00]) }, + Buffer.from([0x0a, 0x01, 0x0e]), + "does not have a decodable method signature", + ], + [ + "a type argument naming a missing TypeRef", + { memberRefSignature: genericRef }, + Buffer.from([0x0a, 0x01, 0x12, (9 << 2) | 1]), + "TypeDefOrRefOrSpec operand 0x25 is invalid", + ], + [ + "an unmanaged-convention signature", + // C | GENERIC: a function pointer convention, not a MethodRefSig one. + { memberRefSignature: Buffer.from([0x11, 0x01, 0x00, 0x01]) }, + Buffer.from([0x0a, 0x01, 0x0e]), + "does not have a decodable method signature", + ], + ])( + "reports a MethodSpec that instantiates %s", + (_case, options, methodSpecInstantiation, detail) => { + const result = inspect({ + ...options, + methodSpecMethods: [(1 << 1) | 1], + methodSpecInstantiation, + ilBody: tokenBody([[call, 0x2b00_0001]]), + }); + + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "invalid-row", + scope: "metadata.MethodSpec:0x2b000001", + detail: expect.stringContaining(detail), + }, + ], + }); + }, + ); + + it("never names a field through a MethodSpec", () => { + const result = inspect({ + memberRefName: "Empty", + // FIELD string: a MethodSpec on it is malformed. + memberRefSignature: Buffer.from([0x06, 0x0e]), + methodSpecMethods: [(1 << 1) | 1], + ilBody: tokenBody([ + [ldsfld, 0x2b00_0001], + [call, 0x2b00_0001], + ]), + }); + + expect(result.field_accesses).toMatchObject([ + { field_name: null, field_declaring_type: null }, + ]); + expect(result.call_edges).toMatchObject([ + { target_name: null, target_declaring_type: null }, + ]); + expect(result.coverage.state).toBe("partial"); + }); + + it("keeps a readable MethodSpec method beside a malformed instantiation", () => { + const result = inspect({ + methodSpecMethods: [(1 << 1) | 1, (9 << 1) | 1], + methodSpecInstantiation: Buffer.from([0x0a, 0x00]), + ilBody: tokenBody([ + [call, 0x2b00_0001], + [call, 0x2b00_0002], + ]), + }); + + expect( + result.call_edges.map(({ target_name, target_declaring_type }) => [ + target_name, + target_declaring_type, + ]), + ).toEqual([ + [".ctor", attribute], + [null, null], + ]); + expect(result.coverage.state).toBe("partial"); + expect( + result.coverage.issues.map(({ code, scope }) => [code, scope]), + ).toEqual([ + ["invalid-blob", "metadata.MethodSpec:0x2b000001"], + ["invalid-row", "metadata.MethodSpec:0x2b000002"], + ["invalid-blob", "metadata.MethodSpec:0x2b000002"], + ]); + }); +}); + describe("managed exported types", () => { it("reports type forwarders and nested exported types with their implementation scope", () => { const bytes = buildManagedPeFixture({ @@ -373,15 +731,9 @@ describe("managed derived edges", () => { body: { anchors: Array.from({ length: 40_000 }, () => anchor) }, } as unknown as ManagedMethod; - expect(() => - edges( - [method], - new Map(), - new Map(), - new Map(), - new ManagedDecodeBudget(), - ), - ).toThrow(ManagedReaderFailure); + expect(() => edges([method], new Map(), new ManagedDecodeBudget())).toThrow( + ManagedReaderFailure, + ); }); }); @@ -413,15 +765,9 @@ describe("managed derived caller names", () => { }, } as unknown as ManagedMethod; - expect(() => - edges( - [method], - new Map(), - new Map(), - new Map(), - new ManagedDecodeBudget(), - ), - ).toThrow(ManagedReaderFailure); + expect(() => edges([method], new Map(), new ManagedDecodeBudget())).toThrow( + ManagedReaderFailure, + ); expect(built).toBe(false); }); }); @@ -671,6 +1017,18 @@ it.each([ }); }); +it.each([ + ["a property header", [0x08, 0x00, 0x08]], + ["an explicit-this header without has-this", [0x40, 0x00, 0x01]], + ["a C calling convention header", [0x01, 0x00, 0x01]], + ["a generic vararg header", [0x15, 0x01, 0x00, 0x01]], +])("reports %s as a malformed member signature", (_name, blob) => { + expect(signature(Buffer.from(blob))).toMatchObject({ + kind: "unknown", + parse_status: "malformed", + }); +}); + it("rejects array ranks beyond the CLR limit before rendering dimensions", () => { expect( signature( diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 24b97f9eb..dba20ac57 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -27,6 +27,8 @@ import { typeRanges, type TypeRange, } from "./ManagedMemberInspectorCore.js"; +import { edgeTargets } from "./ManagedMethodSpecs.js"; +import { typeNameLookup } from "./ManagedTypeNames.js"; import { edges, parseExportedTypes, @@ -171,7 +173,11 @@ export const inspectManagedMembersBytes = ( const exportedTypes = parseExportedTypes(bytes, layout); issues.push(...exportedTypes.issues); const fields = parseFields(bytes, layout, ranges, selected); - const memberRefs = parseMemberRefs(bytes, layout); + const memberRefs = parseMemberRefs( + bytes, + layout, + typeNameLookup(bytes, layout, ranges, issues), + ); issues.push(...memberRefs.issues); const methods = parseMethods({ bytes, @@ -181,6 +187,12 @@ export const inspectManagedMembersBytes = ( selected, }); issues.push(...fields.issues, ...methods.issues); + const targets = edgeTargets(bytes, layout, methods.methods, [ + fields.core, + memberRefs.core, + methods.core, + ]); + issues.push(...targets.issues); const coverageIssues = uniqueManagedParseIssues(issues); const incompleteMembers = [...fields.fields, ...memberRefs.refs, ...methods.methods].some( @@ -193,9 +205,7 @@ export const inspectManagedMembersBytes = ( ); const related = edges( methods.methods, - methods.core, - fields.core, - memberRefs.core, + targets.members, managedDecodeBudget(layout), ); return managedMemberInspectionSchema.parse( diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index f54a11769..7776c0090 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -7,6 +7,7 @@ import type { ManagedMemberInspection, ManagedParseIssue, } from "../domain/managed/managedArtifact.js"; +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; import type { ManagedMetadataLayout, MetadataTableLayout, @@ -49,20 +50,22 @@ export interface TypeRange { readonly methodEnd: number; } -export interface MethodCore { +/** + * Name and declaring type of a field, method, member reference, or method + * specification, keyed by its token so edges resolve any target uniformly. + */ +export interface MemberCore { readonly token: string; readonly name: string; readonly declaringType: string | null; -} - -export interface FieldCore { - readonly token: string; - readonly name: string; -} - -export interface MemberRefCore { - readonly token: string; - readonly name: string; + /** Fields and methods by table; member references by decoded signature. */ + readonly kind: ManagedSignature["kind"]; + /** + * Generic arity from the owner's decoded signature (see + * `signatureGenericArity`); absent when no owner decoded it, as for a + * MethodDef outside the type selection. + */ + readonly genericArity?: number | null; } export interface ParsedInstruction { @@ -88,7 +91,7 @@ const rowRange = ( return { first: start, last: end - 1, count: end - start }; }; -const fullName = (namespace: string, name: string): string => +export const fullName = (namespace: string, name: string): string => namespace.length === 0 ? name : `${namespace}.${name}`; const readTypeRange = ( @@ -242,7 +245,7 @@ export const parseTypes = ( return { types: items, issues }; }; -const readCompressed = ( +export const readCompressed = ( bytes: Buffer, offset: number, ): { readonly value: number; readonly next: number } => { @@ -341,13 +344,38 @@ interface MethodSignatureHeader { readonly next: number; } +// MethodDefSig and MethodRefSig headers (ECMA-335 II.23.2.1-2) without +// HASTHIS and EXPLICITTHIS: DEFAULT, VARARG, or GENERIC, which implies DEFAULT. +const MEMBER_METHOD_HEADERS = new Set([0x00, 0x05, 0x10]); + +// FNPTR also carries the unmanaged C, STDCALL, THISCALL, FASTCALL, and +// UNMANAGED conventions that compilers emit for unmanaged function pointers. +const FUNCTION_POINTER_CONVENTIONS = new Set([0, 1, 2, 3, 4, 5, 9]); + +/** + * Read a method signature's calling convention and counts. A header that is + * not a method signature in its context (a MethodDef or MemberRef row, or an + * FNPTR type), sets the reserved bit, or sets EXPLICITTHIS without HASTHIS is + * malformed, so no caller consumes its counts. FIELD, LOCAL_SIG, PROPERTY, + * and GENERICINST headers are never method signatures. + */ const readMethodSignatureHeader = ( blob: Buffer, offset: number, + context: "member" | "function-pointer", ): MethodSignatureHeader => { const first = blob[offset]; if (first === undefined) return malformedSignature("truncated method signature", offset); + const allowed = + context === "member" + ? MEMBER_METHOD_HEADERS.has(first & 0x9f) + : (first & 0x80) === 0 && FUNCTION_POINTER_CONVENTIONS.has(first & 0x0f); + if (!allowed || ((first & 0x40) !== 0 && (first & 0x20) === 0)) + return malformedSignature( + `calling convention 0x${first.toString(16)} does not head a ${context} method signature`, + offset, + ); let next = offset + 1; let genericParameterCount: number | null = null; if ((first & 0x10) !== 0) { @@ -366,12 +394,6 @@ const readMethodSignatureHeader = ( const MAX_ARRAY_RANK = 32; -// FNPTR carries a method signature: managed DEFAULT/VARARG, or the unmanaged -// C, STDCALL, THISCALL, FASTCALL, and UNMANAGED conventions that compilers -// emit for unmanaged function pointers. FIELD, LOCAL_SIG, PROPERTY, and -// GENERICINST headers are not method signatures. -const FUNCTION_POINTER_CONVENTIONS = new Set([0, 1, 2, 3, 4, 5, 9]); - const arrayShape = ( blob: Buffer, offset: number, @@ -449,10 +471,40 @@ const POSITION_ELEMENTS: ReadonlyMap = [0x10, ["return", "parameter", "field"]], ]); +/** Decode a type operand once; spec callers also validate its metadata coordinate. */ +const readTypeOperand = ( + blob: Buffer, + offset: number, + layout?: ManagedMetadataLayout, + tables: readonly number[] = [2, 1, 27], +): ReturnType => { + const token = readCompressed(blob, offset); + if (layout !== undefined) { + const reason = + token.value === 0 + ? "type operand is null" + : metadataCodedTokenInvalidReason( + token.value, + 2, + tables, + layout.rowCounts, + ); + if (reason !== null) + throw managedFailure( + "invalid-row", + "signature", + `TypeDefOrRefOrSpec operand 0x${token.value.toString(16)} is invalid: ${reason}`, + offset, + ); + } + return token; +}; + const readTypeSignature = ( blob: Buffer, offset: number, base: SignaturePosition, + layout?: ManagedMetadataLayout, ): { readonly value: string; readonly next: number } => { const stack: TypeContinuation[] = []; let cursor = offset; @@ -479,7 +531,7 @@ const readTypeSignature = ( stack.push({ kind: "suffix", suffix }); cursor += 1; } else if (kind === 0x1f || kind === 0x20) { - const token = readCompressed(blob, cursor + 1); + const token = readTypeOperand(blob, cursor + 1, layout); const modifier = `${kind === 0x1f ? "modreq" : "modopt"}:${String(token.value)}`; // A modifier frame on top was pushed by the directly preceding byte; // keep an adjacent run together and in source order. @@ -488,7 +540,7 @@ const readTypeSignature = ( else stack.push({ kind: "modifiers", modifiers: [modifier] }); cursor = token.next; } else if (kind === 0x11 || kind === 0x12) { - const token = readCompressed(blob, cursor + 1); + const token = readTypeOperand(blob, cursor + 1, layout); value = `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`; cursor = token.next; } else if (kind === 0x1e || kind === 0x13) { @@ -508,7 +560,7 @@ const readTypeSignature = ( "generic instantiation does not name a class or value type", cursor + 1, ); - const token = readCompressed(blob, cursor + 2); + const token = readTypeOperand(blob, cursor + 2, layout, [2, 1]); const argumentCount = readCompressed(blob, token.next); if (argumentCount.value === 0) return malformedSignature( @@ -525,16 +577,11 @@ const readTypeSignature = ( }); cursor = argumentCount.next; } else if (kind === 0x1b) { - const header = readMethodSignatureHeader(blob, cursor + 1); - if ( - !FUNCTION_POINTER_CONVENTIONS.has(header.first & 0x0f) || - (header.first & 0x80) !== 0 || - ((header.first & 0x40) !== 0 && (header.first & 0x20) === 0) - ) - return malformedSignature( - `function pointer calling convention 0x${header.first.toString(16)} does not head a method signature`, - cursor + 1, - ); + const header = readMethodSignatureHeader( + blob, + cursor + 1, + "function-pointer", + ); const convention = callingConvention(header.first); const head = header.genericParameterCount === null @@ -602,22 +649,81 @@ const callingConvention = (value: number): string => { return flags.length === 0 ? name : `${name} ${flags.join(" ")}`; }; -export const signature = ( +/** + * Decode a whole TypeSpec blob, or a MethodSpec instantiation (GENERICINST, + * argument count, Types), with the member-signature grammar and return the + * rendered types. A malformed or unsupported blob throws invalid-blob or + * unsupported-signature under the owning row, so coverage records it. + */ +export const specSignature = ( + layout: ManagedMetadataLayout, blob: Buffer, - rawSha256?: string, -): ManagedSignature => { - const raw = { - raw_length: blob.length, - raw_sha256: rawSha256 ?? sha256Bytes(blob), - }; + owner: { + readonly form: "type" | "instantiation"; + readonly scope: string; + readonly offset: number; + }, +): readonly string[] => { + const budget = managedDecodeBudget(layout); + // Rendering is transient, so charge it as member signatures are charged. + budget.admitTransient(blob.length * 256, owner.offset); + try { + let cursor = 0; + let count = 1; + if (owner.form === "instantiation") { + if (blob[0] !== 0x0a) + malformedSignature("MethodSpec instantiation is not GENERICINST", 0); + const header = readCompressed(blob, 1); + if (header.value === 0) + malformedSignature("MethodSpec instantiation has no type arguments", 1); + count = header.value; + cursor = header.next; + } + const types: string[] = []; + // Each Type consumes at least one byte, so a false count fails on truncation. + for (let index = 0; index < count; index += 1) { + const type = readTypeSignature(blob, cursor, "type", layout); + types.push(type.value); + cursor = type.next; + } + if (cursor !== blob.length) + malformedSignature("Trailing signature data", cursor); + return types; + } catch (cause: unknown) { + if ( + !(cause instanceof ManagedReaderFailure) || + (cause.issue.code !== "invalid-blob" && + cause.issue.code !== "invalid-row" && + cause.issue.code !== "unsupported-signature") + ) + throw cause; + throw managedFailure( + cause.issue.code, + owner.scope, + cause.issue.detail, + owner.offset, + ); + } finally { + budget.releaseTransient(); + } +}; + +type DecodedSignature = Omit; + +/** + * Decode a field or member method signature. A malformed or unsupported blob + * decodes as kind `unknown` with its reason. + */ +const decodeSignature = (blob: Buffer): DecodedSignature => { try { if (blob.length === 0) throw managedFailure("invalid-blob", "signature", "empty signature", 0); const first = blob[0] ?? 0; - if ((first & 0x0f) === 6) { + // FieldSig (II.23.2.4) is exactly FIELD; a flagged FIELD header is not a + // method header either, so it is malformed below. + if (first === 0x06) { const fieldType = readTypeSignature(blob, 1, "field"); return { - ...raw, kind: "field", parse_status: fieldType.next === blob.length ? "decoded" : "partial", calling_convention: callingConvention(first), @@ -630,7 +736,7 @@ export const signature = ( fieldType.next === blob.length ? null : "Trailing signature data", }; } - const header = readMethodSignatureHeader(blob, 0); + const header = readMethodSignatureHeader(blob, 0, "member"); const returnType = readTypeSignature(blob, header.next, "return"); let offset = returnType.next; const parameters: string[] = []; @@ -640,7 +746,6 @@ export const signature = ( offset = parameter.next; } return { - ...raw, kind: "method", parse_status: offset === blob.length ? "decoded" : "partial", calling_convention: callingConvention(first), @@ -659,7 +764,6 @@ export const signature = ( ) throw cause; return { - ...raw, kind: "unknown", parse_status: cause.issue.code === "unsupported-signature" @@ -675,3 +779,41 @@ export const signature = ( }; } }; + +/** + * Generic parameter count of a method signature that decodes completely, 0 + * when it is not generic, or null when the blob is not a decodable method + * signature. The rendered decode is transient. + */ +export const methodGenericArity = ( + layout: ManagedMetadataLayout, + blob: Buffer, +): number | null => { + const budget = managedDecodeBudget(layout); + budget.admitTransient(blob.length * 256, layout.blob.offset); + try { + return signatureGenericArity(decodeSignature(blob)); + } finally { + budget.releaseTransient(); + } +}; + +/** + * Generic parameter count of a completely decoded method signature, 0 when it + * is not generic, or null for any other signature. + */ +export const signatureGenericArity = ( + decoded: DecodedSignature, +): number | null => + decoded.kind === "method" && decoded.parse_status === "decoded" + ? (decoded.generic_parameter_count ?? 0) + : null; + +export const signature = ( + blob: Buffer, + rawSha256?: string, +): ManagedSignature => ({ + raw_length: blob.length, + raw_sha256: rawSha256 ?? sha256Bytes(blob), + ...decodeSignature(blob), +}); diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index f4a63db5a..201ff1c45 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -9,15 +9,14 @@ import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { signature, - type FieldCore, + signatureGenericArity, type ManagedCallEdge, type ManagedExportedType, type ManagedField, type ManagedFieldAccess, type ManagedMemberRef, type ManagedMethod, - type MemberRefCore, - type MethodCore, + type MemberCore, type TypeRange, } from "./ManagedMemberInspectorCore.js"; import { @@ -51,11 +50,11 @@ export const parseFields = ( selected: SelectedTypeTokens = null, ): { readonly fields: readonly ManagedField[]; - readonly core: ReadonlyMap; + readonly core: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; } => { const fields: ManagedField[] = []; - const core = new Map(); + const core = new Map(); const issues: ManagedParseIssue[] = []; const table = layout.table(4); const ownerRow = readManagedValue( @@ -78,7 +77,12 @@ export const parseFields = ( : (readManagedValue(() => ownerRow(row), issues) ?? 0); const declared = ranges[owner - 1]; const token = metadataToken(4, row); - core.set(token, { token, name }); + core.set(token, { + token, + name, + declaringType: declared?.fullName ?? null, + kind: "field", + }); if (!isSelected(selected, declared)) return; const sig = readMetadataBlob( bytes, @@ -174,13 +178,14 @@ export const parseExportedTypes = ( export const parseMemberRefs = ( bytes: Buffer, layout: ManagedMetadataLayout, + typeName: (token: string | null) => string | null, ): { readonly refs: readonly ManagedMemberRef[]; - readonly core: ReadonlyMap; + readonly core: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; } => { const refs: ManagedMemberRef[] = []; - const core = new Map(); + const core = new Map(); const issues: ManagedParseIssue[] = []; const table = layout.table(10); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { @@ -218,19 +223,29 @@ export const parseMemberRefs = ( ? "MemberRef parent coded index 0x0 is null, but the Class column must reference a row" : `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parentReason}`, }); + const parentToken = metadataCodedToken( + parentRaw, + 3, + [2, 1, 26, 6, 27], + layout.rowCounts, + ); + const parentName = typeName(parentToken); + const decoded = budgetedSignature(sig, layout); refs.push({ token, row_offset: cursor.start, - parent_token: metadataCodedToken( - parentRaw, - 3, - [2, 1, 26, 6, 27], - layout.rowCounts, - ), + parent_token: parentToken, + parent_name: parentName, name, - signature: budgetedSignature(sig, layout), + signature: decoded, + }); + core.set(token, { + token, + name, + declaringType: parentName, + kind: decoded.kind, + genericArity: signatureGenericArity(decoded), }); - core.set(token, { token, name }); }, issues); } return { refs, core, issues }; @@ -252,11 +267,11 @@ export const parseMethods = ({ selected = null, }: ParseMethodsInput): { readonly methods: readonly ManagedMethod[]; - readonly core: ReadonlyMap; + readonly core: ReadonlyMap; readonly issues: readonly ManagedParseIssue[]; } => { const methods: ManagedMethod[] = []; - const core = new Map(); + const core = new Map(); const issues: ManagedParseIssue[] = []; const table = layout.table(6); const ownerRow = readManagedValue( @@ -281,11 +296,13 @@ export const parseMethods = ({ : (readManagedValue(() => ownerRow(row), issues) ?? 0); const declared = ranges[owner - 1]; const token = metadataToken(6, row); - core.set(token, { + const member = { token, name, declaringType: declared?.fullName ?? null, - }); + kind: "method", + } as const; + core.set(token, member); if (!isSelected(selected, declared)) return; const sig = readMetadataBlob( bytes, @@ -294,6 +311,11 @@ export const parseMethods = ({ layout.blob.size, ); cursor.readIndex(layout.tableIndexSize(8)); + const decoded = budgetedSignature(sig, layout); + core.set(token, { + ...member, + genericArity: signatureGenericArity(decoded), + }); methods.push({ token, row_offset: cursor.start, @@ -303,7 +325,7 @@ export const parseMethods = ({ rva, impl_flags: implFlags, flags, - signature: budgetedSignature(sig, layout), + signature: decoded, body: methodBody( bytes, pe, @@ -317,6 +339,15 @@ export const parseMethods = ({ return { methods, core, issues }; }; +/** + * Member kinds each operand kind may name. A MemberRef whose signature does + * not decode keeps its name on calls, as before, but is never named a field. + */ +const NAMEABLE_KINDS = new Map>([ + ["method", new Set(["method", "unknown"])], + ["field", new Set(["field"])], +]); + const targetKind = (token: string): ManagedCallEdge["target_kind"] => token.startsWith("0x06") ? "method-def" @@ -326,11 +357,22 @@ const targetKind = (token: string): ManagedCallEdge["target_kind"] => ? "method-spec" : "unknown"; +/** The member an operand names, if the operand kind can name that member's kind. */ +const namedTarget = ( + members: ReadonlyMap, + operandKind: string, + operand: string, +): MemberCore | undefined => { + const member = members.get(operand); + return member !== undefined && + NAMEABLE_KINDS.get(operandKind)?.has(member.kind) === true + ? member + : undefined; +}; + export const edges = ( methods: readonly ManagedMethod[], - methodCore: ReadonlyMap, - fieldCore: ReadonlyMap, - refCore: ReadonlyMap, + members: ReadonlyMap, budget: ManagedDecodeBudget, ): { readonly callEdges: readonly ManagedCallEdge[]; @@ -350,11 +392,10 @@ export const edges = ( const caller = derives ? callerName(method) : method.name; for (const anchor of method.body.anchors) { if (anchor.opcode === "ldtoken") continue; - if (anchor.operand_kind === "method" && anchor.operand !== null) { - const named = - methodCore.get(anchor.operand)?.name ?? - refCore.get(anchor.operand)?.name ?? - null; + const { operand } = anchor; + if (operand === null) continue; + const target = namedTarget(members, anchor.operand_kind, operand); + if (anchor.operand_kind === "method") { // Each derived record is admitted before the graph grows further. callEdges.push( admitManagedProjection( @@ -362,23 +403,25 @@ export const edges = ( caller_token: method.token, caller, opcode: anchor.opcode, - target_token: anchor.operand, - target_kind: targetKind(anchor.operand), - target_name: named, + target_token: operand, + target_kind: targetKind(operand), + target_name: target?.name ?? null, + target_declaring_type: target?.declaringType ?? null, }, budget, ), ); } - if (anchor.operand_kind === "field" && anchor.operand !== null) { + if (anchor.operand_kind === "field") { fieldAccesses.push( admitManagedProjection( { method_token: method.token, method: caller, opcode: anchor.opcode, - field_token: anchor.operand, - field_name: fieldCore.get(anchor.operand)?.name ?? null, + field_token: operand, + field_name: target?.name ?? null, + field_declaring_type: target?.declaringType ?? null, }, budget, ), diff --git a/src/dotnet/ManagedMethodSpecs.ts b/src/dotnet/ManagedMethodSpecs.ts new file mode 100644 index 000000000..057090862 --- /dev/null +++ b/src/dotnet/ManagedMethodSpecs.ts @@ -0,0 +1,154 @@ +import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; +import { + methodGenericArity, + specSignature, + type ManagedMethod, + type MemberCore, +} from "./ManagedMemberInspectorCore.js"; +import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { + metadataCodedToken, + metadataCodedTokenInvalidReason, + metadataRowCursor, + readMetadataBlob, +} from "./ManagedMetadataHeaps.js"; +import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js"; + +/** + * Index fields, member references, and methods by token, then name each + * MethodSpec an inspected body calls after the generic method it instantiates, + * so every edge target resolves through one lookup. + */ +export const edgeTargets = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + methods: readonly ManagedMethod[], + cores: readonly ReadonlyMap[], +): { + readonly members: ReadonlyMap; + readonly issues: readonly ManagedParseIssue[]; +} => { + const members = new Map(cores.flatMap((core) => [...core])); + const issues: ManagedParseIssue[] = []; + const seen = new Set(); + // Generic arity by method token, so instantiations of one method decode it once. + const arities = new Map(); + for (const method of methods) + for (const { operand } of method.body.anchors) { + if (operand?.startsWith("0x2b") !== true || seen.has(operand)) continue; + seen.add(operand); + readManagedValue( + () => + readMethodSpec({ bytes, layout, members, issues, arities }, operand), + issues, + ); + } + return { members, issues }; +}; + +/** + * Map one MethodSpec to its generic method, then check its instantiation + * decodes and matches that method's generic arity. A readable Method column + * keeps its name when the rest is malformed; the failure makes coverage partial. + */ +const readMethodSpec = ( + { + bytes, + layout, + members, + issues, + arities, + }: { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly members: Map; + readonly issues: ManagedParseIssue[]; + readonly arities: Map; + }, + token: string, +): void => { + const scope = `metadata.MethodSpec:${token}`; + const cursor = metadataRowCursor( + bytes, + layout, + 0x2b, + Number.parseInt(token.slice(4), 16), + ); + const methodRaw = cursor.readIndex(layout.codedIndexSize("MethodDefOrRef")); + const instantiation = cursor.readIndex(layout.blobIndexSize); + const methodReason = metadataCodedTokenInvalidReason( + methodRaw, + 1, + [6, 10], + layout.rowCounts, + ); + if (methodReason !== null || methodRaw === 0) + issues.push({ + code: "invalid-row", + scope, + offset: cursor.start, + detail: + methodReason === null + ? "MethodSpec method coded index 0x0 is null, but the Method column must reference a row" + : `MethodSpec method coded index 0x${methodRaw.toString(16)} is invalid: ${methodReason}`, + }); + const generic = metadataCodedToken(methodRaw, 1, [6, 10], layout.rowCounts); + const target = generic === null ? undefined : members.get(generic); + // A MethodSpec instantiates a method, so a field target never lends it a + // name, even to a field operand. + if (target !== undefined && target.kind !== "field") + members.set(token, { ...target, token }); + const types = specSignature( + layout, + readMetadataBlob(bytes, layout, instantiation, layout.blob.size), + { form: "instantiation", scope, offset: cursor.start }, + ); + if (generic === null) return; + // Prefer the arity the target's owner decoded; read the raw signature only + // for a target nothing decoded, such as a MethodDef outside the selection. + if (target?.genericArity === undefined && !arities.has(generic)) + arities.set( + generic, + methodGenericArity(layout, methodSignatureBlob(bytes, layout, generic)), + ); + const arity = + target?.genericArity === undefined + ? (arities.get(generic) ?? null) + : target.genericArity; + if (arity !== types.length) + throw managedFailure( + "invalid-row", + scope, + arity === null + ? "MethodSpec method does not have a decodable method signature" + : `MethodSpec supplies ${String(types.length)} type argument(s), but its method declares ${String(arity)} generic parameter(s)`, + cursor.start, + ); +}; + +/** The Signature column of a MethodDef or MemberRef row. */ +const methodSignatureBlob = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + token: string, +): Buffer => { + const table = Number.parseInt(token.slice(2, 4), 16); + const cursor = metadataRowCursor( + bytes, + layout, + table, + Number.parseInt(token.slice(4), 16), + ); + if (table === 6) { + cursor.readUInt32(); + cursor.readUInt16(); + cursor.readUInt16(); + } else cursor.readIndex(layout.codedIndexSize("MemberRefParent")); + cursor.readIndex(layout.stringIndexSize); + return readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + layout.blob.size, + ); +}; diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 25f819925..7ea2b06b4 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -29,6 +29,16 @@ interface ManagedPeFixtureOptions { readonly moduleRowCount?: number; readonly assemblyRowCount?: number; readonly memberRefParentRaw?: number; + readonly memberRefName?: string; + readonly memberRefSignature?: Buffer; + /** Raw MethodDefOrRef coded values, one MethodSpec row each. */ + readonly methodSpecMethods?: readonly number[]; + /** Instantiation blob shared by every MethodSpec row. */ + readonly methodSpecInstantiation?: Buffer; + /** TypeSpec signature blobs, one TypeSpec row each. */ + readonly typeSpecSignatures?: readonly Buffer[]; + /** Raw ResolutionScope coded value of the TypeRef row; null by default. */ + readonly typeRefScopeRaw?: number; readonly mvid?: Buffer; readonly pinvoke?: { readonly importName?: string; @@ -174,10 +184,10 @@ const typeRefRow = ( name: number, namespace: number, stringSize: MetadataIndexSize, - resolutionScopeSize: MetadataIndexSize, + resolutionScope: Buffer, ): Buffer => Buffer.concat([ - metadataIndex(0, resolutionScopeSize), + resolutionScope, metadataIndex(name, stringSize), metadataIndex(namespace, stringSize), ]); @@ -524,6 +534,8 @@ interface FixtureMetadataRowContext { readonly methodSignature: number; readonly constructorSignature: number; readonly attributeBlob: number; + readonly typeSpecSignatures: readonly number[]; + readonly methodSpecInstantiation: number; }; readonly heapSizes: { readonly strings: MetadataIndexSize; @@ -595,6 +607,37 @@ const exportedTypeTable = ( ], ]; +const specTables = ( + options: ManagedPeFixtureOptions, + indexes: FixtureMetadataRowContext["indexes"], + heapSizes: FixtureMetadataRowContext["heapSizes"], + widths: FixtureIndexWidths, +): readonly (readonly [number, readonly Buffer[]])[] => [ + ...(indexes.typeSpecSignatures.length === 0 + ? [] + : ([ + [ + 27, + indexes.typeSpecSignatures.map((signature) => + metadataIndex(signature, heapSizes.blobs), + ), + ], + ] as const)), + ...(options.methodSpecMethods === undefined + ? [] + : ([ + [ + 43, + options.methodSpecMethods.map((method) => + Buffer.concat([ + metadataIndex(method, widths.coded([6, 10], 1)), + metadataIndex(indexes.methodSpecInstantiation, heapSizes.blobs), + ]), + ), + ], + ] as const)), +]; + const fixtureIndexWidths = ({ options, referenceStringIndexes, @@ -616,10 +659,12 @@ const fixtureIndexWidths = ({ ], ...(indexes.pinvokeModuleName === null ? [] : ([[26, 1]] as const)), ...(indexes.pinvokeImportName === null ? [] : ([[28, 1]] as const)), + [27, indexes.typeSpecSignatures.length], [32, options.assemblyRowCount ?? 1], [35, referenceStringIndexes.length], [39, indexes.exportedTypes.length], [40, 1], + [43, options.methodSpecMethods?.length ?? 0], ]); const count = (table: number): number => rowCounts.get(table) ?? 0; const table = (index: number): MetadataIndexSize => indexSize(count(index)); @@ -673,7 +718,7 @@ const fixtureMetadataRows = ({ indexes.attributeName, indexes.attributeNamespace, heapSizes.strings, - widths.resolutionScope, + metadataIndex(options.typeRefScopeRaw ?? 0, widths.resolutionScope), ), ], ], @@ -780,6 +825,7 @@ const fixtureMetadataRows = ({ ), ], ...exportedTypeTable(options, indexes, heapSizes, widths), + ...specTables(options, indexes, heapSizes, widths), [ 40, [ @@ -795,6 +841,29 @@ const fixtureMetadataRows = ({ return rows; }; +/** Blob indexes for the MemberRef and optional spec rows; defaults leave the heap unchanged. */ +const referenceBlobIndexes = ( + blobs: BlobHeap, + options: ManagedPeFixtureOptions, +): Pick< + FixtureMetadataRowContext["indexes"], + "constructorSignature" | "typeSpecSignatures" | "methodSpecInstantiation" +> => ({ + constructorSignature: blobs.add( + options.memberRefSignature ?? Buffer.from([0x20, 0x01, 0x01, 0x0e]), + ), + typeSpecSignatures: (options.typeSpecSignatures ?? []).map((signature) => + blobs.add(signature), + ), + // Defaults to GENERICINST with one string type argument. + methodSpecInstantiation: + options.methodSpecMethods === undefined + ? 0 + : blobs.add( + options.methodSpecInstantiation ?? Buffer.from([0x0a, 0x01, 0x0e]), + ), +}); + const buildManagedFixtureMetadata = ( options: ManagedPeFixtureOptions, methodRva: number, @@ -805,7 +874,7 @@ const buildManagedFixtureMetadata = ( const assemblyName = strings.add("Fixture.Managed"); const attributeName = strings.add("TargetFrameworkAttribute"); const attributeNamespace = strings.add("System.Runtime.Versioning"); - const constructorName = strings.add(".ctor"); + const constructorName = strings.add(options.memberRefName ?? ".ctor"); const typeName = strings.add(options.typeName ?? "Program"); const additionalTypeNames = Array.from( { length: options.additionalEmptyTypes ?? 0 }, @@ -840,7 +909,7 @@ const buildManagedFixtureMetadata = ( const methodSignature = blobs.add( options.methodSignature ?? Buffer.from([0x00, 0x00, 0x01]), ); - const constructorSignature = blobs.add(Buffer.from([0x20, 0x01, 0x01, 0x0e])); + const referenceBlobs = referenceBlobIndexes(blobs, options); const attributeBlob = blobs.add( fixedStringAttributeBlob( options.targetFramework === null @@ -873,8 +942,8 @@ const buildManagedFixtureMetadata = ( tokenBlob, fieldSignature, methodSignature, - constructorSignature, attributeBlob, + ...referenceBlobs, }, heapSizes: { strings: stringSize, blobs: blobSize, guids: guidSize }, }); diff --git a/src/dotnet/ManagedTypeNames.ts b/src/dotnet/ManagedTypeNames.ts new file mode 100644 index 000000000..ab3b34c8f --- /dev/null +++ b/src/dotnet/ManagedTypeNames.ts @@ -0,0 +1,137 @@ +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; +import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; +import { + fullName, + readCompressed, + specSignature, + type TypeRange, +} from "./ManagedMemberInspectorCore.js"; +import { + metadataRowOffset, + type ManagedMetadataLayout, +} from "./ManagedMetadataLayout.js"; +import { + metadataCodedTokenInvalidReason, + metadataRowCursor, + metadataToken, + readMetadataBlob, + readMetadataString, +} from "./ManagedMetadataHeaps.js"; +import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js"; + +/** + * Name the type a TypeDef, TypeRef, or generic-instantiation TypeSpec token + * denotes, in the namespace-qualified form TypeDef `full_name` uses, once per + * token. Other decoded TypeSpecs stay null: the type is unknown. Malformed or + * unsupported rows and blobs are reported to `issues` and also stay null, + * except that a TypeRef with an invalid ResolutionScope keeps its readable name. + */ +export const typeNameLookup = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + ranges: readonly TypeRange[], + issues: ManagedParseIssue[], +): ((token: string | null) => string | null) => { + const names = new Map(); + const checkedScopes = new Set(); + const validateScopeChain = (row: number): void => { + const path = new Set(); + let current = row; + while (!checkedScopes.has(current)) { + const cursor = metadataRowCursor(bytes, layout, 1, current); + if (path.has(current)) { + issues.push({ + code: "invalid-row", + scope: `metadata.TypeRef:${metadataToken(1, current)}`, + offset: cursor.start, + detail: "TypeRef ResolutionScope chain contains a cycle", + }); + break; + } + // Admit the chain's visited entry and retained validation-cache entry. + managedDecodeBudget(layout).reserve(128, cursor.start); + path.add(current); + const raw = cursor.readIndex(layout.codedIndexSize("ResolutionScope")); + const reason = metadataCodedTokenInvalidReason( + raw, + 2, + [0, 26, 35, 1], + layout.rowCounts, + ); + if (reason !== null) { + issues.push({ + code: "invalid-row", + scope: `metadata.TypeRef:${metadataToken(1, current)}`, + offset: cursor.start, + detail: `TypeRef ResolutionScope coded index 0x${raw.toString(16)} is invalid: ${reason}`, + }); + break; + } + // Null scopes are legal; non-TypeRef scopes terminate local validation. + if (raw === 0 || (raw & 3) !== 3) break; + current = Math.floor(raw / 4); + } + for (const visited of path) checkedScopes.add(visited); + }; + const definitionName = (table: 1 | 2, row: number): string | null => { + if (table === 2) { + metadataRowOffset(layout, 2, row); + return ranges[row - 1]?.fullName ?? null; + } + const cursor = metadataRowCursor(bytes, layout, 1, row); + cursor.readIndex(layout.codedIndexSize("ResolutionScope")); + readManagedValue(() => validateScopeChain(row), issues); + const name = cursor.readIndex(layout.stringIndexSize); + const namespace = cursor.readIndex(layout.stringIndexSize); + return fullName( + readMetadataString(bytes, layout, namespace, layout.strings.size), + readMetadataString(bytes, layout, name, layout.strings.size), + ); + }; + const specName = (row: number): string | null => { + const cursor = metadataRowCursor(bytes, layout, 27, row); + const spec = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + layout.blob.size, + ); + const owner = { + form: "type", + scope: `metadata.TypeSpec:${metadataToken(27, row)}`, + offset: cursor.start, + } as const; + specSignature(layout, spec, owner); + if (spec[0] !== 0x15) return null; + // GENERICINST (CLASS | VALUETYPE) TypeDefOrRefEncoded: never a TypeSpec, + // so naming never recurses. + const encoded = readCompressed(spec, 2).value; + const table = ([2, 1] as const)[encoded & 3]; + if (table === undefined) + throw managedFailure( + "invalid-blob", + owner.scope, + "Generic instantiation does not name a TypeDef or TypeRef", + owner.offset, + ); + return definitionName(table, Math.floor(encoded / 4)); + }; + return (token) => { + if (token === null) return null; + if (names.has(token)) return names.get(token) ?? null; + const table = Number.parseInt(token.slice(2, 4), 16); + const row = Number.parseInt(token.slice(4), 16); + const name = + readManagedValue( + () => + table === 27 + ? specName(row) + : table === 1 || table === 2 + ? definitionName(table, row) + : null, + issues, + ) ?? null; + names.set(token, name); + return name; + }; +}; From 352c46aa7567afcd142524e5f583379c27a1075d Mon Sep 17 00:00:00 2001 From: Aravind Rao <135997804+rekcilyssup@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:42:50 +0530 Subject: [PATCH 31/92] fix(javascript): keep CommonJS require file URLs unresolved (#2077) * fix(javascript): keep CommonJS require file URLs unresolved require("file:///dep.cjs") was reported as a resolved dependency of the inventoried dep.cjs although Node rejects the string with MODULE_NOT_FOUND. The shared module-specifier resolver decoded file URLs before the declared CommonJS loader semantics. File-URL decoding now applies only to non-require module specifiers, so a CommonJS file URL reports external with its declared string and source evidence preserved. ESM file URLs and ordinary relative require paths keep their established lookup. * test(javascript): consolidate CommonJS file URL public workflow coverage --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- tests/acceptance/applications/packageSubpathsCli.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/acceptance/applications/packageSubpathsCli.test.ts b/tests/acceptance/applications/packageSubpathsCli.test.ts index 15f734e22..869a8deba 100644 --- a/tests/acceptance/applications/packageSubpathsCli.test.ts +++ b/tests/acceptance/applications/packageSubpathsCli.test.ts @@ -141,6 +141,13 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ).toBe("node_modules/fi%xture/actual.cjs"); const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + expect(graph.coverage.status).toBe("complete"); + const requireFileUrl = graph.edges.find( + ({ properties }) => + properties.module_link_kind === "require" && + properties.specifier === "file:///src/uri-dep.cjs", + ); + expect(requireFileUrl?.evidence.limitations.length).toBeGreaterThan(0); const imports = graph.edges.filter( ({ properties }) => properties.module_link_kind === "require", ); From 9c14999efd94ae04bfaf6532a74613b7a914810c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B0=E5=BE=97=E5=90=83=E4=B8=AD=E9=A5=AD?= <17373302683@163.com> Date: Tue, 13 Oct 2026 21:13:01 +0800 Subject: [PATCH 32/92] feat(ghidra): support raw NMOS 6502 analysis (#2079) * feat(ghidra): admit raw NMOS 6502 analysis * test(ghidra): require verified POSIX verifier cleanup * test(ghidra): verify 6502 low-memory raw imports * docs(skill): document raw NMOS 6502 support * fix(ghidra): preserve requested Windows raw source paths * docs(ghidra): include raw 6502 in Windows installation scope * test(ghidra): verify Windows raw junction rejection through CLI and MCP * fix(validation): make fixtures portable and preserve explicit Codex home * fix(ghidra): attest raw mapping state and address space * fix(ghidra): align diagnostics and regression groups * fix(ghidra): bind raw attestation to language address space * chore(ghidra): avoid verifier insertion conflict * docs(ghidra): align linked raw admission guides * fix(ghidra): retain 6502 verifier workspace on failure * test(ghidra): isolate Windows authority doctor fixture * fix(binary): re-admit Windows raw paths on session reopen * test(binary): require fresh provider execution after path change * Constrain 6502 to raw lifecycle and snapshot targets * Bypass saved reads for unadmitted Windows paths * Guard direct CLI snapshot replay on Windows paths * fix(snapshot): gate Windows Ghidra replay on native admission * fix(snapshot): constrain nested 6502 subjects to raw images * test: make dyld slice fixtures portable --- .../references/native-and-artifacts.md | 7 +- bridge/ghidra/ReaGhidraPrepareRaw.java | 17 +- docs/cli.md | 4 +- docs/ghidra-mips.md | 2 +- docs/ghidra-riscv.md | 4 +- docs/installation.md | 5 +- docs/native-investigation.md | 7 +- docs/raw-binary.md | 63 ++- docs/testing.md | 10 + docs/windows-ghidra-p0.md | 14 +- .../analysis-efficiency/agent-trial.py | 4 +- .../analysis-efficiency/test_codex_context.py | 5 +- package.json | 1 + scripts/verify-real-ghidra-6502.mjs | 479 ++++++++++++++++++ src/application/BinaryTargetResolver.ts | 12 +- src/application/DirectAnalysis.ts | 6 + .../binary/AnalysisSnapshotCache.ts | 5 + src/application/binary/BinarySession.ts | 12 + .../binary/BinarySessionExecution.ts | 16 +- src/application/binary/BinarySessionOpen.ts | 18 +- src/contracts/toolOutputReferences.test.ts | 28 + src/contracts/toolOutputSchemaGroups.ts | 2 +- src/domain/analysisSnapshot.test.ts | 67 ++- src/domain/analysisSnapshot.ts | 75 ++- src/domain/binaryTarget.ts | 4 +- src/domain/binaryTargetTypes.ts | 16 +- src/domain/evidence.test.ts | 27 + src/domain/evidence.ts | 76 +-- src/domain/evidenceBundle.ts | 23 +- src/domain/native/nativeLoadImage.ts | 3 +- src/domain/rawBinary.test.ts | 38 ++ src/domain/rawBinary.ts | 13 +- src/ghidra/GhidraAnalysisProfile.ts | 2 +- src/ghidra/GhidraLoadImageValues.ts | 33 +- src/ghidra/GhidraProvider.behavior.test.ts | 73 ++- src/ghidra/GhidraProvider.ts | 9 +- src/ghidra/GhidraProviderCapabilities.ts | 2 +- src/ghidra/GhidraProviderClient.ts | 2 +- src/ghidra/GhidraRawBinaryLanguage.ts | 37 +- src/ghidra/GhidraRawBinaryLoadImage.test.ts | 138 +++++ src/ghidra/GhidraRiscvProfile.test.ts | 11 +- src/hopper/HopperAnalysisProfile.ts | 3 +- src/hopper/HopperMachOImage.ts | 11 +- tests/boundary/cli/dylibResolutionCli.test.ts | 16 +- tests/boundary/cli/rawBinaryCli.test.ts | 30 ++ .../directAnalysisSnapshotReplay.test.ts | 95 +++- .../javascriptArtifactReconstruction.test.ts | 6 +- .../filesystem/rawBinaryTarget.test.ts | 39 +- tests/boundary/mcp/rawBinaryMcp.test.ts | 231 ++++++++- ...actProvider.extractionCompleteness.test.ts | 2 +- tests/conformance/ghidra/6502.cfg | 9 + tests/conformance/ghidra/6502.s | 19 + tests/conformance/readmeConsistency.test.ts | 14 + tests/fixtures/strippedAsarAddon.ts | 2 +- 54 files changed, 1650 insertions(+), 197 deletions(-) create mode 100644 scripts/verify-real-ghidra-6502.mjs create mode 100644 tests/conformance/ghidra/6502.cfg create mode 100644 tests/conformance/ghidra/6502.s diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 6a942c73c..a4fc6d18f 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -224,10 +224,13 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`, -`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at +`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at that address. Hopper and IDA refuse this interpretation; Ghidra uses `BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only, -and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md). +and Cortex-M variants are unsupported. `6502` selects NMOS 6502; the entry and +complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge +containers, bank switching, and mapper semantics are unsupported. Windows +Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md). ### Function annotations in Ghidra diff --git a/bridge/ghidra/ReaGhidraPrepareRaw.java b/bridge/ghidra/ReaGhidraPrepareRaw.java index 1a5db9627..143331390 100644 --- a/bridge/ghidra/ReaGhidraPrepareRaw.java +++ b/bridge/ghidra/ReaGhidraPrepareRaw.java @@ -1,6 +1,8 @@ import ghidra.app.script.GhidraScript; import ghidra.program.model.address.Address; import ghidra.program.model.listing.Program; +import ghidra.program.model.mem.MemoryBlock; +import ghidra.program.model.mem.MemoryBlockSourceInfo; /** Seed the caller-supplied raw-binary entry before default auto-analysis. */ public final class ReaGhidraPrepareRaw extends GhidraScript { @@ -21,8 +23,19 @@ public final class ReaGhidraPrepareRaw extends GhidraScript { ); } Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex); - Address start = currentProgram.getMinAddress(); - if (entry == null || !entry.equals(start)) { + MemoryBlock block = entry == null ? null : currentProgram.getMemory().getBlock(entry); + boolean fileStart = false; + if (block != null) { + // Processor-defined zero page/stack blocks can precede the imported file. + for (MemoryBlockSourceInfo source : block.getSourceInfos()) { + if (source.getFileBytes().isPresent() && source.getFileBytesOffset() == 0 && + entry.equals(source.getMinAddress())) { + fileStart = true; + break; + } + } + } + if (!fileStart) { throw new IllegalArgumentException( "Raw binary import must map file offset 0 at the caller-supplied entry" ); diff --git a/docs/cli.md b/docs/cli.md index dcd949e11..6d81ec7c1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -174,7 +174,9 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json ``` -An exact CLI cache hit is read before starting a provider process. +An exact CLI cache hit is normally read before starting a provider process. +On Windows, Ghidra first admits the selected source through its native snapshot +owner, so replay can start that provider. In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically for its matching target; an MCP provider may still start before returning a cached result. `close_binary` accepts `snapshot_path` and optional diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 59d04a8b0..d5b58f00b 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -111,7 +111,7 @@ MIPS header and ABI record facts are committed in the analysis profile so interpretation changes invalidate profile-bound snapshots. The support-lane identity is versioned independently of file identity. Evidence, lifecycle outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters -do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only. +do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets. ### Soft-float prototype diff --git a/docs/ghidra-riscv.md b/docs/ghidra-riscv.md index 5b3407361..afae01948 100644 --- a/docs/ghidra-riscv.md +++ b/docs/ghidra-riscv.md @@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The target also retains the raw ELF declarations that decide a lane: class, byte order, `e_type`, and `e_flags`. Recognition does not mean every provider or RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets -are unchanged, and caller-supplied headerless raw-binary entry still accepts only -its existing four families. +are unchanged. Headerless inputs use the separate [raw-binary admission +boundary](raw-binary.md); raw RISC-V images are not supported. The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`, diff --git a/docs/installation.md b/docs/installation.md index 3bd4e0928..d6dc8e3b0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -512,7 +512,10 @@ and `set_addresses_names` name a function entry or any other mapped address session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls require Hopper; Windows P0 remains read-only. -Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes. +Windows P0 admits native x86 and x86-64 PE applications and DLLs, plus explicitly +selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied +entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain +unsupported on Windows. The npm package bundles native Job Object ownership, protected private runtime DACLs, and handle-based path admission; no separate addon installation is needed. See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 27381246b..5a2fcbd31 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -476,8 +476,11 @@ the tool fails with a `debugger-attach-denied` reason. Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires the matching native decompiler. Experimental Windows x64 P0 admits native -x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership, -protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and +x86 and x86-64 PE applications and DLLs, plus explicitly selected +[raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address, +on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows. +The boundary uses bundled Job Object ownership, protected runtime DACLs, and +handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and [issue #527](https://github.com/morluto/rea/issues/527). On Linux and macOS, `annotate_native_function` atomically edits a function name and entry comments in the ephemeral database, returning refreshed analysis diff --git a/docs/raw-binary.md b/docs/raw-binary.md index 8e277568b..8d251d010 100644 --- a/docs/raw-binary.md +++ b/docs/raw-binary.md @@ -25,10 +25,12 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina } ``` -`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a +`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a hexadecimal address of the form `0x…`. The complete file is mapped starting at that address, and Ghidra seeds an `entry` function there before auto-analysis. -ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned. +ARM and AArch64 entries must be 4-byte aligned; x86 and 6502 entries may be byte-aligned. +For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`; +images that cross the 16-bit boundary are rejected before provider startup. `open_binary` and an open `binary_session` return the canonical `entry_point` for raw targets so callers can verify the selected mapping directly. Omitted `format` keeps header-based detection; architecture and entry without @@ -37,12 +39,13 @@ Omitted `format` keeps header-based detection; architecture and entry without Ghidra imports a private snapshot with `BinaryLoader` and these little-endian generic languages and compiler specifications: -| architecture | language_id | compiler_spec_id | -| ------------ | ------------------- | ---------------- | -| x86 | `x86:LE:32:default` | `gcc` | -| x86_64 | `x86:LE:64:default` | `gcc` | -| arm | `ARM:LE:32:v7` | `default` | -| arm64 | `AARCH64:LE:64:v8A` | `default` | +| architecture | language_id | compiler_spec_id | +| ------------ | -------------------- | ---------------- | +| x86 | `x86:LE:32:default` | `gcc` | +| x86_64 | `x86:LE:64:default` | `gcc` | +| arm | `ARM:LE:32:v7` | `default` | +| arm64 | `AARCH64:LE:64:v8A` | `default` | +| 6502 | `6502:LE:16:default` | `default` | Compiler specifications select analysis conventions; they do not identify the original compiler or ABI of a headerless target. Ghidra's x86 languages do not @@ -57,11 +60,51 @@ The mapped start is independent of Ghidra's program image base, which remains `0x0` for BinaryLoader. Ghidra may report additional language-defined entry points, including unmapped ARM vectors. Load-image verification requires the caller-supplied entry and preserves those other entries as observations. +For 6502, BinaryLoader maps the file before adding its language-defined zero-page +and stack blocks. Conflicting default blocks are skipped; low-address files stay +in the default address space rather than an overlay. Non-conflicting uninitialized +blocks remain observations, not file-backed bytes. Independent raw verification streams the complete private snapshot digest and extent with bounded memory and supports cancellation during the read. +It also requires the source-backed block to be initialized, loaded, and +non-overlay in the bound language's default address space. Contradictory mapping +observations are retained with `mismatch`, even when the source hashes agree. -Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit -native PE applications and DLLs only; raw-binary targets are unsupported. +Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 admits native PE +applications/DLLs and explicitly selected raw 6502 images. Other raw CPU families +remain outside the Windows boundary. Native path admission, private DACLs, Job +Object ownership, immutable snapshots, and the no-mutation policy still apply. + +## NMOS 6502 + +```bash +rea function /absolute/path/to/image.bin 0x8001 --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json +rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json +``` + +Select only headerless NMOS 6502 code with a known entry at file offset 0. +The built-in Ghidra processor supplies static instructions, functions, +pseudocode, and references. Evidence preserves `architecture: "6502"`, the +selected language, entry, file digest, and verified source mapping. Register and +calling conventions are analysis conventions, not a recovered source ABI. + +This lane does not interpret iNES headers, cartridge mappers, bank switching, +or other console-specific memory layouts. 65C02 and 65816 programs need different +processor semantics and are not admitted as those variants. REA does not infer +the CPU from the file suffix, guess the reset vector, or emulate the target. + +`npm run verify:ghidra:6502` assembles and links the source-owned fixture with +`ca65` and `ld65` (tested from cc65 tag `V2.19`, whose tools report `V2.18`). +Set `REA_CA65` and `REA_LD65` to explicit executable paths if needed. The lane +checks real CLI/MCP load-image parity, mapped bytes, decoded calls, decompilation, +invalid mappings, unchanged source bytes and runtime cleanup. +Low-memory checks include a file spanning the zero-page/stack boundary at `0xff` +and a complete 64-KiB image at `0x0`, with verified default-space mappings through +both CLI and MCP. On Windows, the lane also checks that a caller-selected +junction path is rejected at snapshot admission through CLI and MCP, with +unchanged source bytes and complete runtime cleanup. Windows requires the +matching native addon; the POSIX provider path has not been exercised by the +Windows run. ## Limitations diff --git a/docs/testing.md b/docs/testing.md index e8f36dc0c..7aa80d22a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -734,6 +734,16 @@ truncation constraint, while leading-zero coordinates still resolve correctly. It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS runtime or PC-98 device execution. +`npm run verify:ghidra:6502` uses `ca65` and `ld65` to build the source-owned +NMOS 6502 fixture from `tests/conformance/ghidra/6502.s` and `6502.cfg`. Set +`REA_CA65` and `REA_LD65` for non-PATH installations (tested with cc65 `V2.19`). +With an installed Ghidra 12.1.4 and full JDK, it verifies real CLI/stdio MCP +mapping parity, exact source bytes, functions, a direct call reference, +decompilation, 16-bit overflow rejection without replacing the active session, +and runtime cleanup. Windows additionally requires the matching native addon; +its verifier lineage remains explicitly unavailable outside the provider's +retained Job Object, as in the other Windows lanes. + `npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image through the compiled CLI and stdio MCP server. With the same Ghidra/JDK prerequisites, it checks preparation and bridge startup, verified load mappings, diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md index 9cd3bb6d8..5def0dae4 100644 --- a/docs/windows-ghidra-p0.md +++ b/docs/windows-ghidra-p0.md @@ -17,14 +17,16 @@ additional permission flag or degraded mode is required. - Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+; - an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4); - a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases); -- an explicit native, non-managed x86 or x86-64 PE application or DLL; and +- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a + [raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and - the 25 read-only Ghidra inventory, memory, and function-analysis operations. Loaded memory reads and file offsets preserve Ghidra's observed source mappings. PE load-image inspection returns an explicit unsupported attestation status. Independent verification covers admitted DOS MZ, explicit COM, and explicit -[raw-binary imports](raw-binary.md) on their supported hosts. This does not -extend Windows P0 admission beyond native PE. For x64 PE +[raw-binary imports](raw-binary.md) on their supported hosts, including the +admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited +to native PE and raw NMOS 6502 images. For x64 PE NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through `inspect_native_load_image` and `inspect_native_data_type` without a JAR or database mutation. A real Windows-host NativeAOT Ghidra run is still required @@ -35,6 +37,12 @@ state, annotations, other target architectures and formats, and Hopper-only operations remain unavailable through this provider. Managed PE/CLI inspection uses its separate execution-free provider. +Raw 6502 imports retain the same native filesystem and process ownership +requirements. The complete image must fit in the 16-bit address space; no +console-specific cartridge loader, CPU variant, or bank-switching model is +implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65` +to check the source-built fixture through real CLI and MCP workflows. + REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a compiler. The adapter uses the packaged Java `HeadlessScript`; Python and PyGhidra are not prerequisites. Users do not build the native addon. diff --git a/experiments/analysis-efficiency/agent-trial.py b/experiments/analysis-efficiency/agent-trial.py index 6dcc7d7f8..dc13e8781 100644 --- a/experiments/analysis-efficiency/agent-trial.py +++ b/experiments/analysis-efficiency/agent-trial.py @@ -94,6 +94,9 @@ def main(): if undeclared: raise ValueError('Declare inherited provider settings explicitly in --providers: ' + ', '.join(undeclared) + '. Values are not logged.') + source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ + else Path.home() / '.codex') + source_auth = source_auth_home / 'auth.json' args.output.mkdir(parents=True, exist_ok=False, mode=0o700) runtime_files, runtime_digest = runtime_fingerprint(repository) (args.output / 'runtime-files.json').write_text(json.dumps(runtime_files)) @@ -103,7 +106,6 @@ def main(): workspace = profile / 'workspace' for directory in [trial_codex_home, account, workspace]: directory.mkdir(mode=0o700) - source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json' auth = trial_codex_home / 'auth.json' started = time.monotonic() timeout = False diff --git a/experiments/analysis-efficiency/test_codex_context.py b/experiments/analysis-efficiency/test_codex_context.py index 37df83a7c..0b8e0247c 100644 --- a/experiments/analysis-efficiency/test_codex_context.py +++ b/experiments/analysis-efficiency/test_codex_context.py @@ -71,7 +71,9 @@ class ContextCaptureTests(unittest.TestCase): self.assertNotIn(marker, text) self.assertIn('caller-selected evidence and paths', text) self.assertIn('original-response', text) - self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600) + # Windows mode bits do not describe access control; this is a POSIX assertion. + if os.name == 'posix': + self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600) def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self): with tempfile.TemporaryDirectory() as directory: @@ -124,6 +126,7 @@ class ContextCaptureTests(unittest.TestCase): patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \ patch.object(agent.subprocess, 'Popen', return_value=child), \ patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \ + patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \ patch('builtins.print'): with self.assertRaisesRegex(OSError, 'fixture capture failure'): agent.main() diff --git a/package.json b/package.json index b2e2a51d3..7ee2b42ab 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", "verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs", + "verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs", "verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs", "verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs", "verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", diff --git a/scripts/verify-real-ghidra-6502.mjs b/scripts/verify-real-ghidra-6502.mjs new file mode 100644 index 000000000..87fb01d41 --- /dev/null +++ b/scripts/verify-real-ghidra-6502.mjs @@ -0,0 +1,479 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs"); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error( + "Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.", + ); +const exec = promisify(execFile); +const run = createVerifierRun(); +const root = fileURLToPath(new URL("..", import.meta.url)); +const ca65 = process.env.REA_CA65 ?? "ca65"; +const ld65 = process.env.REA_LD65 ?? "ld65"; +const toolchain = {}; +for (const [name, command] of Object.entries({ ca65, ld65 })) { + const version = await exec(command, ["--version"], { timeout: 10000 }); + toolchain[name] = (version.stdout + version.stderr).trim(); +} +await mkdir(join(root, "build"), { recursive: true }); +const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-")); +const runtime = join(workspace, "runtime"); +await mkdir(runtime); +const path = join(workspace, "image.bin"); +const object = join(workspace, "image.o"); +const entrypoint = join(root, "scripts", "rea.mjs"); +const env = { + ...process.env, + TMPDIR: runtime, + TMP: runtime, + TEMP: runtime, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_PROCESS_RUN_ID: run.run_id, +}; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", +}); +const client = new Client({ name: "rea-real-6502-proof", version: "1" }); +let stderr = ""; +transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); +}); +let report; +try { + await exec( + ca65, + [join(root, "tests/conformance/ghidra/6502.s"), "-o", object], + { timeout: 10000 }, + ); + await exec( + ld65, + ["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object], + { timeout: 10000 }, + ); + const bytes = await readFile(path); + // Independently known opcodes and link addresses from the source fixture. + assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await client.connect(transport); + const opened = await call("open_binary", { + path, + format: "raw-binary", + architecture: "6502", + entry_point: "0x008001", + provider_id: "ghidra", + }); + assert.equal(opened.architecture, "6502"); + assert.equal(opened.entry_point, "0x8001"); + const status = await call("binary_session"); + const image = evidence(await call("inspect_native_load_image"), sha256); + assert.equal( + image.normalized_result.status, + "verified", + JSON.stringify(image.normalized_result), + ); + assert.equal(image.normalized_result.architecture, "6502"); + assert.equal(image.normalized_result.base_address, "0x8001"); + assert.equal(image.normalized_result.language_id, "6502:LE:16:default"); + assert.equal(image.normalized_result.compiler_spec_id, "default"); + assert.equal(image.normalized_result.file_bytes, bytes.length); + const memory = evidence( + await call("read_bytes", { address: "0x8001", length: bytes.length }), + sha256, + ); + assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); + const procedures = evidence(await call("list_procedures"), sha256); + for (const address of ["0x8001", "0x8010"]) + assert.ok( + procedures.normalized_result.some( + (procedure) => procedure.address === address, + ), + ); + const instruction = evidence( + await call("inspect_native_instruction", { address: "0x8003" }), + sha256, + ); + assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR"); + assert.ok( + instruction.normalized_result.references.some( + (reference) => reference.call && reference.target_address === "0x8010", + ), + ); + const pseudo = evidence( + await call("procedure_pseudo_code", { procedure: "0x8010" }), + sha256, + ); + assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u); + const filePseudo = evidence( + await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }), + sha256, + ); + assert.equal(filePseudo.normalized_result, pseudo.normalized_result); + assert.ok( + filePseudo.locations.some( + (location) => location.kind === "file-offset" && location.offset === 15, + ), + ); + const rejected = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture: "6502", + entry_point: "0xffff", + }, + }); + assert.equal(rejected.isError, true); + assert.match(JSON.stringify(rejected.content), /6502 address width/u); + const preserved = await call("binary_session"); + assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id); + assert.equal(preserved.entry_point, "0x8001"); + await call("close_binary"); + await client.ping(); + const { stdout } = await exec( + process.execPath, + [ + entrypoint, + "inspect-native-load-image", + path, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0x8001", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const cli = parseEvidence(JSON.parse(stdout)); + assert.deepEqual(cli.normalized_result, image.normalized_result); + assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest); + assert.equal(cli.subject.digest.sha256, sha256); + const fileCli = await exec( + process.execPath, + [ + entrypoint, + "decompile", + path, + "--file-offset=0xf", + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0x8001", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout)); + assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result); + assert.deepEqual(fileCliEvidence.locations, filePseudo.locations); + assert.equal(fileCliEvidence.subject.digest.sha256, sha256); + assert.deepEqual(await readFile(path), bytes); + assert.deepEqual(await readdir(runtime), []); + const lowMemory = []; + // BinaryLoader maps file bytes before trying processor-defined blocks. Prove + // that overlapping zero-page/stack definitions never overlay the selected image. + for (const [entry, fullAddressSpace] of [ + [0xff, false], + [0, true], + ]) { + const lowPath = join(workspace, `low-${entry}.bin`); + await exec( + ld65, + [ + "-C", + join(root, "tests/conformance/ghidra/6502.cfg"), + "--start-addr", + String(entry), + "-o", + lowPath, + object, + ], + { timeout: 10000 }, + ); + const linked = await readFile(lowPath); + const expectedCode = Buffer.from( + "a907200000c90ad003a92a60a9006018690360", + "hex", + ); + expectedCode.writeUInt16LE(entry + 15, 3); + assert.deepEqual(linked, expectedCode); + const lowBytes = fullAddressSpace + ? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)]) + : linked; + await writeFile(lowPath, lowBytes); + const lowSha256 = createHash("sha256").update(lowBytes).digest("hex"); + const lowEntry = `0x${entry.toString(16)}`; + const lowOpened = await call("open_binary", { + path: lowPath, + format: "raw-binary", + architecture: "6502", + entry_point: lowEntry, + provider_id: "ghidra", + }); + assert.equal(lowOpened.entry_point, lowEntry); + const lowImage = evidence( + await call("inspect_native_load_image"), + lowSha256, + ); + assert.equal( + lowImage.normalized_result.status, + "verified", + JSON.stringify(lowImage.normalized_result), + ); + assert.equal(lowImage.normalized_result.base_address, lowEntry); + assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length); + const fileMappings = + lowImage.normalized_result.observations.mappings.filter( + (mapping) => mapping.source_file_index !== null, + ); + assert.equal(fileMappings.length, 1); + assert.equal( + fileMappings[0].address_space, + lowImage.normalized_result.observations.default_address_space, + ); + assert.equal(fileMappings[0].overlay, false); + assert.equal(fileMappings[0].start, lowEntry); + assert.equal(fileMappings[0].length, lowBytes.length); + const lowRead = evidence( + await call("read_bytes", { address: lowEntry, length: linked.length }), + lowSha256, + ); + assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex")); + const lowCall = evidence( + await call("inspect_native_instruction", { + address: `0x${(entry + 2).toString(16)}`, + }), + lowSha256, + ); + assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR"); + assert.ok( + lowCall.normalized_result.references.some( + (reference) => + reference.call && + reference.target_address === `0x${(entry + 15).toString(16)}`, + ), + ); + await call("close_binary"); + const lowCli = await exec( + process.execPath, + [ + entrypoint, + "inspect-native-load-image", + lowPath, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + lowEntry, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256); + assert.deepEqual( + lowCliEvidence.normalized_result, + lowImage.normalized_result, + ); + assert.equal( + lowCliEvidence.analysis_profile.digest, + lowImage.analysis_profile.digest, + ); + assert.deepEqual(await readFile(lowPath), lowBytes); + assert.deepEqual(await readdir(runtime), []); + lowMemory.push({ + entry: lowEntry, + bytes: lowBytes.length, + sha256: lowSha256, + }); + } + let windowsReparseAdmission = "not-applicable"; + if (process.platform === "win32") { + const junction = join(workspace, "linked"); + await symlink(workspace, junction, "junction"); + try { + const linkedPath = join(junction, "image.bin"); + // Reopening the same bytes through a junction must replace the direct session. + await call("open_binary", { + path, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + provider_id: "ghidra", + }); + const directImage = evidence( + await call("inspect_native_load_image"), + sha256, + ); + assert.equal(directImage.normalized_result.status, "verified"); + // Canonical file identity must not bypass admission of the new path. + await call("open_binary", { + path: linkedPath, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + provider_id: "ghidra", + }); + // open_binary selects a lazy session; the first analysis admits the snapshot. + const linkedImage = await client.callTool( + { name: "inspect_native_load_image", arguments: {} }, + { timeout: 240000 }, + ); + assert.equal(linkedImage.isError, true); + assert.match( + JSON.stringify(linkedImage), + /Reparse path component is unsupported/u, + ); + await call("close_binary"); + const snapshotPath = join(workspace, "direct-snapshot.json"); + const cliArgs = (selectedPath) => [ + entrypoint, + "analyze", + selectedPath, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0x8001", + "--provider", + "ghidra", + "--snapshot", + snapshotPath, + "--json", + ]; + const saved = await exec(process.execPath, cliArgs(path), { + env, + timeout: 240000, + maxBuffer: 16 * 1024 * 1024, + }); + assert.equal( + evidence(JSON.parse(saved.stdout), sha256).operation, + "binary_overview", + ); + const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8")); + assert.ok( + savedSnapshot.workflow_entries.some( + ({ operation }) => operation === "binary_overview", + ), + ); + assert.deepEqual(await readdir(runtime), []); + // Replaying a matching snapshot must still reach native path admission. + await assert.rejects( + exec(process.execPath, cliArgs(linkedPath), { + env, + timeout: 240000, + maxBuffer: 16 * 1024 * 1024, + }), + (cause) => { + assert.match( + cause.stdout + cause.stderr, + /Reparse path component is unsupported/u, + ); + return true; + }, + ); + assert.deepEqual(await readFile(path), bytes); + assert.deepEqual(await readdir(runtime), []); + windowsReparseAdmission = "cli-snapshot-mcp-rejected"; + } finally { + await unlink(junction); + } + } + report = { + ok: true, + mocked: false, + lane: "ghidra-6502", + host: process.platform, + toolchain, + provider: image.provider, + target: { + sha256, + bytes: bytes.length, + architecture: "6502", + entry: "0x8001", + }, + cli_mcp_load_image_parity: true, + instructions_and_call_reference: true, + decompilation: true, + file_offset_decompilation_cli_mcp: true, + invalid_mapping_preserves_session: true, + low_memory_cli_mcp_parity: lowMemory, + windows_reparse_admission: windowsReparseAdmission, + source_unchanged: true, + runtime_cleanup: "complete", + }; +} catch (cause) { + if (stderr) process.stderr.write(stderr); + throw cause; +} finally { + try { + await client.close(); + } finally { + await transport.close(); + } +} +const verifierRun = await completeVerifierRun(run); +if (process.platform !== "win32") { + assert.equal(verifierRun.process_lineage.status, "verified"); + assert.deepEqual(verifierRun.process_lineage.descendants, []); +} +// Keep the workspace for diagnosis unless verification and cleanup both pass. +await rm(workspace, { recursive: true, force: true }); +process.stdout.write( + JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n", +); + +async function call(name, args = {}) { + const result = await client.callTool( + { name, arguments: args }, + { timeout: 240000 }, + ); + assert.notEqual(result.isError, true, JSON.stringify(result)); + if (["open_binary", "binary_session", "close_binary"].includes(name)) + return requireMcpOperationResult(result, name); + return result.structuredContent; +} + +function evidence(value, sha256) { + const result = parseEvidence(value); + assert.equal(result.subject.digest.sha256, sha256); + assert.equal(result.subject.architecture, "6502"); + assert.equal(result.subject.format, "raw-binary"); + return result; +} diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index ac691b55e..6f1bc4366 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -76,6 +76,8 @@ export const parseBinaryTarget = async ( await access(candidate, constants.R_OK); throwIfTargetResolutionCancelled(signal); const canonical = await realpath(candidate); + // Windows native admission must inspect the caller-selected reparse components. + const sourcePath = process.platform === "win32" ? candidate : canonical; throwIfTargetResolutionCancelled(signal); const resolved = await resolveAppBundle(canonical); throwIfTargetResolutionCancelled(signal); @@ -113,7 +115,7 @@ export const parseBinaryTarget = async ( ); return ok({ path, - sourcePath: canonical, + sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), kind: "executable", format: "raw-binary", @@ -152,7 +154,7 @@ export const parseBinaryTarget = async ( ); return ok({ path, - sourcePath: canonical, + sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), kind: "executable", format: "dos-com", @@ -166,7 +168,7 @@ export const parseBinaryTarget = async ( ) return ok({ path, - sourcePath: process.platform === "win32" ? candidate : canonical, + sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), kind: "database", format: "analysis-database", @@ -175,7 +177,7 @@ export const parseBinaryTarget = async ( if (artifactFormat !== undefined) { const identity = { path, - sourcePath: process.platform === "win32" ? candidate : canonical, + sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), }; return isArchiveFormat(artifactFormat) @@ -195,7 +197,7 @@ export const parseBinaryTarget = async ( ); return ok({ path, - sourcePath: process.platform === "win32" ? candidate : canonical, + sourcePath, ...bundle, sha256: await sha256Handle(handle, path, metadata, signal), kind: "executable", diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index c4d130f63..0bdb69864 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -23,6 +23,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { access } from "node:fs/promises"; import { readAnalysisSnapshot } from "./binary/AnalysisSnapshotFiles.js"; +import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js"; import { parseBinaryTarget } from "./BinaryTargetResolver.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { @@ -254,9 +255,14 @@ const runAnalysis = async ( const route = preview.value.route; const bindingProfile = route.profile ?? undefined; const evidenceProfile = analysisProfileForRoute(route, tool); + // A parsed Windows path does not prove Ghidra's native snapshot owner + // admitted the selected source; CLI replay must wait for that client. if ( bindingProfile !== undefined && evidenceProfile !== undefined && + !requiresNativeSourceAdmissionBeforeReplay( + route.binding?.identity.id, + ) && allowsSnapshotReplay(route, tool) ) { const cached = snapshotEvidenceForQuery(snapshot, { diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 7e307baa0..79e212cac 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -84,6 +84,11 @@ export const isSnapshotCacheable = ( descriptor.effects.mayWriteFilesystem === false) && descriptor.effects.changesPermissions === false; +/** Windows Ghidra must admit the selected source through its native snapshot owner. */ +export const requiresNativeSourceAdmissionBeforeReplay = ( + providerId: string | undefined, +): boolean => process.platform === "win32" && providerId === "ghidra"; + /** Bounded in-memory cache for one immutable binary identity. */ export class AnalysisSnapshotCache { readonly #entries = new Map(); diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index f7b2d3529..3da571832 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -58,6 +58,7 @@ interface SessionBinding { readonly profile: AnalysisProfileCommitment | null; readonly route: SessionProviderRoute; readonly runId: string; + nativeSourceAdmitted: boolean; } /** @@ -323,6 +324,7 @@ export class BinarySession profile, route, runId, + nativeSourceAdmitted: false, }; this.#clearRuntimeAvailability(); this.resetSnapshotInvalidation(); @@ -609,6 +611,7 @@ export class BinarySession active: this.#active, operation: name, parameters: arguments_, + nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true, unboundOperationError: (operation, route) => this.#providerRouter.unboundOperationError(operation, route), lookupSnapshot: (target, profile, operation, parameters) => @@ -645,6 +648,14 @@ export class BinarySession }, }) : profiled; + // A successful Ghidra operation proves this client's native source snapshot + // was admitted; a composite health response and parsed path do not. + if ( + execution.ok && + capability.provider.id === "ghidra" && + this.#active === active + ) + this.#active.nativeSourceAdmitted = true; this.#observeRuntimeAvailability(name, execution); if ( execution.ok && @@ -838,6 +849,7 @@ export class BinarySession profile: previous.profile, route: previous.route, runId, + nativeSourceAdmitted: false, }; return ok(null); } diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index 996749aae..73dfe4eea 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -13,7 +13,10 @@ import type { AnalysisOperation, CapabilityDescriptor, } from "../AnalysisProvider.js"; -import { isSnapshotCacheable } from "./AnalysisSnapshotCache.js"; +import { + isSnapshotCacheable, + requiresNativeSourceAdmissionBeforeReplay, +} from "./AnalysisSnapshotCache.js"; import type { SessionProviderRoute } from "./SessionProviderRouter.js"; interface ActiveExecutionBinding { @@ -35,6 +38,7 @@ interface PrepareSessionExecutionInput { readonly active: ActiveExecutionBinding | undefined; readonly operation: AnalysisOperation; readonly parameters: Readonly>; + readonly nativeSourceAdmitted: boolean; readonly unboundOperationError: ( operation: AnalysisOperation, route: SessionProviderRoute, @@ -55,6 +59,7 @@ export const prepareSessionExecution = ( active, operation, parameters, + nativeSourceAdmitted, unboundOperationError, lookupSnapshot, } = input; @@ -79,9 +84,12 @@ export const prepareSessionExecution = ( const cacheable = profile !== undefined && isSnapshotCacheable(operation, capability, parameters); - const cached = cacheable - ? lookupSnapshot(active.target, profile, operation, parameters) - : undefined; + const cached = + cacheable && + (!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) || + nativeSourceAdmitted) + ? lookupSnapshot(active.target, profile, operation, parameters) + : undefined; return ok({ active, capability, profile, cacheable, cached }); }; diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index 8d810e671..cd5f6be4a 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -92,9 +92,7 @@ export const resolveSessionTarget = async ( input: ResolveSessionTargetInput, ): Promise> => { const { router, current, target, options } = input; - const sameTarget = - current?.target.path === target.path && - snapshotMatchesTarget(snapshotTarget(current.target), target); + const sameTarget = sameSessionTarget(current?.target, target); const resolvedRoute = sameTarget && options.providerId === undefined && current !== undefined ? ({ ok: true, value: current.route } as const) @@ -113,9 +111,7 @@ export const validateResolvedSessionTarget = ( }, ): Result => { const { current, target, route, options, stagedSnapshotMatches } = input; - const sameTarget = - current?.target.path === target.path && - snapshotMatchesTarget(snapshotTarget(current.target), target); + const sameTarget = sameSessionTarget(current?.target, target); const snapshotError = validateSnapshot({ snapshot: options.snapshot, target, @@ -127,6 +123,16 @@ export const validateResolvedSessionTarget = ( return { ok: true, value: { target, route, sameTarget } }; }; +const sameSessionTarget = ( + current: BinaryTarget | undefined, + target: BinaryTarget, +): boolean => + current !== undefined && + current.path === target.path && + // Native admission is bound to the requested path, not just its realpath. + (current.sourcePath ?? current.path) === (target.sourcePath ?? target.path) && + snapshotMatchesTarget(snapshotTarget(current), target); + interface ValidateSnapshotInput { readonly snapshot: AnalysisSnapshot | undefined; readonly target: BinaryTarget; diff --git a/src/contracts/toolOutputReferences.test.ts b/src/contracts/toolOutputReferences.test.ts index cda49f1d5..1cd890bf3 100644 --- a/src/contracts/toolOutputReferences.test.ts +++ b/src/contracts/toolOutputReferences.test.ts @@ -6,6 +6,34 @@ import { z } from "zod"; import { toolContract } from "./toolContracts.js"; import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js"; +import { createEvidence } from "../domain/evidence.js"; + +it.each(["draft-2020-12", "draft-07"] as const)( + "advertises the raw-only 6502 Evidence subject constraint in %s", + (target) => { + const valid = createEvidence( + { + path: "/fixture/image.bin", + sha256: "a".repeat(64), + format: "raw-binary", + architecture: "6502", + }, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "address_name", parameters: {}, result: "entry" }, + ); + const advertised = toolOutputSchemaWithMetadata( + toolContract("address_name"), + )["~standard"].jsonSchema.output({ target }); + const validate = new AjvJsonSchemaValidator().getValidator(advertised); + expect(validate(valid).valid).toBe(true); + expect( + validate({ + ...valid, + subject: { ...valid.subject, format: "pe" }, + }).valid, + ).toBe(false); + }, +); it("keeps dialect-specific validators distinct after JSON schema serialization", () => { const contract = { diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index dcf7ad033..76294e2db 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -444,11 +444,11 @@ const lifecycleTargetFields = { path: z.string(), kind: targetKindSchema, sha256: z.string().regex(/^[a-f0-9]{64}$/u), - architecture: z.enum(BINARY_ARCHITECTURES).nullable(), }; const detectedLifecycleTargetSchema = z.object({ ...lifecycleTargetFields, format: targetFormatSchema.exclude(["raw-binary"]), + architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(), }); const rawLifecycleTargetSchema = z.object({ ...lifecycleTargetFields, diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index ee3920be5..d83ddac30 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -52,7 +52,7 @@ const snapshotWithHistoricalEvidence = () => { }; return { current, - snapshot: { + snapshot: analysisSnapshotSchema.parse({ target, binding, entries: [entry], @@ -66,11 +66,32 @@ const snapshotWithHistoricalEvidence = () => { result: { summary: "unprofiled" }, }), ]), - } satisfies AnalysisSnapshot, + }), }; }; describe("analysis snapshot target identity", () => { + it("keeps 6502 confined to executable raw snapshots", () => { + const target = { + sha256: ANALYSIS_SNAPSHOT_TARGET.sha256, + kind: "executable", + architecture: "6502", + }; + expect( + analysisSnapshotSchema.shape.target.safeParse({ + ...target, + format: "pe", + }).success, + ).toBe(false); + expect( + analysisSnapshotSchema.shape.target.safeParse({ + ...target, + format: "raw-binary", + entry_point: "0x8001", + }).success, + ).toBe(true); + }); + it("partitions raw snapshots and query identities by the canonical entry", () => { const target: BinaryTarget = { path: "/fixture/image.bin", @@ -120,6 +141,48 @@ describe("analysis snapshot target identity", () => { }); }); +describe("analysis snapshot nested subject contract", () => { + it("rejects raw-only 6502 in nested detected execution subjects", () => { + const { snapshot } = snapshotWithHistoricalEvidence(); + const entry = snapshot.entries[0]; + if (entry === undefined) throw new Error("Fixture has no snapshot entry"); + const malformedSubject = { + ...entry.execution.subject, + format: "pe", + architecture: "6502", + }; + expect( + analysisSnapshotSchema.safeParse({ + ...snapshot, + entries: [ + { + ...entry, + execution: { + ...entry.execution, + subject: malformedSubject, + }, + }, + ], + }).success, + ).toBe(false); + expect( + analysisSnapshotSchema.safeParse({ + ...snapshot, + workflow_entries: [ + { + ...entry, + execution: { + ...entry.execution, + analysis_profile: snapshot.binding.analysis_profile, + subject: malformedSubject, + }, + }, + ], + }).success, + ).toBe(false); + }); +}); + describe("analysis snapshot contract", () => { it.each([ ["dos-mz", "x86"], diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 57e876653..4e1af5fa8 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -41,7 +41,10 @@ import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; -import { rawBinaryEntryPointSchema } from "./rawBinary.js"; +import { + binaryArchitectureSchema, + rawBinaryEntryPointSchema, +} from "./rawBinary.js"; import { relationshipCoverageSchema, type RelationshipCoverage, @@ -73,20 +76,30 @@ const formatSchema = z.enum([ "source-map", ]); const kindSchema = z.enum(["executable", "database", "archive", "artifact"]); -const subjectSchema = z.object({ +const subjectBaseSchema = z.object({ path: localPathStringSchema, sha256: digestSchema, - format: z.enum([ - ...formatSchema.options, - "hopper", - "directory", - "file", - "unknown", - "javascript-bundle", - "entitlements", - ]), - architecture: architectureSchema.nullable(), }); +const subjectFormatSchema = z.enum([ + ...formatSchema.options, + "hopper", + "directory", + "file", + "unknown", + "javascript-bundle", + "entitlements", +]); +// Snapshot entries retain the same raw-only architecture boundary as targets. +const subjectSchema = z.discriminatedUnion("format", [ + subjectBaseSchema.extend({ + format: subjectFormatSchema.exclude(["raw-binary"]), + architecture: architectureSchema.exclude(["6502"]).nullable(), + }), + subjectBaseSchema.extend({ + format: z.literal("raw-binary"), + architecture: architectureSchema.nullable(), + }), +]); const targetIdentitySchema = z.object({ sha256: digestSchema, kind: kindSchema, @@ -97,9 +110,12 @@ const targetSchema = z.discriminatedUnion("format", [ format: z.enum( formatSchema.options.filter((format) => format !== "raw-binary"), ), + architecture: architectureSchema.exclude(["6502"]).nullable(), }), targetIdentitySchema.extend({ format: z.literal("raw-binary"), + kind: z.literal("executable"), + architecture: binaryArchitectureSchema, entry_point: rawBinaryEntryPointSchema, }), ]); @@ -332,18 +348,21 @@ const canonicalJson = (value: unknown): string => { }; /** Project a binary into the path-free identity used for cache invalidation. */ -export const snapshotTarget = ( - target: BinaryTarget, -): AnalysisSnapshotTarget => { - const identity = { - sha256: target.sha256, - kind: target.kind, - architecture: target.architecture ?? null, - }; - return target.format === "raw-binary" - ? { ...identity, format: target.format, entry_point: target.entryPoint } - : { ...identity, format: target.format }; -}; +export const snapshotTarget = (target: BinaryTarget): AnalysisSnapshotTarget => + target.format === "raw-binary" + ? { + sha256: target.sha256, + kind: target.kind, + architecture: target.architecture, + format: target.format, + entry_point: target.entryPoint, + } + : { + sha256: target.sha256, + kind: target.kind, + architecture: target.architecture ?? null, + format: target.format, + }; /** Build the immutable provider/profile binding used by a cached snapshot. */ export const snapshotBinding = ( @@ -475,10 +494,10 @@ export const createAnalysisSnapshotWorkflowEntry = (input: { subject: execution.subject === null ? null - : { + : subjectSchema.parse({ ...execution.subject, architecture: execution.subject.architecture ?? null, - }, + }), }, }; }; @@ -538,10 +557,10 @@ export const createAnalysisSnapshotEntry = (input: { subject: execution.subject === null ? null - : { + : subjectSchema.parse({ ...execution.subject, architecture: execution.subject.architecture ?? null, - }, + }), }, }; }; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 99bc6dd24..94828ee6e 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -351,7 +351,9 @@ const machArchitecture = (cpu: number): HeaderArchitecture | undefined => { return undefined; }; -const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { +const elfArchitecture = ( + machine: number, +): Exclude | undefined => { switch (machine) { case 3: return "x86"; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index ab5046549..99ad8487f 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -1,12 +1,13 @@ import type { ZipPackageFormat } from "./zipPackageFormat.js"; -/** Provider-neutral CPU families detected from supported executable headers. */ +/** Provider-neutral CPU families detected from headers or explicitly selected raw loads. */ export const BINARY_ARCHITECTURES = [ "x86", "x86_64", "arm", "arm64", "mips", + "6502", "riscv", ] as const; export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number]; @@ -40,7 +41,7 @@ export const isElfMetadataArchitecture = ( /** Recognized family with no ELF metadata member; the header alone is the fact. */ export type HeaderArchitecture = Exclude< BinaryArchitecture, - ElfMetadataArchitecture + ElfMetadataArchitecture | "6502" >; /** Raw ELF facts; family recognition does not establish ISA or ABI support. */ @@ -144,8 +145,17 @@ export type BinaryTarget = readonly executableRole?: never; readonly managed?: never; }) - | (ExecutableTarget & { + | (BinaryTargetIdentity & { + readonly kind: "executable"; readonly format: "raw-binary"; + readonly architecture: Exclude< + BinaryArchitecture, + ElfMetadataArchitecture + >; + readonly availableArchitectures: readonly Exclude< + BinaryArchitecture, + ElfMetadataArchitecture + >[]; readonly entryPoint: string; readonly entryPointSource: string; readonly executableRole?: never; diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index 5cdf30a27..be923e3a6 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -19,6 +19,33 @@ const TARGET: BinaryTarget = { const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" }; const PROFILE = createAnalysisProfile(PROVIDER, { loader: "default" }); +it("rejects a detected Evidence subject with the raw-only 6502 architecture", () => { + const selected = { + path: TARGET.path, + sha256: TARGET.sha256, + format: "raw-binary" as const, + architecture: "6502" as const, + }; + expect( + createEvidence(selected, PROVIDER, { + operation: "inspect", + parameters: {}, + result: null, + }).subject, + ).toMatchObject({ format: "raw-binary", architecture: "6502" }); + const malformed = { + ...selected, + format: "pe" as const, + }; + expect(() => + createEvidence(malformed, PROVIDER, { + operation: "inspect", + parameters: {}, + result: null, + }), + ).toThrow(); +}); + it("preserves omitted relationship diagnostics and authenticates reported diagnostics", () => { const observation = { operation: "procedure_callees", diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 8a450f478..49cb2a229 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -38,42 +38,52 @@ export const providerSchema = z.strictObject({ name: z.string().min(1), version: z.string().nullable(), }); -const subjectSchema = z.strictObject({ +const subjectFormatSchema = z.enum([ + "hopper", + "analysis-database", + "mach-o", + "elf", + "pe", + "dos-mz", + "dos-com", + "raw-binary", + "zip", + "ipa", + "apk", + "msix", + "appx", + "hap", + "hsp", + "app-pack", + "asar", + "dmg", + "pkg", + "plist", + "javascript", + "source-map", + "directory", + "file", + "unknown", + "mach-o-universal", + "javascript-bundle", + "entitlements", +]); +const subjectBaseSchema = z.strictObject({ name: z.string().min(1), digest: z.strictObject({ sha256: digestSchema }), - format: z.enum([ - "hopper", - "analysis-database", - "mach-o", - "elf", - "pe", - "dos-mz", - "dos-com", - "raw-binary", - "zip", - "ipa", - "apk", - "msix", - "appx", - "hap", - "hsp", - "app-pack", - "asar", - "dmg", - "pkg", - "plist", - "javascript", - "source-map", - "directory", - "file", - "unknown", - "mach-o-universal", - "javascript-bundle", - "entitlements", - ]), - architecture: z.enum(BINARY_ARCHITECTURES).nullable(), local_path: nulFreeStringSchema, }); +// The format owns the architecture claim, including nested Evidence subjects. +const subjectSchema = z.discriminatedUnion("format", [ + subjectBaseSchema.extend({ + format: subjectFormatSchema.exclude(["raw-binary"]), + architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(), + }), + subjectBaseSchema.extend({ + format: z.literal("raw-binary"), + architecture: z.enum(BINARY_ARCHITECTURES).nullable(), + }), +]); /** Source location attached to an evidence observation. */ /** Source-location schema shared by evidence-bearing persistence formats. */ export const evidenceLocationSchema = z.discriminatedUnion("kind", [ @@ -186,7 +196,7 @@ const immutableResultSchema = z }); export type EvidenceLocation = z.infer; -/** Minimal immutable local artifact identity accepted by Evidence. */ +/** Candidate local artifact identity validated by the Evidence subject schema. */ export interface EvidenceSubjectTarget { readonly path: string; readonly sha256: string; diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 7e6fd2b3b..a70969b8e 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -16,14 +16,17 @@ import { } from "./residualUnknown.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; -const artifactManifestSchema = evidenceSchema.shape.subject - .unwrap() - .pick({ - digest: true, - format: true, - architecture: true, - }) - .strict(); +const [detectedSubjectSchema, rawSubjectSchema] = + evidenceSchema.shape.subject.unwrap().options; +const manifestFields = { + digest: true, + format: true, + architecture: true, +} as const; +const artifactManifestSchema = z.discriminatedUnion("format", [ + detectedSubjectSchema.pick(manifestFields), + rawSubjectSchema.pick(manifestFields), +]); const providerManifestSchema = providerSchema; const environmentManifestSchema = evidenceSchema.shape.environment.unwrap(); const scenarioManifestSchema = z.strictObject({ @@ -85,11 +88,11 @@ export const createEvidenceBundle = ( subject === null ? [] : [ - { + artifactManifestSchema.parse({ digest: subject.digest, format: subject.format, architecture: subject.architecture, - }, + }), ], ), ), diff --git a/src/domain/native/nativeLoadImage.ts b/src/domain/native/nativeLoadImage.ts index 6a3be4421..a4efc2847 100644 --- a/src/domain/native/nativeLoadImage.ts +++ b/src/domain/native/nativeLoadImage.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js"; import { jsonValueSchema } from "../jsonValue.js"; +import { binaryArchitectureSchema } from "../rawBinary.js"; const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); const digest = z.string().regex(/^[a-f0-9]{64}$/u); @@ -112,7 +113,7 @@ export const nativeLoadImageSchema = z.union([ status: z.enum(["verified", "mismatch"]), format: z.literal("raw-binary"), target_sha256: digest, - architecture: z.enum(["x86", "x86_64", "arm", "arm64"]), + architecture: binaryArchitectureSchema, language_id: z.string().min(1), compiler_spec_id: z.string().min(1), base_address: address, diff --git a/src/domain/rawBinary.test.ts b/src/domain/rawBinary.test.ts index f22e5fced..5cb6ed129 100644 --- a/src/domain/rawBinary.test.ts +++ b/src/domain/rawBinary.test.ts @@ -39,6 +39,44 @@ describe("raw-binary entry addresses", () => { }); describe("raw-binary admission", () => { + it("admits byte-aligned 6502 images up to the 16-bit address boundary", () => { + for (const [entryPoint, fileBytes] of [ + ["0x8001", 8], + ["0xffff", 1], + ["0x0", 65536], + ] as const) { + const result = admitRawBinary({ + architecture: "6502", + entryPoint, + fileBytes, + }); + if (!result.ok) throw new Error(result.error.reason); + expect(result.value).toEqual({ + architecture: "6502", + entryPoint, + entryPointSource: entryPoint, + }); + } + }); + it.each([ + ["0x10000", 1], + ["0xffff", 2], + ["0x0", 65537], + ])( + "rejects a 6502 mapping at %s with %i bytes that exceeds 16 bits", + (entryPoint, fileBytes) => { + const result = admitRawBinary({ + architecture: "6502", + entryPoint, + fileBytes, + }); + if (result.ok) throw new Error("expected 16-bit overflow rejection"); + expect(result.error).toEqual({ + reason: `raw-binary mapping from ${entryPoint} must fit in the 6502 address width`, + constraint: "load_selection_rejected", + }); + }, + ); it("admits a little-endian ARM image mapped at the caller entry", () => { expect( admitRawBinary({ diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index 94fdf6ce7..22d94f4d6 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -13,6 +13,7 @@ export const BINARY_ARCHITECTURES = [ "x86_64", "arm", "arm64", + "6502", ] as const satisfies readonly BinaryArchitecture[]; /** Provider-neutral CPU family for a caller-supplied raw-binary load. */ @@ -58,7 +59,7 @@ export const validateRawBinaryLength = ( /** Admitted headerless load identity used by target resolution and profiles. */ export interface RawBinaryAdmission { - readonly architecture: HeaderArchitecture; + readonly architecture: HeaderArchitecture | "6502"; readonly entryPoint: string; readonly entryPointSource: string; } @@ -74,9 +75,11 @@ export interface RawBinaryAdmissionRejection { } const addressWidth = (architecture: BinaryArchitecture): bigint => - architecture === "x86" || architecture === "arm" - ? 0xffffffffn - : 0xffffffffffffffffn; + architecture === "6502" + ? 0xffffn + : architecture === "x86" || architecture === "arm" + ? 0xffffffffn + : 0xffffffffffffffffn; /** Validate architecture, entry, and that the mapped image fits the CPU width. */ export const admitRawBinary = (input: { @@ -88,7 +91,7 @@ export const admitRawBinary = (input: { if (!architecture.success) return err({ reason: - "raw-binary format requires architecture (x86, x86_64, arm, or arm64)", + "raw-binary format requires architecture (x86, x86_64, arm, arm64, or 6502)", constraint: "load_selection_rejected", }); if (typeof input.entryPoint !== "string") diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index fd7cdff11..8cc5f2807 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -136,7 +136,7 @@ export const resolveGhidraAnalysisProfile = async ( installation.platform === "win32" ? "official-headless-script-v1" : "inspected-jvm-launch-support-v1", - load_image_evidence: "independent-raw-mapping-context-v2", + load_image_evidence: "independent-raw-mapping-context-v4", jump_table_evidence: "typed-case-default-blocks-v1", decompiler_jump_loads: true, loader: "BinaryLoader", diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index 1c80bb635..f06f4fdaa 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -361,7 +361,10 @@ export const attestGhidraRawBinaryLoadImage = ( }); }; const source = observations.source_files[0]; - const mapping = observations.mappings[0]; + const fileMappings = observations.mappings.filter( + (item) => item.source_file_index !== null, + ); + const mapping = fileMappings[0]; check("executable_format", "Raw Binary", observations.executable_format); check("language_id", language.languageId, observations.language_id); check( @@ -371,11 +374,34 @@ export const attestGhidraRawBinaryLoadImage = ( ); // BinaryLoader's block base is independent of the Program image base. check("image_base", "0x0", observations.image_base); + check( + "default_address_space", + language.defaultAddressSpace, + observations.default_address_space, + ); check("source_file_count", 1, observations.source_files.length); check("source_file_size", snapshot.bytes, source?.size ?? null); check("original_file_sha256", target.sha256, source?.original_sha256 ?? null); check("modified_file_sha256", target.sha256, source?.modified_sha256 ?? null); - check("mapping_count", 1, observations.mappings.length); + // Language-defined uninitialized zero page/stack blocks are observations, not file bytes. + check("mapping_count", 1, fileMappings.length); + check( + "initialized_unbacked_mappings", + 0, + observations.mappings.filter( + (item) => item.initialized && item.source_file_index === null, + ).length, + ); + check("mapping.source_file_index", 0, mapping?.source_file_index ?? null); + // Matching bytes alone do not establish the admitted default-space load. + check("mapping.initialized", true, mapping?.initialized ?? null); + check("mapping.loaded", true, mapping?.loaded ?? null); + check("mapping.overlay", false, mapping?.overlay ?? null); + check( + "mapping.address_space", + observations.default_address_space, + mapping?.address_space ?? null, + ); check( "mapping.file_offset", 0, @@ -427,8 +453,9 @@ export const attestGhidraRawBinaryLoadImage = ( checks, limitations: [ "Raw binary is a headerless interpretation explicitly selected by the caller, not format identification from file bytes.", - "Verification covers complete original/modified source identity, mapped file bytes, the caller-supplied entry, and the bound little-endian Ghidra language. File offset 0 is mapped at the entry address.", + "Verification covers complete original/modified source identity, initialized and loaded non-overlay file bytes in the default address space, the caller-supplied entry, and the bound little-endian Ghidra language. File offset 0 is mapped at the entry address.", "Ghidra may report additional language-defined external entries outside the mapped image. They are preserved as observations; verification requires the caller-supplied entry and does not attest other entries.", + "Language-defined uninitialized memory, such as 6502 zero page and stack, is preserved as observations but is not attested as file-backed bytes.", "Reset vectors, memory-mapped I/O, big-endian and Thumb-only variants, and runtime boot behavior are not modeled or verified. No original executable bytes are changed.", "Mapping ends are inclusive; a mismatching digest identifies its source range, not the first differing byte.", ], diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 3979222d9..a41bf096f 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -8,7 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js"; import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js"; import { @@ -30,6 +30,8 @@ import { err, ok } from "../domain/result.js"; import type { Result } from "../domain/result.js"; import { GhidraSessionError } from "./GhidraSessionError.js"; import { silentLogger } from "../logger.js"; +import * as WindowsNativeLoader from "../windows/WindowsNativeLoader.js"; +import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; @@ -190,6 +192,36 @@ describe("Ghidra provider", () => { }); describe("Ghidra raw-binary profile", () => { + it("admits raw 6502 on Windows and binds its 16-bit language without mutation authority", async () => { + const ghidra = provider({ ...installationHost(), platform: "win32" }); + const target: BinaryTarget = { + path: "/tmp/6502.bin", + sha256: "a".repeat(64), + kind: "executable", + format: "raw-binary", + architecture: "6502", + availableArchitectures: ["6502"], + entryPoint: "0x8001", + entryPointSource: "0x008001", + }; + expect(ghidra.inspectTargetSupport(target).status).toBe("supported"); + const resolved = await ghidra.resolveAnalysisProfile(target); + if (!resolved.ok) throw resolved.error; + expect(resolved.value.profile?.parameters).toMatchObject({ + architecture: "6502", + loader: "BinaryLoader", + language_id: "6502:LE:16:default", + compiler_spec_id: "default", + entry_point: "0x8001", + language_selection: "caller-supplied-v1", + load_image_evidence: "independent-raw-mapping-context-v4", + }); + expect( + ghidra + .capabilities() + .some(({ available, effects }) => available && effects.mutatesArtifact), + ).toBe(false); + }); it("commits BinaryLoader language and caller-supplied entry", async () => { const ghidra = provider(); const target: BinaryTarget = { @@ -324,6 +356,13 @@ describe("Ghidra platform support", () => { }); it("keeps Windows P0 unavailable until native isolation authority exists", () => { + const native = vi + .spyOn(WindowsNativeLoader, "systemWindowsNativeAuthority") + .mockReturnValue({ + available: false, + reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + }); + onTestFinished(() => native.mockRestore()); const ghidra = provider({ ...installationHost(), platform: "win32" }); const nativeApplication = peTarget("x86_64"); @@ -812,11 +851,22 @@ describe("Ghidra relationship coverage", () => { }); }); -describe("Ghidra measured load-image projection", () => { - it.each(["verified", "changed-snapshot", "cancelled"] as const)( +describe("Ghidra measured raw load-image projection", () => { + it.each([ + "verified", + "mapping-mismatch", + "changed-snapshot", + "cancelled", + ] as const)( "streams raw snapshot identity and preserves %s outcomes", async (state) => { const fixture = fixtureRawBinaryLoadImage(); + if (state === "mapping-mismatch") { + const mapping = fixture.observation.mappings[0]; + if (mapping === undefined) + throw new Error("Missing source mapping fixture"); + mapping.overlay = true; + } const controller = new AbortController(); const signals: (AbortSignal | undefined)[] = []; const ghidra = provider(installationHost(), () => ({ @@ -872,12 +922,23 @@ describe("Ghidra measured load-image projection", () => { { signal: controller.signal }, ); expect(signals).toEqual([controller.signal]); - if (state === "verified") { + if (state === "verified" || state === "mapping-mismatch") { if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ - status: "verified", + status: state === "verified" ? "verified" : "mismatch", observations: fixture.observation, }); + if (state === "mapping-mismatch") { + expect(result.value.result).toMatchObject({ + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "mapping.overlay", + observed: true, + matched: false, + }), + ]), + }); + } } else { expect(result).toMatchObject({ ok: false, @@ -894,7 +955,9 @@ describe("Ghidra measured load-image projection", () => { } }, ); +}); +describe("Ghidra measured DOS load-image projection", () => { it.each([ "verified", "mismatch", diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index c48f58957..0a1cde0f0 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -66,6 +66,7 @@ const SUPPORTED_ARCHITECTURES = new Set([ "arm", "arm64", "mips", + "6502", "riscv", ]); @@ -184,7 +185,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { status: "unsupported", code: "architecture_unsupported", reason: - "Ghidra v1 requires a supported x86, x86_64, arm, arm64, or MIPS target profile.", + "Ghidra v1 requires a supported x86, x86_64, arm, arm64, MIPS, RISC-V, or raw 6502 target profile.", diagnostics, }; return { @@ -304,11 +305,15 @@ const inspectWindowsP0TargetSupport = ( target: BinaryTarget, diagnostics: Readonly>, ): ProviderTargetSupport => { + // The raw 6502 lane uses the same immutable snapshot and native ownership boundary. + if (target.format === "raw-binary" && target.architecture === "6502") + return { status: "supported", code: null, reason: null, diagnostics }; if (target.format !== "pe") return { status: "unsupported", code: "target_format_unsupported", - reason: "Windows Ghidra P0 accepts PE targets only.", + reason: + "Windows Ghidra P0 accepts native PE and explicitly selected raw 6502 targets only.", diagnostics, }; if (target.architecture !== "x86" && target.architecture !== "x86_64") diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index ea863e8a7..68c3045ce 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -33,7 +33,7 @@ export const healthLimitations = Object.freeze([ /** Additional limitations applied to the experimental Windows x64 P0 boundary. */ export const windowsP0Limitations = Object.freeze([ - "Windows Ghidra P0 accepts approved native x86 and x86-64 PE applications and DLLs; managed, hostile, sensitive, and mutable-path targets are unsupported.", + "Windows Ghidra P0 accepts native x86 and x86-64 PE applications and DLLs, and explicitly selected raw 6502 images; managed, hostile, sensitive, and mutable-path targets are unsupported.", "The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.", "Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.", ]); diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 5911985df..3a15dbc4e 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -516,7 +516,7 @@ export const createGhidraProviderClient = (input: { ...(target.format === "raw-binary" ? [ "Headerless raw-binary analysis uses the caller-supplied architecture and entry address; file offset 0 is mapped at that address. Headers are not discovered from file bytes.", - "Little-endian generic Ghidra languages are used for x86, x86_64, arm, and arm64. Big-endian, Thumb-only, Cortex-M, and other processor variants are unsupported in this increment.", + "Little-endian generic Ghidra languages are used for x86, x86_64, arm, arm64, and NMOS 6502. Big-endian, Thumb-only, Cortex-M, 65C02, 65816, and other processor variants are unsupported in this increment.", "Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.", ] : []), diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts index ea024bdba..e9046975d 100644 --- a/src/ghidra/GhidraRawBinaryLanguage.ts +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -1,28 +1,51 @@ -import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js"; +import type { RawBinaryAdmission } from "../domain/rawBinary.js"; /** Bound Ghidra language for a caller-supplied raw-binary architecture. */ export interface GhidraRawBinaryLanguage { readonly languageId: string; readonly compilerSpecId: "default" | "gcc"; + readonly defaultAddressSpace: "ram" | "RAM"; } /** - * Map the four caller-visible CPU families to little-endian generic Ghidra + * Map caller-visible CPU families to little-endian generic Ghidra * languages with a supported generic compiler specification. These are * analysis conventions, not evidence of the target's original compiler. * Big-endian, Thumb-only, and Cortex-M variants are out of scope. */ export const ghidraRawBinaryLanguage = ( - architecture: HeaderArchitecture, + architecture: RawBinaryAdmission["architecture"], ): GhidraRawBinaryLanguage => { switch (architecture) { + case "6502": + return { + languageId: "6502:LE:16:default", + compilerSpecId: "default", + defaultAddressSpace: "RAM", + }; case "x86": - return { languageId: "x86:LE:32:default", compilerSpecId: "gcc" }; + return { + languageId: "x86:LE:32:default", + compilerSpecId: "gcc", + defaultAddressSpace: "ram", + }; case "x86_64": - return { languageId: "x86:LE:64:default", compilerSpecId: "gcc" }; + return { + languageId: "x86:LE:64:default", + compilerSpecId: "gcc", + defaultAddressSpace: "ram", + }; case "arm": - return { languageId: "ARM:LE:32:v7", compilerSpecId: "default" }; + return { + languageId: "ARM:LE:32:v7", + compilerSpecId: "default", + defaultAddressSpace: "ram", + }; case "arm64": - return { languageId: "AARCH64:LE:64:v8A", compilerSpecId: "default" }; + return { + languageId: "AARCH64:LE:64:v8A", + compilerSpecId: "default", + defaultAddressSpace: "ram", + }; } }; diff --git a/src/ghidra/GhidraRawBinaryLoadImage.test.ts b/src/ghidra/GhidraRawBinaryLoadImage.test.ts index 2fc11502e..d09b3a504 100644 --- a/src/ghidra/GhidraRawBinaryLoadImage.test.ts +++ b/src/ghidra/GhidraRawBinaryLoadImage.test.ts @@ -9,23 +9,161 @@ describe("raw-binary Ghidra language binding", () => { expect(ghidraRawBinaryLanguage("x86")).toEqual({ languageId: "x86:LE:32:default", compilerSpecId: "gcc", + defaultAddressSpace: "ram", }); expect(ghidraRawBinaryLanguage("x86_64")).toEqual({ languageId: "x86:LE:64:default", compilerSpecId: "gcc", + defaultAddressSpace: "ram", }); expect(ghidraRawBinaryLanguage("arm")).toEqual({ languageId: "ARM:LE:32:v7", compilerSpecId: "default", + defaultAddressSpace: "ram", }); expect(ghidraRawBinaryLanguage("arm64")).toEqual({ languageId: "AARCH64:LE:64:v8A", compilerSpecId: "default", + defaultAddressSpace: "ram", + }); + }); +}); + +describe("raw-binary mapped-block state", () => { + it.each(["RAM", "ram"])( + "requires the bound 6502 address space for %s observations", + (space) => { + const f = fixtureRawBinaryLoadImage(); + const target = { + ...f.target, + architecture: "6502" as const, + availableArchitectures: ["6502" as const], + entryPoint: "0x8001", + entryPointSource: "0x8001", + }; + f.observation.language_id = "6502:LE:16:default"; + f.observation.default_address_space = space; + f.observation.entry_points = [target.entryPoint]; + const mapping = f.observation.mappings[0]; + if (mapping === undefined) + throw new Error("Missing source mapping fixture"); + mapping.start = target.entryPoint; + mapping.end = "0x8004"; + mapping.address_space = space; + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: space === "RAM" ? "verified" : "mismatch", + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "default_address_space", + expected: "RAM", + observed: space, + matched: space === "RAM", + }), + ]), + }, + }); + }, + ); + it.each([ + ["initialized", false], + ["loaded", false], + ["overlay", true], + ["address_space", "overlay"], + ] as const)( + "retains a mismatch when the file mapping declares %s=%s", + (property, value) => { + const f = fixtureRawBinaryLoadImage(); + const mapping = f.observation.mappings[0]; + if (mapping === undefined) + throw new Error("Missing source mapping fixture"); + Object.assign(mapping, { [property]: value }); + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: "mismatch", + observations: f.observation, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: `mapping.${property}`, + matched: false, + observed: value, + }), + ]), + }, + }); + }, + ); + it("does not verify a foreign default address space even when its mapping agrees", () => { + const f = fixtureRawBinaryLoadImage(); + f.observation.default_address_space = "foreign"; + const mapping = f.observation.mappings[0]; + if (mapping === undefined) + throw new Error("Missing source mapping fixture"); + mapping.address_space = "foreign"; + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: "mismatch", + observations: f.observation, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "default_address_space", + matched: false, + observed: "foreign", + }), + ]), + }, }); }); }); describe("raw-binary independent load-image checks", () => { + it("retains uninitialized language memory without treating it as file-backed bytes", () => { + const f = fixtureRawBinaryLoadImage(); + const zeroPage = { + block: "ZERO_PAGE", + start: "0x0", + end: "0xff", + address_space: "ram", + initialized: false, + loaded: true, + overlay: false, + length: 256, + source_file_index: null, + file_offset: null, + sha256: null, + }; + f.observation.mappings.unshift(zeroPage); + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { status: "verified", observations: f.observation }, + }); + zeroPage.initialized = true; + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: "mismatch", + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "initialized_unbacked_mappings", + matched: false, + }), + ]), + }, + }); + }); it("verifies mapped bytes at the caller-supplied entry", () => { const f = fixtureRawBinaryLoadImage(); const result = attestGhidraRawBinaryLoadImage( diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts index 881ba0078..718fe8d46 100644 --- a/src/ghidra/GhidraRiscvProfile.test.ts +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, parse, relative } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import type { @@ -92,7 +92,14 @@ const target = (change: Partial = {}): BinaryTarget => ({ let installDir = ""; beforeAll(async () => { - installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-riscv-languages-")); + const root = + process.platform === "win32" ? join(process.cwd(), "build") : tmpdir(); + await mkdir(root, { recursive: true }); + installDir = await mkdtemp(join(root, "rea-ghidra-riscv-languages-")); + // The simulated Linux host needs a POSIX absolute path. A current-drive root + // path also reaches this real fixture on Windows without changing admission. + if (process.platform === "win32") + installDir = `/${relative(parse(installDir).root, installDir).replaceAll("\\", "/")}`; const languages = join(installDir, "Ghidra/Processors/RISCV/data/languages"); await mkdir(languages, { recursive: true }); await writeFile(join(languages, "riscv.ldefs"), RISCV_LDEFS); diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 299862a97..a4848533f 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -123,6 +123,8 @@ export const hopperLoaderArgsForTarget = ( if (target.kind === "database") return ok([]); if (target.kind !== "executable") return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); + if (target.format === "raw-binary") + return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); const architecture = target.architecture; if (isElfMetadataArchitecture(architecture)) return err( @@ -155,7 +157,6 @@ export const hopperLoaderArgsForTarget = ( return ok(["-l", "WinPE", flag]); case "dos-mz": case "dos-com": - case "raw-binary": return err( new ProviderAdapterError("hopper", "resolve_analysis_profile"), ); diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 20a53105c..ed81ea749 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -2,11 +2,7 @@ import { createHash } from "node:crypto"; import { open, type FileHandle } from "node:fs/promises"; import { z } from "zod"; -import { - isMachOFormat, - isElfMetadataArchitecture, - type BinaryTarget, -} from "../domain/binaryTargetTypes.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { @@ -48,10 +44,7 @@ export const resolveHopperMachOImage = async ( ), ); if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - if ( - !isMachOFormat(target.format) || - isElfMetadataArchitecture(target.architecture) - ) + if (target.format !== "mach-o" && target.format !== "mach-o-universal") return invalid("Hopper FAT64 image preparation requires a Mach-O target"); let file: FileHandle; try { diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts index 66fa21ba3..21ea99a9c 100644 --- a/tests/boundary/cli/dylibResolutionCli.test.ts +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -180,14 +180,17 @@ describe("trace-dylib-resolution CLI", () => { // A root below a symlinked directory that points outside the bundle. const outside = await createTestTempDirectory("rea-dylib-outside-"); await writeFiles(outside, { Tool: machoImage({}) }); - await symlink(outside, join(app, "Contents/Linked")); + await symlink(outside, join(app, "Contents/Linked"), "dir"); for (const [root, reason, message] of [ ["../outside", "invalid_format", "Use a normalized path"], ["Contents/Info.plist", "invalid_value", "is not a Mach-O image"], [ "Contents/Linked/Tool", "invalid_value", - "resolves outside the analyzed root", + // Windows link text is outside the POSIX dyld path representation. + process.platform === "win32" + ? "is not a regular file in the analyzed root" + : "resolves outside the analyzed root", ], ] as const) { await expectRootInputError(cli, { app, root, reason, message }); @@ -378,7 +381,8 @@ cliTest( }, ); -describe.skipIf(process.getuid?.() === 0)( +// This permission fixture requires POSIX mode bits and a non-root caller. +describe.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "trace-dylib-resolution permissions", () => { cliTest( @@ -510,6 +514,8 @@ cliTest( app, "--root", "Contents/MacOS/App", + "--architecture", + "arm64e", "--json", ], environment: ENVIRONMENT, @@ -518,9 +524,7 @@ cliTest( const trace = dylibResolutionResultSchema.parse( parseEvidence(result.json).normalized_result, ); - expect( - trace.roots.filter(({ architecture }) => architecture === "arm64e"), - ).toHaveLength(2); + expect(trace.roots).toHaveLength(2); const environments = trace.findings .filter(({ kind }) => kind === "dyld-environment-present") .map(({ explanation }) => diff --git a/tests/boundary/cli/rawBinaryCli.test.ts b/tests/boundary/cli/rawBinaryCli.test.ts index 1cff4cac6..f12312d02 100644 --- a/tests/boundary/cli/rawBinaryCli.test.ts +++ b/tests/boundary/cli/rawBinaryCli.test.ts @@ -38,6 +38,36 @@ cliTest( }, ); +cliTest( + "rejects a 6502 image crossing the 16-bit boundary before provider startup", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-6502-cli-overflow-"); + const path = join(root, "image.bin"); + await writeFile(path, Buffer.from("a92a60", "hex")); + const result = await cli.run({ + arguments: [ + "inspect", + path, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0xffff", + "--provider", + "ghidra", + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { reason: expect.stringContaining("6502 address width") }, + }); + }, +); + cliTest( "rejects an unaligned AArch64 entry before provider startup", async ({ cli }) => { diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 6687b0ef7..7adf193f4 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -1,5 +1,5 @@ import { parseConfig } from "../../../src/config/parseConfig.js"; -import { writeFile } from "node:fs/promises"; +import { symlink, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -14,7 +14,7 @@ import type { import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; -import { ok } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; import { readAnalysisSnapshot, writeAnalysisSnapshot, @@ -40,6 +40,7 @@ import { } from "../../fixtures/binarySession.js"; import { BinarySession } from "../../../src/application/binary/BinarySession.js"; import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js"; +import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { CAPABILITIES as GHIDRA_CAPABILITIES, @@ -396,7 +397,85 @@ describe("snapshot output cleanup lifecycle", () => { }); }); +const verifyWindowsSnapshotAdmission = async ( + selectedPath: "direct" | "junction", +): Promise => { + const fixture = await createSnapshotReplayFixture( + "rea-direct-snapshot-junction-", + ); + const rawPath = join(dirname(fixture.path), "image.bin"); + await writeFile(rawPath, Buffer.from("a92a60", "hex")); + const linked = join(dirname(fixture.path), "linked"); + const linkedPath = join(linked, basename(rawPath)); + const identity = { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }; + const profile = createAnalysisProfile(identity, { + fixture: true, + }); + const baseProvider = fixture.createProvider({ + identity, + profile, + supportedOperations: ["search_strings"], + }); + let rejectAdmission = false; + const provider: AnalysisProvider = { + ...baseProvider, + createClient: (target, profile, context) => { + const client = baseProvider.createClient(target, profile, context); + return { + ...client, + execute: (operation, parameters, options) => + rejectAdmission && operation === "search_strings" + ? Promise.resolve( + err(new ProviderAdapterError("ghidra", operation)), + ) + : client.execute(operation, parameters, options), + }; + }, + }; + const dependencies = fixture.dependencies(provider); + const arguments_ = { pattern: "fixture", document: "fixture" }; + const options = { + snapshotPath: fixture.snapshotPath, + formatHint: "raw-binary" as const, + architecture: "6502" as const, + entryPoint: "0x8001", + }; + const first = await runDirectAnalysis( + dependencies, + rawPath, + "search_strings", + arguments_, + options, + ); + expect(first).toMatchObject({ operation: "search_strings" }); + expect(fixture.starts).toHaveLength(1); + if (selectedPath === "junction") + await symlink(dirname(fixture.path), linked, "junction"); + rejectAdmission = true; + const second = await runDirectAnalysis( + dependencies, + selectedPath === "junction" ? linkedPath : rawPath, + "search_strings", + arguments_, + options, + ); + expect(second).toMatchObject({ error: "Analysis failed" }); + expect(fixture.starts).toHaveLength(2); + expect( + fixture.calls.filter((call) => call === "search_strings"), + ).toHaveLength(1); +}; + describe("direct analysis composed snapshot replay", () => { + it.skipIf(process.platform !== "win32")( + "does not replay a CLI snapshot through a Windows junction", + () => verifyWindowsSnapshotAdmission("junction"), + ); + it.skipIf(process.platform !== "win32")( + "does not replay a CLI snapshot before native admission on a canonical path", + () => verifyWindowsSnapshotAdmission("direct"), + ); + it.each([ { tool: "search_strings", @@ -516,7 +595,7 @@ describe("Ghidra composed workflow snapshot replay", () => { { tool: "inspect_native_api", arguments: { procedure: "0x1000" } }, { tool: "trace_feature", arguments: { query: "fixture" } }, ] as const)( - "persists and replays $tool with production Ghidra capability policies", + "persists $tool with production Ghidra capability policies", async ({ tool, arguments: parameters }) => { const fixture = await createSnapshotReplayFixture("rea-ghidra-workflow-"); const profile = createAnalysisProfile( @@ -554,8 +633,14 @@ describe("Ghidra composed workflow snapshot replay", () => { { snapshotPath: fixture.snapshotPath }, ); expect(replay).toEqual(first); - expect(fixture.starts).toHaveLength(1); - expect(fixture.calls).toEqual(initialCalls); + if (process.platform === "win32") { + // The first provider call in a new CLI session admits its source. + expect(fixture.starts).toHaveLength(2); + expect(fixture.calls.length).toBeGreaterThan(initialCalls.length); + } else { + expect(fixture.starts).toHaveLength(1); + expect(fixture.calls).toEqual(initialCalls); + } }, ); }); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts index 6b7253b6d..416337097 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -388,7 +388,7 @@ it("produces deterministic ASAR graphs with unpacked native linkage and complete await mkdir(source); await writeJavaScriptArtifactFixture(source); const archive = join(root, "app.asar"); - await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); const first = await reconstructJavaScriptArtifact({ input_path: archive, @@ -487,7 +487,7 @@ it("keeps ASAR analysis usable when unpacked native companion bytes are absent", await mkdir(source); await writeJavaScriptArtifactFixture(source); const archive = join(root, "app.asar"); - await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); await rm(join(`${archive}.unpacked`, "native", "addon.node")); const snapshot = await scanArtifactInventory(archive); @@ -515,7 +515,7 @@ it("recurses into filesystem-backed ASAR containers without losing container-rel const resources = join(outer, "resources"); await mkdir(resources, { recursive: true }); await createPackageWithOptions(source, join(resources, "app.asar"), { - unpack: "**/*.node", + unpack: "*.node", }); const result = await reconstructJavaScriptArtifact({ diff --git a/tests/boundary/filesystem/rawBinaryTarget.test.ts b/tests/boundary/filesystem/rawBinaryTarget.test.ts index 5fd171d20..2757f0507 100644 --- a/tests/boundary/filesystem/rawBinaryTarget.test.ts +++ b/tests/boundary/filesystem/rawBinaryTarget.test.ts @@ -1,5 +1,11 @@ import { createHash } from "node:crypto"; -import { readFile, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + realpath, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -7,6 +13,37 @@ import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("raw-binary admission", () => { + it("preserves the requested raw path for Windows reparse admission", async () => { + const directory = await createTestTempDirectory("rea-raw-linked-"); + const actual = join(directory, "actual"); + const linked = join(directory, "linked"); + await mkdir(actual); + const bytes = Buffer.from("a90160", "hex"); + await writeFile(join(actual, "fixture.bin"), bytes); + await symlink( + actual, + linked, + process.platform === "win32" ? "junction" : "dir", + ); + const requested = join(linked, "fixture.bin"); + const canonical = await realpath(requested); + const target = await parseBinaryTarget("linked/fixture.bin", { + cwd: directory, + formatHint: "raw-binary", + architecture: "6502", + entryPoint: "0x8000", + }); + expect(target).toMatchObject({ + ok: true, + value: { + path: canonical, + sourcePath: process.platform === "win32" ? requested : canonical, + sha256: createHash("sha256").update(bytes).digest("hex"), + format: "raw-binary", + architecture: "6502", + }, + }); + }); it("requires explicit architecture and entry even with a misleading suffix", async () => { const directory = await createTestTempDirectory("rea-raw-target-"); const path = join(directory, "fixture.js"); diff --git a/tests/boundary/mcp/rawBinaryMcp.test.ts b/tests/boundary/mcp/rawBinaryMcp.test.ts index c1d2029c2..26a0e6501 100644 --- a/tests/boundary/mcp/rawBinaryMcp.test.ts +++ b/tests/boundary/mcp/rawBinaryMcp.test.ts @@ -1,4 +1,4 @@ -import { writeFile } from "node:fs/promises"; +import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -10,7 +10,8 @@ import { createServer } from "../../../src/server/createServer.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; -import { ok } from "../../../src/domain/result.js"; +import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; +import { err, ok } from "../../../src/domain/result.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { createCacheProvider, @@ -18,6 +19,7 @@ import { } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { silentLogger } from "../../../src/logger.js"; +import { GHIDRA_PROVIDER_IDENTITY } from "../../../src/ghidra/GhidraProviderCapabilities.js"; import type { CallToolResult } from "@modelcontextprotocol/server"; const connect = async ( @@ -50,29 +52,97 @@ const structured = (result: CallToolResult): Record => { return z.record(z.string(), z.unknown()).parse(result.structuredContent); }; -it("opens a headerless image when architecture and entry are supplied", async () => { - const root = await createTestTempDirectory("rea-raw-mcp-"); - const path = join(root, "ecu.bin"); - await writeFile(path, Buffer.from("1eff2fe1", "hex")); - const { client } = await connect(); - const opened = await client.callTool({ - name: "open_binary", - arguments: { +const withGhidraFixtureIdentity = ( + provider: AnalysisProvider, +): AnalysisProvider => { + const identity = { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }; + const profile = createAnalysisProfile(identity, { fixture: true }); + return { + ...provider, + identity: () => identity, + capabilities: () => + provider.capabilities().map((capability) => ({ + ...capability, + provider: identity, + })), + resolveAnalysisProfile: () => Promise.resolve(ok({ profile })), + createClient: (target, _profile, context) => { + const client = provider.createClient(target, profile, context); + return { + ...client, + execute: async (operation, parameters, options) => { + const result = await client.execute(operation, parameters, options); + return result.ok + ? ok({ + ...result.value, + provider: identity, + analysisProfile: profile, + }) + : result; + }, + }; + }, + }; +}; + +it("keeps 6502 out of detected lifecycle output contracts", () => { + const schema = toolContract("open_binary").outputSchema; + const result = { + path: "/fixture/image.bin", + kind: "executable", + sha256: "a".repeat(64), + architecture: "6502", + }; + expect( + schema.safeParse({ result: { ...result, format: "pe" } }).success, + ).toBe(false); + expect( + schema.safeParse({ + result: { ...result, format: "raw-binary", entry_point: "0x8001" }, + }).success, + ).toBe(true); + const validateAdvertised = new Ajv2020({ strict: false }).compile( + z.toJSONSchema(schema), + ); + expect(validateAdvertised({ result: { ...result, format: "pe" } })).toBe( + false, + ); + expect( + validateAdvertised({ + result: { ...result, format: "raw-binary", entry_point: "0x8001" }, + }), + ).toBe(true); +}); + +it.each([ + ["arm", "0x08000000", "0x8000000", "1eff2fe1"], + ["6502", "0x008001", "0x8001", "a92a60"], +])( + "opens a headerless %s image when architecture and entry are supplied", + async (architecture, entryPoint, canonical, bytes) => { + const root = await createTestTempDirectory("rea-raw-mcp-"); + const path = join(root, "ecu.bin"); + await writeFile(path, Buffer.from(bytes, "hex")); + const { client } = await connect(); + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture, + entry_point: entryPoint, + }, + }); + expect(opened.isError).not.toBe(true); + expect(structured(opened).result).toMatchObject({ path, format: "raw-binary", - architecture: "arm", - entry_point: "0x08000000", - }, - }); - expect(opened.isError).not.toBe(true); - expect(structured(opened).result).toMatchObject({ - path, - format: "raw-binary", - kind: "executable", - architecture: "arm", - entry_point: "0x8000000", - }); -}); + kind: "executable", + architecture, + entry_point: canonical, + }); + }, +); it("rejects an unaligned AArch64 entry before selecting or starting a provider", async () => { const root = await createTestTempDirectory("rea-raw-mcp-alignment-"); @@ -217,3 +287,118 @@ it("reuses a canonical raw entry and replaces the session when the entry changes expect(created).toEqual(["0x8000000", "0x9000000"]); expect(closed).toEqual(["0x8000000"]); }); + +it.skipIf(process.platform !== "win32")( + "does not replay a saved read after switching to a Windows junction", + async () => { + const root = await createTestTempDirectory("rea-raw-mcp-linked-reopen-"); + const actual = join(root, "actual"); + const linked = join(root, "linked"); + await mkdir(actual); + const path = join(actual, "image.bin"); + await writeFile(path, Buffer.from("a92a60", "hex")); + await symlink(actual, linked, "junction"); + const created: string[] = []; + const executed: string[] = []; + const fixture = createCacheProvider([]); + const { client } = await connect( + withGhidraFixtureIdentity({ + ...fixture, + createClient: (target) => { + created.push(target.sourcePath ?? target.path); + return { + execute: (operation) => { + if (operation === "address_name") + executed.push(target.sourcePath ?? target.path); + if ( + operation === "address_name" && + target.sourcePath === join(linked, "image.bin") + ) + return Promise.resolve( + err(new ProviderAdapterError("fixture", operation)), + ); + return Promise.resolve( + observed(operation === "address_name" ? "entry" : null), + ); + }, + close: () => Promise.resolve(ok(null)), + }; + }, + }), + ); + const linkedPath = join(linked, "image.bin"); + for (const requested of [path, linkedPath]) { + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path: requested, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const address = await client.callTool({ + name: "address_name", + arguments: { address: "0x8001", document: "image.bin" }, + }); + expect(address.isError === true, JSON.stringify(address)).toBe( + requested === linkedPath, + ); + } + expect(created).toEqual([path, linkedPath]); + expect(executed).toEqual([path, linkedPath]); + }, +); + +it.skipIf(process.platform !== "win32")( + "reuses saved reads after a fixture admits a noncanonical Windows path", + async () => { + const root = await createTestTempDirectory("rea-raw-mcp-admitted-path-"); + const actual = join(root, "actual"); + const linked = join(root, "linked"); + await mkdir(actual); + const path = join(actual, "image.bin"); + await writeFile(path, Buffer.from("a92a60", "hex")); + await symlink(actual, linked, "junction"); + const calls: string[] = []; + const fixture = createCacheProvider([]); + // This fixture accepts the alias to isolate cache behavior after admission. + // The real Ghidra verifier checks that a junction itself is rejected. + const { client, session } = await connect( + withGhidraFixtureIdentity({ + ...fixture, + createClient: () => ({ + execute: (operation) => { + if (operation === "address_name") calls.push(operation); + return Promise.resolve( + observed(operation === "health" ? null : "entry"), + ); + }, + close: () => Promise.resolve(ok(null)), + }), + }), + ); + const linkedPath = join(linked, "image.bin"); + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path: linkedPath, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + expect(session.activeTarget()?.sourcePath).toBe(linkedPath); + expect(session.activeTarget()?.path).toBe(path); + for (const _ of [0, 1]) { + const address = await client.callTool({ + name: "address_name", + arguments: { address: "0x8001", document: "image.bin" }, + }); + expect(address.isError, JSON.stringify(address)).not.toBe(true); + } + expect(calls).toEqual(["address_name"]); + }, +); diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index 0bcf12bb2..53ffcd7d3 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -135,7 +135,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr const source = join(root, "bundle"); await mkdir(source); const archive = join(source, "app.asar"); - await createPackageWithOptions(contents, archive, { unpack: "**/*.node" }); + await createPackageWithOptions(contents, archive, { unpack: "*.node" }); await rm(join(`${archive}.unpacked`, "native", "addon.node")); const nestedOutput = join(root, "nested-output"); diff --git a/tests/conformance/ghidra/6502.cfg b/tests/conformance/ghidra/6502.cfg new file mode 100644 index 000000000..cb85a4e76 --- /dev/null +++ b/tests/conformance/ghidra/6502.cfg @@ -0,0 +1,9 @@ +FEATURES { + STARTADDRESS: default = $8001; +} +MEMORY { + IMAGE: start = %S, size = $10000 - %S, file = %O; +} +SEGMENTS { + CODE: load = IMAGE, type = ro; +} diff --git a/tests/conformance/ghidra/6502.s b/tests/conformance/ghidra/6502.s new file mode 100644 index 000000000..201f8bcd2 --- /dev/null +++ b/tests/conformance/ghidra/6502.s @@ -0,0 +1,19 @@ +; Source-owned NMOS 6502 fixture: direct call, conditional branch, and arithmetic. +.setcpu "6502" +.segment "CODE" +.export entry, add_three + +entry: + lda #7 + jsr add_three + cmp #10 + bne mismatch + lda #42 + rts +mismatch: + lda #0 + rts +add_three: + clc + adc #3 + rts diff --git a/tests/conformance/readmeConsistency.test.ts b/tests/conformance/readmeConsistency.test.ts index 79a1fc518..90f8e5886 100644 --- a/tests/conformance/readmeConsistency.test.ts +++ b/tests/conformance/readmeConsistency.test.ts @@ -35,6 +35,20 @@ const shellExamples = (content: string): string[] => ); describe("onboarding documentation product facts", () => { + it.each([ + ["docs/native-investigation.md", "raw-binary.md#nmos-6502"], + ["docs/ghidra-riscv.md", "raw-binary.md"], + ])( + "routes raw admission guidance in %s to the owning guide", + async (path, target) => { + const content = await readFile(resolve(path), "utf8"); + const links = [...content.matchAll(/\]\(([^)\s]+)\)/gu)].map( + (match) => match[1], + ); + expect(links, path).toContain(target); + }, + ); + it("keeps the installation MCP example aligned with the package registration command", async () => { const content = await readFile(resolve("docs/installation.md"), "utf8"); expect(jsonExamples(content)).toContainEqual( diff --git a/tests/fixtures/strippedAsarAddon.ts b/tests/fixtures/strippedAsarAddon.ts index 5ff76356c..3422ac2ca 100644 --- a/tests/fixtures/strippedAsarAddon.ts +++ b/tests/fixtures/strippedAsarAddon.ts @@ -29,7 +29,7 @@ export const createStrippedAsarAddon = async ( await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.contents); } - await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); await writeFile( join(`${archive}.unpacked`, "node_modules", "btime", "binding.node"), stripped, From cdc41fc86c440317160652c1000fe8b082ba76f1 Mon Sep 17 00:00:00 2001 From: TonyWu <75903587+TonyWu2333@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:13:18 +0800 Subject: [PATCH 33/92] fix(managed): classify ReadyToRun images without ILONLY as modern .NET (#2075) * fix(managed): classify ReadyToRun images without ILONLY as modern .NET crossgen2 clears COMIMAGE_FLAGS_ILONLY and sets IL_LIBRARY on ReadyToRun output. inspect_managed_artifact checked the clear ILONLY flag before the ReadyToRun signature, so real ReadyToRun assemblies were reported as runtime_family mixed-clr-native and implementation cpp-cli-mixed, and cil-and-ready-to-run was unreachable for crossgen2 output. Check the ReadyToRun signature first; a clear ILONLY flag without it still reports mixed CLR/native code. * test(managed): exhaust capacity through inspected assembly references --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/dotnet/ManagedArtifactInspector.test.ts | 57 +++++++++++++++++++++ src/dotnet/ManagedArtifactInspector.ts | 18 +++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/dotnet/ManagedArtifactInspector.test.ts b/src/dotnet/ManagedArtifactInspector.test.ts index 3b734c176..50de3a39b 100644 --- a/src/dotnet/ManagedArtifactInspector.test.ts +++ b/src/dotnet/ManagedArtifactInspector.test.ts @@ -547,6 +547,63 @@ describe("managed fixture data layout", () => { }); }); +describe("managed implementation classification", () => { + it("classifies crossgen2 ReadyToRun output without ILONLY as modern .NET", () => { + // crossgen2 writes IL_LIBRARY | STRONGNAMESIGNED and clears ILONLY. + const bytes = buildManagedPeFixture({ readyToRun: true, cliFlags: 0xc }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.classification).toMatchObject({ + runtime_family: "modern-dotnet", + implementation: "cil-and-ready-to-run", + }); + expect(result.classification.evidence).toContainEqual( + expect.objectContaining({ code: "ready-to-run-signature-observed" }), + ); + expect(result.classification.evidence).not.toContainEqual( + expect.objectContaining({ code: "cli-il-only-clear" }), + ); + }); + + it("preserves ReadyToRun runtime identity after metadata capacity is exhausted", () => { + const bytes = buildManagedPeFixture({ + readyToRun: true, + cliFlags: 0xc, + references: ["x".repeat(12 * 1024 * 1024)], + }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.classification).toMatchObject({ + status: "managed", + runtime_family: "modern-dotnet", + }); + expect(result.coverage.issues).toContainEqual( + expect.objectContaining({ code: "resource-limit" }), + ); + }); + + it("keeps a clear ILONLY flag without ReadyToRun as mixed CLR/native", () => { + const bytes = buildManagedPeFixture({ cliFlags: 0 }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.classification).toMatchObject({ + runtime_family: "mixed-clr-native", + implementation: "cpp-cli-mixed", + }); + expect(result.classification.evidence).toContainEqual( + expect.objectContaining({ code: "cli-il-only-clear" }), + ); + }); +}); + describe("managed artifact metadata identity", () => { it("accepts CLI metadata GUIDs without RFC UUID version or variant bits", () => { const bytes = buildManagedPeFixture({ diff --git a/src/dotnet/ManagedArtifactInspector.ts b/src/dotnet/ManagedArtifactInspector.ts index 7e7acb087..625969aba 100644 --- a/src/dotnet/ManagedArtifactInspector.ts +++ b/src/dotnet/ManagedArtifactInspector.ts @@ -158,6 +158,15 @@ interface RuntimeClassificationInput { readonly inventory: ManagedMetadataInventory; } +const runtimeFamilyFromCliHeader = ( + cli: ManagedCliHeader | null, +): ManagedArtifactInspection["classification"]["runtime_family"] => + cli?.readyToRunSignature === true + ? "modern-dotnet" + : cli !== null && (cli.flags & 1) === 0 + ? "mixed-clr-native" + : "unknown"; + const runtimeFamilyFor = ( cli: ManagedCliHeader, inventory: ManagedMetadataInventory, @@ -180,7 +189,9 @@ const runtimeFamilyFor = ( ); let runtimeFamily: ManagedArtifactInspection["classification"]["runtime_family"] = "unknown"; - if ((cli.flags & 1) === 0) { + // crossgen2 clears ILONLY on ReadyToRun output, so only a clear flag + // without the ReadyToRun signature indicates mixed CLR/native code. + if (runtimeFamilyFromCliHeader(cli) === "mixed-clr-native") { runtimeFamily = "mixed-clr-native"; evidence.push({ code: "cli-il-only-clear", @@ -239,8 +250,8 @@ const implementationFor = ( cli: ManagedCliHeader, metadata: ManagedMetadataLayout, ): ManagedArtifactInspection["classification"]["implementation"] => { - if ((cli.flags & 1) === 0) return "cpp-cli-mixed"; if (cli.readyToRunSignature) return "cil-and-ready-to-run"; + if ((cli.flags & 1) === 0) return "cpp-cli-mixed"; return (metadata.rowCounts[6] ?? 0) === 0 ? "metadata-only" : "cil"; }; @@ -303,8 +314,7 @@ const partialMetadataResult = ({ classification: { status: issue.code === "resource-limit" ? "managed" : "malformed", container: "pe", - runtime_family: - (layout.cli?.flags ?? 1) & 1 ? "unknown" : "mixed-clr-native", + runtime_family: runtimeFamilyFromCliHeader(layout.cli), implementation: "unknown", managed_architecture: "unknown", evidence: [ From 97dc6581f45adfe5fcaa9c98e77e7dc6bc423a6e Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:14:31 -0700 Subject: [PATCH 34/92] fix(dotnet): name the instruction when a CIL operand leaves the method body (#2697) The decoder already reports "Instruction X at IL offset N leaves method body" when an operand's computed end passes the IL body, but only after the operand was read. Operand readers use Buffer reads, so an operand cut off by the body end threw a RangeError first. The catch-all stored Node's message ("Attempt to access memory outside buffer bounds" or an offset RangeError) as body.issue, dropping the instruction and offset. A body ending in a lone 0xFE two-byte opcode prefix failed the same way. Treat a RangeError from the operand read as the operand leaving the body so the existing diagnostic applies, and check the second opcode byte before reading it. Malformed status, partial coverage and earlier decoded instructions are unchanged. Fixes #2677 Co-authored-by: Claude Opus 5.5 --- src/dotnet/ManagedCilDecoding.test.ts | 38 +++++++++++++++++++ src/dotnet/ManagedMemberInstructionDecoder.ts | 28 +++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/dotnet/ManagedCilDecoding.test.ts b/src/dotnet/ManagedCilDecoding.test.ts index c1ed21130..55a008b72 100644 --- a/src/dotnet/ManagedCilDecoding.test.ts +++ b/src/dotnet/ManagedCilDecoding.test.ts @@ -510,3 +510,41 @@ describe("managed exception section decoding", () => { }); }); }); + +describe("managed CIL truncated operands", () => { + const tinyBody = (il: readonly number[]) => { + const bytes = buildManagedPeFixture({ + ilBody: Buffer.from([(il.length << 2) | 0x02, ...il]), + }); + const inspection = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + return { body: inspection.methods[0]?.body, coverage: inspection.coverage }; + }; + + it.each([ + [[0x20], 0, "Instruction ldc.i4 at IL offset 0 leaves method body"], + [ + [0x00, 0x20, 0x01], + 1, + "Instruction ldc.i4 at IL offset 1 leaves method body", + ], + [[0x2b], 0, "Instruction br.s at IL offset 0 leaves method body"], + [[0x45, 0x02], 0, "Instruction switch at IL offset 0 leaves method body"], + [ + [0xfe, 0x09, 0x00], + 0, + "Instruction ldarg at IL offset 0 leaves method body", + ], + [[0x00, 0xfe], 1, "Two-byte CIL opcode at IL offset 1 leaves method body"], + ])("names the instruction and IL offset for IL %j", (il, decoded, issue) => { + const { body, coverage } = tinyBody(il); + expect(body).toMatchObject({ + status: "malformed", + decoded_instruction_count: decoded, + issue, + }); + expect(coverage.state).toBe("partial"); + }); +}); diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index 7ef518733..b05dd083f 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -384,6 +384,22 @@ const readOperand = ( return { next: offset, anchorKind: "unknown", value: null }; }; +/** An operand cut off by the end of the IL body has no decodable value. */ +const readOperandWithin = ( + il: Buffer, + offset: number, + kind: string, +): OperandResult | undefined => { + try { + return readOperand(il, offset, kind); + } catch (cause: unknown) { + // Buffer reads past the IL body throw RangeError; report it as the + // instruction leaving the method body rather than as a Buffer message. + if (cause instanceof RangeError) return undefined; + throw cause; + } +}; + export const decodeInstructions = ( il: Buffer, admitInstruction?: (offset: number) => void, @@ -404,14 +420,22 @@ export const decodeInstructions = ( const start = offset; const first = il.readUInt8(offset); offset += 1; + if (first === 0xfe && offset >= il.length) { + issue = `Two-byte CIL opcode at IL offset ${String(start)} leaves method body`; + break; + } const code = first === 0xfe ? 0xfe00 + il.readUInt8(offset++) : first; const descriptor = OPCODES.get(code); if (descriptor === undefined) { issue = `Unsupported CIL opcode 0x${code.toString(16)} at IL offset ${String(start)}`; break; } - const operand = readOperand(il, offset, descriptor.operand); - if (operand.next < offset || operand.next > il.length) { + const operand = readOperandWithin(il, offset, descriptor.operand); + if ( + operand === undefined || + operand.next < offset || + operand.next > il.length + ) { issue = `Instruction ${descriptor.name} at IL offset ${String(start)} leaves method body`; break; } From ece15ed6a18122f95e59d7d9f8bf9f0d3589f9f6 Mon Sep 17 00:00:00 2001 From: xin zhuang <65798732+1052326311@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:15:33 +0800 Subject: [PATCH 35/92] fix(electron): identify each unresolved scenario path (#2279) Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../cli/scenarioCliInputDiagnostics.test.ts | 41 +++++++++++++++++ tests/boundary/mcp/electronMcp.test.ts | 46 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/tests/boundary/cli/scenarioCliInputDiagnostics.test.ts b/tests/boundary/cli/scenarioCliInputDiagnostics.test.ts index bc98d9d9f..ae03568f4 100644 --- a/tests/boundary/cli/scenarioCliInputDiagnostics.test.ts +++ b/tests/boundary/cli/scenarioCliInputDiagnostics.test.ts @@ -1,3 +1,5 @@ +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { describe, expect } from "vitest"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -62,3 +64,42 @@ describe("scenario CLI schema diagnostics", () => { }); }); }); + +for (const field of [ + "executable_path", + "application_path", + "application_root", +] as const) { + cliTest(`names the unresolved Electron ${field}`, async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-path-"); + const missing = join(root, "missing"); + const result = await cli.run({ + arguments: [ + "capture-electron-scenario", + JSON.stringify({ + executable_path: process.execPath, + application_path: root, + application_root: root, + actions: [], + [field]: missing, + }), + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: [field], + reason: "invalid_value", + message: expect.stringContaining("ENOENT"), + }, + ], + }, + }); + expect(JSON.stringify(result.json)).toContain(missing); + }); +} diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index fef9839aa..72d9fdaf6 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -471,3 +471,49 @@ it("exposes the target-free static JavaScript application workflow", async () => const evidenceFor = (value: unknown) => { return parseEvidence(value); }; + +it("reports every unresolved Electron path without starting a provider", async () => { + const root = await createTestTempDirectory("rea-electron-invalid-paths-"); + const capture = vi.fn(); + const session = createTestBinarySession(() => { + throw new Error("must not start"); + }); + const server = createServer( + { kind: "session", session }, + { + electronActiveObservation: { + capture, + identity: () => ({ id: "test", name: "test", version: "1" }), + }, + }, + ); + const client = new Client({ name: "electron-path-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + const fields = ["executable_path", "application_path", "application_root"]; + const result = await client.callTool({ + name: "capture_electron_scenario", + arguments: { + ...Object.fromEntries(fields.map((field) => [field, join(root, field)])), + actions: [], + }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: fields.map((field) => ({ + path: [field], + reason: "invalid_value", + message: expect.stringContaining("ENOENT"), + })), + }, + }, + }); + expect(capture).not.toHaveBeenCalled(); + await client.ping(); +}); From e46bed73f190484984c42efcb6fae0ccc42da4ee Mon Sep 17 00:00:00 2001 From: Angelo Perez <102934797+Angelopgit@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:16:00 -0400 Subject: [PATCH 36/92] fix(managed): decode the CIL no. prefix (#2241) The CIL opcode table held five of the six ECMA-335 prefix opcodes and omitted no. (FE 19 , III.2.2). A method body using it was reported as malformed with "Unsupported CIL opcode 0xfe19", losing its instruction counts and normalized IL digest. Decode it with its one-byte check mask and treat it as a prefix, so it shares an instruction boundary with the opcode it modifies. Claude-Session: https://claude.ai/code/session_012pRGskbPGjT444CgJfuhhj Co-authored-by: Claude Fable 5.1 --- src/dotnet/ManagedCilDecoding.test.ts | 25 +++++++++++++++++++ src/dotnet/ManagedMemberInstructionDecoder.ts | 2 ++ 2 files changed, 27 insertions(+) diff --git a/src/dotnet/ManagedCilDecoding.test.ts b/src/dotnet/ManagedCilDecoding.test.ts index 55a008b72..4ba33f4c9 100644 --- a/src/dotnet/ManagedCilDecoding.test.ts +++ b/src/dotnet/ManagedCilDecoding.test.ts @@ -345,6 +345,31 @@ describe("managed exception region prefix boundaries", () => { }); }); + it("decodes the no. prefix and keeps it with its opcode", () => { + // ldarg.0; no. nullcheck; ldfld 0x04000001; pop; ret + const il = Buffer.from([2, 0xfe, 0x19, 4, 0x7b, 1, 0, 0, 4, 0x26, 0x2a]); + expect( + managedBodyWithRegion({ il, tryLength: 10, handlerOffset: 10 }), + ).toMatchObject({ + status: "present", + issue: null, + decoded_instruction_count: 5, + opcode_counts: { "ldarg.0": 1, "no.": 1, ldfld: 1, pop: 1, ret: 1 }, + }); + expect( + managedBodyWithRegion({ + il, + tryOffset: 4, + tryLength: 5, + handlerOffset: 10, + }), + ).toMatchObject({ + status: "malformed", + issue: + "Exception clause try range does not align with CIL instruction boundaries", + }); + }); + it.each([ ["start", { tryOffset: 5, tryLength: 1, handlerOffset: 6 }], [ diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index b05dd083f..01c2b7814 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -12,6 +12,7 @@ const PREFIX_OPCODES = new Set([ "tail.", "constrained.", "readonly.", + "no.", ]); const OPCODES = new Map< @@ -232,6 +233,7 @@ const OPCODES = new Map< [0xfe16, { name: "constrained.", operand: "type" }], [0xfe17, { name: "cpblk", operand: "none" }], [0xfe18, { name: "initblk", operand: "none" }], + [0xfe19, { name: "no.", operand: "short-i" }], [0xfe1a, { name: "rethrow", operand: "none" }], [0xfe1c, { name: "sizeof", operand: "type" }], [0xfe1d, { name: "refanytype", operand: "none" }], From 11268fe241a86fde0b462270dd8708934c74ab06 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:16:29 -0700 Subject: [PATCH 37/92] perf(artifacts): share sealed inventory and report in inspect_artifact (#2690) inspect_artifact parsed a fresh inventory and inspection report but left both mutable, so each later Evidence boundary cloned and re-digested the complete payload: createArtifactInspection re-authenticated the nested inventory Evidence, createAnalysisExecution re-parsed the report, and recordEvidence re-authenticated the outer Evidence. Seal both exclusively owned values with freezeJsonSnapshot so those boundaries take the existing immutable-snapshot path. Output is byte-identical. Co-authored-by: Claude Opus 5.5 --- src/artifacts/ArtifactProvider.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index c95cec6d5..47d5906ea 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -40,6 +40,7 @@ import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; import { artifactCapabilities } from "./ArtifactProviderMetadata.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js"; import { createEvidence } from "../domain/evidence.js"; +import { freezeJsonSnapshot } from "../domain/immutableJson.js"; import { createArtifactInspection } from "../domain/artifactInspection.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -341,7 +342,11 @@ class ArtifactClient implements AnalysisClient { total: 1, message: "inventory substep started", }); - const inventory = await this.inventory(inventoryParameters, options); + // The freshly parsed inventory is exclusively owned here. Sealing it lets + // the nested Evidence share it instead of cloning and re-digesting it. + const inventory = freezeJsonSnapshot( + await this.inventory(inventoryParameters, options), + ); const subject = subjectFor( this.target.sourcePath ?? this.target.path, inventory.manifest, @@ -355,7 +360,10 @@ class ArtifactClient implements AnalysisClient { // The nested inventory already names every occurrence's logical path. locations: [], }); - const result = createArtifactInspection(inventoryEvidence); + // The returned execution and its Evidence then reuse this owned report. + const result = freezeJsonSnapshot( + createArtifactInspection(inventoryEvidence), + ); await options?.progress?.report({ phase: "inspect_artifact.inventory", completed: 1, From 59d33a2733ce50ed0ac3d0371b4b646237d33619 Mon Sep 17 00:00:00 2001 From: FengCi <119940611+FengCi0@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:16:49 +0800 Subject: [PATCH 38/92] fix(native): keep lipo path echoes out of FAT records (#2605) * fix(native): keep lipo path echoes out of FAT records Parse FAT records only after separating the exact echoed operand path. Pass the same lipo operand at all three consumers and cover multiline paths through parser, slice-reader and compiled CLI regressions. * fix(native): use parsed lipo framing for container offsets * test(native): use explicit FAT fixtures for CLI regressions --- src/artifacts/MachOSliceArtifactReader.ts | 4 +- src/native/NativeMacOSProvider.ts | 7 +- src/native/NativeMachoInspection.ts | 14 +-- src/native/parsers/lipo.test.ts | 115 ++++++++++++++++-- src/native/parsers/lipo.ts | 55 ++++++--- .../cli/nativeMachoEchoedPathCli.test.ts | 91 ++++++++++---- .../providers/native/machoSliceReader.test.ts | 54 +++++++- .../providers/native/nativeProvider.test.ts | 23 ++-- .../native/nativeSelectedArchitecture.test.ts | 72 ++++++----- .../native/nativeTextRobustness.test.ts | 18 ++- 10 files changed, 347 insertions(+), 106 deletions(-) diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts index f567458c7..b57b0247e 100644 --- a/src/artifacts/MachOSliceArtifactReader.ts +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -8,7 +8,7 @@ import { type NativeCommandRunner, } from "../native/CommandRunner.js"; import { - parseLipoArchitectures, + parseLipoOutput, type LipoArchitecture, } from "../native/parsers/lipo.js"; import type { MachoSlice } from "../domain/apple/dylibResolution.js"; @@ -114,7 +114,7 @@ export class MachOSliceArtifactReader implements ArtifactReader { }; await this.#verifySource(source); const fileSize = source.initial.size; - const architectures = parseLipoArchitectures(captured.value.stdout); + const { architectures } = parseLipoOutput(captured.value.stdout, this.path); const structural = await readStructuralSlices(source.handle, fileSize); await this.#verifySource(source); if (structural.status === "malformed") diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index a59ec90a7..691e830d9 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -73,7 +73,7 @@ import { } from "./SignatureTargetBinding.js"; import { parseCodeSignature } from "./parsers/codesign.js"; import { parseDemangledSymbols } from "./parsers/demangle.js"; -import { parseLipoArchitectures } from "./parsers/lipo.js"; +import { parseLipoOutput } from "./parsers/lipo.js"; import { parsePlistJson, parsePlistXml, @@ -507,7 +507,10 @@ class NativeMacOSClient implements AnalysisClient { ); if (!capture.ok) return capture; const provenance = [invocation(capture.value, this.target.path)]; - const architectures = parseLipoArchitectures(capture.value.stdout); + const { architectures } = parseLipoOutput( + capture.value.stdout, + this.target.path, + ); const result = listArchitecturesSchema.parse({ architectures: { items: architectures, diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 5fca089cb..bb1966c3d 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -14,10 +14,7 @@ import { selectMachoArchitectureName, } from "./MachoArchitectureSelection.js"; import { parseDyldSymbols } from "./parsers/dyldInfo.js"; -import { - parseLipoArchitectures, - type LipoArchitecture, -} from "./parsers/lipo.js"; +import { parseLipoOutput, type LipoArchitecture } from "./parsers/lipo.js"; import { withoutEchoedPathHeader } from "./parsers/echoedPath.js"; import { parseOtoolLoadCommands } from "./parsers/otool.js"; import { reconcileMachoUuid } from "./parsers/machoUuid.js"; @@ -74,7 +71,10 @@ export const inspectNativeMacho = async ( } const lipo = captures.find(({ tool }) => tool === "lipo"); if (lipo === undefined) throw new TypeError("Missing lipo capture"); - const architectures = parseLipoArchitectures(lipo.stdout); + const { format, architectures } = parseLipoOutput( + lipo.stdout, + context.target.path, + ); const selection = selectArchitecture(context.target, architectures); if (selection.status === "absent") return err( @@ -145,7 +145,7 @@ export const inspectNativeMacho = async ( const containerOffset = selectedArchitectureName === null ? undefined - : /^Non-fat file:/mu.test(lipo.stdout) + : format === "thin" ? 0 : (architectures.find(({ name }) => name === selectedArchitectureName) ?.file_offset ?? undefined); @@ -185,7 +185,7 @@ const normalizeMacho = ( toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation, additionalLimitations: readonly string[], load: ReturnType, - architectures: ReturnType, + architectures: readonly LipoArchitecture[], artifactPath: string, selectedArchitecture: { readonly name: string | null; diff --git a/src/native/parsers/lipo.test.ts b/src/native/parsers/lipo.test.ts index 96f2afbbc..b5c97a415 100644 --- a/src/native/parsers/lipo.test.ts +++ b/src/native/parsers/lipo.test.ts @@ -1,10 +1,28 @@ import { describe, expect, it } from "vitest"; -import { parseLipoArchitectures } from "./lipo.js"; +import { parseLipoOutput } from "./lipo.js"; + +const fatRecords = `fat_magic 0xcafebabe +nfat_arch 2 +architecture arm64 + cputype CPU_TYPE_ARM64 + cpusubtype CPU_SUBTYPE_ARM64_ALL + capabilities 0x0 + offset 4096 + size 32 + align 2^12 (4096) +architecture x86_64 + cputype CPU_TYPE_X86_64 + cpusubtype CPU_SUBTYPE_X86_64_ALL + capabilities 0x0 + offset 8192 + size 32 + align 2^12 (4096) +`; describe("lipo architecture facts", () => { it("normalizes symbolic CPU fields and arm64e capability bits", () => { - const architectures = parseLipoArchitectures(`Fat header in: /bin/ls + const { format, architectures } = parseLipoOutput(`Fat header in: /bin/ls fat_magic 0xcafebabe nfat_arch 2 architecture x86_64 @@ -22,6 +40,7 @@ architecture arm64e size 89088 align 2^14 (16384) `); + expect(format).toBe("fat"); expect(architectures).toEqual([ expect.objectContaining({ name: "x86_64", @@ -50,9 +69,9 @@ architecture arm64e it("keeps unknown symbolic CPU values as source text without a code", () => { expect( - parseLipoArchitectures( + parseLipoOutput( "architecture future\n cputype CPU_TYPE_FUTURE\n cpusubtype CPU_SUBTYPE_FUTURE\n offset 4096\n size 64\n align 2^12\n", - ), + ).architectures, ).toMatchObject([ { cpu_type: "CPU_TYPE_FUTURE", @@ -70,18 +89,18 @@ architecture arm64e ["0x0", 3], ])("applies printed x86_64 capabilities %s", (capabilities, code) => { expect( - parseLipoArchitectures( + parseLipoOutput( `architecture x86_64\n cpusubtype CPU_SUBTYPE_X86_64_ALL\n capabilities ${capabilities}\n`, - )[0]?.cpu_subtype_code, + ).architectures[0]?.cpu_subtype_code, ).toBe(code); }); it("leaves the subtype code unknown when lipo prints no capabilities", () => { // macOS 26 lipo prints this blank line for LIB64 and plain x86_64 slices. expect( - parseLipoArchitectures( + parseLipoOutput( "architecture x86_64\n cputype CPU_TYPE_X86_64\n cpusubtype CPU_SUBTYPE_X86_ALL\n capabilities \n offset 16384\n", - ), + ).architectures, ).toMatchObject([ { cpu_subtype: "CPU_SUBTYPE_X86_ALL", @@ -99,9 +118,9 @@ architecture arm64e "retains an arm64e base subtype with capabilities '$capabilities'", ({ capabilities, code }) => { expect( - parseLipoArchitectures( + parseLipoOutput( `architecture arm64e.v1\n cputype CPU_TYPE_ARM64\n cpusubtype CPU_SUBTYPE_ARM64E\n capabilities ${capabilities}\n`, - ), + ).architectures, ).toMatchObject([ { cpu_type_code: 0x0100000c, @@ -112,3 +131,79 @@ architecture arm64e }, ); }); + +describe("lipo FAT operand framing", () => { + it.each([ + "/fixture/\narchitecture forged\n offset 123\n size 64", + "/fixture/\nfat_magic 0xcafebabe\nnfat_arch 1\narchitecture forged", + "/fixture/\nNon-fat file: fake is architecture: forged", + "/fixture/first\nsecond\nthird", + "/fixture/first\r\narchitecture forged\r\n offset 123", + "/fixture/[app](arm64).*+$^?\\name", + ])( + "keeps the complete echoed operand out of FAT slice records: %j", + (path) => { + const output = `Fat header in: ${path}\n${fatRecords}`; + + expect(parseLipoOutput(output, path)).toEqual( + parseLipoOutput(fatRecords), + ); + expect( + parseLipoOutput(output, path).architectures.map(({ name }) => name), + ).toEqual(["arm64", "x86_64"]); + }, + ); + + it("keeps a CRLF pathname separate from a CRLF record terminator", () => { + const path = "/fixture/first\r\narchitecture forged\r\n size 64"; + const output = `Fat header in: ${path}\r\n${fatRecords.replaceAll("\n", "\r\n")}`; + + expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(fatRecords)); + }); + + it("preserves ordinary FAT metadata with and without an operand", () => { + const path = "/fixture/app"; + const output = `Fat header in: ${path}\n${fatRecords}`; + + expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(output)); + }); + + it("does not require a FAT path header in captured fixtures", () => { + expect(parseLipoOutput(fatRecords, "/fixture/app")).toEqual( + parseLipoOutput(fatRecords), + ); + }); + + it("does not strip a header for a different operand that shares a prefix", () => { + const path = "/fixture/app-extra\narchitecture forged"; + const output = `Fat header in: ${path}\n${fatRecords}`; + + expect(parseLipoOutput(output, "/fixture/app")).toEqual( + parseLipoOutput(output), + ); + }); + + it("preserves thin-file parsing when the operand contains FAT-like lines", () => { + const path = "/fixture/\nFat header in: fake\narchitecture forged"; + const output = `Non-fat file: ${path} is architecture: arm64\n`; + + expect(parseLipoOutput(output, path)).toEqual({ + format: "thin", + architectures: [ + { + name: "arm64", + cpu_type: null, + cpu_subtype: null, + cpu_type_code: null, + cpu_subtype_code: null, + cpu_subtype_base: null, + capabilities: null, + file_offset: null, + size: null, + alignment: null, + }, + ], + }); + expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(output)); + }); +}); diff --git a/src/native/parsers/lipo.ts b/src/native/parsers/lipo.ts index cc65cca41..7f74a51b9 100644 --- a/src/native/parsers/lipo.ts +++ b/src/native/parsers/lipo.ts @@ -16,8 +16,26 @@ const architectureSchema = z.object({ export type LipoArchitecture = z.infer; -/** Parse `lipo -detailed_info` into deterministic slice metadata. */ -export const parseLipoArchitectures = (output: string): LipoArchitecture[] => { +interface LipoOutput { + readonly format: "thin" | "fat"; + readonly architectures: LipoArchitecture[]; +} + +/** Parse `lipo -detailed_info` framing and deterministic slice metadata. */ +export const parseLipoOutput = ( + output: string, + operandPath?: string, +): LipoOutput => { + // The echoed FAT operand is one value, even when its pathname contains lines. + if (operandPath !== undefined) { + const header = `Fat header in: ${operandPath}`; + if (output.startsWith(header)) { + if (output.startsWith("\r\n", header.length)) + output = output.slice(header.length + 2); + else if (output.startsWith("\n", header.length)) + output = output.slice(header.length + 1); + } + } // lipo echoes a thin file's pathname verbatim, including embedded newlines. // Match its final architecture field before splitting records into lines. const thinArchitecture = @@ -25,20 +43,23 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => { output, )?.[1]; if (thinArchitecture !== undefined) - return [ - architectureSchema.parse({ - name: thinArchitecture, - cpu_type: null, - cpu_subtype: null, - cpu_type_code: null, - cpu_subtype_code: null, - cpu_subtype_base: null, - capabilities: null, - file_offset: null, - size: null, - alignment: null, - }), - ]; + return { + format: "thin", + architectures: [ + architectureSchema.parse({ + name: thinArchitecture, + cpu_type: null, + cpu_subtype: null, + cpu_type_code: null, + cpu_subtype_code: null, + cpu_subtype_base: null, + capabilities: null, + file_offset: null, + size: null, + alignment: null, + }), + ], + }; const architectures: LipoArchitecture[] = []; let current: Record | undefined; const flush = (): void => { @@ -90,7 +111,7 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => { flush(); if (architectures.length === 0) throw new TypeError("lipo output contained no architectures"); - return architectures; + return { format: "fat", architectures }; }; const integer = (value: string | undefined): number | null => { diff --git a/tests/boundary/cli/nativeMachoEchoedPathCli.test.ts b/tests/boundary/cli/nativeMachoEchoedPathCli.test.ts index 0c362d263..a3118c1f0 100644 --- a/tests/boundary/cli/nativeMachoEchoedPathCli.test.ts +++ b/tests/boundary/cli/nativeMachoEchoedPathCli.test.ts @@ -1,9 +1,13 @@ import { copyFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { describe, expect } from "vitest"; import { z } from "zod"; +import { + inspectMachoSchema, + listArchitecturesSchema, +} from "../../../src/domain/native/nativeInspection.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -34,27 +38,68 @@ const resultSchema = z.object({ describe.skipIf(process.platform !== "darwin")( "native Mach-O CLI with echoed path text", () => { - cliTest( - "does not read load commands or symbols from the file name", - async ({ cli }) => { - const directory = await createTestTempDirectory("rea-macho-echo-"); - const inspect = async (name: string) => { - const path = join(directory, name); - await copyFile("/usr/bin/true", path); - const result = await cli.run({ - arguments: ["inspect-macho", path, "--json"], - environment, - }); - expect(result.exitCode).toBe(0); - const { provenance: _provenance, ...facts } = resultSchema.parse( - result.json, - ).normalized_result; - return facts; - }; - const forged = await inspect(FORGED_NAME); - expect(forged).toEqual(await inspect("tool")); - expect(JSON.stringify(forged)).not.toMatch(/forged|4242/u); - }, - ); + const lipoName = "tool\narchitecture forged\n offset 123\n size 32"; + for (const [facts, command, filename] of [ + ["load commands or symbols", "inspect-macho", FORGED_NAME], + ["FAT architecture records", "inspect-macho", lipoName], + ["FAT architecture inventory", "list-architectures", lipoName], + ["thin-file framing", "inspect-macho", "tool\nNon-fat file: forged"], + ] as const) { + cliTest( + `does not read ${facts} from the file name`, + async ({ cli, processes }) => { + const directory = await createTestTempDirectory( + "rea-native-cli-echo-", + ); + const fixture = join(directory, "fat-fixture"); + const compiled = await processes.run( + "/usr/bin/xcrun", + [ + "clang", + "-arch", + "arm64", + "-arch", + "x86_64", + resolve("tests/conformance/c/fixture.c"), + "-o", + fixture, + ], + { timeoutMs: 30_000 }, + ); + expect(compiled.exitCode, compiled.stderr).toBe(0); + const inspect = async (name: string) => { + const path = join(directory, name); + await copyFile(fixture, path); + const result = await cli.run({ + arguments: [command, path, "--json"], + environment, + }); + expect(result.exitCode).toBe(0); + const { provenance: _provenance, ...facts } = resultSchema.parse( + result.json, + ).normalized_result; + return facts; + }; + const ordinary = await inspect("tool"); + // Prove this comparison exercised FAT framing, independent of host binaries. + const architectures = + listArchitecturesSchema.shape.architectures.parse( + ordinary.architectures, + ); + expect(architectures.total).toBeGreaterThan(1); + if (command === "inspect-macho") { + const selected = + inspectMachoSchema.shape.selected_architecture.parse( + ordinary.selected_architecture, + ); + expect(selected.container_file_offset).toBeGreaterThan(0); + } + const forged = await inspect(filename); + expect(forged).toEqual(ordinary); + expect(JSON.stringify(forged)).not.toMatch(/forged|4242/u); + }, + 60_000, + ); + } }, ); diff --git a/tests/boundary/providers/native/machoSliceReader.test.ts b/tests/boundary/providers/native/machoSliceReader.test.ts index 5891eaf53..b2f1867a1 100644 --- a/tests/boundary/providers/native/machoSliceReader.test.ts +++ b/tests/boundary/providers/native/machoSliceReader.test.ts @@ -1,10 +1,12 @@ -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { describe, expect, it, onTestFinished } from "vitest"; import { MachOSliceArtifactReader } from "../../../../src/artifacts/MachOSliceArtifactReader.js"; import { XcrunCommandRunner } from "../../../../src/native/CommandRunner.js"; import { DarwinProcessOwnershipInspectionError } from "../../../../src/process/DarwinProcessRunTokenReader.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; /** * Read a 32-bit FAT table directly. The host's /bin/ls changes between macOS @@ -42,6 +44,56 @@ describe("Mach-O slice reader with system lipo", () => { }); }); + it.skipIf(process.platform !== "darwin")( + "keeps FAT slice records separate from the echoed source pathname", + async () => { + const directory = await createTestTempDirectory("rea-lipo-fat-path-"); + const path = join( + directory, + "source\narchitecture forged\n offset 123\n size 32", + ); + const bytes = Buffer.alloc(8224); + bytes.writeUInt32BE(0xca_fe_ba_be, 0); + bytes.writeUInt32BE(2, 4); + const slices = [ + { cpu: 0x0100_000c, subtype: 0, offset: 4096 }, + { cpu: 0x0100_0007, subtype: 3, offset: 8192 }, + ]; + slices.forEach(({ cpu, subtype, offset }, index) => { + const entry = 8 + index * 20; + bytes.writeUInt32BE(cpu, entry); + bytes.writeUInt32BE(subtype, entry + 4); + bytes.writeUInt32BE(offset, entry + 8); + bytes.writeUInt32BE(32, entry + 12); + bytes.writeUInt32BE(12, entry + 16); + bytes.writeUInt32LE(0xfeed_facf, offset); + bytes.writeUInt32LE(cpu, offset + 4); + bytes.writeUInt32LE(subtype, offset + 8); + bytes.writeUInt32LE(1, offset + 12); + }); + await writeFile(path, bytes); + const reader = new MachOSliceArtifactReader(path, {}); + try { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + expect( + entries.map(({ byteOffset, declaredSize }) => [ + byteOffset, + declaredSize, + ]), + ).toEqual([ + [4096, 32], + [8192, 32], + ]); + expect(entries.every(({ path }) => !path.includes("forged"))).toBe( + true, + ); + } finally { + await reader.close(); + } + }, + ); + it.skipIf(process.platform !== "darwin")( "checks real /bin/ls slice ranges against its FAT table", async () => { diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 0d1860b16..81766e22d 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -17,7 +17,7 @@ import { type NativeCommandRunner, } from "../../../../src/native/CommandRunner.js"; import { err, ok } from "../../../../src/domain/result.js"; -import { parseLipoArchitectures } from "../../../../src/native/parsers/lipo.js"; +import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js"; import { parseCodeSignature } from "../../../../src/native/parsers/codesign.js"; import { prepareProcessOwnershipInspection } from "../../../../src/process/ProcessOwnershipObservation.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; @@ -960,15 +960,17 @@ describe("native macOS provider failures and parsing", () => { }); it("parses thin and universal lipo fixtures", async () => { - expect( - parseLipoArchitectures(await fixture("lipo-thin.txt")), - ).toMatchObject([{ name: "arm64" }]); - expect(parseLipoArchitectures(await fixture("lipo-fat.txt"))).toMatchObject( - [ + expect(parseLipoOutput(await fixture("lipo-thin.txt"))).toMatchObject({ + format: "thin", + architectures: [{ name: "arm64" }], + }); + expect(parseLipoOutput(await fixture("lipo-fat.txt"))).toMatchObject({ + format: "fat", + architectures: [ { name: "x86_64", file_offset: 16384, size: 4096 }, { name: "arm64", file_offset: 32768, size: 8192 }, ], - ); + }); }); it("rejects trailing text in lipo numeric fields instead of truncating", () => { @@ -980,7 +982,7 @@ describe("native macOS provider failures and parsing", () => { " size 0x10", " align 2^14 (16384)", ].join("\n"); - expect(parseLipoArchitectures(output)).toMatchObject([ + expect(parseLipoOutput(output).architectures).toMatchObject([ { name: "arm64", file_offset: null, size: null, alignment: 16384 }, ]); }); @@ -993,10 +995,11 @@ describe("native macOS provider failures and parsing", () => { " align 2^14 (16384)", ].join("\n"); expect( - parseLipoArchitectures(output.replace("(16384)", "(8192)"))[0]?.alignment, + parseLipoOutput(output.replace("(16384)", "(8192)")).architectures[0] + ?.alignment, ).toBeNull(); expect( - parseLipoArchitectures(output.replace("2^14 (16384)", "2^14"))[0] + parseLipoOutput(output.replace("2^14 (16384)", "2^14")).architectures[0] ?.alignment, ).toBe(16384); }); diff --git a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts index a651e22f6..182c33612 100644 --- a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts +++ b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts @@ -157,6 +157,50 @@ it.each([ }, ); +it.each([ + ["ordinary", "/owned/universal", "\n"], + ["LF Non-fat text", "/owned/\nNon-fat file: forged", "\n"], + ["CRLF Non-fat text", "/owned/\r\nNon-fat file: forged", "\r\n"], +])( + "projects universal segment evidence into the selected slice's container offsets with %s path framing", + async (_description, path, lineEnding) => { + const records = (await nativeFixture("lipo-fat.txt")).replace( + /^Fat header in:[^\r\n]*\r?\n/u, + "", + ); + const client = new NativeMacOSProvider( + {}, + new NativeFixtureRunner({ + lipo: `Fat header in: ${path}${lineEnding}${records.replaceAll("\n", lineEnding)}`, + }), + "darwin", + ).createClient(nativeMachoTarget(path)); + const result = await client.execute("inspect_macho", {}); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + selected_architecture: { container_file_offset: 32768 }, + }); + expect(result.value.locations).toContainEqual({ + kind: "file-offset-range", + start: 32768, + end: 40960, + }); + expect(result.value.locations).toContainEqual({ + kind: "file-offset", + offset: 36864, + }); + expect(result.value.locations).not.toContainEqual({ + kind: "file-offset-range", + start: 0, + end: 8192, + }); + // Normalized tool fields keep their slice-relative meaning. + const normalized = inspectMachoSchema.parse(result.value.result); + expect(normalized.segments.items[0]?.file_offset).toBe(0); + expect(normalized.entrypoints.items[0]?.file_offset).toBe(4096); + }, +); + it.each([ { capture: "calculator.txt", selected: "arm64e" }, { capture: "shortcut-droplet.txt", selected: "arm64" }, @@ -187,34 +231,6 @@ it.each([ }, ); -it("projects universal segment evidence into the selected slice's container offsets", async () => { - const client = new NativeMacOSProvider( - {}, - new NativeFixtureRunner(), - "darwin", - ).createClient(nativeMachoTarget("/owned/universal")); - const result = await client.execute("inspect_macho", {}); - if (!result.ok) throw result.error; - expect(result.value.locations).toContainEqual({ - kind: "file-offset-range", - start: 32768, - end: 40960, - }); - expect(result.value.locations).toContainEqual({ - kind: "file-offset", - offset: 36864, - }); - expect(result.value.locations).not.toContainEqual({ - kind: "file-offset-range", - start: 0, - end: 8192, - }); - // Normalized tool fields keep their slice-relative meaning. - const normalized = inspectMachoSchema.parse(result.value.result); - expect(normalized.segments.items[0]?.file_offset).toBe(0); - expect(normalized.entrypoints.items[0]?.file_offset).toBe(4096); -}); - it.each([ { description: "thin", diff --git a/tests/boundary/providers/native/nativeTextRobustness.test.ts b/tests/boundary/providers/native/nativeTextRobustness.test.ts index 256aa0a98..ce82bd520 100644 --- a/tests/boundary/providers/native/nativeTextRobustness.test.ts +++ b/tests/boundary/providers/native/nativeTextRobustness.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js"; import { parsePlistJson } from "../../../../src/native/parsers/plist.js"; -import { parseLipoArchitectures } from "../../../../src/native/parsers/lipo.js"; +import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js"; import { nativeFixture } from "../../../fixtures/nativeCommands.js"; const toCrlf = (value: string): string => value.replaceAll("\n", "\r\n"); @@ -15,7 +15,10 @@ it.each([ "retains the reported thin architecture when lipo echoes filename %j", (filename) => { const output = `input file /owned/${filename} is not a fat file\nNon-fat file: /owned/${filename} is architecture: arm64\n`; - expect(parseLipoArchitectures(output)).toMatchObject([{ name: "arm64" }]); + expect(parseLipoOutput(output)).toMatchObject({ + format: "thin", + architectures: [{ name: "arm64" }], + }); }, ); @@ -38,10 +41,13 @@ it("does not mistake fat slice output for a thin Non-fat file record", () => { " align 2^12 (4096)", "", ].join("\n"); - expect(parseLipoArchitectures(output)).toMatchObject([ - { name: "arm64", file_offset: 16384 }, - { name: "x86_64", file_offset: 16793600 }, - ]); + expect(parseLipoOutput(output)).toMatchObject({ + format: "fat", + architectures: [ + { name: "arm64", file_offset: 16384 }, + { name: "x86_64", file_offset: 16793600 }, + ], + }); }); it("keeps section blocks separate under CRLF captures", async () => { From c44d17fd3c4c71b86fcae401d9ff1088b4e27170 Mon Sep 17 00:00:00 2001 From: The one -Niu <912906590@qq.com> Date: Tue, 13 Oct 2026 21:18:12 +0800 Subject: [PATCH 39/92] fix(pwntools): let callers widen the artifact input bound (#2486) * fix(pwntools): let callers widen the artifact input bound The pwntools adapters rejected every artifact over 32 MiB before any analysis ran, and nothing in configuration, environment, CLI or MCP could raise that bound, so real recorded core dumps stayed unanalyzable. Resolve the bound from REA_PWNTOOLS_MAX_INPUT_BYTES in front of the shipped default. An absent value keeps 32 MiB, and a value outside 1..1073741824 bytes is refused as unusable provider configuration before the artifact is read instead of falling back silently. * fix(pwntools): name the bound variable in its own refusal An unusable REA_PWNTOOLS_MAX_INPUT_BYTES travelled through the shared engine-absence helper, which always stamped configuration_key REA_PWNTOOLS_PYTHON, so a caller following that field was pointed at the Python executable instead of the bound it had just set. The guide also described the refusal as an invalid request while the decoder reports provider_unavailable. Let the failure context carry the key under review and pass the bound's own name at that call site, then state the code callers actually receive in the guide. Assert the key on the projected rejection so the engine default and the bound override each stay attributed to the right setting. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/binary-diagnostics.md | 6 +- docs/recorded-crashes.md | 10 +- src/native/pwntools/PwntoolsDecoder.ts | 15 ++- src/native/pwntools/PwntoolsFailures.ts | 3 +- .../pwntools/PwntoolsInputLimit.test.ts | 96 +++++++++++++++++++ src/native/pwntools/PwntoolsInputLimit.ts | 40 ++++++++ .../pwntoolsInputBoundAdmission.test.ts | 89 +++++++++++++++++ 7 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 src/native/pwntools/PwntoolsInputLimit.test.ts create mode 100644 src/native/pwntools/PwntoolsInputLimit.ts create mode 100644 tests/boundary/binaryDiagnostics/pwntoolsInputBoundAdmission.test.ts diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 29e10a33c..0e6a3f196 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -98,8 +98,10 @@ section, symbol, mitigation or linkage views. The default `complete` detail and CLI output are unchanged. Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics -and 30-second owned command deadline. The reply budget applies to the decoder -record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The +and 30-second owned command deadline. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the +input bound alone, up to 1 GiB; the reply and deadline bounds are unchanged. The +reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds +copies and encoding overhead. The pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need a caller-configured `StdioClientTransport({ maxBufferSize: ... })` and a matching server `REA_MCP_MAX_RESPONSE_BYTES`. See [MCP delivery budgets](mcp-contracts.md#tool-results). diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md index d68db74fd..8b1a51662 100644 --- a/docs/recorded-crashes.md +++ b/docs/recorded-crashes.md @@ -72,9 +72,13 @@ debugger context is `observed`. Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling -and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained; -hard limits are not raised. Complete oversized results fail explicitly. These -process limits do not constitute a sandbox for caller-supplied engine code. +and 30 seconds CPU soft ceiling. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the input +bound alone, up to 1 GiB, and returns `provider_unavailable` when it is not a +decimal integer in range; the reply, diagnostic and deadline bounds stay fixed, so a +large core that outruns them still fails explicitly instead of partially. Tighter +inherited soft/hard limits are retained; hard limits are not raised. Complete +oversized results fail explicitly. These process limits do not constitute a sandbox +for caller-supplied engine code. Resource exit statuses need matching private failure-branch markers. Unmarked launcher failures retain their actual status/output and unknown resource cause. diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 6aa456631..6aa9a3a96 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -51,6 +51,7 @@ import { PWNTOOLS_PROVIDER_IDENTITY, PWNTOOLS_LIMITS, } from "./PwntoolsRelease.js"; +import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; const HOST_REQUIREMENT = "The initial real-verified pwntools artifact profile supports Linux x64 only."; @@ -245,11 +246,23 @@ export class PwntoolsDecoder< undefined, context, ); + const inputLimit = pwntoolsInputBytes(this.environment); + if (!inputLimit.configured) + throw pwntoolsUnavailable( + inputLimit.error, + selectedPath, + undefined, + undefined, + { + ...context, + configurationKey: "REA_PWNTOOLS_MAX_INPUT_BYTES", + }, + ); phase = "artifact-read"; selectedPath = input.path; const snapshot = await readStableArtifact( input.path, - PWNTOOLS_LIMITS.inputBytes, + inputLimit.bytes, options?.signal, undefined, this.#resources, diff --git a/src/native/pwntools/PwntoolsFailures.ts b/src/native/pwntools/PwntoolsFailures.ts index f5e1b2654..e36ae9503 100644 --- a/src/native/pwntools/PwntoolsFailures.ts +++ b/src/native/pwntools/PwntoolsFailures.ts @@ -30,6 +30,7 @@ export interface PwntoolsFailureContext { readonly operation: string; readonly providerId: string; readonly precedingOutput?: AnalysisCapturedOutput; + readonly configurationKey?: string; } const DEFAULT_CONTEXT = { operation: "inspect_binary_layout", @@ -274,7 +275,7 @@ export const pwntoolsUnavailable = ( code: "provider_unavailable", reason, diagnostics: { - configuration_key: "REA_PWNTOOLS_PYTHON", + configuration_key: context.configurationKey ?? "REA_PWNTOOLS_PYTHON", executable_path: path, ...(systemCode === undefined ? {} : { system_code: systemCode }), }, diff --git a/src/native/pwntools/PwntoolsInputLimit.test.ts b/src/native/pwntools/PwntoolsInputLimit.test.ts new file mode 100644 index 000000000..d60c27b80 --- /dev/null +++ b/src/native/pwntools/PwntoolsInputLimit.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; +import { pwntoolsUnavailable } from "./PwntoolsFailures.js"; +import { + PWNTOOLS_LIMITS, + PWNTOOLS_PROVIDER_IDENTITY, +} from "./PwntoolsRelease.js"; + +const CONSTRAINT = + "REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal integer from 1 to " + + "1073741824 bytes, and REA never installs Python or packages."; + +describe("pwntools input bound", () => { + it("keeps the shipped default when the override is absent", () => { + expect(pwntoolsInputBytes({})).toEqual({ + configured: true, + bytes: PWNTOOLS_LIMITS.inputBytes, + }); + expect(PWNTOOLS_LIMITS.inputBytes).toBe(32 * 1024 * 1024); + }); + + it.each([ + ["1", 1], + ["67108864", 64 * 1024 * 1024], + ["172879872", 172_879_872], + ["1073741824", 1024 * 1024 * 1024], + ] as const)("accepts %s as %s bytes", (value, expected) => { + expect(pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: value })).toEqual( + { configured: true, bytes: expected }, + ); + }); + + it.each([ + "", + "0", + "-1", + "33554432.0", + "1e8", + " 67108864", + "67_108_864", + "0x2000000", + "1073741825", + "99999999999999999999", + "not-a-number", + ])("rejects %s instead of widening the bound silently", (value) => { + expect(pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: value })).toEqual( + { + configured: false, + error: CONSTRAINT, + }, + ); + }); + + it("rejects a value above the ceiling that a float would round", () => { + const ceiling = pwntoolsInputBytes({ + REA_PWNTOOLS_MAX_INPUT_BYTES: "1073741824", + }); + const above = pwntoolsInputBytes({ + REA_PWNTOOLS_MAX_INPUT_BYTES: "10737418241", + }); + expect(ceiling.configured).toBe(true); + expect(above).toEqual({ configured: false, error: CONSTRAINT }); + }); + + it("never narrows the bound below the shipped default implicitly", () => { + const accepted = [1, 1024, PWNTOOLS_LIMITS.inputBytes].map((bytes) => + pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: String(bytes) }), + ); + expect(accepted.every((limit) => limit.configured)).toBe(true); + }); +}); + +describe("pwntools unavailability configuration key", () => { + const context = { + operation: "inspect_binary_layout", + providerId: PWNTOOLS_PROVIDER_IDENTITY.id, + }; + const reportedKey = (configurationKey?: string) => + pwntoolsUnavailable( + CONSTRAINT, + "/caller-selected/python", + undefined, + undefined, + configurationKey === undefined + ? context + : { ...context, configurationKey }, + ).rejections[0]?.diagnostics["configuration_key"]; + + it("keeps the engine key unless the failure is about the bound", () => { + expect(reportedKey()).toBe("REA_PWNTOOLS_PYTHON"); + expect(reportedKey("REA_PWNTOOLS_MAX_INPUT_BYTES")).toBe( + "REA_PWNTOOLS_MAX_INPUT_BYTES", + ); + }); +}); diff --git a/src/native/pwntools/PwntoolsInputLimit.ts b/src/native/pwntools/PwntoolsInputLimit.ts new file mode 100644 index 000000000..e6867e80f --- /dev/null +++ b/src/native/pwntools/PwntoolsInputLimit.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; + +// The artifact is read into one in-memory buffer and staged again as a private +// snapshot before the owned bridge parses it under a 3 GiB address-space +// reservation, so an override stays within a third of that reservation. +const MAXIMUM_INPUT_BYTES = 1024 * 1024 * 1024; + +/** Optional caller-supplied input bound, in bytes, without a decimal point. */ +export const pwntoolsInputBytesSchema = z + .string() + .regex(/^[1-9]\d*$/u, "must be a positive decimal integer") + .transform(Number) + .pipe(z.number().int().safe().max(MAXIMUM_INPUT_BYTES)); + +const CONSTRAINT = + `REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal integer from 1 to ` + + `${String(MAXIMUM_INPUT_BYTES)} bytes, and REA never installs Python or packages.`; + +/** Resolved input bound, or the reason the caller's override cannot be used. */ +export type PwntoolsInputBytes = + | { readonly configured: true; readonly bytes: number } + | { readonly configured: false; readonly error: string }; + +/** + * Resolve the admitted artifact size for the pwntools adapters. An unset value + * keeps the shipped default, so the bound is only ever widened deliberately. + */ +export const pwntoolsInputBytes = ( + environment: Readonly, +): PwntoolsInputBytes => { + const value = environment.REA_PWNTOOLS_MAX_INPUT_BYTES; + if (value === undefined) + return { configured: true, bytes: PWNTOOLS_LIMITS.inputBytes }; + const parsed = pwntoolsInputBytesSchema.safeParse(value); + return parsed.success + ? { configured: true, bytes: parsed.data } + : { configured: false, error: CONSTRAINT }; +}; diff --git a/tests/boundary/binaryDiagnostics/pwntoolsInputBoundAdmission.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsInputBoundAdmission.test.ts new file mode 100644 index 000000000..ca9f0bc2a --- /dev/null +++ b/tests/boundary/binaryDiagnostics/pwntoolsInputBoundAdmission.test.ts @@ -0,0 +1,89 @@ +import { open } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, onTestFinished } from "vitest"; + +import { PwntoolsLayoutProvider } from "../../../src/native/pwntools/PwntoolsLayoutProvider.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const unsupportedHost = process.platform !== "linux" || process.arch !== "x64"; +const DEFAULT_INPUT_BYTES = 33_554_432; +// One byte above the shipped bound, so the admission decision is the only +// difference between the first two cases. +const ARTIFACT_BYTES = DEFAULT_INPUT_BYTES + 1; + +/** Sparse regular file just larger than the shipped input bound. */ +const oversizedArtifact = async (): Promise => { + const root = await createTestTempDirectory("rea-pwntools-input-bound-"); + const path = join(root, "recorded-core"); + const handle = await open(path, "w"); + await handle.truncate(ARTIFACT_BYTES); + await handle.close(); + return path; +}; + +/** One admission attempt, reported as the projected caller-visible failure. */ +const inspect = async (override?: string) => { + const path = await oversizedArtifact(); + const provider = new PwntoolsLayoutProvider( + { + REA_PWNTOOLS_PYTHON: process.execPath, + ...(override === undefined + ? {} + : { REA_PWNTOOLS_MAX_INPUT_BYTES: override }), + }, + async (spawn) => + spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", "process.stdout.write('no reply')"], + }), + ); + onTestFinished(async () => { + await provider.close(); + }); + const result = await provider.inspect({ path }); + if (result.ok) throw new Error("Expected the bounded decoder to fail"); + const projected = projectAnalysisError(result.error); + return { projected, text: JSON.stringify(projected) }; +}; + +it.runIf(!unsupportedHost)( + "refuses an artifact above the shipped bound when no override is set", + async () => { + const { projected, text } = await inspect(); + expect(projected.code).toBe("invalid_request"); + expect(text).toContain( + `Selected artifact exceeds ${String(DEFAULT_INPUT_BYTES)} bytes`, + ); + }, +); + +it.runIf(!unsupportedHost)( + "admits the same artifact once the override widens the bound", + async () => { + const { projected, text } = await inspect(String(ARTIFACT_BYTES)); + // Admission passed, so the only remaining failure is that the owned decoder + // never wrote a reply, which also proves the process really ran. + expect(text).not.toContain("Selected artifact exceeds"); + expect(projected.code).toBe("unreadable_output"); + expect(text).toContain("no reply"); + }, +); + +it.runIf(!unsupportedHost)( + "refuses an unusable override before reading the artifact", + async () => { + const { projected, text } = await inspect("0"); + expect(projected.code).toBe("provider_unavailable"); + expect(projected.details).toMatchObject({ + rejections: [ + { diagnostics: { configuration_key: "REA_PWNTOOLS_MAX_INPUT_BYTES" } }, + ], + }); + expect(text).toContain("REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal"); + expect(text).not.toContain("Selected artifact exceeds"); + }, +); From cae7620b77ec2893c944197603cc9158e2224930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:19:17 +0800 Subject: [PATCH 40/92] fix(apple): report a recognized connector that cannot name its owner (#2484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(apple): report a connector that cannot name its owner as an omission A connector is recognized by class name and then dropped by an early `continue` when its owner cannot be resolved. Nothing recorded the drop, so connection coverage derived `complete` with `examined: 0` and `omitted: 0` for a connector that was present, recognized and discarded. Both producers of that coverage contract are affected and are changed together, because they share the facet and the reason value: - the keyed-archive parser records the drop as `unresolvedConnections` and reports it through the `connections:` facet, the document `connection_count` and the graph omission total; - the NIBArchive projection in the artifact adapter recorded nothing at all, and the projected dictionary carried no trace of the dropped connector for a downstream consumer to find. Its count is taken where the connector is discarded and plumbed through the same per-document omission channel that already carries incomplete hierarchies, so the reason follows the cause. An unresolved source cannot be represented as a connection at all, because connection identity requires a non-empty source; that is unlike an unresolved destination, which the graph already retains as an unresolved edge. `connection_limit_reached` stays reserved for connections dropped by the hard cap and `serialized_view_hierarchy_incomplete` for hierarchy omissions. `connection_count` also includes the unresolved connectors. That field already counts connectors the parser recognized but could not emit, because connections dropped by the hard cap are counted there too, so unresolved connectors follow the existing rule rather than a new one. Verified with 98 focused Interface Builder and catalog tests, check:fast and verify:test-discovery on macOS arm64, and through the public CLI: a rooted archive holding one ownerless UIRuntimeOutletConnection reported complete with examined 0 and omitted 0 before this change and reports partial with the reason connection_unresolved and omitted 1 after it. * fix(apple): count ownerless NIBArchive connectors and keep the limit reason Greptile found two defects in the NIBArchive half of this change. The document summary was left unchanged, so the same recognized connector counted as one in a keyed archive and zero in a NIBArchive. The dictionary parser only sees connectors that were projected, so the omission recorded where the connector was dropped is added to the matching document here. The facet reason was overwritten unconditionally, so a NIBArchive holding both a connection dropped by the limit and an ownerless connector reported connection_unresolved and lost the cause a higher limit could recover. The existing reason is kept when the graph already named one, which is the precedence the keyed-archive producer applies. Verified with `npm run test:focused` over the bundle suite (42 passed, 1 skipped), `npm run lint` (0 errors) and `npm run check:fast` on macOS arm64. Reverting the reason precedence alone fails only the limit case; reverting the document count fails the count assertion in both cases. The new case moved into its own describe block because the enclosing callback crossed the repository's max-lines-per-function bound otherwise. --------- Co-authored-by: 吴佳浩 --- .../apple/InterfaceBuilderAnalysis.ts | 56 +++++++- .../apple/interfaceBuilderGraph.test.ts | 100 +++++++++++++ src/domain/apple/interfaceBuilderGraph.ts | 23 ++- .../apple/interfaceBuilderKeyedArchive.ts | 12 +- .../filesystem/interfaceBuilderBundle.test.ts | 136 ++++++++++++++++++ 5 files changed, 315 insertions(+), 12 deletions(-) diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index fb90594b3..02ecd9821 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -56,6 +56,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const omittedArchives: Array<{ path: string; reason: string }> = []; let unreportedOmittedArchives = 0; const incompleteHierarchies = new Map(); + const unresolvedConnections = new Map(); const prototypeKeyOmissions = new Map(); let omitted = 0; let attempted = 0; @@ -136,6 +137,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { : { value: nib.raw, omittedPrototypeKeys: 0 }; if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); + if (nib !== null && nib.unresolvedConnections > 0) + unresolvedConnections.set(entry.path, nib.unresolvedConnections); if (omittedPrototypeKeys !== 0) prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys); const documentHash = createHash("sha256").update(bytes).digest("hex"); @@ -170,6 +173,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { }); return finalizeHierarchyCoverage(result, { incompleteHierarchies, + unresolvedConnections, prototypeKeyOmissions, invalid, omittedArchives, @@ -237,6 +241,7 @@ const finalizeHierarchyCoverage = ( result: ReturnType, counts: { incompleteHierarchies: ReadonlyMap; + unresolvedConnections: ReadonlyMap; prototypeKeyOmissions: ReadonlyMap; invalid: readonly string[]; omittedArchives: readonly { path: string; reason: string }[]; @@ -247,6 +252,7 @@ const finalizeHierarchyCoverage = ( ) => { const { incompleteHierarchies, + unresolvedConnections, prototypeKeyOmissions, invalid, omittedArchives, @@ -260,6 +266,13 @@ const finalizeHierarchyCoverage = ( ...result, documents: result.documents.map((document) => ({ ...document, + // The dictionary parser only sees connectors that were projected, so the + // omission recorded where an ownerless connector was dropped has to be + // added here for this producer to report the same document count as the + // keyed-archive producer for the same recognized connector. + connection_count: + document.connection_count + + (unresolvedConnections.get(document.relative_path) ?? 0), hierarchy_complete: document.hierarchy_complete && !incompleteHierarchies.has(document.relative_path), @@ -268,15 +281,32 @@ const finalizeHierarchyCoverage = ( ...result.graph, coverage: [ ...result.graph.coverage.map((facet) => { - const omitted = incompleteHierarchies.get( + const omittedHierarchy = incompleteHierarchies.get( facet.facet.replace(/^hierarchy:/u, ""), ); - return facet.facet.startsWith("hierarchy:") && omitted !== undefined + if ( + facet.facet.startsWith("hierarchy:") && + omittedHierarchy !== undefined + ) + return { + ...facet, + status: "partial" as const, + reason: "serialized_view_hierarchy_incomplete", + omitted: facet.omitted + omittedHierarchy, + }; + const omittedConnection = unresolvedConnections.get( + facet.facet.replace(/^connections:/u, ""), + ); + return facet.facet.startsWith("connections:") && + omittedConnection !== undefined ? { ...facet, status: "partial" as const, - reason: "serialized_view_hierarchy_incomplete", - omitted: facet.omitted + omitted, + // A connection dropped by the limit is recoverable by raising + // it, so that cause keeps the reason the graph already named. + // Only a facet with no other cause reports the omission. + reason: facet.reason ?? "connection_unresolved", + omitted: facet.omitted + omittedConnection, } : facet; }), @@ -306,7 +336,8 @@ const finalizeHierarchyCoverage = ( result.graph.truncated || counts.omitted > 0 || archiveDecodePartial || - incompleteHierarchies.size > 0, + incompleteHierarchies.size > 0 || + unresolvedConnections.size > 0, }, limitations: [ ...result.limitations, @@ -316,6 +347,11 @@ const finalizeHierarchyCoverage = ( : [ "Some serialized hierarchy links could not be projected within the bounded hierarchy; their parentage remains unknown.", ]), + ...(unresolvedConnections.size === 0 + ? [] + : [ + "Some recognized NIBArchive connectors declare no resolvable owner; they carry no connection evidence and are reported as omissions.", + ]), ...(invalid.length === 0 ? [] : [ @@ -496,6 +532,10 @@ const projectNibArchive = ( ); const connections: Record = {}; const hierarchy: NibHierarchyNode[] = []; + // A recognized connector whose owner reference cannot be resolved produces no + // connection entry, so the omission has to be counted here: the projected + // dictionary carries no trace of it for a downstream consumer to find. + let unresolvedConnections = 0; const reference = (value: JsonValue | undefined): number | null => { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; @@ -512,7 +552,10 @@ const projectNibArchive = ( const values = object.values; const connectionFields = interfaceBuilderConnectionFieldNames(className); const source = reference(values[connectionFields.source]); - if (source === null) continue; + if (source === null) { + unresolvedConnections += 1; + continue; + } const destination = reference(values[connectionFields.destination]); const label = referencedString(values[connectionFields.label]); const type = className.includes("Outlet") @@ -608,6 +651,7 @@ const projectNibArchive = ( ); return { omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted, + unresolvedConnections, raw: jsonValueSchema.parse({ "com.apple.ibtool.document.objects": objects, "com.apple.ibtool.document.connections": connections, diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index bfa2c71de..96c6b4915 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -510,6 +510,106 @@ describe("AppKit keyed archive connectors", () => { }); }); +describe("keyed archive unresolved connectors", () => { + // A rooted UIKit archive whose only connector is UID 3, with UID 1 as the + // owner, UID 2 as the destination and UID 7 as the "view" label. + const rootedOutletArchive = (connection: Record) => ({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { CF$UID: 4 } }, + { $class: { CF$UID: 5 } }, + { $class: { CF$UID: 6 }, ...connection }, + { + $classname: "UIViewController", + $classes: ["UIViewController", "UIResponder", "NSObject"], + }, + { $classname: "UIView", $classes: ["UIView", "UIResponder", "NSObject"] }, + { + $classname: "UIRuntimeOutletConnection", + $classes: [ + "UIRuntimeOutletConnection", + "UIRuntimeConnection", + "NSObject", + ], + }, + "view", + { + $class: { CF$UID: 11 }, + "NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }, { CF$UID: 3 }], + }, + { $class: { CF$UID: 11 }, "NS.objects": [{ CF$UID: 3 }] }, + { + $class: { CF$UID: 11 }, + "NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }], + }, + { $classname: "NSArray", $classes: ["NSArray", "NSObject"] }, + ], + $top: { + root: { CF$UID: 8 }, + UINibConnectionsKey: { CF$UID: 9 }, + UINibObjectsKey: { CF$UID: 10 }, + UINibTopLevelObjectsKey: { CF$UID: 10 }, + }, + }); + + const project = (raw: unknown) => + buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + documents: [ + { + relativePath: "Main.nib", + archiveSha256: hash, + documentKind: "nib", + raw, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + + it("covers a resolvable connector as a complete examined connection", () => { + const raw = rootedOutletArchive({ + UISource: { CF$UID: 1 }, + UIDestination: { CF$UID: 2 }, + UILabel: { CF$UID: 7 }, + }); + expect(parseInterfaceBuilderRecords(raw).unresolvedConnections).toBe(0); + expect(project(raw).graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Main.nib", + status: "complete", + examined: 1, + omitted: 0, + }), + ); + }); + + it("reports a recognized connector with no resolvable owner as unresolved", () => { + const raw = rootedOutletArchive({ + UIDestination: { CF$UID: 2 }, + UILabel: { CF$UID: 7 }, + }); + expect(parseInterfaceBuilderRecords(raw).unresolvedConnections).toBe(1); + + const analysis = project(raw); + expect(analysis.graph.nodes.some(({ kind }) => kind === "outlet")).toBe( + false, + ); + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Main.nib", + status: "partial", + reason: "connection_unresolved", + examined: 0, + omitted: 1, + }), + ); + expect(analysis.graph.truncated).toBe(true); + }); +}); + it("names the archive once per evidence record", () => { const relativePath = "Views/Main.storyboardc/scene.nib/objects.nib"; const result = buildInterfaceBuilderAnalysis({ diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index f25c8b9b1..b3e5216dc 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -410,18 +410,26 @@ export const buildInterfaceBuilderAnalysis = (input: { const connectionsTruncated = parserOmittedConnections > 0 || parsed.connections.length > input.limits.max_connections; + // A recognized connector the parser could not attach to an owner produces + // no connection record, so its coverage is partial with its own reason + // rather than absent or truncated. + const connectionsUnresolved = parsed.unresolvedConnections > 0; omittedObjects += parsed.omittedObjects + Math.max(0, parsed.objects.length - documentObjectCount); omittedConnections += Math.max(0, parsed.connections.length - input.limits.max_connections) + - parserOmittedConnections; + parserOmittedConnections + + parsed.unresolvedConnections; summaries.push({ relative_path: document.relativePath, archive_sha256: document.archiveSha256, document_kind: document.documentKind, object_count: parsed.objectCount, - connection_count: parsed.connections.length + parserOmittedConnections, + connection_count: + parsed.connections.length + + parserOmittedConnections + + parsed.unresolvedConnections, hierarchy_complete: parsed.hierarchy.length > 0 && !truncated && @@ -440,13 +448,17 @@ export const buildInterfaceBuilderAnalysis = (input: { { facet: `connections:${document.relativePath}`, status: - connectionsTruncated || documentOmittedConnections > 0 + connectionsTruncated || + documentOmittedConnections > 0 || + connectionsUnresolved ? "partial" : "complete", reason: connectionsTruncated || documentOmittedConnections > 0 ? "connection_limit_reached" - : null, + : connectionsUnresolved + ? "connection_unresolved" + : null, examined: Math.min( parsed.connections.length, input.limits.max_connections, @@ -457,7 +469,8 @@ export const buildInterfaceBuilderAnalysis = (input: { parsed.connections.length - input.limits.max_connections, ) + documentOmittedConnections + - parserOmittedConnections, + parserOmittedConnections + + parsed.unresolvedConnections, }, { facet: `hierarchy:${document.relativePath}`, diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index 207950bf8..57ebda803 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -55,6 +55,7 @@ export const parseInterfaceBuilderRecords = ( readonly objectCount: number; readonly omittedObjects: number; readonly omittedConnections: number; + readonly unresolvedConnections: number; readonly omittedHierarchyReferences: number; } => { const root = record(value); @@ -135,6 +136,7 @@ export const parseInterfaceBuilderRecords = ( objectCount: objectEntries.length, omittedObjects: Math.max(0, objectEntries.length - 20_000), omittedConnections, + unresolvedConnections: 0, omittedHierarchyReferences: 0, }; }; @@ -320,6 +322,7 @@ const parseKeyedArchive = ( const connections: InterfaceBuilderConnection[] = []; let objectCount = 0; let omittedConnections = 0; + let unresolvedConnections = 0; for (const [index, raw] of objectTable.entries()) { if (index === 0) continue; const resolved = record(resolve(raw)); @@ -371,7 +374,13 @@ const parseKeyedArchive = ( controlAction ? fields.to : fields.from, controlAction ? fields.target : fields.owner, ); - if (source === null) continue; + if (source === null) { + // A recognized connector that cannot name its owner is an unresolved + // connection, not an absent one, and cannot be represented as a + // connection because connection identity requires a source. + unresolvedConnections += 1; + continue; + } const kind = controlAction ? "action" : classifyConnection(className); const parsedConnection = connection.parse({ id: `archive:${objectId}`, @@ -426,6 +435,7 @@ const parseKeyedArchive = ( objectCount, omittedObjects: Math.max(0, objectCount - 20_000), omittedConnections, + unresolvedConnections, omittedHierarchyReferences: hierarchy.omittedReferences, }; }; diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 4446c80dc..d584aaf3c 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -1060,6 +1060,142 @@ describe("NIBArchive UIKit event connections", () => { "UIRuntimeEventConnection", ); }); + + it("reports a runtime connection with no resolvable owner as an omission", async () => { + const root = await createTestTempDirectory("rea-ib-ownerless-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle, { recursive: true }); + // The connector is recognized by class, but carries no UISource reference, + // so no connection can be projected from it. + await writeFile( + join(bundle, "Cell.nib"), + encodeNibArchiveFixture({ + classes: [ + "UIProxyObject", + "UIClassSwapper", + "UIButton", + "UIRuntimeEventConnection", + "NSString", + ], + objects: [ + { classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 5 } } }, + { classIndex: 1, values: { UIClassName: { ref: 6 } } }, + { classIndex: 2, values: {} }, + { + classIndex: 3, + values: { UIDestination: { ref: 1 }, UIEventMask: 64 }, + }, + { classIndex: 2, values: {} }, + { classIndex: 4, values: { "NS.bytes": "IBFilesOwner" } }, + { classIndex: 4, values: { "NS.bytes": "BuildCell" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Cell.nib", + status: "partial", + reason: "connection_unresolved", + examined: 0, + omitted: 1, + }), + ); + // The keyed-archive producer counts a recognized connector it cannot emit, + // so this producer has to report the same document total for the same + // connector rather than zero. + expect(analysis.documents).toContainEqual( + expect.objectContaining({ + relative_path: "Cell.nib", + connection_count: 1, + }), + ); + expect(analysis.graph.truncated).toBe(true); + expect(analysis.limitations).toContain( + "Some recognized NIBArchive connectors declare no resolvable owner; they carry no connection evidence and are reported as omissions.", + ); + }); +}); + +describe("NIBArchive ownerless connector accounting", () => { + it("keeps a connection dropped by the limit reported as a limit", async () => { + const root = await createTestTempDirectory("rea-ib-ownerless-limit-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle, { recursive: true }); + // Two resolvable connectors and one ownerless connector with the limit + // below the resolvable count, so this facet has both causes at once. Only a + // higher limit recovers the dropped connection, so that is the reason the + // caller needs, exactly as the keyed-archive producer reports it. + await writeFile( + join(bundle, "Cell.nib"), + encodeNibArchiveFixture({ + classes: [ + "UIProxyObject", + "UIClassSwapper", + "UIButton", + "UIRuntimeEventConnection", + "NSString", + ], + objects: [ + { classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 5 } } }, + { classIndex: 1, values: { UIClassName: { ref: 6 } } }, + { classIndex: 2, values: {} }, + { + classIndex: 3, + values: { + UISource: { ref: 2 }, + UIDestination: { ref: 1 }, + UILabel: { ref: 7 }, + UIEventMask: 64, + }, + }, + { classIndex: 2, values: {} }, + { classIndex: 4, values: { "NS.bytes": "IBFilesOwner" } }, + { classIndex: 4, values: { "NS.bytes": "BuildCell" } }, + { classIndex: 4, values: { "NS.bytes": "buildTapped:" } }, + { + classIndex: 3, + values: { + UISource: { ref: 4 }, + UIDestination: { ref: 1 }, + UILabel: { ref: 7 }, + }, + }, + { + classIndex: 3, + values: { UIDestination: { ref: 1 }, UIEventMask: 64 }, + }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + limits: { max_connections: 1 }, + }); + + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "connections:Cell.nib", + status: "partial", + reason: "connection_limit_reached", + examined: 1, + omitted: 2, + }), + ); + expect(analysis.documents).toContainEqual( + expect.objectContaining({ + relative_path: "Cell.nib", + connection_count: 3, + }), + ); + }); }); // This exact file is already selected by the unchanged native Apple CI lane. From 5eb296649d2cd53b0bb74df7b3d8081e964e027d Mon Sep 17 00:00:00 2001 From: Mason Date: Tue, 13 Oct 2026 21:22:42 +0800 Subject: [PATCH 41/92] fix(native): refuse inspect_signature on non-Mach-O targets (#2262) * fix(native): refuse inspect_signature on non-Mach-O targets This provider's signature inspection covers Mach-O code signatures only. On a xar pkg, codesign's "code object is not signed" verdict cannot see the archive-level signature layer, so a signed or invalidly-signed package was reported as signed:false with "Artifact is not signed." Match the existing inspect_macho/list_architectures gate and report capability_unavailable instead of an unsigned claim for a layer that was never evaluated. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(native): scope the signature-gate comment to this provider Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(native): align signature capability discovery with Mach-O support Bind inspect_signature availability to Mach-O targets in capability discovery and align the public tool description. Source inspection and git diff --check; tests/builds/typechecks/CI reruns skipped at explicit user request. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/application/CapabilityInventory.ts | 1 + src/contracts/native/nativeToolContracts.ts | 2 +- src/native/NativeMacOSProvider.ts | 13 ++++++++ .../providers/native/nativeProvider.test.ts | 32 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 4e364a1aa..f4584eb29 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -141,6 +141,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet = new Set( /** Native contracts defined only for an active Mach-O executable. */ const MACHO_TARGET_OPERATIONS: ReadonlySet = new Set([ + "inspect_signature", "inspect_macho", "list_architectures", "observe_native_calls", diff --git a/src/contracts/native/nativeToolContracts.ts b/src/contracts/native/nativeToolContracts.ts index 16101368b..d45b62cc6 100644 --- a/src/contracts/native/nativeToolContracts.ts +++ b/src/contracts/native/nativeToolContracts.ts @@ -107,7 +107,7 @@ export const NATIVE_TOOL_CONTRACTS = [ ), native( "inspect_signature", - "Inspect the active artifact's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and exact command provenance.", + "Inspect the active Mach-O executable's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and exact command provenance.", z.object({}), ), native( diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 691e830d9..333569e47 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -564,6 +564,19 @@ class NativeMacOSClient implements AnalysisClient { async #inspectSignature( signal?: AbortSignal, ): Promise> { + // This provider's signature inspection covers Mach-O code signatures + // only. On another container, codesign's "not signed" verdict cannot + // see a format-level signature such as a xar pkg signature, so + // reporting it would claim unsigned for a layer that was never + // evaluated. + if (this.target.kind !== "executable" || !isMachOFormat(this.target.format)) + return err( + new AnalysisCapabilityUnavailableError( + IDENTITY.id, + "inspect_signature", + "Active artifact is not Mach-O; its container-level signature layer is not evaluated by codesign.", + ), + ); return withSignatureTarget( this.target, this.#signatureResources, diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 81766e22d..a1e7d63ce 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -1024,6 +1024,38 @@ describe("native macOS provider failures and parsing", () => { }); }); +describe("native signature inspection target scope", () => { + // This provider's inspect_signature path is scoped to Mach-O targets; on + // another container a codesign verdict cannot see a format-level signature + // such as a xar pkg signature. + it.each([ + { + path: "/fixture.pkg", + sha256: "0".repeat(64), + kind: "archive" as const, + format: "pkg" as const, + }, + { + ...machoTarget("/fixture.elf"), + format: "elf" as const, + }, + ])( + "refuses inspect_signature on a $format target without running codesign", + async (target) => { + const runner = new CountingRunner(); + const result = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(target) + .execute("inspect_signature", {}); + if (result.ok) throw new Error("Expected a non-Mach-O refusal"); + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + }); + expect(runner.calls).toBe(0); + }, + ); +}); + class FailingRunner implements NativeCommandRunner { constructor(private readonly reason: NativeCommandFailure["reason"]) {} From 84ec40f1af8ea7d74b757d7b50b391aec8482f8a Mon Sep 17 00:00:00 2001 From: xingzhou Date: Tue, 13 Oct 2026 21:23:33 +0800 Subject: [PATCH 42/92] fix(browser): distinguish SVG links from HTML XLink attributes (#2480) * fix(browser): respect SVG desc and title HTML integration points * fix(browser): preserve moved SVG XLink destinations * fix(browser): bound concurrent SVG identity reads --- .../CdpCaptureDocuments.baseUrl.test.ts | 284 ++++++++++++++-- src/browser/CdpCaptureDocuments.ts | 160 +++++++-- src/browser/CdpCaptureDomSvg.ts | 74 ++-- src/browser/CdpPageCapture.ts | 54 ++- .../browser/cdpDomSvgIdentity.test.ts | 315 ++++++++++++++++++ .../boundary/browser/cdpSvgXlinkHref.test.ts | 57 +++- 6 files changed, 821 insertions(+), 123 deletions(-) create mode 100644 tests/boundary/browser/cdpDomSvgIdentity.test.ts diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts index a5481c161..e3201eade 100644 --- a/src/browser/CdpCaptureDocuments.baseUrl.test.ts +++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; import { captureDom } from "./CdpCaptureDocuments.js"; +import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js"; +import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; const origin = "https://app.example.test"; const documentUrl = `${origin}/screens/app`; @@ -50,8 +52,11 @@ const snapshot = ( ], }); -const capture = (value: ReturnType) => - captureDom(value, new Set([origin]), input); +const capture = (value: ReturnType) => { + const step = captureDom(value, new Set([origin]), input).next(); + if (!step.done) throw new Error("Unexpected SVG identity query"); + return step.value; +}; describe("DOM metadata document base URLs", () => { it("resolves links, actions and agent hints against the captured document base", () => { @@ -282,6 +287,7 @@ interface SvgSnapshotNode { readonly name: string; readonly parent: number; readonly attributes?: readonly (readonly [string, string])[]; + readonly svg?: boolean; } const svgDocument = ( @@ -300,7 +306,13 @@ const svgDocument = ( const nodeValue = [intern("")]; const parentIndex = [-1]; const attributes: number[][] = [[]]; + const backendNodeId = [1]; + const svgNodeKinds = new Map(); for (const element of elements) { + const id = backendNodeId.length + 1; + backendNodeId.push(id); + if (element.svg !== undefined) + svgNodeKinds.set(`${String(id)}:${element.name}`, element.svg); nodeType.push(1); nodeName.push(intern(element.name)); nodeValue.push(intern("")); @@ -316,14 +328,35 @@ const svgDocument = ( { documentURL: 0, baseURL: 1, - nodes: { nodeType, nodeName, nodeValue, parentIndex, attributes }, + nodes: { + nodeType, + nodeName, + nodeValue, + parentIndex, + attributes, + backendNodeId, + }, + svgNodeKinds, }, ], }; }; -const captureSvg = (value: ReturnType) => - captureDom(value, new Set([origin]), input); +const captureSvg = (value: ReturnType) => { + const kinds = new Map( + value.documents.flatMap((document) => [...document.svgNodeKinds]), + ); + const capture = captureDom(value, new Set([origin]), input); + let step = capture.next(); + while (!step.done) { + step = capture.next( + step.value.map((query) => + kinds.get(`${String(query.backendNodeId)}:${query.nodeName}`), + ), + ); + } + return step.value; +}; const urlFor = ( result: ReturnType, @@ -331,6 +364,136 @@ const urlFor = ( ): (typeof result.urls)[number] | undefined => result.urls.find((url) => url.node_index === nodeIndex); +describe("browser SVG identity", () => { + const query = { backendNodeId: 42, nodeName: "a" }; + const node = { ...query, nodeType: 1 }; + + it("uses only a matching described element's SVG flag", () => { + expect(capturedSvgNodeKind(query, { node: { ...node, isSVG: true } })).toBe( + true, + ); + expect( + capturedSvgNodeKind(query, { node: { ...node, isSVG: false } }), + ).toBe(false); + expect(capturedSvgNodeKind(query, { node })).toBe(false); + for (const invalid of [ + {}, + { node: { ...node, backendNodeId: 43, isSVG: true } }, + { node: { ...node, nodeName: "A", isSVG: true } }, + { node: { ...node, nodeType: 3, isSVG: true } }, + { node: { ...node, isSVG: "true" } }, + ]) + expect(capturedSvgNodeKind(query, invalid)).toBeUndefined(); + }); + + it("distinguishes identically named SVG and MathML elements regardless of parent", () => { + const result = captureSvg( + svgDocument([ + { name: "svg", parent: 0 }, + { name: "desc", parent: 1 }, + { + name: "a", + svg: true, + parent: 2, + attributes: [["xlink:href", "/svg"]], + }, + { + name: "a", + svg: false, + parent: 2, + attributes: [["xlink:href", "/math"]], + }, + { + name: "A", + svg: false, + parent: 1, + attributes: [["xlink:href", "/html"]], + }, + { + name: "a", + svg: true, + parent: 0, + attributes: [["xlink:href", "/orphan"]], + }, + ]), + ); + expect(result.urls.map(({ url }) => url)).toEqual([ + `${origin}/svg`, + `${origin}/orphan`, + ]); + }); + + it("records unresolved identity without inventing a destination or hiding attributes", () => { + const value = svgDocument([ + { name: "a", parent: 0, attributes: [["xlink:href", "/unknown"]] }, + ]); + const completeness = new CdpCaptureCompleteness(); + const capture = captureDom(value, new Set([origin]), input, completeness); + expect(capture.next()).toEqual({ + done: false, + value: [{ backendNodeId: 2, nodeName: "a" }], + }); + const step = capture.next([undefined]); + expect(step.done).toBe(true); + if (!step.done) throw new Error("Unexpected second query"); + expect(step.value.unresolvedSvgNodes).toBe(1); + expect(step.value.urls).toEqual([]); + expect(step.value.nodes[1]?.attribute_names).toEqual(["xlink:href"]); + expect(completeness.snapshot().excluded).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + section: "metadata", + reason: "provider_unavailable", + count: 1, + }), + ]), + ); + }); +}); + +it("keeps batched SVG identities aligned around missing IDs and ordinary links", () => { + const value = svgDocument([ + { name: "a", parent: 0, attributes: [["xlink:href", "/svg"]] }, + { name: "A", parent: 0, attributes: [["href", "/ordinary"]] }, + { name: "a", parent: 0, attributes: [["xlink:href", "/missing-id"]] }, + { name: "a", parent: 0, attributes: [["xlink:href", "/non-svg"]] }, + ]); + const completeness = new CdpCaptureCompleteness(); + const capture = captureDom( + { + ...value, + documents: value.documents.map((document) => ({ + ...document, + nodes: { ...document.nodes, backendNodeId: [1, 2, 3, null, 5] }, + })), + }, + new Set([origin]), + input, + completeness, + ); + expect(capture.next()).toEqual({ + done: false, + value: [ + { backendNodeId: 2, nodeName: "a" }, + { backendNodeId: 5, nodeName: "a" }, + ], + }); + const step = capture.next([true, false]); + if (!step.done) throw new Error("Unexpected second batch"); + expect( + step.value.urls.map(({ node_index, url }) => [node_index, url]), + ).toEqual([ + [1, `${origin}/svg`], + [2, `${origin}/ordinary`], + ]); + expect(step.value.unresolvedSvgNodes).toBe(1); + expect(completeness.snapshot().excluded).toContainEqual({ + section: "metadata", + reason: "provider_unavailable", + count: 1, + }); +}); + describe("SVG xlink:href destinations", () => { it("reports an SVG anchor that only has xlink:href, keeping the first value", () => { const result = captureSvg( @@ -339,6 +502,7 @@ describe("SVG xlink:href destinations", () => { { name: "g", parent: 1 }, { name: "a", + svg: true, parent: 2, attributes: [ ["xlink:href", "/xl-s2h"], @@ -371,6 +535,7 @@ describe("SVG xlink:href destinations", () => { { name: "svg", parent: 0 }, { name: "a", + svg: true, parent: 1, attributes: [ [first, valueFor(first)], @@ -395,18 +560,21 @@ describe("SVG xlink:href destinations", () => { svgDocument([ { name: "A", + svg: false, parent: 0, attributes: [["xlink:href", "/xl-html"]], }, { name: "math", parent: 0 }, { name: "mrow", + svg: false, parent: 2, attributes: [["xlink:href", "/xl-math"]], }, { name: "svg", parent: 0 }, { name: "a", + svg: true, parent: 4, attributes: [["xlink:href", "/xl-s2h"]], }, @@ -425,6 +593,7 @@ describe("SVG xlink:href destinations", () => { { name: "svg", parent: 0 }, { name: "a", + svg: true, parent: 1, attributes: [["xlink:href", "https://cdn.example.test/out"]], }, @@ -444,8 +613,18 @@ describe("SVG xlink:href destinations", () => { const result = captureSvg( svgDocument([ { name: "svg", parent: 0 }, - { name: "image", parent: 1, attributes: [["xlink:href", "guide"]] }, - { name: "use", parent: 1, attributes: [["xlink:href", "icon.svg"]] }, + { + name: "image", + svg: true, + parent: 1, + attributes: [["xlink:href", "guide"]], + }, + { + name: "use", + svg: true, + parent: 1, + attributes: [["xlink:href", "icon.svg"]], + }, ]), ); expect(result.urls).toEqual([ @@ -466,14 +645,24 @@ describe("SVG xlink:href destinations", () => { }); describe("SVG xlink:href context", () => { - it("keeps forward ancestor context local to each document", () => { + it("binds SVG identity to the selected element across documents", () => { const svg = svgDocument([ - { name: "a", parent: 2, attributes: [["xlink:href", "/svg-link"]] }, + { + name: "a", + svg: true, + parent: 2, + attributes: [["xlink:href", "/svg-link"]], + }, { name: "g", parent: 3 }, { name: "svg", parent: 0 }, ]); const html = svgDocument([ - { name: "A", parent: 2, attributes: [["xlink:href", "/html-link"]] }, + { + name: "A", + svg: false, + parent: 2, + attributes: [["xlink:href", "/html-link"]], + }, { name: "DIV", parent: 3 }, { name: "BODY", parent: 0 }, ]); @@ -507,32 +696,39 @@ describe("SVG xlink:href context", () => { ]); }); - it("stops SVG context at foreignObject and resumes it for a nested svg", () => { - const result = captureSvg( - svgDocument([ - { name: "svg", parent: 0 }, - { name: "foreignObject", parent: 1 }, - { - name: "A", - parent: 2, - attributes: [["xlink:href", "/xl-html-fo"]], - }, - { name: "svg", parent: 2 }, - { - name: "a", - parent: 4, - attributes: [["xlink:href", "/xl-nested"]], - }, - ]), - ); - expect(urlFor(result, 3)).toBeUndefined(); - expect(urlFor(result, 5)).toMatchObject({ - attribute: "href", - url: `${origin}/xl-nested`, - destination_scope: "approved", - }); - }); + it.each(["foreignObject", "desc", "title"])( + "preserves browser SVG identity across %s HTML integration points", + (integrationPoint) => { + const result = captureSvg( + svgDocument([ + { name: "svg", parent: 0 }, + { name: integrationPoint, parent: 1 }, + { + name: "A", + svg: false, + parent: 2, + attributes: [["xlink:href", "/xl-html-fo"]], + }, + { name: "svg", parent: 2 }, + { + name: "a", + svg: true, + parent: 4, + attributes: [["xlink:href", "/xl-nested"]], + }, + ]), + ); + expect(urlFor(result, 3)).toBeUndefined(); + expect(urlFor(result, 5)).toMatchObject({ + attribute: "href", + url: `${origin}/xl-nested`, + destination_scope: "approved", + }); + }, + ); +}); +describe("SVG XLink precedence and unknown identity", () => { it("keeps an empty href ahead of xlink:href", () => { const baseUrl = `${origin}/screens/assets/`; const result = captureSvg( @@ -541,6 +737,7 @@ describe("SVG xlink:href context", () => { { name: "svg", parent: 0 }, { name: "a", + svg: true, parent: 1, attributes: [ ["href", ""], @@ -567,6 +764,7 @@ describe("SVG xlink:href context", () => { { name: "svg", parent: 0 }, { name: "a", + svg: true, parent: 1, attributes: [["xlink:href", "javascript:void(0)"]], }, @@ -582,7 +780,7 @@ describe("SVG xlink:href context", () => { ]); }); - it("does not treat a cyclic or dangling parent chain as SVG", () => { + it("keeps an unavailable SVG identity unknown despite ancestor names", () => { const cyclic = captureSvg( svgDocument([ { name: "g", parent: 2 }, @@ -652,7 +850,12 @@ describe("XML SVG roots and namespace scope restoration", () => { ["xmlns:p", XLINK_NAMESPACE], ], }, - { name: "a", parent: 1, attributes: [["p:href", "/linked"]] }, + { + name: "a", + svg: true, + parent: 1, + attributes: [["p:href", "/linked"]], + }, ]), ); expect(result.urls).toEqual([approvedHref(2, `${origin}/linked`)]); @@ -764,6 +967,7 @@ describe("XHTML and XML SVG namespace prefixes", () => { { name: "svg:foreignObject", parent: 2 }, { name: "a", + svg: true, parent: 3, attributes: [["xlink:href", "/xl-html-fo"]], }, @@ -799,6 +1003,7 @@ describe("XHTML and XML SVG namespace prefixes", () => { }, { name: "a", + svg: true, parent: 2, attributes: [ ["xlink:href", "/not-xlink"], @@ -918,11 +1123,13 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => { }, { name: "a", + svg: true, parent: 2, attributes: [["xlink:href", "/xl-literal"]], }, { name: "a", + svg: true, parent: 2, attributes: [["x:href", "/xl-custom-html"]], }, @@ -937,6 +1144,7 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => { }, { name: "A", + svg: false, parent: 1, attributes: [["xlink:href", "/xl-html-anchor"]], }, @@ -944,7 +1152,9 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => { ); expect(result.urls).toEqual([approvedHref(3, `${origin}/xl-literal`)]); }); +}); +describe("XML namespace resolution ordering and capacity", () => { it("applies an element's own xmlns declarations to its name and href", () => { const result = captureSvg( svgDocument([ diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 3c7fb98ed..f7eb4fe31 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -11,7 +11,11 @@ import { } from "./CdpCaptureValues.js"; import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js"; import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; -import { createDocumentSvgAncestry, domSvgLink } from "./CdpCaptureDomSvg.js"; +import { + createDocumentSvgAncestry, + domSvgLink, + type DomSvgNodeQuery, +} from "./CdpCaptureDomSvg.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; export type CapturedResource = Omit< @@ -135,18 +139,39 @@ export const walkFrameTrees = function* ( } }; -export const captureDom = ( +interface DomMetadataCapture { + readonly urls: WebPageInspection["metadata"]["dom_urls"]; + readonly agentHints: WebPageInspection["metadata"]["agent_hints"]; + readonly excludedUrls: number; + readonly unresolvedSvgNodes: number; +} + +export interface DomCapture extends DomMetadataCapture { + readonly total: number; + readonly nodes: WebPageInspection["dom"]["nodes"]; +} + +// Bound retained projections and outstanding CDP reads independently of page size. +// Eight leaves room for the session's other cancellation listeners. +const DOM_METADATA_BATCH_SIZE = 8; + +interface PendingDomMetadata { + readonly options: DomMetadataOptions; + // Undefined means no query is needed; null means the candidate has no valid ID. + readonly svgQuery: DomSvgNodeQuery | null | undefined; +} + +/** Project one snapshot, requesting bounded batches of SVG identity facts. */ +export function* captureDom( result: unknown, allowedOrigins: ReadonlySet, input: InspectWebPageInput, completeness?: CdpCaptureCompleteness, -): { - readonly total: number; - readonly nodes: WebPageInspection["dom"]["nodes"]; - readonly urls: WebPageInspection["metadata"]["dom_urls"]; - readonly agentHints: WebPageInspection["metadata"]["agent_hints"]; - readonly excludedUrls: number; -} => { +): Generator< + readonly DomSvgNodeQuery[], + DomCapture, + readonly (boolean | undefined)[] +> { const root = requiredRecord(result); const strings = Array.isArray(root.strings) ? root.strings.map((value) => cdpStringValue(value) ?? "") @@ -156,6 +181,7 @@ export const captureDom = ( const agentHints: WebPageInspection["metadata"]["agent_hints"] = []; let excludedUrls = 0; let total = 0; + let unresolvedSvgNodes = 0; for (const document of recordsValue(root.documents)) { const documentUrl = indexedString(strings, document.documentURL); if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) { @@ -172,6 +198,9 @@ export const captureDom = ( const attributes = Array.isArray(documentNodes.attributes) ? documentNodes.attributes : []; + const backendNodeIds = Array.isArray(documentNodes.backendNodeId) + ? documentNodes.backendNodeId + : []; const baseIndex = nodes.length; const ancestry = createDocumentSvgAncestry({ nodeTypes, @@ -181,12 +210,29 @@ export const captureDom = ( attributes, }); total += nodeTypes.length; + const pendingMetadata: PendingDomMetadata[] = []; for (let index = 0; index < nodeTypes.length; index += 1) { const attributeIndexes = numberArray(attributes[index]); const parent = Math.trunc(parents[index] ?? -1); const nodeIndex = nodes.length; const nodeName = indexedString(strings, nodeNames[index]); const attributeNames = uniqueAttributeNames(attributeIndexes, strings); + const svgLink = domSvgLink(ancestry, index); + let svgQuery: DomSvgNodeQuery | null | undefined; + if ( + !svgLink.xmlDocument && + nodeTypes[index] === 1 && + attributeNames.includes("xlink:href") && + !attributeNames.includes("href") + ) { + const backendNodeId = numberValue(backendNodeIds[index]); + svgQuery = + backendNodeId !== undefined && + Number.isSafeInteger(backendNodeId) && + backendNodeId > 0 + ? { backendNodeId, nodeName } + : null; + } nodes.push({ index: nodeIndex, parent_index: parent < 0 ? -1 : baseIndex + parent, @@ -195,30 +241,34 @@ export const captureDom = ( node_value_length: indexedString(strings, nodeValues[index]).length, attribute_names: attributeNames, }); - const metadata = domMetadata({ - strings, - attributes: attributeIndexes, - // The BASE element itself resolves against the document fallback URL. - baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl, - documentUrl, - nodeIndex, - nodeName, - allowedOrigins, - ...domSvgLink(ancestry, index), + pendingMetadata.push({ + svgQuery, + options: { + strings, + attributes: attributeIndexes, + // The BASE element itself resolves against the document fallback URL. + baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl, + documentUrl, + nodeIndex, + nodeName, + allowedOrigins, + ...svgLink, + }, }); - for (const url of metadata.urls) { - urls.push(url); - if (url.destination_scope !== "approved") { - excludedUrls += 1; - completeness?.exclude( - "metadata", - url.destination_scope === "outside_policy" - ? "disallowed_origin" - : "unsupported_url", - ); - } + if ( + pendingMetadata.length === DOM_METADATA_BATCH_SIZE || + index === nodeTypes.length - 1 + ) { + const metadata = yield* captureDomMetadata( + pendingMetadata, + completeness, + ); + urls.push(...metadata.urls); + agentHints.push(...metadata.agentHints); + excludedUrls += metadata.excludedUrls; + unresolvedSvgNodes += metadata.unresolvedSvgNodes; + pendingMetadata.length = 0; } - agentHints.push(...metadata.agentHints); } } return { @@ -227,8 +277,54 @@ export const captureDom = ( urls, agentHints, excludedUrls, + unresolvedSvgNodes, }; -}; +} + +function* captureDomMetadata( + pending: readonly PendingDomMetadata[], + completeness: CdpCaptureCompleteness | undefined, +): Generator< + readonly DomSvgNodeQuery[], + DomMetadataCapture, + readonly (boolean | undefined)[] +> { + const queries = pending.flatMap(({ svgQuery }) => + svgQuery == null ? [] : [svgQuery], + ); + const kinds = queries.length > 0 ? yield queries : []; + const urls: DomMetadataCapture["urls"] = []; + const agentHints: DomMetadataCapture["agentHints"] = []; + let queryIndex = 0; + let unresolvedSvgNodes = 0; + let excludedUrls = 0; + for (const { options, svgQuery } of pending) { + let svgElement = options.svgElement; + if (svgQuery !== undefined) { + const kind = svgQuery === null ? undefined : kinds[queryIndex++]; + svgElement = kind === true; + if (kind === undefined) { + unresolvedSvgNodes += 1; + completeness?.exclude("metadata", "provider_unavailable"); + } + } + const metadata = domMetadata({ ...options, svgElement }); + for (const url of metadata.urls) { + urls.push(url); + if (url.destination_scope !== "approved") { + excludedUrls += 1; + completeness?.exclude( + "metadata", + url.destination_scope === "outside_policy" + ? "disallowed_origin" + : "unsupported_url", + ); + } + } + agentHints.push(...metadata.agentHints); + } + return { urls, agentHints, unresolvedSvgNodes, excludedUrls }; +} export const captureAccessibility = ( results: readonly unknown[], diff --git a/src/browser/CdpCaptureDomSvg.ts b/src/browser/CdpCaptureDomSvg.ts index 3d1a5c977..4d021766c 100644 --- a/src/browser/CdpCaptureDomSvg.ts +++ b/src/browser/CdpCaptureDomSvg.ts @@ -1,4 +1,4 @@ -import { numberValue } from "./CdpCaptureValues.js"; +import { numberValue, recordValue } from "./CdpCaptureValues.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"; @@ -18,11 +18,34 @@ interface XmlSvgLink { interface DocumentSvgAncestry { readonly xml: boolean; - readonly nodes: DomSnapshotNodes; - readonly svgContexts: Map; readonly xmlLinks: Map; } +export interface DomSvgNodeQuery { + readonly backendNodeId: number; + readonly nodeName: string; +} + +/** Bind the browser's SVG flag to the exact snapshotted element. */ +export const capturedSvgNodeKind = ( + query: DomSvgNodeQuery, + result: unknown, +): boolean | undefined => { + const node = recordValue(recordValue(result)?.node); + if ( + node?.backendNodeId !== query.backendNodeId || + node.nodeName !== query.nodeName || + node.nodeType !== 1 + ) + return undefined; + // CDP omits isSVG for non-SVG nodes. Other values are malformed, not false. + return node.isSVG === undefined + ? false + : typeof node.isSVG === "boolean" + ? node.isSVG + : undefined; +}; + interface DomSvgLink { readonly svgElement: boolean; readonly xmlDocument: boolean; @@ -79,8 +102,6 @@ export const createDocumentSvgAncestry = ( const xml = xmlDocument(nodes); return { xml, - nodes, - svgContexts: new Map(), xmlLinks: xml ? resolveXmlElements(nodes) : new Map(), }; }; @@ -91,11 +112,7 @@ export const domSvgLink = ( ): DomSvgLink => { if (!ancestry.xml) { return { - svgElement: elementInSvgContext( - index, - ancestry.nodes, - ancestry.svgContexts, - ), + svgElement: false, xmlDocument: false, xlinkHref: undefined, }; @@ -108,43 +125,6 @@ export const domSvgLink = ( }; }; -// Chrome's HTML parser records SVG local names (`svg`, `foreignObject`). -// Descendants of foreignObject are HTML until a nested `svg`. -const elementInSvgContext = ( - index: number, - nodes: DomSnapshotNodes, - contexts: Map, -): boolean => { - let current = index; - const seen = new Set(); - let inSvg = false; - while ( - current >= 0 && - current < nodes.nodeNames.length && - !seen.has(current) - ) { - const cached = contexts.get(current); - if (cached !== undefined) { - inSvg = cached; - break; - } - seen.add(current); - if (stringAt(nodes.strings, nodes.nodeNames[current]) === "svg") { - inSvg = true; - break; - } - const parent = Math.trunc(nodes.parents[current] ?? -1); - if (parent < 0 || parent === current) break; - if (stringAt(nodes.strings, nodes.nodeNames[parent]) === "foreignObject") - break; - current = parent; - } - // Cache the entire examined chain, including forward references and cycles, - // so shared ancestors are examined once per document. - for (const node of seen) contexts.set(node, inSvg); - return inSvg; -}; - interface NamespaceRestore { readonly prefix: string; readonly previous: string | undefined; diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 0d97576b5..f49d4e186 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -17,8 +17,10 @@ import { captureFrames, captureResources, type CapturedResource, + type DomCapture, mainFrameUrl, } from "./CdpCaptureDocuments.js"; +import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js"; import { allowedSanitizedUrl, delayWithCancellation, @@ -52,7 +54,7 @@ interface CaptureState { interface CapturedSections { readonly attachedUrl: string; readonly frames: WebPageInspection["frames"]; - readonly dom: ReturnType; + readonly dom: DomCapture; readonly accessibility: ReturnType; readonly scripts: WebPageInspection["scripts"]; readonly resources: WebPageInspection["resources"]; @@ -260,9 +262,7 @@ const authorizeObservationWindow = async ( throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); }; -const capturePageDom = async ( - state: CaptureState, -): Promise> => { +const capturePageDom = async (state: CaptureState): Promise => { const { context, allowedOrigins } = state; const result = await context.connection.send( "DOMSnapshot.captureSnapshot", @@ -274,12 +274,56 @@ const capturePageDom = async ( context.sessionId, context.signal, ); - return captureDom( + const capture = captureDom( result, allowedOrigins, context.input, state.events.completeness, ); + let step = capture.next(); + let descriptionUnavailable = false; + const pendingController = new AbortController(); + const signal = + context.signal === undefined + ? pendingController.signal + : AbortSignal.any([context.signal, pendingController.signal]); + while (!step.done) { + const pending = step.value.map(async (query) => { + if (descriptionUnavailable) return undefined; + try { + return capturedSvgNodeKind( + query, + await context.connection.send( + "DOM.describeNode", + { backendNodeId: query.backendNodeId, depth: 0 }, + context.sessionId, + signal, + ), + ); + } catch (cause: unknown) { + // A detached node can disappear between snapshot and description. + // Preserve partial metadata, but propagate transport/cancellation errors. + if (!(cause instanceof CdpCommandRejection)) throw cause; + if (cause.code === -32_601) descriptionUnavailable = true; + else if (cause.code !== -32_000) throw cause; + return undefined; + } + }); + try { + // Promise.all preserves snapshot order even when CDP replies out of order. + step = capture.next(await Promise.all(pending)); + } catch (cause: unknown) { + // Stop and settle this bounded batch before the session can be detached. + pendingController.abort(); + await Promise.allSettled(pending); + throw cause; + } + } + if (step.value.unresolvedSvgNodes > 0) + state.limitations.push( + `SVG identity was unavailable for ${String(step.value.unresolvedSvgNodes)} snapshotted XLink candidate nodes; their URL destinations remain unknown.`, + ); + return step.value; }; const normalizedInspection = ( diff --git a/tests/boundary/browser/cdpDomSvgIdentity.test.ts b/tests/boundary/browser/cdpDomSvgIdentity.test.ts new file mode 100644 index 000000000..7d42ae336 --- /dev/null +++ b/tests/boundary/browser/cdpDomSvgIdentity.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import { + startFakeCdpBrowser, + type FakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; +import { trackBrowser } from "./cdpBrowserProvider.support.js"; + +const startBrowser = async (options: FakeOptions = {}) => { + const browser = await startFakeCdpBrowser({ + ...options, + commandResult(command, origin, frameReads) { + const override = options.commandResult?.(command, origin, frameReads); + if (override !== undefined) return override; + if (command.method === "DOMSnapshot.captureSnapshot") { + const nodes = { + nodeType: [9, 1, 1, 1, 1], + nodeName: [1, 2, 3, 3, 3], + nodeValue: [4, 4, 4, 4, 4], + parentIndex: [-1, 0, 1, 1, 1], + backendNodeId: [1, 2, 3, 4, 5], + attributes: [[], [], [5, 6], [5, 7], [5, 6, 8, 9]], + }; + return { + strings: [ + `${origin}/app`, + "#document", + "HTML", + "a", + "", + "xlink:href", + "/first", + "/second", + "href", + "/ordinary", + "https://excluded.example/page", + ], + documents: [ + { documentURL: 0, nodes }, + { + documentURL: 10, + nodes: { ...nodes, backendNodeId: [11, 12, 13, 14, 15] }, + }, + ], + }; + } + if (command.method === "DOM.describeNode") + return { + node: { + backendNodeId: command.params.backendNodeId, + nodeType: 1, + nodeName: "a", + isSVG: true, + }, + }; + return undefined; + }, + }); + trackBrowser(browser); + return browser; +}; + +const inspect = (browser: FakeCdpBrowser, signal?: AbortSignal) => + new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + observation_ms: 0, + }), + signal === undefined ? undefined : { signal }, + ); + +describe("CDP SVG identity at the snapshot boundary", () => { + it("describes only in-scope XLink candidates without a winning href", async () => { + const browser = await startBrowser(); + const result = await inspect(browser); + if (!result.ok) throw result.error; + expect(result.value.metadata.dom_urls.map(({ url }) => url)).toEqual( + ["/first", "/second", "/ordinary"].map( + (path) => browser.allowedOrigin + path, + ), + ); + expect( + browser.commands + .filter(({ method }) => method === "DOM.describeNode") + .map(({ params }) => params), + ).toEqual([ + { backendNodeId: 3, depth: 0 }, + { backendNodeId: 4, depth: 0 }, + ]); + }); + + it.each(["removed", "mismatched"])( + "retains partial metadata when a candidate is %s", + async (failure) => { + const browser = await startBrowser({ + commandError: (command) => + failure === "removed" && + command.method === "DOM.describeNode" && + command.params.backendNodeId === 3 + ? { code: -32_000, message: "No node found for given backend id" } + : undefined, + commandResult: (command) => + failure === "mismatched" && + command.method === "DOM.describeNode" && + command.params.backendNodeId === 3 + ? { + node: { + backendNodeId: 99, + nodeType: 1, + nodeName: "a", + isSVG: true, + }, + } + : undefined, + }); + const result = await inspect(browser); + if (!result.ok) throw result.error; + expect(result.value.metadata.dom_urls.map(({ url }) => url)).toEqual( + ["/second", "/ordinary"].map((path) => browser.allowedOrigin + path), + ); + expect(result.value.completeness.excluded).toContainEqual({ + section: "metadata", + reason: "provider_unavailable", + count: 1, + }); + expect(result.value.limitations).toContain( + "SVG identity was unavailable for 1 snapshotted XLink candidate nodes; their URL destinations remain unknown.", + ); + }, + ); + + it("stops querying after the first batch reports an unsupported method", async () => { + const browser = await startBrowser({ + commandResult: manyCandidates, + unsupportedMethods: ["DOM.describeNode"], + }); + const result = await inspect(browser); + if (!result.ok) throw result.error; + expect(result.value.metadata.dom_urls).toEqual([]); + expect(result.value.completeness.excluded).toContainEqual({ + section: "metadata", + reason: "provider_unavailable", + count: 25, + }); + expect(descriptions(browser).length).toBeGreaterThan(0); + expect(descriptions(browser).length).toBeLessThanOrEqual(8); + }); + + it.each([ + { options: { closeOnMethod: "DOM.describeNode" }, reason: "disconnected" }, + { + options: { + commandError: (command: { method: string }) => + command.method === "DOM.describeNode" + ? { code: -32_602, message: "Invalid parameters" } + : undefined, + }, + reason: "protocol_error", + }, + ])( + "propagates $reason during node description", + async ({ options, reason }) => { + const browser = await startBrowser(options); + expect(await inspect(browser)).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason }, + }); + }, + ); + + it("cancels a pending description and detaches without closing the target", async () => { + const browser = await startBrowser({ hangOnMethod: "DOM.describeNode" }); + const controller = new AbortController(); + const pending = inspect(browser, controller.signal); + await expect.poll(() => descriptions(browser).length).toBeGreaterThan(1); + controller.abort(); + expect(await pending).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", operation: "inspect_web_page" }, + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + expect(methods).not.toContain("Browser.close"); + }); +}); + +const manyCandidates: NonNullable = ( + command, + origin, +) => { + if (command.method !== "DOMSnapshot.captureSnapshot") return undefined; + const count = 25; + return { + strings: [ + origin, + "#document", + "HTML", + "a", + "", + "xlink:href", + ...Array.from({ length: count }, (_, index) => `/link-${String(index)}`), + ], + documents: [ + { + documentURL: 0, + nodes: { + nodeType: [9, 1, ...Array(count).fill(1)], + nodeName: [1, 2, ...Array(count).fill(3)], + nodeValue: Array(count + 2).fill(4), + parentIndex: [-1, 0, ...Array(count).fill(1)], + backendNodeId: Array.from( + { length: count + 2 }, + (_, index) => index + 1, + ), + attributes: [ + [], + [], + ...Array.from({ length: count }, (_, index) => [5, index + 6]), + ], + }, + }, + ], + }; +}; + +const descriptions = (browser: FakeCdpBrowser) => + browser.commands.filter(({ method }) => method === "DOM.describeNode"); + +describe("CDP SVG query concurrency", () => { + it("bounds in-flight reads and preserves metadata order across reversed replies", async () => { + const browser = await startBrowser({ + commandResult: manyCandidates, + hangOnMethod: "DOM.describeNode", + }); + const controller = new AbortController(); + const pending = inspect(browser, controller.signal); + try { + await expect.poll(() => descriptions(browser).length).toBeGreaterThan(1); + let replied = 0; + while (replied < 25) { + await expect + .poll(() => descriptions(browser).length) + .toBeGreaterThan(replied); + const batch = descriptions(browser).slice(replied); + expect(batch.length).toBeLessThanOrEqual(8); + for (const command of [...batch].reverse()) { + const id = Number(command.params.backendNodeId); + browser.emitRawMessage( + JSON.stringify({ + id: command.id, + result: { + node: { + backendNodeId: id, + nodeType: 1, + nodeName: "a", + isSVG: id % 2 === 1, + }, + }, + }), + ); + } + replied += batch.length; + } + const result = await pending; + if (!result.ok) throw result.error; + expect(result.value.metadata.dom_urls.map(({ url }) => url)).toEqual( + Array.from( + { length: 13 }, + (_, index) => `${browser.allowedOrigin}/link-${String(index * 2)}`, + ), + ); + expect(descriptions(browser)).toHaveLength(25); + } finally { + controller.abort(); + await pending; + } + }); + + it("cancels sibling reads on a fatal reply without waiting for hung nodes", async () => { + const browser = await startBrowser({ + commandResult: manyCandidates, + hangOnMethod: "DOM.describeNode", + }); + const controller = new AbortController(); + const pending = inspect(browser, controller.signal); + try { + await expect.poll(() => descriptions(browser).length).toBeGreaterThan(1); + const batch = descriptions(browser); + browser.emitRawMessage( + JSON.stringify({ + id: batch.at(-1)?.id, + error: { code: -32_602, message: "Invalid parameters" }, + }), + ); + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + expect(descriptions(browser)).toHaveLength(batch.length); + const methods = browser.commands.map(({ method }) => method); + expect(methods).toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + expect(methods).not.toContain("Browser.close"); + } finally { + controller.abort(); + await pending; + } + }); +}); diff --git a/tests/boundary/browser/cdpSvgXlinkHref.test.ts b/tests/boundary/browser/cdpSvgXlinkHref.test.ts index 56c3fc3cf..38048861b 100644 --- a/tests/boundary/browser/cdpSvgXlinkHref.test.ts +++ b/tests/boundary/browser/cdpSvgXlinkHref.test.ts @@ -45,8 +45,38 @@ const pageHtml = `x out + move desc + move title + + not an SVG link + HTML link + nested + + + <a xlink:href="/xl-html-title">not an SVG link</a> + <a href="/html-title">HTML link</a> + <svg><a xlink:href="/xl-nested-title"><text>nested</text></a></svg> + html + `; it.skipIf(chromeExecutable === undefined)( @@ -143,17 +173,36 @@ it.skipIf(chromeExecutable === undefined)( url: null, destination_scope: "outside_policy", }), + ...[ + "/html-desc", + "/html-title", + "/xl-nested-desc", + "/xl-nested-title", + "/xl-moved-desc", + "/xl-moved-title", + "/xl-created-desc", + "/xl-created-title", + "/xl-created-nested-desc", + "/xl-created-nested-title", + ].map((path) => + expect.objectContaining({ + attribute: "href", + url: `${origin}${path}`, + destination_scope: "approved", + }), + ), ]), ); expect( urls.some( (url) => - url.url?.endsWith("/xl-html") === true || + url.url?.includes("/xl-html") === true || + url.url?.includes("/xl-math") === true || url.url?.endsWith("/xlink-loses") === true, ), ).toBe(false); expect(urls.every((url) => url.attribute === "href")).toBe(true); - expect(urls).toHaveLength(5); + expect(urls).toHaveLength(15); } finally { child.kill("SIGTERM"); if (!(await waitForExit(child, 2_000))) child.kill("SIGKILL"); @@ -173,6 +222,8 @@ const xhtmlPage = ` p + desc + title no no @@ -202,6 +253,8 @@ for (const { format, page, contentType, nodeNames, link, destinations } of [ destinations: [ "/xl-pa1", "/xl-image", + "/xl-xml-desc", + "/xl-xml-title", "/xl-fo-svg", "/xl-orphan", "/xl-custom", From dd4bfed6141efcb57aef81cf653962272efdf866 Mon Sep 17 00:00:00 2001 From: Jin Date: Tue, 13 Oct 2026 09:23:58 -0400 Subject: [PATCH 43/92] fix(javascript): keep ASAR-named files without an ASAR header opaque (#2347) Inventory expands an ASAR-named filesystem file only when its bytes are an ASAR. JavaScript reconstruction expanded it on the suffix alone, so a directory holding any other file named *.asar failed the whole analysis with an invalid ASAR header. Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../javascript/JavaScriptArtifactFiles.ts | 6 ++++ .../javascriptIntegrityPolicy.test.ts | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index e3849f814..78d8cf750 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -37,6 +37,7 @@ interface ExpectedFile { interface ExpectedContainer { readonly hash_status: ArtifactOccurrence["hash_status"]; + readonly format: ArtifactOccurrence["artifact_format"]; readonly inventory: JavaScriptArtifactContainer | null; } @@ -219,6 +220,9 @@ const visitReader = async ( "integrity", `Nested ASAR was not integrity-verified: ${path}`, ); + // Inventory expands an ASAR-named file only when its bytes are an + // ASAR. One it recorded as ordinary bytes has no members to read. + if (container.format !== "asar") continue; const inventory = container.inventory; if (inventory === null) throw new ArtifactReaderFailure( @@ -395,6 +399,7 @@ const expectedInventory = ( if (isNestedAsar) containers.set(occurrence.logical_path, { hash_status: occurrence.hash_status, + format: occurrence.artifact_format, inventory: null, }); continue; @@ -412,6 +417,7 @@ const expectedInventory = ( if (isNestedAsar) containers.set(occurrence.logical_path, { hash_status: occurrence.hash_status, + format: occurrence.artifact_format, inventory: { path: occurrence.logical_path, sha256: node.sha256, diff --git a/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts b/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts index 47b77114a..67b167bdf 100644 --- a/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts +++ b/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts @@ -105,6 +105,34 @@ it("keeps a contradicted nested ASAR opaque during JavaScript reconstruction", a expect(result.graph.coverage.omitted_count).toBeNull(); }); +it("keeps an ASAR-named file without an ASAR header opaque during JavaScript reconstruction", async () => { + const root = await createTestTempDirectory("rea-opaque-asar-name-"); + await writeFile(join(root, "main.js"), "export const outer = true;\n"); + // Inventory records this file as ordinary bytes and does not expand it. + await writeFile(join(root, "ordinary.asar"), "ordinary bytes"); + + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const read = await readJavaScriptArtifactFiles(reader, snapshot); + expect(read.files.map(({ path }) => path)).toEqual(["main.js"]); + expect(read.containers).toEqual([]); + } finally { + await reader.close(); + } + + const analyzed = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!analyzed.ok) throw analyzed.error; + const result = javascriptApplicationAnalysisResultSchema.parse( + analyzed.value.normalized_result, + ); + expect(result.statistics.nested_asar_containers).toBe(0); + expect(result.integrity_contradictions).toEqual([]); +}); + it("retains semantic resource limits alongside recorded integrity contradictions", async () => { const { archive } = await createStrippedAsarAddon([ { path: "app.js", contents: primitiveCandidateExpansionSource() }, From 003707f712d06e49b84a48e04d58b50f9a097469 Mon Sep 17 00:00:00 2001 From: Luis <86675342+LuisTaffelli@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:24:40 -0300 Subject: [PATCH 44/92] fix(verification): align Linux Hopper setup lane with current contracts (#1942) * fix(verification): align Linux Hopper setup lane with current contracts * fix(verification): retain orphan checks on clean Linux runners * fix(verification): detect Hopper through alternate launcher paths * fix(verification): report identified Hopper leaks before proc errors * fix(verification): distinguish Hopper string recognition from byte fidelity * fix(verification): report full literal decoding coverage explicitly * test(verification): require a clean host before orphan checks --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- tests/process-global/cleanLinuxHopperRuntime.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts index d356f5abf..f6b9fc677 100644 --- a/tests/process-global/cleanLinuxHopperRuntime.test.ts +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -30,6 +30,9 @@ it await chmod(launcher, 0o700); await symlink(launcher, alternate); } + // A rejection must come from the fixture we are about to launch. An + // existing Xvfb would otherwise mask a broken executable-identity check. + await assertCleanLinuxHopperRuntime(alternateMode ? [launcher] : []); const command = alternateMode ? alternate : process.execPath; const arguments_ = alternateMode ? ["30"] : ["-e", childCode]; const parentCode = ` From 48b92e1be1110fd200cc6d2fd0cdf7c6e2f9e21a Mon Sep 17 00:00:00 2001 From: Joshwong1908 <136307740+Joshwong1908@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:25:32 +0800 Subject: [PATCH 45/92] fix(go): classify JVM class files instead of universal Mach-O for CAFEBABE bytes (#2458) * test(go): pin JVM class files as unsupported Go images The regression fails on main: readGoBinaryImage(javaClassFile()) reports "Universal/fat Mach-O containers require selecting a thin slice" for a JVM class file whose bytes 4..7 are minor/major version, not nfat_arch. * fix(go): classify JVM class files instead of universal Mach-O Route the four FAT magics through the shared readFatContainerHeader and classifyFatContainerIdentity facts added for the same disambiguation in the artifact and target lanes (921bb9575). Keep the thin-slice failure only when the container identity is confirmed Mach-O; a JVM class file now falls through to the generic only-ELF/PE/thin-Mach-O failure. * test(go): pin universal Mach-O thin-slice failure with FAT fixtures Cover the kept behavior with the repository's own fatImage/machoImage builders (narrow and FAT64 tables) so the regression also proves real universal containers keep the slice-selection failure. * test(go): cover little-endian FAT magics in the universal container control * fix(go): require positive universal Mach-O identity Require positive Mach-O identity before reporting a universal thin-slice remedy; malformed FAT-shaped bytes are not proof. Add the JVM constant-pool CPU lookalike to the existing fixture control. Source inspection and git diff --check; tests/builds/typechecks/CI reruns skipped at explicit user request. * fix(go): retain malformed FAT header diagnostics Preserve the owning FAT header parser reason for malformed headers while requiring positive Mach-O proof before the universal-slice remedy. Source inspection and git diff --check only. Tests, builds and typechecks skipped by explicit user request. GitHub review writes paused globally. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/go/GoBinaryImage.ts | 30 +++++++++++++++++---- tests/boundary/go/goBinaryImage.test.ts | 35 +++++++++++++++++++++++++ tests/fixtures/javaClassFile.ts | 3 ++- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index 03d0866ab..4a845c197 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -8,6 +8,10 @@ import { type GoBinaryContainer, type GoFileMapping, } from "./GoBinaryContainer.js"; +import { + classifyFatContainerIdentity, + readFatContainerHeader, +} from "../domain/apple/machoContainer.js"; import { readGoElfImage } from "./GoElfImage.js"; import { readGoMachoImage } from "./GoMachoImage.js"; import { readGoPeImage } from "./GoPeImage.js"; @@ -187,11 +191,27 @@ const readContainer = (bytes: Buffer): GoBinaryContainer => { return readGoPeImage(bytes); if (bytes.length >= 4) { const magic = bytes.readUInt32BE(); - if ([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(magic)) - throw new GoBinaryFormatFailure( - "unsupported", - "Universal/fat Mach-O containers require selecting a thin slice", - ); + if ([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(magic)) { + const fatHeader = readFatContainerHeader(bytes, bytes.length); + if (fatHeader.state === "malformed") + throw new GoBinaryFormatFailure("malformed", fatHeader.reason); + const identity = + fatHeader.state === "parsed" + ? classifyFatContainerIdentity(fatHeader, (offset) => { + if (offset >= bytes.length) return undefined; + if (offset + 4 > bytes.length) return null; + return bytes.readUInt32BE(offset); + }) + : undefined; + // An invalid FAT-shaped table is not proof of a Mach-O container: JVM + // constant-pool bytes can share both the magic and a known CPU value. + const confirmedFatMachO = identity?.state === "mach-o"; + if (confirmedFatMachO) + throw new GoBinaryFormatFailure( + "unsupported", + "Universal/fat Mach-O containers require selecting a thin slice", + ); + } if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) return readGoMachoImage(bytes); } diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts index 24321ecc8..29f0e952c 100644 --- a/tests/boundary/go/goBinaryImage.test.ts +++ b/tests/boundary/go/goBinaryImage.test.ts @@ -1,4 +1,10 @@ import { expect, it } from "vitest"; +import { javaClassFile } from "../../fixtures/javaClassFile.js"; +import { + CPU, + fatImage, + machoImage, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js"; import { createGoBinaryFixture, @@ -221,6 +227,35 @@ it("distinguishes unsupported containers from malformed recognized images", () = expect(() => readGoBinaryImage(pe.bytes)).toThrowError(/range|truncated/i); }); +it("reports JVM class files as unsupported images instead of fat Mach-O containers", () => { + expect(() => readGoBinaryImage(javaClassFile())).toThrowError( + /Only ELF, PE, and thin Mach-O native images are supported/, + ); + expect(() => readGoBinaryImage(javaClassFile(4000))).toThrowError( + /Only ELF, PE, and thin Mach-O native images are supported/, + ); + expect(() => readGoBinaryImage(javaClassFile(4000, true))).toThrowError( + /Only ELF, PE, and thin Mach-O native images are supported/, + ); +}); + +it("keeps the thin-slice failure for confirmed universal Mach-O containers", () => { + const slices = [ + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + { cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }, + ]; + for (const universal of [ + fatImage(slices), + fatImage(slices, true), + fatImage(slices, false, true), + fatImage(slices, true, true), + ]) { + expect(() => readGoBinaryImage(Buffer.from(universal))).toThrowError( + /fat Mach-O containers require selecting a thin slice/, + ); + } +}); + it("rejects ELF header tables overlapping the native header", () => { const fixture = createGoBinaryFixture({ sectionless: true }); fixture.bytes.writeBigUInt64LE(0n, 32); diff --git a/tests/fixtures/javaClassFile.ts b/tests/fixtures/javaClassFile.ts index 4f7727193..30f5ffc37 100644 --- a/tests/fixtures/javaClassFile.ts +++ b/tests/fixtures/javaClassFile.ts @@ -1,5 +1,5 @@ /** Build a valid minimal JVM class file, optionally with an unused UTF-8 constant. */ -export const javaClassFile = (extraUtf8Bytes = 0): Buffer => { +export const javaClassFile = (extraUtf8Bytes = 0, fatCpuLookalike = false): Buffer => { const utf8 = (value: string): Buffer => { const bytes = Buffer.from(value, "utf8"); const entry = Buffer.alloc(3 + bytes.length); @@ -13,6 +13,7 @@ export const javaClassFile = (extraUtf8Bytes = 0): Buffer => { utf8("Hello"), Buffer.from([7, 0, 4]), utf8("java/lang/Object"), + ...(fatCpuLookalike ? [utf8("x"), Buffer.from([3, 0, 0, 0, 7])] : []), ...(extraUtf8Bytes === 0 ? [] : [utf8("x".repeat(extraUtf8Bytes))]), ]; const headerAndPool = Buffer.alloc(10); From dea770d5d9e4af92bc3fa6a8ffad9ff414f6e4e6 Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:26:13 +0800 Subject: [PATCH 46/92] fix(javascript): retain getter receivers through local aliases (#2778) * fix(javascript): retain getter receivers through local aliases * fix(javascript): resolve projected private getter receivers --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/javascript-application-workflows.md | 12 + ...iptSemanticAnalysis.getter_aliases.test.ts | 351 +++++++++++++++ .../javascriptSemanticMemberMutations.ts | 69 +-- .../javascriptSemanticMutationInitializers.ts | 61 ++- .../javascriptSemanticObjectReferences.ts | 424 +++++++++++++++--- .../javascriptSemanticReferenceSources.ts | 53 +++ .../getterLocalAliasesCli.test.ts | 85 ++++ 7 files changed, 942 insertions(+), 113 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.getter_aliases.test.ts create mode 100644 src/domain/javascript/javascriptSemanticReferenceSources.ts create mode 100644 tests/acceptance/applications/getterLocalAliasesCli.test.ts diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 041015b9c..e9fed813f 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -208,6 +208,18 @@ arrays, and later explicit data properties and methods replace earlier object spread references. A known array prefix does not expose values from a following spread. Accessors, custom or uncertain iteration, unknown computed keys, and unknown spread lengths retain conservative uncertainty. +Getter-local aliases of `this`, including direct destructuring and assignments, +retain their receiver when a returned shared reference is written. These writes +invalidate the earlier literal instead of claiming the getter executed or the +new value is known. Straight-line alias reads retain their capture order; +uncertain control flow remains conservative. A binding outside the getter or +`this` in a nested ordinary function is not attributed to the getter's receiver. +Private reads keep receiver projections separate from the private value's path. +Receiver projections follow known data fields initialized from `this` or earlier +self-referencing fields; static fields retain their declaring class identity. +An alias cycle that repeatedly selects deeper children retains escape uncertainty +at its recovered roots rather than claiming a fixed number of loop iterations. + Writes through an object getter's returned receiver retain that uncertainty when the object is an array element, nested inside mixed object/array containers, or assigned to a previously declared identifier. Saving the getter diff --git a/src/domain/javascript/javascriptSemanticAnalysis.getter_aliases.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.getter_aliases.test.ts new file mode 100644 index 000000000..1eeaec350 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.getter_aliases.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +const returnedValue = (getter: string, write = "box.v.x = 2;") => + onlyCallable( + analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1, y: 1 }; + const box = { s: shared, get v() { ${getter} } }; + ${write} + return shared.x; + }`), + "result", + ).returnSites[0]?.value; + +describe("projected private getter receivers", () => { + it.each([ + [ + "destructured self field", + "shared", + "peer = this;", + "const { peer: self } = this; return self.#s;", + true, + "unknown", + ], + [ + "unrelated private child", + "{ peer: shared }", + "peer = this;", + "const { peer: self } = this; return self.#s;", + true, + "literal", + ], + [ + "read without write", + "shared", + "peer = this;", + "const { peer: self } = this; return self.#s;", + false, + "literal", + ], + [ + "two self fields", + "shared", + "peer = this; next = this.peer;", + "const { next: self } = this; return self.#s;", + true, + "unknown", + ], + [ + "nested receiver projection", + "shared", + "peer = this;", + "const { peer: { peer: self } } = this; return self.#s;", + true, + "unknown", + ], + [ + "member receiver alias", + "shared", + "peer = this;", + "const self = this.peer.peer; return self.#s;", + true, + "unknown", + ], + [ + "projected result suffix", + "{ child: shared }", + "peer = this;", + "const { peer: self } = this; return self.#s.child;", + true, + "unknown", + ], + [ + "external receiver", + "shared", + "peer = external;", + "const { peer: self } = this; return self.#s;", + true, + "literal", + ], + [ + "field receiver cycle", + "shared", + "peer = this.other; other = this.peer;", + "const { peer: self } = this; return self.#s;", + true, + "literal", + ], + ] as const)( + "keeps receiver and result paths separate for %s", + (_name, value, fields, getter, write, status) => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + class Box { + #s = ${value}; ${fields} + get v() { ${getter} } + } + const box = new Box(); + ${write ? "box.v.x = 2;" : "const saved = box.v;"} + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject( + status === "literal" ? { status, value: 1 } : { status }, + ); + }, + ); +}); + +describe("private receiver field ownership", () => { + it("bounds growing receiver projections under cyclic class inheritance", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + class A extends B { + #s = shared; + peer = this.other.other; + get v() { const { peer: self } = this; return self.#s; } + } + class B extends A { other = this.peer.peer; } + new A().v.x = 2; + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({ + status: "literal", + value: 1, + }); + }, 1000); + + it.each([ + [ + "own static receiver", + "class Box { static #s=shared; static peer=this; static get v(){const {peer:self}=this;return self.#s;} } Box.v.x=2;", + "unknown", + ], + [ + "inherited instance receiver", + "class Base {peer=this;} class Box extends Base {#s=shared;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;", + "unknown", + ], + [ + "inherited static field retains its declaring class", + "class Base {static peer=this;} class Box extends Base {static #s=shared;static get v(){const {peer:self}=this;return self.#s;}} Box.v.x=2;", + "literal", + ], + [ + "inherited static getter's own private field", + "class Base {static #s=shared;static peer=this;static get v(){const {peer:self}=this;return self.#s;}} class Box extends Base {} Box.v.x=2;", + "unknown", + ], + [ + "a field that is not initialized yet", + "class Box {#s=shared;peer=this.later;later=this;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;", + "literal", + ], + [ + "remaining getter projection after a captured field", + "class Box {#s=shared;peer=this;next=this.peer;later=this;get v(){const {next:{later:self}}=this;return self.#s;}} new Box().v.x=2;", + "unknown", + ], + [ + "a replaced self field", + "class Box {#s=shared;peer=this;peer=external;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;", + "literal", + ], + ] as const)("respects %s", (_name, body, status) => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + ${body} + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject( + status === "literal" ? { status, value: 1 } : { status }, + ); + }); +}); + +describe("getter-local receiver aliases (#2653)", () => { + it.each([ + ["initialized alias", "const self = this; return self.s;"], + ["assigned alias", "let self; self = this; return self.s;"], + [ + "chained assignment", + "let self; let alias; self = alias = this; return self.s;", + ], + ["destructured receiver", "const { s: value } = this; return value;"], + ["alias chain", "const self = this; const saved = self; return saved.s;"], + [ + "capture before reassignment", + "let self = this; const saved = self; self = { s: { x: 1 } }; return saved.s;", + ], + [ + "conditional receiver alias", + "const self = flag ? this : { s: { x: 1 } }; return self.s;", + ], + ])("invalidates shared values through %s", (_name, getter) => { + expect(returnedValue(getter)?.status).toBe("unknown"); + }); + + it.each([ + ["no write", "const self = this; return self.s;", "const saved = box.v;"], + [ + "an unrelated property write", + "const self = this; return self.s;", + "box.v.y = 2;", + ], + [ + "a cycle that does not grow the selected path", + "let self = this; while (flag) self = self; return self.s;", + "box.v.y = 2;", + ], + [ + "an alias replaced before return", + "let self = this; self = { s: { x: 1 } }; return self.s;", + "box.v.x = 2;", + ], + [ + "a shadowing block binding", + "const self = this; { const self = { s: { x: 1 } }; return self.s; }", + "box.v.x = 2;", + ], + [ + "a nested function's receiver", + "function other() { const self = this; return self.s; } return other();", + "box.v.x = 2;", + ], + [ + "an alias assigned in a different receiver scope", + "let self; function assign() { self = this; } assign(); return self.s;", + "box.v.x = 2;", + ], + [ + "an excluded destructuring default", + "const { value = shared } = { value: { x: 1 } }; return value;", + "box.v.x = 2;", + ], + ])("preserves known values with %s", (_name, getter, write) => { + expect(returnedValue(getter, write)).toMatchObject({ + status: "literal", + value: 1, + }); + }); +}); + +describe("getter receiver alias boundaries", () => { + it("does not reinterpret an outer this binding as the getter receiver", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + const outside = this; + const box = { s: shared, get v() { return outside.s; } }; + box.v.x = 2; + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({ + status: "literal", + value: 1, + }); + }); + + it("retains a private field's lexical owner through a receiver alias", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + class Box { + #s = shared; + get v() { const self = this; return self.#s; } + } + new Box().v.x = 2; + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe( + "unknown", + ); + }); + + it("does not append a receiver projection to a private field's result path", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + class Box { + #s = { peer: shared }; + peer = this; + get v() { const { peer: self } = this; return self.#s; } + } + new Box().v.x = 2; + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({ + status: "literal", + value: 1, + }); + }); + + it("resolves a long local alias chain without recursive expansion", () => { + const aliases = ["const a0 = this;"]; + for (let index = 1; index <= 2000; index++) + aliases.push(`const a${String(index)} = a${String(index - 1)};`); + expect(returnedValue(`${aliases.join("\n")} return a2000.s;`)?.status).toBe( + "unknown", + ); + }); + + it("bounds a growing alias cycle without losing the original receiver", () => { + expect( + returnedValue( + "let self = this; while (flag) self = self.s; return self.s;", + )?.status, + ).toBe("unknown"); + }); + + it.each([true, false])( + "retains deeper shared references through an alias cycle (write=%s)", + (write) => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + const box = { + start: { child: { child: shared } }, + get v() { + let self = this.start; + while (flag) self = self.child; + return self; + } + }; + ${write ? "box.v.x = 2;" : "const saved = box.v;"} + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject( + write ? { status: "unknown" } : { status: "literal", value: 1 }, + ); + }, + ); + + it("does not widen a growing cycle to unrelated receiver properties", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + const unrelated = { x: 1 }; + const box = { + start: { child: { child: shared } }, + unrelated, + get v() { + let self = this.start; + while (flag) self = self.child; + return self; + } + }; + box.v.x = 2; + return unrelated.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({ + status: "literal", + value: 1, + }); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 35b63c815..7b988af88 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -10,10 +10,7 @@ import { evaluateSemanticBinding, evaluateSemanticExpression, } from "./javascriptSemanticValues.js"; -import { - semanticContainer, - semanticSlotAtPath, -} from "./javascriptSemanticSlots.js"; +import { semanticContainer } from "./javascriptSemanticSlots.js"; import { semanticClassCandidates, semanticClassPropertyReferences, @@ -24,6 +21,10 @@ import { semanticIterationSources } from "./javascriptSemanticIterationSources.j import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticReferenceSourcesInactive, + semanticSelectedReferenceSlot, +} from "./javascriptSemanticReferenceSources.js"; import { semanticArrayIndex, SemanticPropertyPathCoverage, @@ -329,7 +330,17 @@ export function* collectSemanticMemberMutationsSteps( for (const value of effect === "escape" ? escapedReferenceLeaves(expression, current.path) : expandedValues(expression, current.path, effect)) { - if (value.iterableFallbackPath !== undefined) + if (value.escapes && effect !== "escape") + markValue( + value.node, + value.path, + new Set(), + "escape", + mutation, + current.originAt, + value.ownerInitializer, + ); + else if (value.iterableFallbackPath !== undefined) deferIterable({ node: value.node, projection: [], @@ -504,7 +515,7 @@ export function* collectSemanticMemberMutationsSteps( for (const [index, reference] of pendingReferences.splice(0).entries()) { if (index % 64 === 0) yield; const { initializer } = reference; - if (referenceSourcesInactive(initializer, state)) + if (semanticReferenceSourcesInactive(initializer, state)) pendingReferences.push(reference); else if (t.isForOfStatement(initializer.node)) deferIterable({ @@ -549,7 +560,7 @@ export function* collectSemanticMemberMutationsSteps( } for (const [index, reference] of [...iterableReferences].entries()) { if (index % 64 === 0) yield; - if (referenceSourcesInactive(reference, state)) continue; + if (semanticReferenceSourcesInactive(reference, state)) continue; if (reference.iterationOnly) { const expanded = expandedIterationReferences(reference, state); if (expanded !== null) { @@ -557,7 +568,7 @@ export function* collectSemanticMemberMutationsSteps( continue; } } - const slot = selectedReferenceSlot(reference, state); + const slot = semanticSelectedReferenceSlot(reference, state); // Custom iteration can yield the iterable itself as well as any child. // Treat its root as escaped, including writes through yielded references. const knownArray = @@ -610,27 +621,6 @@ interface IterableReference extends ReferencedValue { }[]; } -const referenceSourcesInactive = ( - reference: Pick, - state: JavaScriptSemanticAnalysisState, -): boolean => - reference.requiredSources?.some( - (source) => - selectedReferenceSlot(source, state, source.node)?.presence === "absent", - ) === true || - reference.fallbackSources?.some((source) => { - // Selection precedes effects through the chosen reference. A later escape - // cannot make a fallback that was excluded at destructuring reachable. - const slot = selectedReferenceSlot(source, state, source.node); - return ( - slot?.presence === "present" && - (slot.value.status === "literal" || - slot.value.status === "union" || - slot.value.status === "object" || - slot.value.status === "array") - ); - }) === true; - const expandedIterationReferences = ( reference: IterableReference, state: JavaScriptSemanticAnalysisState, @@ -645,7 +635,10 @@ const expandedIterationReferences = ( ...(reference.fallbackSources ?? []), ].find((source) => source.node === iterable && source.projection[0] === null); if (source === undefined) return null; - const slot = selectedReferenceSlot({ node: iterable, projection: [] }, state); + const slot = semanticSelectedReferenceSlot( + { node: iterable, projection: [] }, + state, + ); const container = slot === undefined ? null : semanticContainer(slot.value); if (container?.coverage.status !== "complete") return null; const select = ( @@ -1141,19 +1134,3 @@ const copiedReferencePath = ( } return [...initializer.projection.slice(0, offset), key, ...remaining]; }; - -const selectedReferenceSlot = ( - source: { readonly node: t.Node; readonly projection: PropertyPath }, - state: JavaScriptSemanticAnalysisState, - capturePoint?: t.Node, -) => { - const path: string[] = []; - for (const key of source.projection) { - if (key === null || typeof key === "object") return undefined; - path.push(String(key)); - } - return semanticSlotAtPath( - evaluateSemanticExpression(source.node, state, capturePoint), - path, - ); -}; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index cfaaf0ef5..6de7217c1 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -149,7 +149,50 @@ export const semanticMutationInitializers = ( const mutationPosition = directMutationPosition(mutation, parents) ?? referenceInitializerPosition(mutation, parents); - if (mutationPosition === null) return binding; + return selectInitializers(binding, mutationPosition, parents); +}; + +/** Select a local alias at its read, before any later straight-line reassignment. */ +export const semanticReadInitializers = ( + binding: JavaScriptSemanticBindingState, + read: t.Node, + parents: WeakMap, +): Pick< + JavaScriptSemanticBindingState, + "initializers" | "referenceInitializers" +> => { + let position: StatementPosition | null = null; + let current = read; + for ( + let parent = parents.get(current); + parent; + parent = parents.get(current) + ) { + if (t.isLoop(parent)) return binding; + if (t.isFunction(parent)) break; + if (position === null) { + if (t.isVariableDeclarator(parent)) { + const declaration = parents.get(parent); + if (t.isVariableDeclaration(declaration)) + position = statementPosition(declaration, parents, parent); + } else if (t.isStatement(parent)) + position = statementPosition(parent, parents); + } + current = parent; + } + return selectInitializers(binding, position, parents, true); +}; + +const selectInitializers = ( + binding: JavaScriptSemanticBindingState, + position: StatementPosition | null, + parents: WeakMap, + atRead = false, +): Pick< + JavaScriptSemanticBindingState, + "initializers" | "referenceInitializers" +> => { + if (position === null) return binding; const origins = [ ...binding.initializers.map((initializer) => ({ initializer, @@ -169,12 +212,20 @@ export const semanticMutationInitializers = ( ]; if ( origins.some( - ({ position }) => - position === null || !positionPrecedes(position, mutationPosition), + ({ position: origin }) => + origin === null || + origin.body !== position.body || + (!atRead && !positionPrecedes(origin, position)), ) ) return binding; - const latest = origins.reduce( + const preceding = atRead + ? origins.filter( + ({ position: origin }) => + origin !== null && positionPrecedes(origin, position), + ) + : origins; + const latest = preceding.reduce( (latest, { position }) => position !== null && (latest === null || positionPrecedes(latest, position)) @@ -183,7 +234,7 @@ export const semanticMutationInitializers = ( null, ); const retained = new Set( - origins + preceding .filter( ({ position }) => position?.index === latest?.index && diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 4d1bf3e76..425e5fea5 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -4,6 +4,8 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; +import { semanticReadInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; import { semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -17,6 +19,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; export interface JavaScriptSemanticObjectReference { readonly node: t.Node; readonly path: PropertyPath; + readonly escapes?: true; /** The allocation owned by this binding, rather than a read of its prior value. */ readonly ownerInitializer?: t.Node; } @@ -73,6 +76,7 @@ const instanceFieldReferences = ( propertyName: string | null, path: PropertyPath, state: JavaScriptSemanticAnalysisState, + initializedBefore?: t.ClassProperty, ): { readonly references: readonly JavaScriptSemanticObjectReference[]; readonly definiteNames: ReadonlySet; @@ -94,7 +98,15 @@ const instanceFieldReferences = ( if (previous !== undefined && previous.size === excluded.size) continue; selections.set(current.klass, excluded); const shadowed = new Set(excluded); - for (const member of [...current.klass.body.body].reverse()) { + const before = + initializedBefore === undefined + ? -1 + : current.klass.body.body.indexOf(initializedBefore); + const members = + before < 0 + ? current.klass.body.body + : current.klass.body.body.slice(0, before); + for (const member of [...members].reverse()) { if (!t.isClassProperty(member) || member.static) continue; const key = semanticStaticPropertyKey(member.key, member.computed); if (key !== null && shadowed.has(key)) continue; @@ -123,12 +135,15 @@ interface ClassPropertyLookup { readonly owner: t.ClassDeclaration | t.ClassExpression; readonly receiver?: t.ClassDeclaration | t.ClassExpression; readonly kind: "instance" | "static" | "prototype"; + readonly fieldsOnly?: true; + readonly initializedBefore?: t.ClassProperty; } const classPropertyDescriptors = ( klass: t.ClassDeclaration | t.ClassExpression, propertyName: string | null, kind: ClassPropertyLookup["kind"], + initializedBefore?: t.ClassProperty, ) => { const isStatic = kind === "static"; const selectedGetters: t.ClassMethod[] = []; @@ -138,7 +153,12 @@ const classPropertyDescriptors = ( readonly index: number; readonly key: string | null; }[] = []; + const before = + initializedBefore === undefined + ? -1 + : klass.body.body.indexOf(initializedBefore); klass.body.body.forEach((member, index) => { + if (before >= 0 && index >= before && t.isClassProperty(member)) return; if ( (t.isClassMethod(member) || (kind === "static" && t.isClassProperty(member))) && @@ -223,7 +243,13 @@ export const semanticClassPropertyReferences = ( // constructors. They take precedence over every prototype descriptor. let instanceFields: ReadonlySet = new Set(); if (lookup.kind === "instance") { - const fields = instanceFieldReferences(root, propertyName, path, state); + const fields = instanceFieldReferences( + root, + propertyName, + path, + state, + lookup.initializedBefore, + ); possibleResults.push(...fields.references); instanceFields = fields.definiteNames; if (propertyName !== null && instanceFields.has(propertyName)) @@ -237,11 +263,12 @@ export const semanticClassPropertyReferences = ( klass, propertyName, lookup.kind, + lookup.initializedBefore, ); for (const field of descriptors.fields) if (field.value != null) possibleResults.push({ node: field.value, path }); - for (const getter of descriptors.getters) { + for (const getter of lookup.fieldsOnly ? [] : descriptors.getters) { const key = semanticStaticPropertyKey(getter.key, getter.computed); if (key !== null && instanceFields.has(key)) continue; possibleResults.push( @@ -345,6 +372,102 @@ interface GetterInvocation { | t.ClassExpression; } +/** Follow only data fields whose initializers retain a class receiver. */ +const privateReceiverProjection = ( + receiver: t.ClassDeclaration | t.ClassExpression, + owner: t.ClassDeclaration | t.ClassExpression, + path: PropertyPath, + isStatic: boolean, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (path.some((key) => key === null || typeof key === "object")) return false; + type Selection = { readonly key: string; readonly before?: t.ClassProperty }; + const pending: { + receiver: typeof receiver; + path: readonly Selection[]; + visits: ReadonlyMap; + }[] = [ + { + receiver, + path: path.map((key) => ({ key: String(key) })), + visits: new Map(), + }, + ]; + const seen = new Set(); + const declaringClass = (field: t.ClassProperty) => { + const body = state.parentsByNode.get(field); + return body === undefined ? undefined : state.parentsByNode.get(body); + }; + for (let cursor = 0; cursor < pending.length; cursor++) { + const current = pending[cursor]; + if (current === undefined) break; + const [selection, ...remaining] = current.path; + if (selection === undefined) { + if (!isStatic || current.receiver === owner) return true; + continue; + } + const origin = JSON.stringify([ + current.receiver.start, + selection.key, + selection.before?.start, + ]); + const previous = current.visits.get(origin); + // Repeated fields may consume a finite getter path. An initializer cycle + // that preserves or grows it cannot establish another receiver identity. + if (previous !== undefined && previous <= current.path.length) continue; + const visits = new Map(current.visits).set(origin, current.path.length); + const identity = JSON.stringify([ + current.receiver.start, + current.path.map(({ key, before }) => [key, before?.start]), + ]); + if (seen.has(identity)) continue; + seen.add(identity); + const lookupOwner = + selection.before === undefined + ? current.receiver + : declaringClass(selection.before); + if (!t.isClass(lookupOwner)) continue; + for (const reference of semanticClassPropertyReferences( + { + owner: lookupOwner, + receiver: current.receiver, + kind: isStatic ? "static" : "instance", + fieldsOnly: true, + ...(selection.before === undefined + ? {} + : { initializedBefore: selection.before }), + }, + [selection.key], + state, + )) { + const field = state.parentsByNode.get(reference.node); + if (!t.isClassProperty(field)) continue; + const fieldOwner = declaringClass(field); + if (!t.isClass(fieldOwner)) continue; + const projection: Selection[] = []; + let value = unwrapJavaScriptExpression(reference.node).node; + while ( + t.isMemberExpression(value) || + t.isOptionalMemberExpression(value) + ) { + const key = semanticStaticPropertyKey(value.property, value.computed); + if (key === null || t.isPrivateName(value.property)) break; + projection.unshift({ key, before: field }); + value = unwrapJavaScriptExpression(value.object).node; + } + if (!t.isThisExpression(value)) continue; + // Instance fields retain the actual instance. Static field `this` belongs + // to its declaring class, even when a subclass inherits the field. + pending.push({ + receiver: isStatic ? fieldOwner : current.receiver, + path: [...projection, ...remaining], + visits, + }); + } + } + return false; +}; + const getterResultReferences = ( invocation: GetterInvocation, path: PropertyPath, @@ -358,40 +481,206 @@ const getterResultReferences = ( // path; recursive getters that preserve or grow that path cannot progress. const active = new Map(activeGetters).set(getter, path.length); const isStatic = t.isClassMethod(getter) && getter.static; - const resolve = ( - node: t.Node, - remaining: PropertyPath, - ): readonly JavaScriptSemanticObjectReference[] => { - const expression = unwrapJavaScriptExpression(node).node; - if (t.isThisExpression(expression)) { - if (t.isClass(receiver)) { - return semanticClassPropertyReferences( - { owner: receiver, kind: isStatic ? "static" : "instance" }, - remaining, - state, - active, - ); + interface AliasVisit { + readonly identity: string; + readonly pathLength: number; + readonly parent: AliasVisit | undefined; + } + const pending: { + readonly node: t.Node; + readonly path: PropertyPath; + readonly aliases: AliasVisit | undefined; + readonly escapes?: true; + readonly privateRead?: { + readonly node: t.MemberExpression | t.OptionalMemberExpression; + readonly name: string; + readonly path: PropertyPath; + }; + }[] = semanticCallableResultExpressions(getter).flatMap(({ node }) => + node === null ? [] : [{ node, path, aliases: undefined }], + ); + const references: JavaScriptSemanticObjectReference[] = []; + const expandedAliases = new Map>(); + const getterScope = state.scopeByNode.get(getter)?.scopeId; + for (let cursor = 0; cursor < pending.length; cursor++) { + const current = pending[cursor]; + if (current === undefined) break; + const { path: remaining } = current; + const expression = unwrapJavaScriptExpression(current.node).node; + const retain = (reference: JavaScriptSemanticObjectReference): void => { + references.push( + current.escapes ? { ...reference, escapes: true } : reference, + ); + }; + const retainPrivateRead = (): void => { + if (current.privateRead !== undefined) + retain({ + node: current.privateRead.node, + path: current.escapes ? [] : current.privateRead.path, + }); + }; + if (t.isIdentifier(expression)) { + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, + ); + let scope = + binding === undefined + ? undefined + : state.scopesById.get(binding.scopeId); + while (scope !== undefined && scope.scopeId !== getterScope) { + if (scope.kind === "function" || scope.parentScopeId === null) { + scope = undefined; + break; + } + scope = state.scopesById.get(scope.parentScopeId); } - return objectReceiverReference(receiver, remaining, state); + if ( + binding !== undefined && + scope !== undefined && + binding.referenceInitializers.every( + (initializer) => initializer.copyKind === undefined, + ) + ) { + const identity = JSON.stringify([ + binding.bindingId, + expression.start, + expression.end, + ]); + const known = expandedAliases.get(identity); + const key = JSON.stringify([ + remaining, + current.privateRead?.name, + current.privateRead?.path, + current.escapes, + ]); + let cyclic = false; + let growing = false; + if (known !== undefined) + for ( + let visit = current.aliases; + visit !== undefined; + visit = visit.parent + ) + if (visit.identity === identity) { + cyclic = true; + growing = remaining.length > visit.pathLength; + break; + } + if (cyclic) { + // A repeated alias can select arbitrarily deep children. Revisit its + // roots as escapes rather than enumerating iterations or dropping them. + if (growing && !current.escapes) + pending.push({ + ...current, + path: [], + aliases: undefined, + escapes: true, + }); + continue; + } + if (known?.has(key)) continue; + const selections = known ?? new Set(); + selections.add(key); + expandedAliases.set(identity, selections); + const aliases = { + identity, + pathLength: remaining.length, + parent: current.aliases, + }; + const origins = semanticReadInitializers( + binding, + expression, + state.parentsByNode, + ); + for (const initializer of [ + ...origins.initializers, + ...origins.referenceInitializers.filter( + (initializer) => + !semanticReferenceSourcesInactive(initializer, state), + ), + ]) + pending.push({ + ...current, + node: initializer.node, + path: [...initializer.projection, ...remaining], + aliases, + }); + continue; + } + } + if (t.isThisExpression(expression)) { + // An outer binding or a nested ordinary function has a different receiver. + let ownerNode = state.parentsByNode.get(expression); + while ( + ownerNode !== undefined && + !t.isClass(ownerNode) && + !(t.isFunction(ownerNode) && !t.isArrowFunctionExpression(ownerNode)) + ) + ownerNode = state.parentsByNode.get(ownerNode); + if (ownerNode !== getter) continue; + if (current.privateRead !== undefined && t.isClass(owner)) { + if ( + !t.isClass(receiver) || + !privateReceiverProjection( + receiver, + owner, + remaining, + isStatic, + state, + ) + ) { + retainPrivateRead(); + continue; + } + for (const member of owner.body.body) + if ( + t.isClassPrivateProperty(member) && + member.static === isStatic && + member.key.id.name === current.privateRead.name && + member.value != null + ) + retain({ + node: member.value, + path: current.escapes ? [] : current.privateRead.path, + }); + continue; + } + for (const reference of t.isClass(receiver) + ? semanticClassPropertyReferences( + { owner: receiver, kind: isStatic ? "static" : "instance" }, + remaining, + state, + active, + ) + : objectReceiverReference(receiver, remaining, state)) + retain(reference); + continue; } if ( t.isMemberExpression(expression) || t.isOptionalMemberExpression(expression) ) { if ( - t.isPrivateName(expression.property) && - t.isThisExpression(expression.object) && - t.isClass(owner) + current.privateRead !== undefined && + t.isPrivateName(expression.property) ) { - const privateName = expression.property.id.name; - return owner.body.body.flatMap((member) => - t.isClassPrivateProperty(member) && - member.static === isStatic && - member.key.id.name === privateName && - member.value != null - ? [{ node: member.value, path: remaining }] - : [], - ); + retainPrivateRead(); + continue; + } + if (t.isPrivateName(expression.property) && t.isClass(owner)) { + pending.push({ + ...current, + node: expression.object, + path: [], + privateRead: { + node: expression, + name: expression.property.id.name, + path: remaining, + }, + }); + continue; } const key = semanticStaticPropertyKey( expression.property, @@ -402,41 +691,52 @@ const getterResultReferences = ( t.isClass(owner) && owner.superClass != null ) { - return semanticClassCandidates(owner.superClass, state).flatMap( - (base) => - semanticClassPropertyReferences( - { - owner: base, - receiver: t.isClass(receiver) ? receiver : owner, - kind: isStatic ? "static" : "prototype", - }, - [key, ...remaining], - state, - active, - ), - ); + for (const base of semanticClassCandidates(owner.superClass, state)) + for (const reference of semanticClassPropertyReferences( + { + owner: base, + receiver: t.isClass(receiver) ? receiver : owner, + kind: isStatic ? "static" : "prototype", + }, + [key, ...remaining], + state, + active, + )) + retain(reference); + continue; } - return resolve(expression.object, [key, ...remaining]); - } - if (t.isConditionalExpression(expression)) - return [ - ...resolve(expression.consequent, remaining), - ...resolve(expression.alternate, remaining), - ]; - if (t.isLogicalExpression(expression)) - return [ - ...resolve(expression.left, remaining), - ...resolve(expression.right, remaining), - ]; - if (t.isSequenceExpression(expression)) { + pending.push({ + ...current, + node: expression.object, + path: [key, ...remaining], + }); + } else if (t.isConditionalExpression(expression)) + pending.push( + { ...current, node: expression.consequent }, + { ...current, node: expression.alternate }, + ); + else if (t.isLogicalExpression(expression)) + pending.push( + { ...current, node: expression.left }, + { ...current, node: expression.right }, + ); + else if (t.isSequenceExpression(expression)) { const last = expression.expressions.at(-1); - return last === undefined ? [] : resolve(last, remaining); - } - return [{ node: expression, path: remaining }]; - }; - return semanticCallableResultExpressions(getter).flatMap(({ node }) => - node === null ? [] : resolve(node, path), - ); + if (last !== undefined) pending.push({ ...current, node: last }); + } else if (t.isAssignmentExpression(expression, { operator: "=" })) + pending.push({ ...current, node: expression.right }); + else if ( + t.isAssignmentExpression(expression) && + ["&&=", "||=", "??="].includes(expression.operator) + ) + pending.push( + { ...current, node: expression.left }, + { ...current, node: expression.right }, + ); + else if (current.privateRead !== undefined) retainPrivateRead(); + else retain({ node: expression, path: remaining }); + } + return references; }; export const semanticObjectPropertyReferences = ( diff --git a/src/domain/javascript/javascriptSemanticReferenceSources.ts b/src/domain/javascript/javascriptSemanticReferenceSources.ts new file mode 100644 index 000000000..54e1d3ba9 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticReferenceSources.ts @@ -0,0 +1,53 @@ +import type * as t from "@babel/types"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; +import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; +import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; + +interface ReferenceSource { + readonly node: t.Node; + readonly projection: PropertyPath; +} + +/** Exclude references whose source slot or destructuring fallback is absent. */ +export const semanticReferenceSourcesInactive = ( + reference: { + readonly requiredSources?: readonly ReferenceSource[]; + readonly fallbackSources?: readonly ReferenceSource[]; + }, + state: JavaScriptSemanticAnalysisState, +): boolean => + reference.requiredSources?.some( + (source) => + semanticSelectedReferenceSlot(source, state, source.node)?.presence === + "absent", + ) === true || + reference.fallbackSources?.some((source) => { + // Selection precedes effects through the chosen reference. A later escape + // cannot make a fallback that was excluded at destructuring reachable. + const slot = semanticSelectedReferenceSlot(source, state, source.node); + return ( + slot?.presence === "present" && + (slot.value.status === "literal" || + slot.value.status === "union" || + slot.value.status === "object" || + slot.value.status === "array") + ); + }) === true; + +/** Select a retained source slot at its capture, before later effects. */ +export const semanticSelectedReferenceSlot = ( + source: ReferenceSource, + state: JavaScriptSemanticAnalysisState, + capturePoint?: t.Node, +) => { + const path: string[] = []; + for (const key of source.projection) { + if (key === null || typeof key === "object") return undefined; + path.push(String(key)); + } + return semanticSlotAtPath( + evaluateSemanticExpression(source.node, state, capturePoint), + path, + ); +}; diff --git a/tests/acceptance/applications/getterLocalAliasesCli.test.ts b/tests/acceptance/applications/getterLocalAliasesCli.test.ts new file mode 100644 index 000000000..2b2b0196c --- /dev/null +++ b/tests/acceptance/applications/getterLocalAliasesCli.test.ts @@ -0,0 +1,85 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains getter-local alias mutations in exported Evidence through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-getter-local-aliases-"); + const cases = [ + ["initialized", "const self = this; return self.s;", true], + ["assigned", "let self; self = this; return self.s;", true], + ["destructured", "const { s: value } = this; return value;", true], + ["readOnly", "const self = this; return self.s;", false], + [ + "cyclic", + "let self = this.start; while (flag) self = self.child; return self;", + true, + "start: { child: { child: shared } }", + ], + ] as const; + const privateCases = [ + ["privateProjection", "shared", true, "unknown"], + ["privateUnrelated", "{ peer: shared }", true, "literal"], + ["privateReadOnly", "shared", false, "literal"], + ] as const; + await writeFile( + join(root, "app.mjs"), + cases + .map( + ([ + name, + getter, + write, + members = "s: shared", + ]) => `export function ${name}() { + const shared = { x: 1 }; + const box = { ${members}, get v() { ${getter} } }; + ${write ? "box.v.x = 2;" : "const saved = box.v;"} + return shared.x; + }`, + ) + .join("\n") + + privateCases + .map( + ([name, value, write]) => `export function ${name}() { + const shared = { x: 1 }; + class Box { + #s = ${value}; + peer = this; + get v() { const { peer: self } = this; return self.#s; } + } + const box = new Box(); + ${write ? "box.v.x = 2;" : "const saved = box.v;"} + return shared.x; + }`, + ) + .join("\n"), + ); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + for (const [name, status] of [ + ...cases.map(([name, , write]) => [name, write ? "unknown" : "literal"]), + ...privateCases.map(([name, , , status]) => [name, status]), + ]) { + const observation = graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === name, + ); + expect(observation, name).toBeDefined(); + expect(observation?.properties.static_return_shapes).toMatchObject([ + { + value_status: status, + fields: [ + { path: "", state: status, value: status === "unknown" ? null : 1 }, + ], + }, + ]); + } + }, +); From 6637161e0272513ccc11c33aa6d8915bce6171ac Mon Sep 17 00:00:00 2001 From: Saim Shafique Date: Tue, 13 Oct 2026 18:26:57 +0500 Subject: [PATCH 47/92] fix(ghidra): warn when callee/caller reads stop at a truncated function body (#2360) procedure_callees and procedure_callers returned a bare address list, so an inferred no-return call that truncates Ghidra's function body silently dropped real edges (fixes #2326). Wrap both views in the function-observation envelope with noReturnCallLimitations, matching procedure_info, procedure_assembly, procedure_pseudo_code, read_function_instructions, and analyze_function (precedent: 69a8b3a95). Version the boundary-observation contract to ghidra-terminal-call-limitations-v2 so older snapshots cannot replay unqualified results. Validation: red->green unit test for the envelope requirement, MCP boundary evidence test extended for both views (limitations retained through Evidence and snapshot replay), 8 harness test files + ghidra behavior tests green, check:fast, docs:check, verify:test-discovery green. Real Ghidra host lane unavailable here (no Ghidra installed); Java change mirrors the existing procedureInfo call pattern. --- bridge/ghidra/ReaGhidraBridge.java | 9 +++++-- docs/ghidra-provider.md | 7 +++--- src/contracts/officialToolContracts.ts | 4 ++-- src/ghidra/GhidraAnalysisProfile.ts | 4 ++-- src/ghidra/GhidraFunctionValues.test.ts | 25 ++++++++++++++++++++ src/ghidra/GhidraFunctionValues.ts | 2 ++ src/ghidra/GhidraProvider.behavior.test.ts | 6 ++++- tests/boundary/mcp/ghidraEvidenceMcp.test.ts | 4 +++- tests/boundary/mcp/ghidraMcpHarness.ts | 2 ++ 9 files changed, 52 insertions(+), 11 deletions(-) diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 70132eeae..c180c5866 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -812,7 +812,7 @@ public final class ReaGhidraBridge extends HeadlessScript { return functionObservation(result, boundaryLimitations); } - private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception { + private JsonObject procedureCalls(JsonObject params, boolean callers) throws Exception { requireKeys(params, Set.of("document", "procedure")); requireDocument(params); Function function = resolveProcedure(requireString(params, "procedure")); @@ -825,7 +825,12 @@ public final class ReaGhidraBridge extends HeadlessScript { for (Function item : ordered) { result.add(canonicalAddress(item.getEntryPoint())); } - return result; + return functionObservation( + result, + noReturnCallLimitations( + currentProgram.getListing().getInstructions(function.getBody(), true) + ) + ); } private JsonObject procedureInfo(JsonObject params) throws Exception { diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 32edbd320..5b83ee152 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -131,9 +131,10 @@ overview merely because its source artifact is unchanged. Function extents and instruction completeness refer to Ghidra's analyzed body. An inferred no-return call can terminate that body before subsequent bytes. -Pseudocode, assembly, procedure info, instruction windows, and dossiers report -observed terminal call sites in Evidence limitations, including resolved callee -names, addresses, and FunctionManager flags when available. Instruction windows +Pseudocode, assembly, procedure info, instruction windows, callee and caller +reads, and dossiers report observed terminal call sites in Evidence limitations, +including resolved callee names, addresses, and FunctionManager flags when +available. Instruction windows and dossiers also retain these warnings in their result limitations. An unresolved callee or unestablished terminal-flow basis remains explicit. These observations qualify the analysis; they do not prove that a no-return flag is diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 82f1dc83d..4750528ad 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -149,12 +149,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "procedure_callees", - "Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty.", + "Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.", z.object({ procedure, document }), ), official( "procedure_callers", - "Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references.", + "Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.", z.object({ procedure, document }), ), official( diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 8cc5f2807..81defabd3 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -127,7 +127,7 @@ export const resolveGhidraAnalysisProfile = async ( "address-first-unique-target-source-mapping-v2", instruction_flow_evidence: "decoded-return-pcode-v1", instruction_references: "exact-address-incoming-v1", - function_boundary_observations: "ghidra-terminal-call-limitations-v1", + function_boundary_observations: "ghidra-terminal-call-limitations-v2", no_return_repair: installation.platform === "win32" ? "disabled-windows-p0" @@ -218,7 +218,7 @@ export const resolveGhidraAnalysisProfile = async ( "address-first-unique-target-source-mapping-v2", instruction_flow_evidence: "decoded-return-pcode-v1", instruction_references: "exact-address-incoming-v1", - function_boundary_observations: "ghidra-terminal-call-limitations-v1", + function_boundary_observations: "ghidra-terminal-call-limitations-v2", no_return_repair: installation.platform === "win32" ? "disabled-windows-p0" diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index 333d2c913..e54692840 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -23,6 +23,8 @@ import { const observationOperations: ReadonlySet = new Set([ "analyze_function", "procedure_assembly", + "procedure_callees", + "procedure_callers", "procedure_info", "procedure_pseudo_code", "read_function_instructions", @@ -93,6 +95,29 @@ describe("Ghidra function-analysis result values", () => { }); }); + it.each(["procedure_callees", "procedure_callers"] as const)( + "requires the function-observation envelope to carry terminal-call limitations for %s", + (operation) => { + // A bare address list is the pre-fix bridge shape; without the envelope + // a truncated function body reports no warning, so it must be rejected. + expect(parseGhidraFunctionResult(operation, ["0x2000"])).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + const limitation = + "Ghidra Listing reports a terminal call at 0x401000 to 0x402000 abort; fallthrough is excluded by Ghidra's flow model."; + expect( + parseGhidraFunctionResult(operation, { + value: ["0x2000"], + limitations: [limitation], + }), + ).toEqual({ + ok: true, + value: { value: ["0x2000"], limitations: [limitation] }, + }); + }, + ); + it.each(["reference_kinds_available", "unresolved_calls"])( "rejects omitted %s instead of inventing complete reference observations", (field) => { diff --git a/src/ghidra/GhidraFunctionValues.ts b/src/ghidra/GhidraFunctionValues.ts index 4e4c5054a..a6e62c93c 100644 --- a/src/ghidra/GhidraFunctionValues.ts +++ b/src/ghidra/GhidraFunctionValues.ts @@ -409,6 +409,8 @@ const functionBoundaryObservationOperations: ReadonlySet { start: () => Promise.resolve(ok(sessionInfo())), callTool: (operation) => Promise.resolve( - ok(operation === "procedure_callees" ? ["0x2000"] : []), + ok( + operation === "procedure_callees" + ? { value: ["0x2000"], limitations: [] } + : { value: [], limitations: [] }, + ), ), close: () => Promise.resolve(ok(null)), })); diff --git a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts index c28fcca39..ed82400aa 100644 --- a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts +++ b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts @@ -46,6 +46,8 @@ it("retains terminal-call qualifications across function views and snapshot repl limitations: [limitation], }, ], + ["procedure_callees", ["0x402000"]], + ["procedure_callers", [identity.address]], ["analyze_function", jsonValueSchema.parse(dossier)], ]); const harness = await connectGhidraMcp( @@ -81,7 +83,7 @@ it("retains terminal-call qualifications across function views and snapshot repl }); expect(evidence.limitations, name).toContain(limitation); expect(evidence.analysis_profile?.parameters).toMatchObject({ - function_boundary_observations: "ghidra-terminal-call-limitations-v1", + function_boundary_observations: "ghidra-terminal-call-limitations-v2", }); const replay = await harness.session.execute(name, evidence.parameters); if (!replay.ok) throw replay.error; diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index ad3565992..a9b2248dc 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -263,6 +263,8 @@ const resultFor = ( const functionObservationOperations: ReadonlySet = new Set([ "analyze_function", "procedure_assembly", + "procedure_callees", + "procedure_callers", "procedure_info", "procedure_pseudo_code", "read_function_instructions", From 117bd23dd5084a0a15286d749a46bf1f7bc8fadb Mon Sep 17 00:00:00 2001 From: AICodeLion <2401112573@stu.pku.edu.cn> Date: Tue, 13 Oct 2026 21:27:45 +0800 Subject: [PATCH 48/92] test(clients): verify Qwen Code on native Windows (#2103) Co-authored-by: AICodeLion <190163814+AICodeLion@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/client-verification.md | 16 ++++++++++-- scripts/verify-qwen-client.mjs | 47 ++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/docs/client-verification.md b/docs/client-verification.md index 42e059382..d9ece9aad 100644 --- a/docs/client-verification.md +++ b/docs/client-verification.md @@ -18,14 +18,26 @@ pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full result that its client offloads, and Pi exercises default codemode execution. Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package. Receipts retain client versions, result digests, host coverage and owned-process -cleanup. These POSIX lanes use a deterministic loopback model, so they do not -prove live model-provider or native Windows compatibility. Qwen, Pi and Hermes +cleanup. These lanes use a deterministic loopback model and do not prove live +model-provider compatibility. Pi and Hermes remain POSIX-only. Qwen, Pi and Hermes bind both `HOME` and `USERPROFILE` to the disposable account. Qwen and Pi discover the shared personal skill through their default search paths without an explicit skill setting; Hermes discovers it through the selected `HERMES_HOME`. Set `REA_VERIFY_HERMES_STICKY_PROFILE=1` to exercise native Hermes selection of a named sticky profile with `HERMES_HOME` still pointing to its root. +The Qwen lane also supports native Windows (verified with Qwen Code 0.25.1 and +Node 24.19.0 for both `chat` and `call`). With Node 24.18.0, the same `call` +journey reached full Evidence readback but the client aborted during exit with +`UV_HANDLE_CLOSING`; that run remains a failure, not a compatibility claim. +Select the npm install's +`node_modules/@qwen-code/qwen-code/cli-entry.js` with `REA_VERIFY_QWEN_COMMAND`, +because `execFile` cannot launch the `qwen.cmd` shim directly. A selected `.js`, +`.mjs` or `.cjs` entry point runs under the verifier's Node. Windows path checks +use native separators; persisted output is read through Qwen's real shell tool +with a shell-independent encoded Node payload. Windows receipts report process +lineage as unavailable when the verifier has no retained native Job Object lease. + `verify:gemini-client` requires an installed Gemini CLI (verified with `@google/gemini-cli@0.63.0` on POSIX and native Windows); select it with `REA_VERIFY_GEMINI_COMMAND`. A selected `.js`, `.mjs` or `.cjs` entry point runs diff --git a/scripts/verify-qwen-client.mjs b/scripts/verify-qwen-client.mjs index ed60ccd4c..fff4338b7 100644 --- a/scripts/verify-qwen-client.mjs +++ b/scripts/verify-qwen-client.mjs @@ -8,7 +8,7 @@ import { realpath, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { createHash } from "node:crypto"; import { exec } from "./lib/verify-package-core.mjs"; @@ -18,10 +18,6 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; -assert( - process.platform !== "win32", - "The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.", -); const repo = fileURLToPath(new URL("..", import.meta.url)); const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo; const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-")); @@ -32,6 +28,13 @@ assert( "Usage: node scripts/verify-qwen-client.mjs [chat|call]", ); const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen"; +// npm exposes only qwen.cmd on Windows. Resolve a selected JavaScript entry +// before the verifier switches to its disposable workspace. +const entryPoint = /\.(c|m)?js$/iu.test(command) ? resolve(command) : undefined; +const launch = (args) => + entryPoint === undefined + ? [command, args] + : [process.execPath, [entryPoint, ...args]]; const verifier = createVerifierRun(); const profile = join(lab, "selected profile"); const account = join(lab, "account"); @@ -54,14 +57,18 @@ const baseEnvironment = { let clientVersion; try { clientVersion = ( - await exec(command, ["--version"], { + await exec(...launch(["--version"]), { env: baseEnvironment, timeout: 30_000, }) ).stdout.trim(); } catch (cause) { + const windowsHint = + process.platform === "win32" + ? " On Windows, select node_modules/@qwen-code/qwen-code/cli-entry.js." + : ""; throw new Error( - `Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`, + `Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND.${windowsHint} ${String(cause)}`, ); } const original = JSON.stringify( @@ -93,8 +100,8 @@ for (const step of [ assert( result.plannedActions.every( (a) => - a.target.startsWith(profile + "/") || - a.target.startsWith(sharedSkills + "/"), + a.target.startsWith(profile + sep) || + a.target.startsWith(sharedSkills + sep), ), ); await writeFile( @@ -163,7 +170,14 @@ const fixture = await createOpenAiModelFixture({ skillSeen ||= JSON.stringify(body.messages ?? []).includes( "reverse-engineer-anything", ); - selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes( + const messageText = (body.messages ?? []) + .map((message) => + typeof message.content === "string" + ? message.content + : (message.content ?? []).map((part) => part.text ?? "").join("\n"), + ) + .join("\n"); + selectedSkillLoaded ||= messageText.includes( join(sharedSkills, "reverse-engineer-anything"), ); const toolText = JSON.stringify( @@ -243,9 +257,7 @@ const fixture = await createOpenAiModelFixture({ const invoke = tools.find((t) => t.name === "tool_call"); const skill = tools.find((t) => t.name === "skill"); const shell = tools.find((t) => t.name === "run_shell_command"); - const persisted = toolText.match( - /Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u, - ); + const persisted = messageText.match(/Full output saved to: ([^\r\n]+)/u); const call = (name, args) => ({ role: "assistant", content: null, @@ -268,13 +280,14 @@ const fixture = await createOpenAiModelFixture({ artifactPath = persisted[1]; assert( (await realpath(artifactPath)).startsWith( - (await realpath(runtime)) + "/", + (await realpath(runtime)) + sep, ), ); const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`; - const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"; + // Keep paths and JSON quotes out of shell syntax on Windows and POSIX. + const encodedScript = Buffer.from(script).toString("base64"); message = call(shell.name, { - command: `node -e ${quote(script)}`, + command: `node -e "eval(Buffer.from('${encodedScript}','base64').toString('utf8'))"`, description: "Read the complete saved REA Evidence and verify the fixture facts.", }); @@ -329,7 +342,7 @@ const args = [ let response; let failure; try { - response = await exec(command, args, { + response = await exec(...launch(args), { env, cwd: workspace, timeout: 120_000, From 9e019ad27dbad078122f87a9bb41ff6d8687d217 Mon Sep 17 00:00:00 2001 From: Danish Rafie Ekaputra <155859586+0xRafie@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:34:38 +0700 Subject: [PATCH 49/92] feat(clients): register Factory Droid during setup (#2309) --- .../references/connection-and-recovery.md | 2 +- docs/client-configuration.md | 8 + docs/installation.md | 1 + src/application/SupportedClients.ts | 13 ++ .../filesystem/clientLifecycle.test.ts | 1 + .../droidClientConfiguration.test.ts | 167 ++++++++++++++++++ 6 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/filesystem/droidClientConfiguration.test.ts diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index ed8393bd9..0feae3ac4 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `trae`, `grok_build`, `omp`, `pi`, -`hermes`, `qoder`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a +`hermes`, `qoder`, `droid`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a scope. `grok_bot` uses a chat registration step rather than local `mcp.json`. For unsupported clients, use version-pinned manual stdio registration or CLI. diff --git a/docs/client-configuration.md b/docs/client-configuration.md index e72ca191f..e27866f7d 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -165,6 +165,14 @@ For [Qoder](https://docs.qoder.com/cli/mcp-reference), setup writes from `~/.qoder/skills` (following `QODER_CONFIG_DIR` when set), so setup installs its skill copy there. +## Factory Droid + +For [Factory Droid](https://docs.factory.com/harness/mcp), setup writes +`mcpServers.rea` into the user-level `~/.factory/mcp.json`. Entries carry +`command`, `args`, and `env`. Droid discovers personal skills from +`~/.factory/skills` ([skills](https://docs.factory.com/harness/skills)), so +setup installs its skill copy there. + ## Trae For [Trae](https://docs.trae.ai/ide/add-mcp-servers), setup writes diff --git a/docs/installation.md b/docs/installation.md index d6dc8e3b0..9285883e3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -283,6 +283,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | Pi | `pi` | | Hermes | `hermes` | | Qoder | `qoder` | +| Factory Droid | `droid` | | Trae | `trae` | | ZCode | `zcode` | | DeepSeek Harness | `dsh` | diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 5839409d4..0a7352627 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -135,6 +135,9 @@ const codexDirectory = ({ home, env }: ClientPathContext): string => const qoderDirectory = ({ home, env }: ClientPathContext): string => env.QODER_CONFIG_DIR || join(home, ".qoder"); +const factoryDirectory = ({ home }: ClientPathContext): string => + join(home, ".factory"); + /** Match Qwen Code's home override, including tilde and cwd-relative paths. */ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { const override = env.QWEN_HOME; @@ -558,6 +561,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: qoderDirectory, format: "json", }, + { + name: "droid", + displayName: "Factory Droid", + skillPath: (context: ClientPathContext) => + join(factoryDirectory(context), "skills"), + configPath: (context: ClientPathContext) => + join(factoryDirectory(context), "mcp.json"), + markerPath: factoryDirectory, + format: "json", + }, { name: "zcode", zcodeFallbackPath: ({ home }: ClientPathContext) => diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 506a6d6df..63ca0667a 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -71,6 +71,7 @@ describe("client configuration filesystem lifecycle", () => { "pi", "hermes", "qoder", + "droid", "zcode", "dsh", "grok_bot", diff --git a/tests/boundary/filesystem/droidClientConfiguration.test.ts b/tests/boundary/filesystem/droidClientConfiguration.test.ts new file mode 100644 index 000000000..6f9b26423 --- /dev/null +++ b/tests/boundary/filesystem/droidClientConfiguration.test.ts @@ -0,0 +1,167 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { skillDestinations } from "../../../src/application/SetupSkill.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const droidPath = ( + home: string, + platform: NodeJS.Platform = "linux", + env: Parameters[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "droid"); + +describe("Factory Droid configuration paths", () => { + it("resolves the user MCP config file and marker directory", () => { + expect(droidPath("/home/a")).toEqual({ + name: "droid", + displayName: "Factory Droid", + configPath: join("/home/a", ".factory", "mcp.json"), + markerPath: join("/home/a", ".factory"), + format: "json", + }); + expect(droidPath("/Users/a", "darwin")?.configPath).toBe( + join("/Users/a", ".factory", "mcp.json"), + ); + expect(droidPath("C:\\Users\\a", "win32")?.configPath).toBe( + join("C:\\Users\\a", ".factory", "mcp.json"), + ); + }); + + it("resolves the personal skills directory beside the config root", () => { + expect( + skillDestinations("/home/a", ["droid"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".factory", "skills", "reverse-engineer-anything"), + ]); + }); +}); + +describe("Factory Droid JSON registration", () => { + const droidClient = async () => { + const home = await createTestTempDirectory("rea-droid-"); + const client = droidPath(home, process.platform); + if (client?.markerPath === undefined) + throw new Error("missing Factory Droid client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("upserts rea into mcpServers while preserving unrelated servers and permissions", async () => { + const { home, client } = await droidClient(); + await writeFile( + client.configPath, + `{ + "mcpServers": { + "other": { + "type": "stdio", + "command": "other", + "args": [], + "disabled": false + } + }, + "persistentPermissions": { + "servers": { + "other": { "approvedAt": "2026-01-01T00:00:00.000Z" } + } + } +}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ + type: "stdio", + command: "other", + args: [], + disabled: false, + }); + expect(parsed.persistentPermissions).toEqual({ + servers: { other: { approvedAt: "2026-01-01T00:00:00.000Z" } }, + }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "droid"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcpServers table in an existing config file without servers", async () => { + const { client } = await droidClient(); + await writeFile( + client.configPath, + JSON.stringify({ persistentPermissions: { servers: {} } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.persistentPermissions).toEqual({ servers: {} }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty config file", async () => { + const { client } = await droidClient(); + await writeFile(client.configPath, ""); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("removes only the rea entry on uninstall", async () => { + const { home, client } = await droidClient(); + await writeFile( + client.configPath, + JSON.stringify( + { mcpServers: { other: { command: "other-server" } } }, + null, + 2, + ), + ); + await configureClientConfiguration(client, {}, command); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toBeUndefined(); + }); +}); From 811bd9f6353748d01023260681f138fdf81313c9 Mon Sep 17 00:00:00 2001 From: Jose Camargo Date: Tue, 13 Oct 2026 10:34:59 -0300 Subject: [PATCH 50/92] docs(contributing): explain Windows native prerequisites for source checkouts (#2295) --- CONTRIBUTING.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8d5f3635..50674afa7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,6 +142,21 @@ caches deterministic builds and static checks across Git worktrees. After a pack Repository invariants live in [AGENTS.md](AGENTS.md). Keep provider-specific protocols in adapters and shared CLI/MCP workflows in the application layer. +### Windows source checkouts + +The published npm package includes the Windows x64 native addon and its +manifest. A source checkout does not: `native/windows/build/` is ignored by +Git, and neither `npm ci` nor `npm run build:cached` (or `npm run build`) +produces `rea-windows-x64.node` or `manifest.json` in that directory. +Windows workflows and tests that require native controls need these artifacts; +without them, those controls are unavailable even after a successful runtime build. + +Follow the [Windows native build guide](native/windows/README.md#build) before +running those workflows from source. The artifact build requires Linux, +MinGW-w64, and `tar`; the build script does not run on Windows. See that guide +for the build and artifact-verification commands and the separate real Windows +verification prerequisites. Keep the generated addon and manifest uncommitted. + ## Documentation website The VitePress site uses the Markdown files in `docs/`. Run `npm run docs:dev` From 5dd2ce3125b0c7918bffe99011ccacbba6592853 Mon Sep 17 00:00:00 2001 From: Max <68940212+AS9929@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:36:52 +0800 Subject: [PATCH 51/92] fix(ida): let one-shot commands exit after retaining cleanup (#1841) * fix(ida): let one-shot commands exit after retaining cleanup When a headless IDA request outlives timeoutMs (for example idb_open on a large binary still running auto-analysis), REA cannot prove the worker stopped and correctly retains the upstream connection and private workspace. The retained stdio child and its pipes stayed referenced, so a one-shot CLI printed its cleanup_incomplete result and then never exited, keeping the supervisor, worker and database alive. Detach the retained connection's child process and pipes from the event loop when cleanup fails. A long-lived MCP server can still retry through the retained connection; a one-shot CLI exits with status 1, and the supervisor observes end of input. Co-Authored-By: Claude Opus 5.5 * fix(ida): report an unverified upstream worker after failed cleanup Upstream ida-pro-mcp spawns headless workers in their own session and deliberately leaves them running when its supervisor exits, so neither a retained connection nor REA exiting proves that a timed-out worker stopped. When cleanup fails before the owned worker's release is confirmed, the cleanup diagnostics now name its private session and state that it may still be running until it finishes and reaches its idle timeout. Failures after a confirmed release, or before any open was attempted, do not report a worker. Co-Authored-By: Claude Opus 5.5 * fix(ida): name the inventory sessions that block worker release When idb_close confirmed the requested worker gone but the follow-up inventory still found a different peer on the private input, the upstream_worker diagnostic named the released session and could misdirect manual cleanup. Record the session ids of the inventory entries that actually blocked release and report those; fall back to the requested private session only when no inventory evidence exists (an incomplete open or an unanswered request). The field becomes session_ids, since more than one entry can block release. Co-Authored-By: Claude Opus 5.5 * fix(ida): keep blocker evidence and backend in cleanup diagnostics The release diagnostic reset its observed blockers on every close, so a retry whose inventory was unavailable fell back to the already-released session; and it kept only session ids, describing a blocking GUI session with detached-worker guidance. Blocker evidence is now replaced only by a successful inventory, and the diagnostic becomes upstream_sessions with each session's id and backend, with guidance that distinguishes workers from GUI sessions. Co-Authored-By: Claude Opus 5.5 * fix(ida): stop naming a worker after its close was confirmed When idb_close confirmed the requested worker's release but the following inventory failed, no blocker evidence existed yet and the diagnostic fell back to the confirmed-absent worker. After a confirmed close the diagnostic now reports an empty session list, meaning any remaining same-input sessions are unknown. Co-Authored-By: Claude Opus 5.5 * test(ida): group cleanup diagnostic cases in their own suite The added cases pushed the reaped-worker describe block past the 150-line limit enforced by lint:built. Co-Authored-By: Claude Opus 5.5 * fix(ida): keep ownership and pre-close peers in cleanup diagnostics Blocking sessions dropped the parsed ownership fact, so an external unowned worker looked the same as REA's own; and recovery inventory recorded same-input peers only when the requested worker was missing, so a peer seen beside it was lost if the following close failed. Each reported session now carries owned (null when no inventory has reported it), and recovery records every same-input session until a later successful inventory replaces that evidence. Co-Authored-By: Claude Opus 5.5 * fix(ida): drop a confirmed worker from retained cleanup blockers Recovery inventory now records the requested worker beside any peers, so once idb_close confirmed its release a failing follow-up inventory still reported it as unreleased. Remove the confirmed session from the retained blockers at close confirmation and keep the remaining peers. Co-Authored-By: Claude Opus 5.5 * test(ida): retry cleanup through a detached stdio connection The retry coverage used a recording connection whose detach() only counts calls, so it could not show that detaching keeps a retained connection usable. Run a real stdio supervisor in a long-lived host, fail the first post-close inventory, and require the retry through the same detached connection to confirm release and remove the workspace. The test fails if detach() destroys the pipes instead of unref-ing them. Co-Authored-By: Claude Opus 5.5 * fix(ida): close the stdio supervisor in failed retry tests Merge main and give its new IdaNativeProtocol connection double the required detach() method. The real-stdio retry test now closes its connection in an unconditional teardown, so a failing assertion cannot leave the supervisor waiting on stdin. Co-Authored-By: Claude Opus 5.5 * test(ida): share the cleanup diagnostic expectation in session tests Repeated upstream_sessions assertions and the unrelated live peer literal pushed repository duplication over the jscpd threshold. Build the expected diagnostic with one helper and reuse one peer constant. Co-Authored-By: Claude Opus 5.5 * test(ida): distinguish observed ownership from unreported input bindings * fix(ida): keep requested-ID conflicts in recovery cleanup Recovery inventory kept only same-input entries, unlike the startup and post-close filters. After an explicitly rejected open, an entry under the requested session ID on another input was dropped, so cleanup could close the connection, remove the private workspace and report success while that session stayed registered. Match the requested ID as well so the entry is retained as a blocker and cleanup stays incomplete. Also split the cleanup diagnostics suite under the lint:built function length limit, and make two startup-inventory fixtures pass a defined session id so the test file typechecks. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Xingchuan Ma Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto --- src/ida/IdaBusyHealth.test.ts | 1 + src/ida/IdaMcpConnection.ts | 17 ++ src/ida/IdaNativeProtocol.test.ts | 1 + src/ida/IdaSessionClient.test.ts | 251 ++++++++++++++++++ src/ida/IdaSessionClient.ts | 127 +++++++-- src/ida/IdaWorkspaceOwnership.test.ts | 6 + .../cli/idaRetainedCleanupCli.test.ts | 92 +++++++ .../ida/retainedConnectionRetry.test.ts | 114 ++++++++ tests/fixtures/idaMcp.ts | 4 + 9 files changed, 596 insertions(+), 17 deletions(-) create mode 100644 tests/boundary/cli/idaRetainedCleanupCli.test.ts create mode 100644 tests/boundary/providers/ida/retainedConnectionRetry.test.ts diff --git a/src/ida/IdaBusyHealth.test.ts b/src/ida/IdaBusyHealth.test.ts index 512cf78a6..b997b2591 100644 --- a/src/ida/IdaBusyHealth.test.ts +++ b/src/ida/IdaBusyHealth.test.ts @@ -18,6 +18,7 @@ const scripted = (answers: readonly JsonValue[]) => { connect: async () => [], serverInfo: () => null, close: async () => undefined, + detach: () => undefined, call: async () => { reads.push(reads.length); const answer = answers[Math.min(reads.length - 1, answers.length - 1)]; diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index ee7861389..d532a66e8 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -1,3 +1,5 @@ +import { ChildProcess } from "node:child_process"; +import { Socket } from "node:net"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { Client, @@ -22,6 +24,12 @@ export interface IdaMcpConnection { readResource?(uri: string): Promise; serverInfo(): JsonValue; close(): Promise; + /** + * Keep a connection retained after failed cleanup from holding the host + * process open. A long-lived MCP server can still retry through it; a + * one-shot CLI exits, and the upstream proxy observes end of input. + */ + detach(): void; } const toolResultSchema = z.object({ @@ -205,5 +213,14 @@ export const createIdaMcpConnection = ( }, serverInfo: () => jsonValueSchema.parse(client.getServerVersion() ?? null), close: () => client.close().catch(transportFailure), + detach() { + if (!(transport instanceof StdioClientTransport)) return; + // The SDK exposes only the child's pid, not the handles it holds. + const child: unknown = Reflect.get(transport, "_process"); + if (!(child instanceof ChildProcess)) return; + child.unref(); + for (const stream of [child.stdin, child.stdout, child.stderr]) + if (stream instanceof Socket) stream.unref(); + }, }; }; diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 5da9d40f7..dc95727f6 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -90,6 +90,7 @@ const fixture = async () => { }, serverInfo: () => producer.serverInfo(), close: () => producer.close(), + detach: () => producer.detach(), }; const client = new IdaSessionClient( { diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index b41a3c34a..7fa366bce 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -16,6 +16,18 @@ afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +/** A failed close whose diagnostic names exactly these unverified sessions. */ +const reportsSessions = (...sessions: readonly object[]) => ({ + error: { + diagnostics: { upstream_sessions: { state: "unverified", sessions } }, + }, +}); +const LIVE_PEER = { + session_id: "unrelated-live-peer", + backend: "worker", + owned: false, +} as const; + const fixture = async ( mode: "attached" | "headless" = "attached", timeoutMs = 1000, @@ -792,10 +804,19 @@ describe("headless IDA session ownership and cleanup", () => { it("retains the private workspace and reports cleanup failure when worker release is unverified", async () => { const { producer, client, root } = await fixture("headless"); await client.execute("health", {}); + const sessionId = producer.database; producer.failClose = true; const result = await client.close(); expect(result.ok).toBe(false); if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true); + expect(result).toMatchObject( + reportsSessions({ + session_id: sessionId, + backend: "worker", + owned: true, + input_path: producer.inputPath, + }), + ); expect( (await readdir(root)).some((name) => name.startsWith("rea-ida-")), ).toBe(true); @@ -843,10 +864,20 @@ describe("headless IDA reaped worker cleanup", () => { const result = await client.close(); expect(result.ok).toBe(false); if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true); + // The released session must not be named as the remaining worker. + expect(result).toMatchObject(reportsSessions(LIVE_PEER)); expect(producer.closes).toBe(0); expect( (await readdir(root)).some((name) => name.startsWith("rea-ida-")), ).toBe(true); + + // A retry whose inventory is unavailable keeps the observed blocker. + producer.beforeCall = async (name) => { + if (name === "idb_list") throw new Error("inventory unavailable"); + }; + const retried = await client.close(); + expect(retried.ok).toBe(false); + expect(retried).toMatchObject(reportsSessions(LIVE_PEER)); }); it("retries independent inventory verification after an absent-session response", async () => { @@ -899,6 +930,203 @@ describe("headless IDA reaped worker cleanup", () => { }); }); +/** A rejected open response, with helpers for separately inventoried workers. */ +const rejectedOpenFixture = async () => { + const { producer, client } = await fixture("headless"); + producer.overrideCall = (name) => + name === "idb_open" ? { error: "open did not complete" } : undefined; + expect((await client.execute("health", {})).ok).toBe(false); + const open = producer.calls.find(({ name }) => name === "idb_open")?.args; + const entry = (session_id: string, owned: boolean) => ({ + session_id, + input_path: String(open?.input_path), + owned, + backend: "worker", + is_active: true, + }); + return { + producer, + client, + requested: String(open?.preferred_session_id), + entry, + }; +}; + +describe("headless IDA cleanup diagnostics", () => { + it("does not name a worker whose close was confirmed when inventory then fails", async () => { + const { producer, client } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + producer.beforeCall = async (name) => { + if (name === "idb_list") throw new Error("inventory unavailable"); + }; + + const result = await client.close(); + expect(result.ok).toBe(false); + expect(producer.calls.map(({ name }) => name)).toContain("idb_close"); + expect(result).toMatchObject(reportsSessions()); + }); + + it("retains peers and confirmed ownership from successful startup when close fails", async () => { + const { producer, client } = await fixture("headless"); + producer.overrideCall = (name) => { + if (name === "idb_list") + return { + sessions: [ + { + session_id: String(producer.database), + input_path: producer.inputPath, + owned: true, + backend: "worker", + is_active: true, + }, + { + session_id: "startup-peer", + input_path: producer.inputPath, + owned: false, + backend: "worker", + is_active: false, + }, + ], + }; + if (name === "idb_close") return { error: "close rejected" }; + return undefined; + }; + expect((await client.execute("health", {})).ok).toBe(true); + const result = await client.close(); + expect(result).toMatchObject( + reportsSessions( + { + session_id: producer.database, + backend: "worker", + owned: true, + input_path: producer.inputPath, + }, + { + session_id: "startup-peer", + backend: "worker", + owned: false, + input_path: producer.inputPath, + }, + ), + ); + }); + + it("preserves an observed conflicting input binding after close", async () => { + const { producer, client } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + const requested = String(producer.database); + producer.overrideCall = (name) => + name === "idb_list" + ? { + sessions: [ + { + session_id: requested, + input_path: "/different/input", + owned: false, + backend: "worker", + is_active: true, + }, + ], + } + : undefined; + const result = await client.close(); + expect(result).toMatchObject( + reportsSessions({ + session_id: requested, + backend: "worker", + owned: false, + input_path: "/different/input", + }), + ); + }); + + it("keeps peers seen beside the requested worker when its close fails", async () => { + const { producer, client, requested, entry } = await rejectedOpenFixture(); + producer.overrideCall = (name) => { + if (name === "idb_list") + return { + sessions: [ + entry(requested, true), + entry("unrelated-live-peer", false), + ], + }; + if (name === "idb_close") return { error: "close rejected" }; + return undefined; + }; + + const result = await client.close(); + expect(result.ok).toBe(false); + expect(result).toMatchObject( + reportsSessions( + { session_id: requested, backend: "worker", owned: true }, + LIVE_PEER, + ), + ); + }); +}); + +describe("headless IDA cleanup diagnostics for observed peers", () => { + it("drops the requested worker from blockers once its close is confirmed", async () => { + const { producer, client, requested, entry } = await rejectedOpenFixture(); + let inventories = 0; + producer.beforeCall = async (name) => { + if (name === "idb_list" && ++inventories > 1) + throw new Error("inventory unavailable"); + }; + producer.overrideCall = (name) => { + if (name === "idb_list") + return { + sessions: [ + entry(requested, true), + entry("unrelated-live-peer", false), + ], + }; + if (name === "idb_close") + return { + success: true, + session_id: requested, + saved: false, + owned: true, + backend: "worker", + }; + return undefined; + }; + + const result = await client.close(); + expect(result.ok).toBe(false); + expect(result).toMatchObject(reportsSessions(LIVE_PEER)); + }); + + it("reports a blocking GUI session by its backend", async () => { + const { producer, client } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + const sessionId = producer.database; + producer.overrideCall = (name) => { + if (name === "idb_close") + return { error: `Session not found: ${sessionId}` }; + if (name === "idb_list") + return { + sessions: [ + { + session_id: "", + input_path: producer.inputPath, + owned: false, + backend: "gui", + is_active: true, + }, + ], + }; + return undefined; + }; + + const result = await client.close(); + expect(result.ok).toBe(false); + expect(result).toMatchObject( + reportsSessions({ session_id: "", backend: "gui", owned: false }), + ); + }); +}); + /** A completed open response is not proof that its private Worker was ever released. */ describe("headless IDA unresolved successful-open ownership", () => { it.each(["matching", "foreign-input", "malformed"] as const)( @@ -976,6 +1204,29 @@ describe("headless IDA unresolved successful-open ownership", () => { expect(producer.closes).toBe(1); expect(await readdir(root)).toEqual(["target.elf"]); }); + + it("retains cleanup when a rejected open leaves the requested ID on another input", async () => { + const { producer, client, requested } = await rejectedOpenFixture(); + producer.overrideCall = (name) => + name === "idb_list" + ? { + sessions: [ + { + session_id: requested, + input_path: "/different/input", + owned: false, + backend: "worker", + is_active: true, + }, + ], + } + : undefined; + + const result = await client.close(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true); + expect(producer.closes).toBe(0); + }); }); describe("headless IDA cleanup verification", () => { diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 14b20b107..dc0ae5d6f 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -93,11 +93,25 @@ interface IdaOperationFailureContext { parameters: Readonly>; } +/** Upstream session ids of inventory entries, omitting unreported ones. */ +interface UpstreamSession { + readonly session_id: string; + readonly backend: "worker" | "gui"; + /** Upstream ownership; null when no inventory has reported it. */ + readonly owned: boolean | null; + /** Last inventoried input binding; null until upstream reports it. */ + readonly input_path: string | null; +} + /** A failed cleanup retains its private workspace and reports the remaining resource. */ class IdaCleanupError extends ProviderAdapterError { override readonly cleanupIncomplete = true; override readonly cleanupResources: readonly string[]; - constructor(path: string, cause: unknown) { + constructor( + path: string, + cause: unknown, + sessions?: readonly UpstreamSession[], + ) { super("ida", "close", { cause, diagnostics: { @@ -105,6 +119,23 @@ class IdaCleanupError extends ProviderAdapterError { "IDA cleanup could not be verified; the remaining connection or private workspace is retained for retry.", failure_reason: cause instanceof Error ? cause.message : String(cause), workspace: path, + ...(sessions === undefined + ? {} + : { + upstream_sessions: { + state: "unverified", + sessions: sessions.map( + ({ session_id, backend, owned, input_path }) => ({ + session_id, + backend, + owned, + input_path, + }), + ), + reason: + "Release of upstream sessions matching the requested session ID or private input was not confirmed; input_path preserves the last observed binding and is null when no inventory has reported it. A worker outlives its supervisor and may keep running until it finishes and reaches its idle timeout; exiting REA does not stop it. A gui session belongs to an IDA instance REA does not control. An empty session_id means upstream reported none; owned is null when no inventory has reported ownership; an empty list means the requested worker's close was confirmed but no inventory has identified any remaining session.", + }, + }), }, }); this.cleanupResources = [path]; @@ -123,6 +154,7 @@ export class IdaSessionClient implements AnalysisClient { #databaseCloseCompleted = false; #openAttempted = false; #openCompleted = false; + #blockingSessions: readonly UpstreamSession[] | undefined; #openRejected = false; #callGraphAdvertised = false; #surveyAdvertised = false; @@ -490,6 +522,14 @@ export class IdaSessionClient implements AnalysisClient { await this.connection.call("idb_list", {}), ); this.#lifecycle.push(jsonValueSchema.parse(inventory)); + const workspace = this.#workspace; + this.#recordBlockingSessions( + inventory.sessions.filter( + (entry) => + entry.session_id === workspace.sessionId || + sameIdaHostPath(entry.input_path, workspace.inputPath), + ), + ); const entry = inventory.sessions.find( ({ session_id }) => session_id === opened.session.session_id, ); @@ -589,6 +629,52 @@ export class IdaSessionClient implements AnalysisClient { return shared; } + /** + * Upstream sessions whose release cleanup has not confirmed: the entries + * the latest successful inventory found blocking release; else the + * requested private worker until its close is confirmed, and then none + * known. + */ + #unreleasedSessions(): readonly UpstreamSession[] | undefined { + if (this.#blockingSessions !== undefined) return this.#blockingSessions; + if (!this.#openAttempted || this.#workspace === undefined) return undefined; + return this.#databaseCloseCompleted + ? [] + : [ + { + session_id: this.#workspace.sessionId, + backend: "worker", + owned: null, + input_path: null, + }, + ]; + } + + /** Drop a session whose release upstream confirmed, keeping other blockers. */ + #forgetReleasedSession(sessionId: string): void { + const remaining = this.#blockingSessions?.filter( + ({ session_id }) => session_id !== sessionId, + ); + this.#blockingSessions = + remaining !== undefined && remaining.length > 0 ? remaining : undefined; + } + + /** Replace blocker evidence from a successful inventory; true when blocked. */ + #recordBlockingSessions( + entries: readonly (UpstreamSession & { readonly owned: boolean })[], + ): boolean { + this.#blockingSessions = + entries.length > 0 + ? entries.map(({ session_id, backend, owned, input_path }) => ({ + session_id, + backend, + owned, + input_path, + })) + : undefined; + return entries.length > 0; + } + async #closeDatabase(database: string): Promise { const response = await this.connection.call("idb_close", { database, @@ -629,12 +715,17 @@ export class IdaSessionClient implements AnalysisClient { entry.session_id === workspace.sessionId && sameIdaHostPath(entry.input_path, workspace.inputPath), )?.session_id; - if ( - this.#database === undefined && - inventory.sessions.some((entry) => - sameIdaHostPath(entry.input_path, workspace.inputPath), - ) - ) + // Every same-input session and any entry under the requested ID, + // whatever its input, stays reported until a later successful + // inventory confirms release. + const blocked = this.#recordBlockingSessions( + inventory.sessions.filter( + (entry) => + entry.session_id === workspace.sessionId || + sameIdaHostPath(entry.input_path, workspace.inputPath), + ), + ); + if (this.#database === undefined && blocked) throw new AnalysisProtocolError( "An unowned worker still uses the private workspace; it was retained.", ); @@ -651,20 +742,20 @@ export class IdaSessionClient implements AnalysisClient { if (!this.#databaseCloseCompleted) { await this.#closeDatabase(this.#database); this.#databaseCloseCompleted = true; + this.#forgetReleasedSession(this.#database); } const inventory = databaseListSchema.parse( await this.connection.call("idb_list", {}), ); - if ( - inventory.sessions.some( - (entry) => - entry.session_id === this.#database || - sameIdaHostPath( - entry.input_path, - this.#workspace?.inputPath ?? "", - ), - ) - ) + const remaining = inventory.sessions.filter( + (entry) => + entry.session_id === this.#database || + sameIdaHostPath( + entry.input_path, + this.#workspace?.inputPath ?? "", + ), + ); + if (this.#recordBlockingSessions(remaining)) throw new AnalysisProtocolError( "IDA worker remains registered after close.", ); @@ -681,10 +772,12 @@ export class IdaSessionClient implements AnalysisClient { this.#closed = true; return ok(null); } catch (cause: unknown) { + this.connection.detach(); return err( new IdaCleanupError( this.#workspace?.directory ?? "IDA MCP connection", cause, + this.#unreleasedSessions(), ), ); } diff --git a/src/ida/IdaWorkspaceOwnership.test.ts b/src/ida/IdaWorkspaceOwnership.test.ts index b39c6e916..1f4a10d65 100644 --- a/src/ida/IdaWorkspaceOwnership.test.ts +++ b/src/ida/IdaWorkspaceOwnership.test.ts @@ -98,11 +98,17 @@ describe("IDA workspace ownership before input preparation", () => { expect(closed.error.cleanupResources).toEqual([ expect.stringContaining(workspace ?? "missing workspace"), ]); + // No open was attempted, so there is no worker to report. + expect(closed.error).not.toHaveProperty( + "diagnostics.upstream_sessions", + ); } + expect(producer.detaches).toBe(1); } finally { await chmod(root, 0o700); } expect((await client.close()).ok).toBe(true); + expect(producer.detaches).toBe(1); expect(await readdir(root)).toEqual(["target.elf"]); }, ); diff --git a/tests/boundary/cli/idaRetainedCleanupCli.test.ts b/tests/boundary/cli/idaRetainedCleanupCli.test.ts new file mode 100644 index 000000000..b31903489 --- /dev/null +++ b/tests/boundary/cli/idaRetainedCleanupCli.test.ts @@ -0,0 +1,92 @@ +import { describe, expect } from "vitest"; + +import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; + +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; + +/** + * A headless IDA supervisor that completes the MCP handshake and then leaves + * every tool call unanswered, as an `idb_open` still running auto-analysis + * would. It exits when REA closes its input. + */ +const UNRESPONSIVE_SUPERVISOR = `import { createInterface } from "node:readline"; +const tools = ["idb_open", "idb_list", "idb_close", "server_health", "list_funcs", + "lookup_funcs", "find_regex", "decompile", "disasm", "callees", "xrefs_to"]; +const send = (message) => + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (request.method === "initialize") + send({ id: request.id, result: { + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "unresponsive-ida-fixture", version: "1" }, + } }); + else if (request.method === "tools/list") + send({ id: request.id, result: { + tools: tools.map((name) => ({ name, inputSchema: { type: "object" } })), + } }); +} +`; + +describe("headless IDA timeout through the one-shot CLI", () => { + workspaceCliTest( + "exits after a retained-cleanup failure instead of waiting on the supervisor", + async ({ cli, workspace }) => { + const supervisor = await workspace.write( + "supervisor.mjs", + UNRESPONSIVE_SUPERVISOR, + ); + const workspaceRoot = await workspace.mkdir("ida-workspaces"); + const registration = await workspace.write( + "ida-mcp.json", + JSON.stringify({ + command: process.execPath, + args: [supervisor], + mode: "headless", + timeoutMs: 500, + workspaceRoot, + }), + ); + const target = await workspace.write("target.elf", elf(2, 1, 62)); + + const result = await cli.run({ + arguments: ["decompile", target, "main", "--json"], + environment: { + ...workspace.environment, + REA_ANALYSIS_PROVIDER: "ida", + REA_IDA_MCP_CONFIG: registration, + REA_LOG_LEVEL: "silent", + }, + timeoutMs: 30_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "cleanup_incomplete", + details: { + provider_id: "ida", + cleanup: "incomplete", + resources: [expect.stringContaining(workspaceRoot)], + cleanup_error: { + details: { + diagnostics: { + upstream_sessions: { + state: "unverified", + sessions: [ + { + session_id: expect.stringMatching(/^rea-/), + backend: "worker", + owned: null, + input_path: null, + }, + ], + }, + }, + }, + }, + }, + }); + }, + ); +}); diff --git a/tests/boundary/providers/ida/retainedConnectionRetry.test.ts b/tests/boundary/providers/ida/retainedConnectionRetry.test.ts new file mode 100644 index 000000000..4f9092842 --- /dev/null +++ b/tests/boundary/providers/ida/retainedConnectionRetry.test.ts @@ -0,0 +1,114 @@ +import { createHash } from "node:crypto"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it, onTestFinished } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import { elf } from "../../../../src/domain/binaryTarget.fixture.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; +import { createIdaMcpConnection } from "../../../../src/ida/IdaMcpConnection.js"; +import { IdaSessionClient } from "../../../../src/ida/IdaSessionClient.js"; + +/** + * A headless IDA supervisor over real stdio that answers the session + * lifecycle, but rejects the first inventory after a close so cleanup fails + * once and must be retried through the retained connection. + */ +const FLAKY_INVENTORY_SUPERVISOR = `import { createInterface } from "node:readline"; +const tools = ["idb_open", "idb_list", "idb_close", "server_health", "list_funcs", + "lookup_funcs", "find_regex", "decompile", "disasm", "callees", "xrefs_to"]; +let session; +let inputPath; +let closed = false; +let inventoryFailed = false; +const send = (message) => + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +const result = (value) => ({ + content: [{ type: "text", text: JSON.stringify(value) }], + structuredContent: value, +}); +const call = ({ name, arguments: args }) => { + if (name === "idb_open") { + session = args.preferred_session_id; + inputPath = args.input_path; + return result({ success: true, session: { session_id: session, input_path: inputPath } }); + } + if (name === "server_health") + return result({ status: "ok", input_path: inputPath, idb_path: inputPath + ".i64", + module: "target.elf", imagebase: "0x1000", auto_analysis_ready: true }); + if (name === "idb_close") { + closed = true; + return result({ success: true, session_id: session, saved: null, owned: true, backend: "worker" }); + } + if (name === "idb_list") { + if (closed && !inventoryFailed) { + inventoryFailed = true; + return { isError: true, content: [{ type: "text", text: "inventory unavailable" }] }; + } + return result({ sessions: closed ? [] : [{ session_id: session, input_path: inputPath, + owned: true, backend: "worker", is_active: true }] }); + } + return { isError: true, content: [{ type: "text", text: "unsupported " + name }] }; +}; +for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (request.method === "initialize") + send({ id: request.id, result: { + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "flaky-inventory-ida-fixture", version: "1" }, + } }); + else if (request.method === "tools/list") + send({ id: request.id, result: { + tools: tools.map((name) => ({ name, inputSchema: { type: "object" } })), + } }); + else if (request.method === "tools/call") + send({ id: request.id, result: call(request.params) }); +} +`; + +describe("retained headless IDA connection in a long-lived host", () => { + it("retries cleanup through the detached stdio connection", async () => { + const root = await createTestTempDirectory("rea-ida-retry-"); + const supervisor = join(root, "supervisor.mjs"); + await writeFile(supervisor, FLAKY_INVENTORY_SUPERVISOR); + const workspaceRoot = join(root, "workspaces"); + await mkdir(workspaceRoot); + const bytes = elf(2, 1, 62); + const targetPath = join(root, "target.elf"); + await writeFile(targetPath, bytes); + const target: BinaryTarget = { + path: targetPath, + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }; + const config = { + command: process.execPath, + args: [supervisor], + env: {}, + mode: "headless" as const, + timeoutMs: 5000, + workspaceRoot, + }; + const connection = createIdaMcpConnection(config, process.env); + // Close the supervisor even when an assertion fails before cleanup does. + onTestFinished(() => connection.close().catch(() => undefined)); + const client = new IdaSessionClient(config, target, connection); + + expect((await client.execute("health", {})).ok).toBe(true); + const failed = await client.close(); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error.cleanupIncomplete).toBe(true); + expect(await readdir(workspaceRoot)).toHaveLength(1); + + // The connection was detached when cleanup failed; it must still serve + // the retry that confirms release and removes the private workspace. + expect((await client.close()).ok).toBe(true); + expect(await readdir(workspaceRoot)).toEqual([]); + }); +}); diff --git a/tests/fixtures/idaMcp.ts b/tests/fixtures/idaMcp.ts index 21e5e38f7..6be35a8bf 100644 --- a/tests/fixtures/idaMcp.ts +++ b/tests/fixtures/idaMcp.ts @@ -68,6 +68,7 @@ export class RecordingIdaMcp implements IdaMcpConnection { }[] = []; connects = 0; closes = 0; + detaches = 0; inputSha256: string; inputPath: string; imageBase = "0x1000"; @@ -104,6 +105,9 @@ export class RecordingIdaMcp implements IdaMcpConnection { async close() { this.closes += 1; } + detach() { + this.detaches += 1; + } async call( name: string, args: Readonly>, From c6503edfc4afdc0de6812ba3f7cd0909df5555f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Llu=C3=ADs=20Ari=C3=B1o?= Date: Tue, 13 Oct 2026 15:38:46 +0200 Subject: [PATCH 52/92] ci: pin GitHub Actions to full commit SHAs (#1901) * ci: pin GitHub Actions to full commit SHAs Replace every third-party `uses: owner/repo@vN` reference in .github/workflows with the 40-character commit SHA that the tag currently resolves to, keeping the exact release as a trailing comment. Each pin was checked with `git ls-remote --tags`: the floating major tag and the commented release dereference to the same commit. Local reusable workflows are unchanged. Re-pin reactivecircus/android-emulator-runner from the SHA of the annotated `v2` tag object (4c44018e) to the commit it points to (a421e438, v2.38.0); Actions resolves both, but only the commit SHA is an immutable reference that tooling can compare with the release. Update the Windows Ghidra workflow trust-boundary test to require SHA-pinned setup-node and upload-artifact references instead of the previous floating tags. Validation: actionlint v1.7.7 (-shellcheck= -pyflakes=, as in CI), YAML parse of all workflows, no remaining unpinned third-party `uses:`, and the workflow boundary/conformance tests. * style(ci): format action identity assertion Retain formatting edits produced before the user stopped validation. Source inspection and git diff --check only. Tests, builds and typechecks skipped by explicit user request. GitHub review writes paused globally. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../actions/portable-build-cache/action.yml | 6 +- .github/workflows/ci-timings.yml | 6 +- .github/workflows/ci.yml | 96 +++++++++---------- .github/workflows/nativeaot-fixtures.yml | 8 +- .github/workflows/pages.yml | 4 +- .github/workflows/real-binary-layout.yml | 4 +- .github/workflows/real-browser.yml | 4 +- .github/workflows/real-evm-interface.yml | 4 +- .github/workflows/real-ghidra-windows.yml | 14 +-- .github/workflows/real-go-binary.yml | 6 +- .github/workflows/real-hopper-linux.yml | 8 +- .github/workflows/real-hopper.yml | 4 +- .../workflows/real-javascript-recovery.yml | 4 +- .github/workflows/real-packet-capture.yml | 4 +- .github/workflows/real-recorded-crash.yml | 4 +- .github/workflows/real-signal-recording.yml | 4 +- .github/workflows/real-termux.yml | 6 +- .../workflows/real-web-network-captures.yml | 4 +- .github/workflows/real-web-runtime.yml | 4 +- .github/workflows/real-web-source-map.yml | 4 +- .github/workflows/release.yml | 26 ++--- .github/workflows/sqlite-runtime.yml | 6 +- .github/workflows/website-check.yml | 4 +- .github/workflows/website-pages.yml | 18 ++-- .../filesystem/ciRequiredLanes.test.ts | 4 +- .../ghidra/realGhidraWindowsWorkflow.test.ts | 8 +- 26 files changed, 134 insertions(+), 130 deletions(-) diff --git a/.github/actions/portable-build-cache/action.yml b/.github/actions/portable-build-cache/action.yml index cee775ec5..799b9476b 100644 --- a/.github/actions/portable-build-cache/action.yml +++ b/.github/actions/portable-build-cache/action.yml @@ -12,7 +12,7 @@ runs: steps: - name: Download portable build outputs from this workflow run if: inputs.build_cache_artifact != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: ${{ inputs.build_cache_artifact }} path: .turbo/cache/ @@ -39,14 +39,14 @@ runs: ' >> "$GITHUB_OUTPUT" - name: Restore portable build outputs if: inputs.build_cache_artifact == '' && inputs.save != 'true' - uses: actions/cache/restore@v4 + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo/cache/ key: ${{ steps.plan.outputs.key }} restore-keys: ${{ steps.plan.outputs.prefix }} - name: Restore and save portable build outputs if: inputs.build_cache_artifact == '' && inputs.save == 'true' - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .turbo/cache/ key: ${{ steps.plan.outputs.key }} diff --git a/.github/workflows/ci-timings.yml b/.github/workflows/ci-timings.yml index 30fa19464..73a9ba4b9 100644 --- a/.github/workflows/ci-timings.yml +++ b/.github/workflows/ci-timings.yml @@ -20,11 +20,11 @@ jobs: timeout-minutes: 5 steps: # Consume only API data and execute batch code from the trusted default branch. - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - name: Record queue, execution, and matched step timing @@ -36,7 +36,7 @@ jobs: - name: Upload CI timing artifacts if: always() && hashFiles('.cache/ci-job-timings/*') != '' continue-on-error: true - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ci-job-timings-batch path: .cache/ci-job-timings/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2cc59493..142e179bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,11 +63,11 @@ jobs: matrix: ${{ steps.plan.outputs.matrix }} test_selection: ${{ steps.plan.outputs.test_selection }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ inputs.source_sha || github.sha }} fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - name: Bind qualification to the caller revision @@ -118,8 +118,8 @@ jobs: env: CHANGES_RESULT: ${{ needs.changes.result }} run: test "${CHANGES_RESULT}" = "success" - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm @@ -138,7 +138,7 @@ jobs: run: npm run format:check - name: Upload complete static diagnostics if: always() && needs.changes.outputs.code == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: static-check-reports path: .cache/static-checks/ @@ -157,13 +157,13 @@ jobs: matrix: shard: [1/6, 2/6, 3/6, 4/6, 5/6, 6/6] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm - run: npm ci - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: rea-test-runtime path: . @@ -177,7 +177,7 @@ jobs: run: npm run test:run -- --reporter=default --reporter=blob --shard=${{ matrix.shard }} env: REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: ${{ !cancelled() }} with: name: vitest-report-${{ strategy.job-index }} @@ -208,8 +208,8 @@ jobs: contents: read actions: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm @@ -231,13 +231,13 @@ jobs: - name: Download previous timing report if: steps.history.outputs.run-id != '' continue-on-error: true - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: test-timings run-id: ${{ steps.history.outputs.run-id }} github-token: ${{ github.token }} path: .cache/ci-test-history - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: rea-test-runtime path: | @@ -254,7 +254,7 @@ jobs: # test:prepare caches only portable JS, skill, and catalog outputs. # Native package jobs still run their build and prepack checks; Turbo # restores matching outputs or rebuilds when their inputs differ. - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: rea-build-cache path: .turbo/cache/ @@ -280,16 +280,16 @@ jobs: env: CHANGES_RESULT: ${{ needs.changes.result }} run: test "${CHANGES_RESULT}" = "success" - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 if: needs.changes.outputs.code == 'true' && success() - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 if: needs.changes.outputs.code == 'true' && success() with: node-version-file: .nvmrc cache: npm - if: needs.changes.outputs.code == 'true' && success() run: npm ci - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 if: needs.changes.outputs.code == 'true' && success() with: path: .vitest/blob @@ -330,7 +330,7 @@ jobs: CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }} CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node scripts/ci/test-report.mjs - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != '' with: name: test-results @@ -345,7 +345,7 @@ jobs: retention-days: 30 - name: Publish successful timing history if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: test-timings path: .cache/ci-test-history/test-results.json @@ -366,7 +366,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Verify fixtures from a clean checkout run: npm run verify:fixtures @@ -377,8 +377,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - name: Prepare cross compiler @@ -395,12 +395,12 @@ jobs: - run: npm run build:windows-native - run: npm run verify:windows-native:artifact - run: node scripts/build-windows-native.mjs --inspection-fixture - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: windows-native-inspection-fixture path: .cache/windows-native-inspection-fixture/ if-no-files-found: error - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: windows-native-controls path: native/windows/build/ @@ -423,8 +423,8 @@ jobs: NODE_OPTIONS: "--max-old-space-size=1024" UV_THREADPOOL_SIZE: "2" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm @@ -438,11 +438,11 @@ jobs: assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE); console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));" - run: npm ci - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: rea-build-cache path: .turbo/cache/ - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: windows-native-controls path: native/windows/build/ @@ -463,7 +463,7 @@ jobs: run: npm run verify:inspector - name: Retain the exact qualified release package if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: verified-release-package path: .cache/verified-package/ @@ -479,13 +479,13 @@ jobs: runs-on: macos-15 timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm - run: npm ci - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: rea-build-cache path: .turbo/cache/ @@ -527,8 +527,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm @@ -539,7 +539,7 @@ jobs: run: | set -o pipefail npm run verify:readiness | tee readiness-summary.json - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: ${{ !cancelled() }} with: name: reconstruction-readiness @@ -556,18 +556,18 @@ jobs: runs-on: windows-latest timeout-minutes: 25 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm - run: npm ci - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: rea-build-cache path: .turbo/cache/ - run: npm run build:cached - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: windows-native-controls path: native/windows/build/ @@ -623,7 +623,7 @@ jobs: tests/process-global/updateMaintenance.test.ts tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts - run: npm run verify:windows-native - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: windows-native-inspection-fixture path: .cache/windows-native-inspection-fixture/ @@ -642,7 +642,7 @@ jobs: tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts tests/boundary/filesystem/javascriptPackagePrecedence.test.ts tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.19.0 - name: Verify native path boundaries on Node 22 @@ -652,7 +652,7 @@ jobs: tests/boundary/filesystem/javascriptPackagePrecedence.test.ts tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts tests/boundary/process/sourceMapUncHosts.test.ts - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: windows-capabilities @@ -666,8 +666,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm @@ -675,7 +675,7 @@ jobs: - run: npm run docs:check - name: Generation leaves authored files unchanged run: git diff --exit-code - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: generated-docs path: | @@ -782,8 +782,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - name: Require every selected lane to pass @@ -794,7 +794,7 @@ jobs: run: node scripts/ci/required.mjs - name: Upload full qualification receipt artifact if: needs.changes.outputs.tier == 'full' && success() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ steps.gate.outputs.receipt_name }} path: ${{ steps.gate.outputs.receipt_path }} diff --git a/.github/workflows/nativeaot-fixtures.yml b/.github/workflows/nativeaot-fixtures.yml index ce7dc1ea1..b2cc5801f 100644 --- a/.github/workflows/nativeaot-fixtures.yml +++ b/.github/workflows/nativeaot-fixtures.yml @@ -19,16 +19,16 @@ jobs: DOTNET_PROCESSOR_COUNT: "1" COMPlus_GCHeapHardLimit: "30000000" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: "8.0.416" - name: Build benign fixture and independent byte oracle run: node scripts/generate-nativeaot-fixtures.mjs - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: nativeaot-win-x64-${{ github.event.pull_request.head.sha || github.sha }} path: | diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e276a9f0d..6b8a05b46 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -19,8 +19,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-binary-layout.yml b/.github/workflows/real-binary-layout.yml index 5cec952ce..0218543f0 100644 --- a/.github/workflows/real-binary-layout.yml +++ b/.github/workflows/real-binary-layout.yml @@ -28,8 +28,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-browser.yml b/.github/workflows/real-browser.yml index 912fdafb1..3c3fe955a 100644 --- a/.github/workflows/real-browser.yml +++ b/.github/workflows/real-browser.yml @@ -21,8 +21,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-evm-interface.yml b/.github/workflows/real-evm-interface.yml index dfdd3970f..6e510c973 100644 --- a/.github/workflows/real-evm-interface.yml +++ b/.github/workflows/real-evm-interface.yml @@ -22,8 +22,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-ghidra-windows.yml b/.github/workflows/real-ghidra-windows.yml index d6de7469f..db92cd0f8 100644 --- a/.github/workflows/real-ghidra-windows.yml +++ b/.github/workflows/real-ghidra-windows.yml @@ -16,8 +16,8 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - name: Prepare cross compiler @@ -31,7 +31,7 @@ jobs: sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64 - run: npm run build:windows-native - run: npm run verify:windows-native:artifact - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: real-ghidra-windows-native path: native/windows/build/ @@ -45,12 +45,12 @@ jobs: runs-on: [self-hosted, Windows, x64, ghidra-12-1-2] timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - run: npm ci - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: real-ghidra-windows-native path: native/windows/build/ @@ -88,7 +88,7 @@ jobs: env: REA_ANALYSIS_PROVIDER: ghidra run: npm run verify:ghidra:windows:package -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-package-proof.log - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: windows-ghidra-proof diff --git a/.github/workflows/real-go-binary.yml b/.github/workflows/real-go-binary.yml index b886bd70a..c7d74b001 100644 --- a/.github/workflows/real-go-binary.yml +++ b/.github/workflows/real-go-binary.yml @@ -44,12 +44,12 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.26.1" cache: false diff --git a/.github/workflows/real-hopper-linux.yml b/.github/workflows/real-hopper-linux.yml index 2355229de..d5e5f8c33 100644 --- a/.github/workflows/real-hopper-linux.yml +++ b/.github/workflows/real-hopper-linux.yml @@ -15,8 +15,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 25 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm @@ -36,8 +36,8 @@ jobs: runs-on: [self-hosted, Linux, hopper] timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-hopper.yml b/.github/workflows/real-hopper.yml index 51135f4b6..b3ceeb7d9 100644 --- a/.github/workflows/real-hopper.yml +++ b/.github/workflows/real-hopper.yml @@ -15,8 +15,8 @@ jobs: runs-on: [self-hosted, macOS, hopper] timeout-minutes: 20 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-javascript-recovery.yml b/.github/workflows/real-javascript-recovery.yml index 817dc4dc0..a5043b62e 100644 --- a/.github/workflows/real-javascript-recovery.yml +++ b/.github/workflows/real-javascript-recovery.yml @@ -23,8 +23,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-packet-capture.yml b/.github/workflows/real-packet-capture.yml index b5e9f9201..8daebfd8a 100644 --- a/.github/workflows/real-packet-capture.yml +++ b/.github/workflows/real-packet-capture.yml @@ -43,8 +43,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-recorded-crash.yml b/.github/workflows/real-recorded-crash.yml index 04467a608..0463df27d 100644 --- a/.github/workflows/real-recorded-crash.yml +++ b/.github/workflows/real-recorded-crash.yml @@ -25,8 +25,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-signal-recording.yml b/.github/workflows/real-signal-recording.yml index cc902037e..5c1380e2d 100644 --- a/.github/workflows/real-signal-recording.yml +++ b/.github/workflows/real-signal-recording.yml @@ -36,8 +36,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-termux.yml b/.github/workflows/real-termux.yml index 204acc1c6..e6f3890e1 100644 --- a/.github/workflows/real-termux.yml +++ b/.github/workflows/real-termux.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Download the pinned Termux app and verify its release checksum shell: bash run: | @@ -38,7 +38,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Build and capture a page inside Termux - uses: reactivecircus/android-emulator-runner@4c44018e59b437e86cdfc41da381398f93ed8808 # v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: 30 arch: x86_64 @@ -50,7 +50,7 @@ jobs: script: bash scripts/verify/termux/emulator.sh - name: Upload Termux and Android diagnostics if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: termux-browser-diagnostics path: ${{ runner.temp }}/rea-termux/diagnostics diff --git a/.github/workflows/real-web-network-captures.yml b/.github/workflows/real-web-network-captures.yml index d41bb37a5..3ee59d2c8 100644 --- a/.github/workflows/real-web-network-captures.yml +++ b/.github/workflows/real-web-network-captures.yml @@ -22,8 +22,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-web-runtime.yml b/.github/workflows/real-web-runtime.yml index 2575c55f9..f5b9f201a 100644 --- a/.github/workflows/real-web-runtime.yml +++ b/.github/workflows/real-web-runtime.yml @@ -17,8 +17,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/real-web-source-map.yml b/.github/workflows/real-web-source-map.yml index 8e0869eb7..2a1e65dc0 100644 --- a/.github/workflows/real-web-source-map.yml +++ b/.github/workflows/real-web-source-map.yml @@ -17,8 +17,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cc798cfa..61e39d029 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: echo "frozen=true" >> "$GITHUB_OUTPUT" fi - - uses: googleapis/release-please-action@v4 + - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 if: steps.editorial.outputs.frozen != 'true' with: token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} @@ -70,13 +70,13 @@ jobs: sha: ${{ steps.release.outputs.sha }} steps: - name: Check out reviewed release merge - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc package-manager-cache: false @@ -104,7 +104,7 @@ jobs: node scripts/release-notes.mjs publication --repository "$GH_REPO" \ --source "$SOURCE_SHA" --pr "$RELEASE_PR" - - uses: googleapis/release-please-action@v4 + - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 id: release with: token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} @@ -191,14 +191,14 @@ jobs: fi - name: Check out release controller and Git history - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - name: Set up Node.js for checkpoint validation - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc package-manager-cache: false @@ -226,7 +226,7 @@ jobs: node scripts/release-notes.mjs publication --repository "$GH_REPO" \ --source "$SOURCE_SHA" - - uses: googleapis/release-please-action@v4 + - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 id: release with: # A dedicated credential starts PR CI automatically; the built-in @@ -255,7 +255,7 @@ jobs: - name: Check out release pull request if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }} fetch-depth: 0 @@ -263,7 +263,7 @@ jobs: - name: Set up Node.js for generated documentation if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc package-manager-cache: false @@ -314,12 +314,12 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }} persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc package-manager-cache: false @@ -328,7 +328,7 @@ jobs: - run: npm ci - name: Download the exact qualified package - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: verified-release-package path: .cache/verified-package/ @@ -392,7 +392,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }} persist-credentials: false diff --git a/.github/workflows/sqlite-runtime.yml b/.github/workflows/sqlite-runtime.yml index 6e5bbcd10..fc379d1dc 100644 --- a/.github/workflows/sqlite-runtime.yml +++ b/.github/workflows/sqlite-runtime.yml @@ -22,14 +22,14 @@ jobs: # The repository-pinned Node 24 version also runs the complete test suite. node: ["22.x", "24.15.0", "24.x", "26.x"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node }} check-latest: true cache: npm - run: npm ci - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: rea-test-runtime path: . diff --git a/.github/workflows/website-check.yml b/.github/workflows/website-check.yml index 2a2c5406f..dc17b4b96 100644 --- a/.github/workflows/website-check.yml +++ b/.github/workflows/website-check.yml @@ -14,9 +14,9 @@ jobs: timeout-minutes: 10 steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python for website assets - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" cache: pip diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index 3c2251658..491b028a6 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -47,9 +47,9 @@ jobs: exit 1 fi - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python for website assets - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" cache: pip @@ -74,9 +74,9 @@ jobs: ) PY - name: Configure Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - name: Upload the website - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: path: website/_site @@ -95,15 +95,15 @@ jobs: steps: - name: Check out publishing configuration if: inputs.target == 'both' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Node.js for Wrangler if: inputs.target == 'both' - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - name: Download the verified website if: inputs.target == 'both' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: github-pages path: ${{ runner.temp }}/website-archive @@ -114,7 +114,7 @@ jobs: tar -xf "$RUNNER_TEMP/website-archive/artifact.tar" -C "$RUNNER_TEMP/rea-website-public" - name: Publish the website to Cloudflare if: inputs.target == 'both' - uses: cloudflare/wrangler-action@v4 + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -123,7 +123,7 @@ jobs: command: deploy --cwd website --env production --assets "${{ runner.temp }}/rea-website-public" - name: Publish to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 - name: Verify the selected published revisions env: PAGES_URL: ${{ steps.deployment.outputs.page_url }} diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts index b6e02b54a..da6c43186 100644 --- a/tests/boundary/filesystem/ciRequiredLanes.test.ts +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -262,8 +262,8 @@ it("binds provider build reuse to the current run while keeping standalone dispa ), ), ); - const download = action.runs.steps.find( - (step) => step.uses === "actions/download-artifact@v4", + const download = action.runs.steps.find((step) => + step.uses?.startsWith("actions/download-artifact@"), ); expect(download?.with).toEqual({ name: "${{ inputs.build_cache_artifact }}", diff --git a/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts b/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts index 39eb8ff61..0e936b062 100644 --- a/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts +++ b/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts @@ -26,14 +26,18 @@ describe("real Windows Ghidra workflow trust boundary", () => { environment: "real-ghidra-windows", "runs-on": expect.arrayContaining(["self-hosted", "Windows", "x64"]), steps: expect.arrayContaining([ - expect.objectContaining({ uses: "actions/setup-node@v4" }), + expect.objectContaining({ + uses: expect.stringMatching(/^actions\/setup-node@[0-9a-f]{40}$/), + }), expect.objectContaining({ run: "npm ci" }), expect.objectContaining({ env: { REA_ANALYSIS_PROVIDER: "ghidra" }, run: "npm run verify:ghidra:windows | Tee-Object -FilePath windows-ghidra-proof.log", }), expect.objectContaining({ - uses: "actions/upload-artifact@v4", + uses: expect.stringMatching( + /^actions\/upload-artifact@[0-9a-f]{40}$/, + ), with: expect.objectContaining({ path: expect.stringContaining("windows-ghidra-proof.log"), }), From a4b8e9ccee50f83bfb72d7ad33db6962c9befa72 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:42:38 +0200 Subject: [PATCH 53/92] feat(ghidra): admit recordless legacy MIPS32 ELFs with a pinned 32-bit language (#1898) * feat(ghidra): admit recordless legacy MIPS32 ELFs with a pinned 32-bit language Firmware built with pre-ABI-flags GNU toolchains declares EF_MIPS_ARCH_32 with an unspecified ABI field and no PT_MIPS_ABIFLAGS record. The lane refused them before provider selection, and Ghidra's own header auto-selection picks a 64-bit R6 language for exactly these files. Admit ELF32 ET_EXEC headers with EF_MIPS_ARCH_32 or EF_MIPS_ARCH_32R2, an unspecified ABI field, EF_MIPS_ABI2 clear and no observed ABI flags record. o32 is recorded as an inferred analysis assumption (mips_abi_basis), floating-point ABI semantics stay unverified, and the existing refusals (FP64, NAN2008, ASEs, machine variants, ELF64) are unchanged. The profile pins -processor MIPS::32:default through a provider-pinned-v1 selection the bridge-reported language_id is checked against after import; a caller-selected REA_GHIDRA_LANGUAGE_ID still wins. The lane identity is versioned elf32-exec-arch32-legacy-pinned-v1. verify:ghidra:mips gains a recordless legacy round for both byte orders that strips the ABI record declarations from a real mips32 fixture, checks REA's interpretation against GNU readelf, asserts the pinned language after import and the retained assumption limitations through a snapshot round-trip. Fixes #1620 * fix(ghidra): require an inspected absence for the recordless MIPS lane Address review: undefined ABI flags means the record was not inspected, not that none exists. Only an inspected absence (null) establishes the recordless legacy admission; a merely unobserved record keeps its specific refusal. Also document the legacy admission and its pinned language in the MIPS provider guide. * fix(ghidra): tighten the recordless MIPS admission boundaries Address review: a recordless-looking header whose ABI-record inspection left declaration sources unexamined (carved ELF with a partial section table and no program-header record) no longer establishes the recordless admission; it is refused with the inspection limitation retained. An EF_MIPS_ARCH_32 header that declares an ABI is refused rather than relabeled through the 32R2 declared-ABI lane. * fix(ghidra): reject EF_MIPS_ABI_ON32 from the recordless MIPS lane Address review: bit 0x40 declares the old n32 ABI. A recordless header carrying it was admitted with the inferred-o32 assumption even though the header itself declares n32; the recordless shape now requires the bit clear and refuses it with a specific constraint. * fix(ghidra): keep recordless relocatable MIPS objects off the legacy lane The recordless admission now requires ET_EXEC: the lane and its verification cover executable firmware images, and a recordless ET_REL object must stay on the declared-ABI path instead of inheriting the inferred-o32 assumption under an executable lane id. The ABI-record requirement paragraph is scoped to the declared-ABI lane so it no longer contradicts the recordless admission documented above it. * fix(ghidra): name the ET_EXEC scope for recordless objects and probe the legacy lane A recordless relocatable object is not an incomplete inspection, so its refusal now names the ET_EXEC scope of the legacy admission instead of claiming unavailable ABI headers remain unknown. The recordless legacy verifier round reads the probe pair and applies the full assertMipsProbe oracle like the standard round, and its report keeps the independent GNU readelf receipt beside the fixture facts. * fix(ghidra): retain the concrete ELF32 legacy compiler pin * fix(ghidra): bind legacy profiles to fully admitted targets --------- Co-authored-by: BaaRai Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/ghidra-mips.md | 36 ++++- scripts/verify-real-ghidra-mips.mjs | 152 ++++++++++++++++++ src/ghidra/GhidraAnalysisProfile.ts | 9 +- src/ghidra/GhidraMipsProfile.test.ts | 179 +++++++++++++++++++++- src/ghidra/GhidraMipsProfile.ts | 127 +++++++++++++-- src/ghidra/GhidraProviderClient.ts | 56 ++++++- tests/conformance/ghidra/mips-checks.mjs | 84 +++++++++- tests/conformance/ghidra/mips-fixture.mjs | 103 ++++++++++++- 8 files changed, 707 insertions(+), 39 deletions(-) diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index d5b58f00b..f010b4f4c 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -22,6 +22,28 @@ lane. MIPS16 shared images have the separate bounded profile below; other shared or compressed images remain unsupported. Their family may be identified, but provider selection returns the specific unsupported constraint rather than guessing a compatible profile. +A second admission covers recordless legacy firmware images +([#1620](https://github.com/morluto/rea/issues/1620)): ELF32 `ET_EXEC` headers +declaring `EF_MIPS_ARCH_32` or `EF_MIPS_ARCH_32R2` with an unspecified ABI +field, `EF_MIPS_ABI2` clear, and an _inspected_ absence of any ABI flags +record (a target whose record was merely not observed stays refused). For +these images o32 is an analysis assumption derived from the ELF32/ET_EXEC +structure, recorded as `mips_abi_basis` in the profile and stated as a +limitation in Evidence, never a declared fact; floating-point ABI semantics +stay unverified. Ghidra's own header auto-selection picks a 64-bit language +for exactly these files, so the lane pins `-processor MIPS::32:default` +through a `provider-pinned-v1` profile selection and checks the +bridge-reported `language_id` after import. A caller-selected +`REA_GHIDRA_LANGUAGE_ID`/`REA_GHIDRA_COMPILER_SPEC_ID` still wins over the +pin; combining the pinned profile with an environment override is a typed +refusal. A header that declares an ABI field but carries no record is still +refused. Two further boundaries apply: an inspection that could not examine +every ABI declaration source (for example a carved ELF whose section table +extends past the captured bytes without a program-header record) does not +establish the recordless admission, and an `EF_MIPS_ARCH_32` header that +_declares_ an ABI is refused rather than relabeled, because the declared-ABI +lane is verified for `EF_MIPS_ARCH_32R2` headers only. + Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and soft-float targets. Target resolution also reads the 24-byte ABI flags record @@ -40,12 +62,14 @@ absent or supply a default ABI. The field layout follows [`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h). -Provider admission requires record version 0, MIPS32r2, 32-bit GPRs, no CPR2, -no ISA extension, ASE or unknown general flags, and one of two floating-point -declarations: 32-bit CPR1 with a double-precision or FPXX ABI, or no CPR1 with -the soft-float ABI. Other combinations, including single-float, FP64 and -unspecified ABIs, are refused. Missing records remain unknown rather than -defaulting to a compatible ABI, and this lane refuses them. Unknown record +Provider admission on the declared-ABI lane above requires record version 0, +MIPS32r2, 32-bit GPRs, no CPR2, no ISA extension, ASE or unknown general +flags, and one of two floating-point declarations: 32-bit CPR1 with a +double-precision or FPXX ABI, or no CPR1 with the soft-float ABI. Other +combinations, including single-float, FP64 and unspecified ABIs, are refused. +Missing records remain unknown rather than defaulting to a compatible ABI, and +the declared-ABI lane refuses them; the recordless legacy admission above is +the only path that admits a recordless image. Unknown record values are retained for diagnosis but do not gain provider support. These checks bind the declared interpretation; the fixtures do not establish full floating-point behavioral conformance. diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index 1c04ea978..12c00366d 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; import { inspectMipsReadelf, + inspectLegacyMipsReadelf, assertMipsLoadedImage, assertMipsProbe, assertMipsGlobal, @@ -122,6 +123,157 @@ async function verifySoftFloat(call, symbols, byteOrder) { const reports = []; let primaryFailure; try { + if (!psp) { + // Recordless legacy admission: ABI field 0, no ABI flags record, pinned + // 32-bit language. Independent GNU readelf checks the header interpretation. + for (const byteOrder of ["little", "big"]) { + const fixture = await buildMipsFixture(workspace, byteOrder, "hard", { + legacy: true, + }); + const legacy = await inspectLegacyMipsReadelf(fixture.path, byteOrder); + const resolved = await parseBinaryTarget(fixture.path); + if (!resolved.ok) throw resolved.error; + assert.equal(resolved.value.mips.flags, 0x50000001); + assert.equal(resolved.value.mips.abiFlags, null); + assert.deepEqual( + resolved.value.mips.limitations ?? [], + legacy.limitations, + "REA legacy ABI-record absence interpretation disagrees with GNU readelf", + ); + assert.equal(resolved.value.sha256, fixture.sha256); + const snapshotPath = join(workspace, `${byteOrder}-legacy.snapshot.json`); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + const client = new Client({ + name: "rea-real-mips-legacy-proof", + version: "1", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); + }); + const call = async (name, args = {}) => { + const reply = await client.callTool( + { name, arguments: args }, + undefined, + { timeout: 180000 }, + ); + return requireMcpOperationResult(reply, name); + }; + try { + await client.connect(transport); + const selected = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(selected.architecture, "mips"); + assert.equal(selected.sha256, fixture.sha256); + const image = await call("inspect_native_load_image"); + assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256); + // The pinned import language is the checked one, not an auto-selection. + assert.equal( + image.observations.language_id, + `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, + ); + const procedures = await call("list_procedures"); + const entry = procedures.find((p) => p.value === "rea_mips_entry"); + assert.ok(entry, "Entry function missing from legacy inventory"); + assert.equal( + BigInt(entry.address), + BigInt(legacy.symbols.rea_mips_entry), + ); + const probe = await call("inspect_native_instruction", { + address: legacy.symbols.rea_mips_probe, + }); + const probeBranch = await call("inspect_native_instruction", { + address: `0x${(BigInt(legacy.symbols.rea_mips_probe) + 4n).toString(16)}`, + }); + assertMipsProbe( + probe, + probeBranch, + byteOrder, + legacy.symbols.rea_mips_probe, + ); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { procedure: entry.address }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + const saved = await call("close_binary", { + snapshot_path: snapshotPath, + }); + assert.ok(saved.evidence_records > 0); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.equal(snapshot.target.architecture, "mips"); + const parameters = snapshot.binding.analysis_profile.parameters; + assert.equal( + parameters.language_id, + `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, + ); + assert.equal(parameters.language_selection, "provider-pinned-v1"); + assert.equal( + parameters.mips_support_lane, + "elf32-exec-arch32-legacy-pinned-v1", + ); + assert.equal(parameters.mips_abi_basis, "inferred-o32-assumption-v1"); + assert.ok( + snapshot.evidence_bundle.records.some((record) => + parseEvidence(record).limitations.some((item) => + item.includes("analysis assumption"), + ), + ), + "Legacy o32 assumption limitation missing from retained Evidence", + ); + assert.ok( + snapshot.evidence_bundle.records.some((record) => + parseEvidence(record).limitations.some((item) => + item.includes("Floating-point ABI semantics are unverified"), + ), + ), + "Legacy floating-point uncertainty missing from retained Evidence", + ); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual(await call("list_procedures"), procedures); + await call("close_binary"); + } catch (cause) { + throw new Error( + `MIPS legacy ${byteOrder} verification failed; MCP stderr: ${stderr}`, + { cause }, + ); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + reports.push({ + ...fixture, + independent_reader: legacy, + variant: "recordless-legacy-pinned", + effective_language: `MIPS:${ + byteOrder === "little" ? "LE" : "BE" + }:32:default`, + procedures: "verified-through-snapshot", + checks: [ + "recordless header admitted with inferred o32 assumption", + "pinned 32-bit language checked after import", + "legacy limitations retained in Evidence", + "snapshot round-trip and profile parameters", + ], + status: "passed", + }); + } + } for (const [byteOrder, floatAbi] of psp ? [["little", "hard"]] : [ diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 81defabd3..66be760bf 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -15,7 +15,7 @@ import { err, ok, type Result } from "../domain/result.js"; import type { GhidraInstallationInspection } from "./GhidraInstallation.js"; import { ghidraMipsProfileParameters, - ghidraMipsSharedLanguageOverride, + ghidraMipsLanguageOverride, } from "./GhidraMipsProfile.js"; import { ghidraRiscvLanguageOverride, @@ -44,8 +44,7 @@ import { export const ghidraPinnedLanguage = ( target: BinaryTarget, ): GhidraLanguageOverride | undefined => - ghidraMipsSharedLanguageOverride(target) ?? - ghidraRiscvLanguageOverride(target); + ghidraMipsLanguageOverride(target) ?? ghidraRiscvLanguageOverride(target); /** Resolve version-bound, deterministic semantics before Ghidra imports a target. */ export const resolveGhidraAnalysisProfile = async ( @@ -168,7 +167,7 @@ export const resolveGhidraAnalysisProfile = async ( `The caller-selected processor or compiler specification is outside the verified ${ target.architecture === "riscv" ? "generic little-endian RISC-V" - : "endian-matched MIPS32 shared-image" + : "endian-matched MIPS32" } profile. The requested override has not been changed.`, ), ); @@ -273,7 +272,7 @@ export const resolveGhidraAnalysisProfile = async ( } : {}), ...analyzerParameters, - ...ghidraMipsProfileParameters(target), + ...ghidraMipsProfileParameters(target, languageOverride), ...ghidraRiscvProfileParameters(target), ...(pspExtension?.ok === true ? { diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index ae72a3069..800de9490 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -10,10 +10,17 @@ import { HopperProvider } from "../hopper/HopperProvider.js"; import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; import { IdaProvider } from "../ida/IdaProvider.js"; import { GhidraProvider } from "./GhidraProvider.js"; +import { + ghidraMipsProfileParameters, + ghidraPinnedMipsLanguage, +} from "./GhidraMipsProfile.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js"; import type { GhidraMipsSoftFloatReport } from "./GhidraMipsProfile.js"; -import { ghidraProfileLanguageOverride } from "./GhidraAnalysisProfile.js"; +import { + ghidraPinnedLanguage, + ghidraProfileLanguageOverride, +} from "./GhidraAnalysisProfile.js"; // Fake installation inspection tests admission/profile contracts, not Ghidra. const host: GhidraInstallationHost = { @@ -161,6 +168,126 @@ describe("bounded Ghidra MIPS admission", () => { }); }); +describe("recordless legacy MIPS32 admission", () => { + it("keeps recordless relocatable objects off the inferred-o32 lane", async () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const object = target({ + flags: 0x50000001, + abiFlags: null, + type: 1, + }); + expect(ghidra.inspectTargetSupport(object)).toMatchObject({ + status: "unsupported", + }); + const resolved = await ghidra.resolveAnalysisProfile(object); + expect(resolved.ok).toBe(false); + }); + + it.each([ + [{ flags: 0x50000001 }, "EF_MIPS_ARCH_32"], + [{ flags: 0x70000001 }, "EF_MIPS_ARCH_32R2"], + ] satisfies [Partial, string][])( + "admits a recordless legacy header (%s) with a pinned 32-bit language", + async (change) => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const little = target({ ...change, abiFlags: null }); + const big = target({ ...change, abiFlags: null, byteOrder: "big" }); + expect(ghidra.inspectTargetSupport(little).status).toBe("supported"); + expect(ghidra.inspectTargetSupport(big).status).toBe("supported"); + const resolved = await ghidra.resolveAnalysisProfile(little); + if (!resolved.ok) throw resolved.error; + expect(resolved.value.profile?.parameters).toMatchObject({ + mips_support_lane: "elf32-exec-arch32-legacy-pinned-v1", + mips_abi_basis: "inferred-o32-assumption-v1", + language_id: "MIPS:LE:32:default", + compiler_spec_id: "default", + language_selection: "provider-pinned-v1", + }); + const resolvedBig = await ghidra.resolveAnalysisProfile(big); + if (!resolvedBig.ok) throw resolvedBig.error; + expect(resolvedBig.value.profile?.parameters).toMatchObject({ + language_id: "MIPS:BE:32:default", + }); + }, + ); + + it("requires an inspected absence; a merely unobserved record stays refused", () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const withRecord = target({ flags: 0x50000001 }) as BinaryTarget & { + mips: MipsElfMetadata; + }; + const { abiFlags: _record, ...unobservedMips } = withRecord.mips; + void _record; + expect( + ghidra.inspectTargetSupport({ + ...withRecord, + mips: unobservedMips, + } as BinaryTarget), + ).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining("recordless legacy header"), + }); + }); + + it.each([ + [{ flags: 0x50000021 }, "n32"], + [{ flags: 0x50000041 }, "EF_MIPS_ABI_ON32"], + [{ flags: 0x50000101 }, "EF_MIPS_32BITMODE"], + [{ flags: 0x70000101 }, "EF_MIPS_32BITMODE"], + [ + { flags: 0x50001001 }, + "EF_MIPS_ARCH_32R2 for headers that declare an ABI", + ], + [{ flags: 0x50000201 }, "EF_MIPS_FP64"], + [{ flags: 0x50000401 }, "EF_MIPS_NAN2008"], + [{ flags: 0x60000001 }, "32R2"], + [ + { + flags: 0x50000001, + limitations: [ + "MIPS section header table at file offset 96 extends beyond the captured file: examined 2 of 5 complete headers. ABI declarations in unavailable headers remain unknown.", + ], + }, + "complete ABI-record inspection", + ], + ] satisfies [Partial, string][])( + "keeps refusing unverified recordless variants %j", + (change, reason) => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + expect( + ghidra.inspectTargetSupport(target({ ...change, abiFlags: null })), + ).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining(reason), + }); + }, + ); + + it("a caller-selected language wins over the provider pin", () => { + const parameters = ghidraMipsProfileParameters( + target({ flags: 0x50000001, abiFlags: null }), + { languageId: "MIPS:LE:32:default", compilerSpecId: "default" }, + ); + expect(parameters.language_selection).toBeUndefined(); + expect(parameters.language_id).toBeUndefined(); + expect(parameters).toMatchObject({ + mips_support_lane: "elf32-exec-arch32-legacy-pinned-v1", + }); + }); + + it("reads the pinned import language back only from pinned profiles", () => { + const pinned = ghidraMipsProfileParameters( + target({ flags: 0x50000001, abiFlags: null }), + ); + expect(ghidraPinnedMipsLanguage(pinned)).toEqual({ + languageId: "MIPS:LE:32:default", + compilerSpecId: "default", + }); + const standard = ghidraMipsProfileParameters(target()); + expect(ghidraPinnedMipsLanguage(standard)).toBeUndefined(); + }); +}); + const report: GhidraMipsSoftFloatReport = { policy: "o32-soft-float-prototype-v1", functions: 5, @@ -218,6 +345,56 @@ const session = async ( }; }; +describe("legacy profile bindings", () => { + it("refuses a legacy endian pin committed for a different target", async () => { + const { counts, client } = await session( + target({ flags: 0x50000001, abiFlags: null, byteOrder: "big" }), + undefined, + target({ flags: 0x50000001, abiFlags: null, byteOrder: "little" }), + ); + expect((await client.execute("health", {})).ok).toBe(false); + expect(counts.starts).toBe(0); + }); + it.each([0x50000201, 0x50000401, 0x54000001, 0x50910001])( + "refuses unsupported same-endian legacy flags %i before startup", + async (flags) => { + const value = target({ flags, abiFlags: null }); + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + const { counts, client } = await session( + value, + undefined, + target({ flags: 0x50000001, abiFlags: null }), + ); + expect((await client.execute("health", {})).ok).toBe(false); + expect(counts.starts).toBe(0); + }, + ); + const unpinnedTargets: BinaryTarget[] = [ + target(), + { + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }, + ]; + it.each(unpinnedTargets)( + "refuses a pinned profile for an unpinned target $architecture", + async (value) => { + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + const { counts, client } = await session( + value, + undefined, + target({ flags: 0x50000001, abiFlags: null }), + ); + expect((await client.execute("health", {})).ok).toBe(false); + expect(counts.starts).toBe(0); + }, + ); +}); + describe("MIPS16 shared-image profile", () => { const shared = (change: Partial = {}) => target({ diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index 031e273f1..76a753ccb 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -1,6 +1,6 @@ +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; const MIPS_FP_ABI_SOFT = 3; const MIPS_ET_REL = 1; @@ -15,17 +15,56 @@ const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean => target.mips.type === MIPS_ET_DYN && (target.mips.flags & 0x0f000000) === MIPS16_HEADER_ASE; -/** Generic MIPS32 permits per-function ISA_MODE; a global 16e processor does not. */ -export const ghidraMipsSharedLanguageOverride = ( +/** Derive the verified MIPS32 import pin from this target's observed byte order. */ +export const ghidraMipsLanguageOverride = ( target: BinaryTarget, ): GhidraLanguageOverride | undefined => - isGhidraMips16SharedTarget(target) && target.architecture === "mips" + (isGhidraMips16SharedTarget(target) || hasLegacyMipsHeaderShape(target)) && + ghidraMipsUnsupportedReason(target) === null && + target.architecture === "mips" ? { languageId: `MIPS:${target.mips.byteOrder === "little" ? "LE" : "BE"}:32:default`, compilerSpecId: "default", } : undefined; +/** + * Recordless legacy ELF32 MIPS admission: the header declares no ABI field and + * carries no ABI flags record, so o32 is an analysis assumption derived from + * ELF32/ET_EXEC structure, never a declared fact. Ghidra's header + * auto-selection picks a 64-bit language for exactly these files, so the lane + * pins the 32-bit language instead. + */ +const hasLegacyMipsHeaderShape = (target: BinaryTarget): boolean => { + if ( + target.kind !== "executable" || + target.architecture !== "mips" || + target.format !== "elf" || + target.mips === undefined + ) + return false; + const { flags, abiFlags, limitations } = target.mips; + const arch = flags & 0xf0000000; + return ( + // The lane and its verification cover executable firmware images only; + // recordless relocatable objects stay outside the inferred-o32 assumption. + target.mips.elfClass === 32 && + target.mips.type === MIPS_ET_EXEC && + (arch === 0x50000000 || arch === 0x70000000) && + (flags & 0x0000f000) === 0 && + (flags & 0x20) === 0 && + // EF_MIPS_ABI_ON32 (bit 0x40) declares the old n32 ABI; it is not an + // unspecified-ABI header and must not inherit the o32 assumption. + (flags & 0x40) === 0 && + (flags & 0x100) === 0 && + // Undefined means the record was not inspected; only an inspected + // absence establishes the recordless legacy admission. An inspection + // that could not examine every declaration source stays unknown. + abiFlags === null && + (limitations === undefined || limitations.length === 0) + ); +}; + /** Keep family recognition separate from the deliberately bounded Ghidra lane. */ export const ghidraMipsUnsupportedReason = ( target: BinaryTarget, @@ -48,13 +87,41 @@ export const ghidraMipsUnsupportedReason = ( return "MIPS16, microMIPS and other ASE encodings are outside this standard-instruction MIPS lane."; if ((metadata.flags & 0x00ff0000) !== 0) return "Machine-specific MIPS variants, including PSP Allegrex, need a separately supported processor profile."; - if ((metadata.flags & 0xf0000000) !== 0x70000000) - return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2; other ISA revisions need separate verification."; + const arch = metadata.flags & 0xf0000000; + if (arch !== 0x70000000 && arch !== 0x50000000) + return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2 or a recordless legacy EF_MIPS_ARCH_32 header; other ISA revisions need separate verification."; + const recordlessShape = + (metadata.flags & 0x0000f000) === 0 && + (metadata.flags & 0x20) === 0 && + metadata.abiFlags === null; + if (recordlessShape) { + if ((metadata.flags & 0x100) !== 0) + return "EF_MIPS_32BITMODE declares code for a 64-bit processor in 32-bit mode; it is outside the recordless MIPS32 admission."; + if ((metadata.flags & 0x40) !== 0) + return "EF_MIPS_ABI_ON32 declares the n32 ABI; recordless headers carrying it are outside the recordless legacy admission."; + if ((metadata.flags & 0x00000200) !== 0) + return "EF_MIPS_FP64 requires separate verification of 64-bit floating-point register semantics in this Ghidra lane."; + if ((metadata.flags & 0x00000400) !== 0) + return "EF_MIPS_NAN2008 requires separate verification of NaN encoding semantics in this Ghidra lane."; + if (hasLegacyMipsHeaderShape(target)) return null; + // A recordless relocatable object is not an incomplete inspection; the + // lane is scoped to executable firmware images by design. + if (metadata.type !== MIPS_ET_EXEC) + return "The recordless legacy admission covers ET_EXEC firmware images only; recordless relocatable objects need their own verified build, and the declared-ABI lane requires an ABI flags record."; + // The inspection could not examine every ABI declaration source, so the + // recordless admission is not established for this header. + return [ + "This recordless legacy admission requires a complete ABI-record inspection; ABI declarations in unavailable headers remain unknown.", + ...(metadata.limitations ?? []), + ].join(" "); + } if ( (metadata.flags & 0x0000f000) !== 0x00001000 || (metadata.flags & 0x20) !== 0 ) - return "This Ghidra MIPS lane requires an explicit o32 ABI; unspecified, n32 and EABI targets remain unsupported."; + return "This Ghidra MIPS lane requires an explicit o32 ABI or a recordless legacy header with an unspecified ABI; n32, EABI and mixed declarations remain unsupported."; + if (arch !== 0x70000000) + return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2 for headers that declare an ABI; EF_MIPS_ARCH_32 headers are admitted only through the recordless legacy admission."; if ((metadata.flags & 0x00000200) !== 0) return "EF_MIPS_FP64 requires separate verification of 64-bit floating-point register semantics in this Ghidra lane."; if ((metadata.flags & 0x00000400) !== 0) @@ -98,6 +165,11 @@ export const ghidraMipsUnsupportedReason = ( return null; }; +/** A legacy lane is established only after every MIPS declaration is admitted. */ +export const isLegacyMipsAdmission = (target: BinaryTarget): boolean => + hasLegacyMipsHeaderShape(target) && + ghidraMipsUnsupportedReason(target) === null; + /** Soft-float prototype policy applied by ReaGhidraMipsSoftFloat.java after analysis. */ export const GHIDRA_MIPS_SOFT_FLOAT_STORAGE = "o32-soft-float-prototype-v1"; @@ -174,6 +246,7 @@ export const ghidraMipsSharedLimitations = ( /** Commit source ELF interpretation without confusing target ISA with host CPU. */ export const ghidraMipsProfileParameters = ( target: BinaryTarget, + languageOverride?: GhidraLanguageOverride, ): Readonly> => { if ( target.kind !== "executable" || @@ -182,6 +255,18 @@ export const ghidraMipsProfileParameters = ( ) return {}; const abi = target.mips.abiFlags; + // A deliberate caller-selected language wins over the legacy pin; the pin + // exists because header auto-selection misselects a 64-bit language. + const legacy = isLegacyMipsAdmission(target); + const language = ghidraMipsLanguageOverride(target); + const pinnedLanguage = + legacy && languageOverride === undefined && language !== undefined + ? { + language_id: language.languageId, + compiler_spec_id: language.compilerSpecId ?? "default", + language_selection: "provider-pinned-v1", + } + : {}; return { mips_elf: { elf_class: target.mips.elfClass, @@ -231,10 +316,28 @@ export const ghidraMipsProfileParameters = ( ? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE } : {}), instruction_context_evidence: "per-address-isa-mode-v1", - mips_support_lane: isGhidraMips16SharedTarget(target) - ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" - : target.mips.type === MIPS_ET_REL - ? "elf32-rel-o32-arch32r2-standard-v1" - : "elf32-exec-o32-arch32r2-standard-v3", + mips_support_lane: legacy + ? "elf32-exec-arch32-legacy-pinned-v1" + : isGhidraMips16SharedTarget(target) + ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" + : target.mips.type === MIPS_ET_REL + ? "elf32-rel-o32-arch32r2-standard-v1" + : "elf32-exec-o32-arch32r2-standard-v3", + ...(legacy ? { mips_abi_basis: "inferred-o32-assumption-v1" } : {}), + ...pinnedLanguage, }; }; + +/** Read the provider-pinned import language back from a resolved legacy profile. */ +export const ghidraPinnedMipsLanguage = ( + parameters: Readonly>, +): GhidraLanguageOverride | undefined => { + if (parameters.language_selection !== "provider-pinned-v1") return undefined; + const languageId = parameters.language_id; + const compilerSpecId = parameters.compiler_spec_id; + if (typeof languageId !== "string" || typeof compilerSpecId !== "string") + return undefined; + return compilerSpecId === "language-default" + ? { languageId } + : { languageId, compilerSpecId }; +}; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 3a15dbc4e..45a20ba6f 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -20,6 +20,7 @@ import { sameGhidraDisabledAnalyzers, sameGhidraLanguageOverride, } from "./GhidraAnalysisProfile.js"; +import { ghidraPinnedMipsLanguage } from "./GhidraMipsProfile.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { @@ -449,13 +450,39 @@ export const createGhidraProviderClient = (input: { }), ); const seeds = seedProfile.data; - const languageOverride = ghidraProfileLanguageOverride( + const parameterLanguage = ghidraProfileLanguageOverride( committedProfile.parameters, ); + const pinnedLanguage = ghidraPinnedMipsLanguage(committedProfile.parameters); + const targetLanguage = ghidraPinnedLanguage(target); + if (pinnedLanguage !== undefined && targetLanguage === undefined) + return unavailableClient( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "The committed profile pins a MIPS language that the current target does not establish; reopen an admitted target to resolve a new profile.", + }, + }), + ); + // Provider-pinned profiles declare their language through the same + // parameter slot caller-configured profiles use, so only an environment + // override conflicts with the legacy pin; the slot itself carries it. + if ( + pinnedLanguage !== undefined && + config.ghidraLanguageOverride !== undefined + ) + return unavailableClient( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "The committed legacy MIPS profile pins its import language; REA_GHIDRA_LANGUAGE_ID/REA_GHIDRA_COMPILER_SPEC_ID cannot be combined with it. Reopen the target without the override.", + }, + }), + ); if ( !sameGhidraLanguageOverride( - languageOverride, - config.ghidraLanguageOverride ?? ghidraPinnedLanguage(target), + parameterLanguage, + config.ghidraLanguageOverride ?? targetLanguage, ) ) return unavailableClient( @@ -466,6 +493,9 @@ export const createGhidraProviderClient = (input: { }, }), ); + // Both caller-selected and provider-pinned languages use the validated + // committed slot; the profile cannot supply its own applicability proof. + const importLanguage = parameterLanguage; const disabledAnalyzers = ghidraProfileDisabledAnalyzers( committedProfile.parameters, ); @@ -483,6 +513,7 @@ export const createGhidraProviderClient = (input: { }, }), ); + const invalidProfile = validateGhidraExtensionProfile( extensions, config, @@ -523,6 +554,13 @@ export const createGhidraProviderClient = (input: { ...(target.kind === "executable" && target.architecture === "mips" ? (target.mips.limitations ?? []) : []), + ...(committedProfile.parameters.mips_abi_basis === + "inferred-o32-assumption-v1" + ? [ + "The ELF header declares no ABI field and no ABI flags record was observed; o32 calling conventions are an analysis assumption of this lane, not a declared fact.", + "Floating-point ABI semantics are unverified for this recordless legacy admission.", + ] + : []), ...ghidraMipsRelocatableLimitations(target), ...ghidraMipsSharedLimitations(target), ...ghidraRiscvLimitations(target), @@ -563,7 +601,9 @@ export const createGhidraProviderClient = (input: { baseAddr: target.entryPoint, }, }), - ...(languageOverride === undefined ? {} : { languageOverride }), + ...(importLanguage === undefined + ? {} + : { languageOverride: importLanguage }), ...(config.ghidraDisabledAnalyzers === undefined ? {} : { disabledAnalyzers: config.ghidraDisabledAnalyzers }), @@ -588,13 +628,13 @@ export const createGhidraProviderClient = (input: { expectedCompilerSpecId: "default", } : rawLanguage === undefined - ? languageOverride === undefined + ? importLanguage === undefined ? {} : { - expectedLanguageId: languageOverride.languageId, - ...(languageOverride.compilerSpecId === undefined + expectedLanguageId: importLanguage.languageId, + ...(importLanguage.compilerSpecId === undefined ? {} - : { expectedCompilerSpecId: languageOverride.compilerSpecId }), + : { expectedCompilerSpecId: importLanguage.compilerSpecId }), } : { expectedLanguageId: rawLanguage.languageId, diff --git a/tests/conformance/ghidra/mips-checks.mjs b/tests/conformance/ghidra/mips-checks.mjs index 3e6af3733..e5c6d335c 100644 --- a/tests/conformance/ghidra/mips-checks.mjs +++ b/tests/conformance/ghidra/mips-checks.mjs @@ -160,7 +160,89 @@ export async function inspectMipsReadelf(path, byteOrder, floatAbi = "hard") { }; } -/** Check effective Ghidra observations, not the requested analysis profile. */ +/** Independent GNU readelf interpretation for the recordless legacy fixture. */ +export async function inspectLegacyMipsReadelf(path, byteOrder) { + const command = process.env.REA_MIPS_READELF ?? "readelf"; + const options = { + env: { ...process.env, LC_ALL: "C" }, + timeout: 10000, + maxBuffer: 1024 * 1024, + }; + const version = await execute(command, ["--version"], options); + assert.match( + version.stdout, + /^GNU readelf\b/u, + "This lane requires GNU readelf", + ); + const header = await execute(command, ["-h", "-W", path], options); + assert.equal( + header.stderr.trim(), + "", + `readelf diagnostics: ${header.stderr}`, + ); + const field = (expression, label) => { + const matches = [...header.stdout.matchAll(expression)]; + assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`); + return matches[0][1].trim(); + }; + assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), "ELF32"); + assert.equal( + field(/^\s*Data:.*?,\s*(little|big) endian\s*$/gmu, "byte order"), + byteOrder, + ); + assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); + assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^MIPS\b/u); + const flags = field(/^\s*Flags:\s*(0x[\da-f]+)\b.*$/gmu, "flags"); + assert.equal( + Number(flags), + 0x50000001, + "Legacy fixture must carry EF_MIPS_ARCH_32 with an unspecified ABI field", + ); + const abi = await execute(command, ["-A", path], options); + assert.equal(abi.stderr.trim(), "", `readelf diagnostics: ${abi.stderr}`); + // -A also prints unrelated MIPS facts such as the static GOT; only the ABI + // flags declaration itself must be gone from the recordless fixture. + assert.doesNotMatch( + abi.stdout, + /MIPS ABI Flags Version/u, + "GNU readelf still observes an ABI flags declaration in the legacy fixture", + ); + const symbols = await execute(command, ["-s", "-W", path], options); + assert.equal( + symbols.stderr.trim(), + "", + `readelf diagnostics: ${symbols.stderr}`, + ); + const parsed = {}; + for (const name of ["rea_mips_entry", "rea_mips_probe"]) { + const matches = [ + ...symbols.stdout.matchAll( + new RegExp( + `^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+FUNC\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`, + "gmu", + ), + ), + ]; + assert.equal( + matches.length, + 1, + `Missing or ambiguous readelf symbol ${name}`, + ); + parsed[name] = `0x${BigInt(`0x${matches[0][1]}`).toString(16)}`; + } + return { + flags: Number(flags), + limitations: [], + symbols: parsed, + command, + version: version.stdout.split("\n")[0], + raw: { + header: header.stdout, + abi: abi.stdout, + symbols: symbols.stdout, + }, + }; +} export function assertMipsLoadedImage(observations, byteOrder, sha256) { assert.equal(observations.language_id, language(byteOrder)); assert.equal(observations.compiler_spec_id, "default"); diff --git a/tests/conformance/ghidra/mips-fixture.mjs b/tests/conformance/ghidra/mips-fixture.mjs index b88669e31..1d19aaa57 100644 --- a/tests/conformance/ghidra/mips-fixture.mjs +++ b/tests/conformance/ghidra/mips-fixture.mjs @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -12,11 +12,13 @@ export async function buildMipsFixture( directory, byteOrder, floatAbi = "hard", + options = {}, ) { if (byteOrder !== "little" && byteOrder !== "big") throw new Error("MIPS fixture byte order must be little or big"); if (floatAbi !== "hard" && floatAbi !== "soft") throw new Error("MIPS fixture float ABI must be hard or soft"); + const legacy = options.legacy === true; const soft = floatAbi === "soft"; const target = byteOrder === "little" ? "mipsel-linux-gnu" : "mips-linux-gnu"; const source = fileURLToPath(new URL("../c/mips.c", import.meta.url)); @@ -24,10 +26,13 @@ export async function buildMipsFixture( new URL("../c/mips-soft-float.c", import.meta.url), ); const probe = fileURLToPath(new URL("./mips-probe.S", import.meta.url)); - const path = join(directory, `${target}${soft ? "-soft-float" : ""}.elf`); + const path = join( + directory, + `${target}${soft ? "-soft-float" : ""}${legacy ? "-legacy" : ""}.elf`, + ); const args = [ `--target=${target}`, - "-march=mips32r2", + `-march=${legacy ? "mips32" : "mips32r2"}`, "-mabi=32", ...(soft ? ["-msoft-float"] : []), "-mno-abicalls", @@ -45,8 +50,88 @@ export async function buildMipsFixture( "-o", path, ]; + const compiled = await compileMipsFixture(path, byteOrder, 2, args, { + expectedFlags: legacy ? 0x50001001 : 0x70001001, + }); + // Legacy variant: retype the ABI record declarations out of the image and + // zero the header ABI field, matching pre-ABI-flags 2000s firmware toolchains + // (their compilers predate the record; the header leaves the ABI unspecified). + let attested = compiled; + if (legacy) { + const raw = await readFile(path); + const write16 = (offset, value) => + byteOrder === "little" + ? raw.writeUInt16LE(value, offset) + : raw.writeUInt16BE(value, offset); + const write32 = (offset, value) => + byteOrder === "little" + ? raw.writeUInt32LE(value, offset) + : raw.writeUInt32BE(value, offset); + const read16 = (offset) => + byteOrder === "little" + ? raw.readUInt16LE(offset) + : raw.readUInt16BE(offset); + const read32 = (offset) => + byteOrder === "little" + ? raw.readUInt32LE(offset) + : raw.readUInt32BE(offset); + const flags = read32(36); + if ((flags & 0xf0000000) !== 0x50000000) + throw new Error("Legacy fixture did not build as EF_MIPS_ARCH_32"); + write32(36, flags & ~0x0000f000); + const programOffset = read32(28); + const programCount = read16(44); + if (read16(42) !== 32) + throw new Error("Legacy fixture has non-standard program headers"); + for (let index = 0; index < programCount; index += 1) { + const offset = programOffset + index * 32; + if (read32(offset) === 0x70000003) write32(offset, 0); + } + const sectionOffset = read32(32); + const sectionCount = read16(48); + if (read16(46) !== 40) + throw new Error("Legacy fixture has non-standard section headers"); + for (let index = 0; index < sectionCount; index += 1) { + const offset = sectionOffset + index * 40; + if (read32(offset + 4) === 0x7000002a) write32(offset + 4, 1); + } + // GNU readelf resolves the record by section name, not type, so a retyped + // header alone keeps a named declaration. Replace the name in the string + // table in place, preserving every remaining name and offset. + const stringTableIndex = read16(50); + const stringTableOffset = + stringTableIndex < sectionCount + ? read32(sectionOffset + stringTableIndex * 40 + 16) + : -1; + const named = + stringTableOffset === -1 + ? -1 + : raw.indexOf( + Buffer.from(".MIPS.abiflags", "ascii"), + stringTableOffset, + ); + if (named === -1) + throw new Error("Legacy fixture lacks the expected ABI section name"); + raw.write("x.rea.no.flags", named, "ascii"); + await writeFile(path, raw); + const patched = await readFile(path); + const patchedFlags = + byteOrder === "little" + ? patched.readUInt32LE(36) + : patched.readUInt32BE(36); + if (patchedFlags !== 0x50000001) + throw new Error( + "Legacy fixture did not end with the recordless o32 declaration", + ); + attested = { + ...compiled, + flags: patchedFlags, + sha256: createHash("sha256").update(patched).digest("hex"), + }; + } return { - ...(await compileMipsFixture(path, byteOrder, 2, args)), + ...attested, + legacy, float_abi: floatAbi, source_sha256: createHash("sha256") .update(await readFile(source)) @@ -108,7 +193,13 @@ export async function buildMipsRelocatableFixture( } /** Compile and attest the shared ELF32/o32/MIPS32r2 fixture boundary. */ -async function compileMipsFixture(path, byteOrder, objectType, args) { +async function compileMipsFixture( + path, + byteOrder, + objectType, + args, + options = {}, +) { const compiler = process.env.REA_MIPS_CLANG ?? "clang"; const version = await execute(compiler, ["--version"], { timeout: 10000 }); await execute(compiler, args, { timeout: 60000 }); @@ -128,7 +219,7 @@ async function compileMipsFixture(path, byteOrder, objectType, args) { bytes[5] !== (byteOrder === "little" ? 1 : 2) || read16(16) !== objectType || read16(18) !== 8 || - read32(36) !== 0x70001001 + read32(36) !== (options.expectedFlags ?? 0x70001001) ) throw new Error( "Compiler did not produce the declared ELF32/o32/MIPS32r2 fixture", From 9958754f6c2002dad39de6a64519af332b81244d Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:43:06 +0800 Subject: [PATCH 54/92] fix(process): preserve discovery ownership across cleanup retries (#2643) Bind retained descriptors and runtime resources to their admitting operation until cleanup succeeds, then rebind newly admitted work. Verify failed retries, successful recovery, shutdown and CLI projection. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- src/captures/TsharkCaptureProvider.ts | 4 +- src/process/ArtifactAnalysisResources.ts | 40 ++++++--- src/signals/SigrokRecordingProvider.ts | 4 +- .../captures/captureDescriptorCleanup.test.ts | 90 ++++++++++++------- .../boundary/captures/captureProvider.test.ts | 34 +++++++ .../recordingDescriptorCleanup.test.ts | 65 +++++++------- tests/fixtures/descriptorCloseFailure.ts | 65 ++++++++++++++ 7 files changed, 220 insertions(+), 82 deletions(-) create mode 100644 tests/fixtures/descriptorCloseFailure.ts diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index d22515929..23496aac4 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -175,7 +175,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { ]; phase = "workspace"; root = await this.createRuntime(); - this.#resources.retain(root, undefined, operation); + this.#resources.retain(root, undefined); const snapshotPath = join(root.path, "capture"); const configurationPath = join(root.path, "configuration"); await mkdir(configurationPath, { mode: 0o700 }); @@ -406,7 +406,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { cause instanceof OwnedCommandFailure && cause.cleanupOwner !== undefined ) - this.#resources.retain(root, cause.cleanupOwner, operation); + this.#resources.retain(root, cause.cleanupOwner); result = err( this.#failure( cause, diff --git a/src/process/ArtifactAnalysisResources.ts b/src/process/ArtifactAnalysisResources.ts index b0083e05b..ae3d9f803 100644 --- a/src/process/ArtifactAnalysisResources.ts +++ b/src/process/ArtifactAnalysisResources.ts @@ -33,7 +33,10 @@ export type ArtifactAnalysisRuntime = Pick< /** Own admission, artifact descriptors, producer cleanup and private workspaces together. */ export class ArtifactAnalysisResources { - readonly #artifacts = new ArtifactResourceScope(); + readonly #artifacts: { + readonly scope: ArtifactResourceScope; + operation: string; + }; readonly #pending = new Map< ArtifactAnalysisRuntime, { @@ -54,7 +57,9 @@ export class ArtifactAnalysisResources { readonly message: string; readonly details: Readonly>; }, - ) {} + ) { + this.#artifacts = { scope: new ArtifactResourceScope(), operation }; + } run( work: () => Promise>, @@ -76,8 +81,15 @@ export class ArtifactAnalysisResources { ); this.#busy = true; const running = this.#tail - .then(() => this.#artifacts.run(work)) - .catch((cause: unknown) => err(this.#artifactFailure(cause, operation))) + .then(() => + this.#artifacts.scope.run(() => { + // Failed descriptor cleanup still belongs to the preceding request until + // this scope has drained it and admits acquisition for the new request. + this.#artifacts.operation = operation; + return work(); + }), + ) + .catch((cause: unknown) => err(this.#artifactFailure(cause))) .finally(() => { this.#busy = false; }); @@ -94,20 +106,23 @@ export class ArtifactAnalysisResources { maximumBytes, signal, undefined, - this.#artifacts, + this.#artifacts.scope, ); } readLimits(limits: LinuxResourceLimits): Promise { - return this.readKernelLimits(limits, this.#artifacts); + return this.readKernelLimits(limits, this.#artifacts.scope); } retain( root: ArtifactAnalysisRuntime, owner?: ProviderProcessSupervisor, - operation = this.operation, ): void { - this.#pending.set(root, { owner, operation }); + this.#pending.set(root, { + owner, + operation: + this.#pending.get(root)?.operation ?? this.#artifacts.operation, + }); } get pendingPaths(): readonly string[] { @@ -251,7 +266,7 @@ export class ArtifactAnalysisResources { async #closeResources(): Promise { let descriptorFailure: AnalysisError | undefined; try { - await this.#artifacts.close(); + await this.#artifacts.scope.close(); } catch (cause: unknown) { descriptorFailure = this.#artifactFailure(cause); } @@ -262,14 +277,17 @@ export class ArtifactAnalysisResources { throw analysisErrorWithCleanupFailure( descriptorFailure, cause, - this.operation, + this.#artifacts.operation, ); throw cause; } if (descriptorFailure !== undefined) throw descriptorFailure; } - #artifactFailure(cause: unknown, operation = this.operation): AnalysisError { + #artifactFailure( + cause: unknown, + operation = this.#artifacts.operation, + ): AnalysisError { if (cause instanceof AnalysisError) return cause; if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) return this.cleanupFailure(cause.cleanup, cause, undefined, operation); diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index 2e903d4c6..f5f823a1a 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -173,7 +173,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort { ]; phase = "workspace"; root = await this.createRuntime(); - this.#resources.retain(root, undefined, operation); + this.#resources.retain(root, undefined); const snapshotPath = join(root.path, "recording"); const execute = async (args: readonly string[]) => { const output = await runOwnedCommand( @@ -371,7 +371,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort { cause instanceof OwnedCommandFailure && cause.cleanupOwner !== undefined ) - this.#resources.retain(root, cause.cleanupOwner, operation); + this.#resources.retain(root, cause.cleanupOwner); result = err( this.#failure( cause, diff --git a/tests/boundary/captures/captureDescriptorCleanup.test.ts b/tests/boundary/captures/captureDescriptorCleanup.test.ts index a1ef5efc1..852d06e22 100644 --- a/tests/boundary/captures/captureDescriptorCleanup.test.ts +++ b/tests/boundary/captures/captureDescriptorCleanup.test.ts @@ -1,3 +1,7 @@ +import { + descriptorFailurePath, + expectRetainedDescriptor, +} from "../../fixtures/descriptorCloseFailure.js"; import { expect, it, onTestFinished, vi } from "vitest"; import { writeFile } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; @@ -15,27 +19,22 @@ const cleanup = vi.hoisted(() => ({ })); vi.mock("node:fs/promises", async (importOriginal) => { const filesystem = await importOriginal(); - return { - ...filesystem, - open: async (...args: Parameters) => { - const handle = await filesystem.open(...args); - if (args[0] === cleanup.path) { - cleanup.handles.push(handle); - const close = handle.close.bind(handle); - handle.close = async () => { - if (cleanup.denied && handle.fd >= 0) - throw new Error("Descriptor close deliberately denied"); - await close(); - }; - } - return handle; - }, - }; + const { withDescriptorCloseFailure } = + await import("../../fixtures/descriptorCloseFailure.js"); + return withDescriptorCloseFailure(filesystem, cleanup); }); it .runIf(process.platform === "linux") - .each(["capture", "tool", "limiter", "kernel"])( + .each([ + "capture", + "tool", + "limiter", + "kernel", + "field-tool", + "field-limiter", + "field-kernel", + ])( "retains failed %s descriptors and retries them before further acquisition and shutdown", async (member) => { const directory = await createTestTempDirectory("rea-capture-descriptor-"); @@ -67,14 +66,16 @@ it }, () => PrivateRuntimeRoot.create({ parent: directory }), ); - cleanup.path = - member === "capture" - ? path - : member === "tool" - ? command - : member === "limiter" - ? limiter - : "/proc/self/limits"; + const discovery = member.startsWith("field-"); + const resource = discovery ? member.slice(6) : member; + const operation = discovery + ? "inspect_packet_fields" + : "inspect_packet_capture"; + cleanup.path = descriptorFailurePath(resource, { + capture: path, + tool: command, + limiter, + }); cleanup.denied = true; cleanup.handles = []; onTestFinished(async () => { @@ -84,24 +85,45 @@ it cleanup.path = ""; }); const service = new PacketCaptureService(provider); - const first = await service.inspect({ path, fields: ["frame.number"] }); - expect(first).toMatchObject({ - ok: false, - error: { cleanupIncomplete: true, cleanupResources: [cleanup.path] }, - }); - expect(cleanup.handles).toHaveLength(1); - expect(cleanup.handles[0]?.fd).toBeGreaterThanOrEqual(0); + const first = discovery + ? await service.inspectFields({ query: "usb" }) + : await service.inspect({ path, fields: ["frame.number"] }); + expectRetainedDescriptor(first, cleanup, operation); const launchCount = launches; expect( await service.inspect({ path, fields: ["frame.number"] }), - ).toMatchObject({ ok: false, error: { cleanupIncomplete: true } }); + ).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true, operation }, + }); expect(cleanup.handles).toHaveLength(1); expect(launches).toBe(launchCount); + let closingOperation = operation; + if (discovery) { + cleanup.denied = false; + const recovered = await service.inspect({ + path, + fields: ["frame.number"], + }); + expect(recovered.ok).toBe(true); + expect(cleanup.handles.every((handle) => handle.fd === -1)).toBe(true); + expect(launches).toBeGreaterThan(launchCount); + cleanup.denied = true; + expect( + await service.inspect({ path, fields: ["frame.number"] }), + ).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true, operation: "inspect_packet_capture" }, + }); + expect(cleanup.handles.at(-1)?.fd).toBeGreaterThanOrEqual(0); + closingOperation = "inspect_packet_capture"; + } await expect(provider.close()).rejects.toMatchObject({ cleanupIncomplete: true, + operation: closingOperation, }); cleanup.denied = false; await provider.close(); - expect(cleanup.handles[0]?.fd).toBe(-1); + expect(cleanup.handles.every((handle) => handle.fd === -1)).toBe(true); }, ); diff --git a/tests/boundary/captures/captureProvider.test.ts b/tests/boundary/captures/captureProvider.test.ts index 2428b39d4..881b43e63 100644 --- a/tests/boundary/captures/captureProvider.test.ts +++ b/tests/boundary/captures/captureProvider.test.ts @@ -11,6 +11,9 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createDeferred } from "../../fixtures/binarySession.js"; +import { Cli } from "incur"; +import { registerCaptureCommands } from "../../../src/cli/captureCommands.js"; +import { silentLogger } from "../../../src/logger.js"; // A command-boundary simulator: real process ownership/filesystem, not real TShark coverage. const fixture = async (mode = "ok", failCleanup = false) => { @@ -625,3 +628,34 @@ it("retains completed discovery on late cancellation and cleanup failure", async await f.service.close(); } }); + +it("preserves discovery resource ownership through CLI shutdown retries", async () => { + const f = await fixture("cleanup", true); + const cli = Cli.create("discovery-cleanup", { sync: false }); + registerCaptureCommands(cli, silentLogger, {}, f.service); + let stdout = ""; + try { + await cli.serve( + ["inspect-packet-fields", "--query", "usb", "--format", "json"], + { + stdout: (text) => { + stdout += text; + }, + exit: () => {}, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { + cleanup_error: { + details: { operation: "inspect_packet_fields" }, + }, + }, + }, + }); + } finally { + f.allowCleanup(); + await f.service.close(); + } +}); diff --git a/tests/boundary/signals/recordingDescriptorCleanup.test.ts b/tests/boundary/signals/recordingDescriptorCleanup.test.ts index 3a1927975..f600f0477 100644 --- a/tests/boundary/signals/recordingDescriptorCleanup.test.ts +++ b/tests/boundary/signals/recordingDescriptorCleanup.test.ts @@ -1,3 +1,7 @@ +import { + descriptorFailurePath, + expectRetainedDescriptor, +} from "../../fixtures/descriptorCloseFailure.js"; import { expect, it, onTestFinished, vi } from "vitest"; import { writeFile } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; @@ -15,27 +19,22 @@ const cleanup = vi.hoisted(() => ({ })); vi.mock("node:fs/promises", async (importOriginal) => { const filesystem = await importOriginal(); - return { - ...filesystem, - open: async (...args: Parameters) => { - const handle = await filesystem.open(...args); - if (args[0] === cleanup.path) { - cleanup.handles.push(handle); - const close = handle.close.bind(handle); - handle.close = async () => { - if (cleanup.denied && handle.fd >= 0) - throw new Error("Descriptor close deliberately denied"); - await close(); - }; - } - return handle; - }, - }; + const { withDescriptorCloseFailure } = + await import("../../fixtures/descriptorCloseFailure.js"); + return withDescriptorCloseFailure(filesystem, cleanup); }); it .runIf(process.platform === "linux") - .each(["capture", "tool", "limiter", "kernel"])( + .each([ + "capture", + "tool", + "limiter", + "kernel", + "field-tool", + "field-limiter", + "field-kernel", + ])( "retains failed %s descriptors and retries them before further acquisition and shutdown", async (member) => { const directory = await createTestTempDirectory("rea-signal-descriptor-"); @@ -67,14 +66,16 @@ it }, () => PrivateRuntimeRoot.create({ parent: directory }), ); - cleanup.path = - member === "capture" - ? path - : member === "tool" - ? command - : member === "limiter" - ? limiter - : "/proc/self/limits"; + const discovery = member.startsWith("field-"); + const resource = discovery ? member.slice(6) : member; + const operation = discovery + ? "inspect_signal_decoders" + : "decode_signal_recording"; + cleanup.path = descriptorFailurePath(resource, { + capture: path, + tool: command, + limiter, + }); cleanup.denied = true; cleanup.handles = []; onTestFinished(async () => { @@ -84,22 +85,20 @@ it cleanup.path = ""; }); const service = new SignalRecordingService(provider); - const first = await service.decode({ path, decoders: "uart" }); - expect(first).toMatchObject({ - ok: false, - error: { cleanupIncomplete: true, cleanupResources: [cleanup.path] }, - }); - expect(cleanup.handles).toHaveLength(1); - expect(cleanup.handles[0]?.fd).toBeGreaterThanOrEqual(0); + const first = discovery + ? await service.inspectDecoders({ query: "uart" }) + : await service.decode({ path, decoders: "uart" }); + expectRetainedDescriptor(first, cleanup, operation); const launchCount = launches; expect(await service.decode({ path, decoders: "uart" })).toMatchObject({ ok: false, - error: { cleanupIncomplete: true }, + error: { cleanupIncomplete: true, operation }, }); expect(cleanup.handles).toHaveLength(1); expect(launches).toBe(launchCount); await expect(provider.close()).rejects.toMatchObject({ cleanupIncomplete: true, + operation, }); cleanup.denied = false; await provider.close(); diff --git a/tests/fixtures/descriptorCloseFailure.ts b/tests/fixtures/descriptorCloseFailure.ts new file mode 100644 index 000000000..04cf29cde --- /dev/null +++ b/tests/fixtures/descriptorCloseFailure.ts @@ -0,0 +1,65 @@ +import type { FileHandle } from "node:fs/promises"; +import { expect } from "vitest"; +import type { Result } from "../../src/domain/result.js"; +import type { AnalysisError } from "../../src/domain/analysisErrorBase.js"; + +export interface DescriptorCloseFailure { + path: string; + denied: boolean; + handles: FileHandle[]; +} + +/** Public failure attribution and a still-live OS handle establish retained ownership. */ +export const expectRetainedDescriptor = ( + result: Result, + cleanup: DescriptorCloseFailure, + operation: string, +): void => { + expect(result).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: [cleanup.path], + operation, + }, + }); + expect(cleanup.handles).toHaveLength(1); + expect(cleanup.handles[0]?.fd).toBeGreaterThanOrEqual(0); +}; + +export const descriptorFailurePath = ( + resource: string, + paths: Readonly>, +): string => { + switch (resource) { + case "capture": + case "tool": + case "limiter": + return paths[resource]; + case "kernel": + return "/proc/self/limits"; + default: + throw new Error(`Unknown descriptor failure resource: ${resource}`); + } +}; + +/** Inject a retryable close failure while retaining the actual OS descriptor. */ +export const withDescriptorCloseFailure = ( + filesystem: typeof import("node:fs/promises"), + cleanup: DescriptorCloseFailure, +) => ({ + ...filesystem, + open: async (...args: Parameters) => { + const handle = await filesystem.open(...args); + if (args[0] === cleanup.path) { + cleanup.handles.push(handle); + const close = handle.close.bind(handle); + handle.close = async () => { + if (cleanup.denied && handle.fd >= 0) + throw new Error("Descriptor close deliberately denied"); + await close(); + }; + } + return handle; + }, +}); From 5d12a0b4b6137f025d9aa88cf001dc2a68c28b8a Mon Sep 17 00:00:00 2001 From: Yann Roberto <165009858+shoyann@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:44:08 +0800 Subject: [PATCH 55/92] test(web): cover nullable HTTP captures through CDP and CLI (#1834) * fix(browser): preserve unknown HTTP response comparison fields * fix(browser): retain proven request inventory changes * fix(javascript): preserve Electron identity and avoid quadratic property scans Share visible Electron mutations with wrapper expansion, index static object literal properties once, and align MCP assertions with current contracts. * fix(javascript): track writes through symbolic Electron aliases Resolve mutation targets through the same factories, module tables, containers, and conditional origins as call analysis. Share mutation coverage between direct and wrapper scans while isolating origin caches and preserving exact property paths, local rebinding semantics, and analysis resource limits. * fix(javascript): preserve container slots and imported mutation origins * perf(javascript): defer container mutation replay until property reads * fix(javascript): preserve captured object identities and write order * fix(javascript): preserve ordered mutation origins * fix(javascript): honor property mutation constraints * test(web): narrow PR to HTTP and CLI comparison regressions --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../browser/cdpHttpComparison.test.ts | 255 ++++++++++++++++++ tests/boundary/browser/webCaptureDiff.test.ts | 125 +++------ .../cli/httpCaptureComparison.test.ts | 77 ++++++ tests/fixtures/httpComparisonBrowser.ts | 83 ++++++ 4 files changed, 457 insertions(+), 83 deletions(-) create mode 100644 tests/boundary/browser/cdpHttpComparison.test.ts create mode 100644 tests/boundary/cli/httpCaptureComparison.test.ts create mode 100644 tests/fixtures/httpComparisonBrowser.ts diff --git a/tests/boundary/browser/cdpHttpComparison.test.ts b/tests/boundary/browser/cdpHttpComparison.test.ts new file mode 100644 index 000000000..2f4ad689c --- /dev/null +++ b/tests/boundary/browser/cdpHttpComparison.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import type { WebPageInspection } from "../../../src/domain/browserObservationSchemas.js"; +import { compareWebCaptures } from "../../../src/domain/webCaptureDiff.js"; +import { compareWebCapturesInputSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; +import type { FakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { + startHttpComparisonBrowser, + type HttpRequestObservation, +} from "../../fixtures/httpComparisonBrowser.js"; +import { trackBrowser } from "./cdpBrowserProvider.support.js"; + +const inspect = async (browser: FakeCdpBrowser): Promise => { + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + return result.value; +}; + +const capturePair = async ( + before: readonly HttpRequestObservation[], + after: readonly HttpRequestObservation[], +): Promise => { + const fixture = await startHttpComparisonBrowser(before); + trackBrowser(fixture.browser); + const left = await inspect(fixture.browser); + fixture.setRequests(after); + return [left, await inspect(fixture.browser)]; +}; + +const compareNetwork = (before: WebPageInspection, after: WebPageInspection) => + compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: before }, + after: { inspection: after }, + }), + ).dimensions.network; + +const response = { + requestId: "request-before", + status: 200, + mimeType: "application/json", +} as const; + +describe("HTTP comparison response coverage", () => { + it("does not compare an unavailable byte count with a completed response", async () => { + const [before, after] = await capturePair( + [response], + [{ ...response, requestId: "request-after", encodedDataLength: 100 }], + ); + expect(before.network.requests[0]?.encoded_data_length).toBeNull(); + expect(after.network.requests[0]?.encoded_data_length).toBe(100); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compareNetwork(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + }); + + it("does not treat an unobserved response as a changed response", async () => { + const [before, after] = await capturePair( + [{ requestId: "request-before" }], + [{ ...response, encodedDataLength: 100 }], + ); + expect(before.network.requests).toEqual([ + expect.objectContaining({ + status: null, + mime_type: null, + encoded_data_length: null, + }), + ]); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compareNetwork(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }); + + it("retains observed status changes when response bytes are unavailable", async () => { + const [before, after] = await capturePair( + [response], + [{ ...response, status: 500 }], + ); + expect(compareNetwork(before, after)).toMatchObject({ + status: "changed", + changes: [expect.objectContaining({ change: "modified" })], + }); + }); + + it.each([ + { mimeType: "text/plain", encodedDataLength: 100 }, + { mimeType: "application/json", encodedDataLength: 200 }, + ])("retains observed response changes: %j", async (change) => { + const [before, after] = await capturePair( + [{ ...response, encodedDataLength: 100 }], + [{ ...response, ...change }], + ); + expect(compareNetwork(before, after).status).toBe("changed"); + }); +}); + +describe("HTTP comparison repeated request coverage", () => { + it("does not assign missing evidence by request order or transient ID", async () => { + const [before, after] = await capturePair( + [ + { ...response, encodedDataLength: 100 }, + { ...response, requestId: "second-before" }, + ], + [ + { ...response, requestId: "second-after", encodedDataLength: 200 }, + { ...response, requestId: "first-after", encodedDataLength: 100 }, + ], + ); + expect(before.network.requests).toHaveLength(2); + expect(after.network.requests).toHaveLength(2); + expect(compareNetwork(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }); + + it("compares complete byte counts even when another field is incomplete", async () => { + const [before, after] = await capturePair( + [ + { ...response, encodedDataLength: 100 }, + { requestId: "second-before", encodedDataLength: 200 }, + ], + [ + { requestId: "second-after", encodedDataLength: 300 }, + { ...response, requestId: "first-after", encodedDataLength: 100 }, + ], + ); + expect(compareNetwork(before, after).status).toBe("changed"); + }); + + it("preserves the association between complete response fields", async () => { + const [before, after] = await capturePair( + [ + { ...response, encodedDataLength: 100 }, + { + ...response, + requestId: "second-before", + status: 201, + encodedDataLength: 200, + }, + ], + [ + { ...response, requestId: "first-after", encodedDataLength: 200 }, + { + ...response, + requestId: "second-after", + status: 201, + encodedDataLength: 100, + }, + ], + ); + expect(compareNetwork(before, after).status).toBe("changed"); + }); +}); + +it.each([ + { + name: "a pending request in an empty inventory", + before: [], + after: [{ requestId: "pending" }], + }, + { + name: "a known request beside an existing pending request", + before: [{ requestId: "pending" }], + after: [ + { requestId: "pending" }, + { + ...response, + requestId: "new", + path: "/api/new", + encodedDataLength: 100, + }, + ], + }, +])("preserves additions and removals of $name", async (observations) => { + const [before, after] = await capturePair( + observations.before, + observations.after, + ); + // Live CDP observations have attach-limited inventories. Their absence + // remains unknown even when a request is seen in the other capture. + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compareNetwork(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + + // Project explicitly complete inventory coverage without changing the + // producer's request facts; response fields remain independently unknown. + markNetworkInventoriesComplete(before, after); + for (const [left, right, change] of [ + [before, after, "added"], + [after, before, "removed"], + ] as const) + expect(compareNetwork(left, right)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [expect.objectContaining({ change })], + }); +}); + +it("requires response field coverage even when capture sections are complete", async () => { + const [before, after] = await capturePair( + [response], + [{ ...response, encodedDataLength: 100 }], + ); + markNetworkInventoriesComplete(before, after); + expect(compareNetwork(before, before).status).toBe("unknown"); + expect(compareNetwork(before, after).status).toBe("unknown"); + expect(compareNetwork(after, after).status).toBe("unchanged"); +}); + +const markNetworkInventoriesComplete = ( + ...inspections: WebPageInspection[] +): void => { + const sections = new Set([ + "network_requests", + "websocket_connections", + "websocket_frames", + ]); + for (const inspection of inspections) + for (const key of [ + "policy_filtered_sections", + "attach_limited_sections", + "truncated_sections", + "unavailable_sections", + ] as const) + inspection.completeness[key] = inspection.completeness[key].filter( + (section) => !sections.has(section), + ); +}; diff --git a/tests/boundary/browser/webCaptureDiff.test.ts b/tests/boundary/browser/webCaptureDiff.test.ts index 5c2ce8785..3680f7dfa 100644 --- a/tests/boundary/browser/webCaptureDiff.test.ts +++ b/tests/boundary/browser/webCaptureDiff.test.ts @@ -446,91 +446,50 @@ describe("web capture WebSocket payload byte coverage", () => { }); describe("web capture diff", () => { - it("treats unavailable HTTP response facts as unknown while retaining observed changes", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const captured = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 0, - }), - ); - if (!captured.ok) throw captured.error; - const before = structuredClone(captured.value); - const request = before.network.requests[0]; - if (request === undefined) throw new Error("Missing network request"); - request.status = null; - request.mime_type = null; - request.encoded_data_length = null; - markSectionsComplete(before, [ - "network_requests", - "websocket_connections", - "websocket_frames", - "json_body_shapes", - ]); - for (const item of before.network.requests) - item.body_shapes.status = "not_approved"; + it.each(["status", "mime_type"] as const)( + "keeps an independently unavailable HTTP %s unknown", + async (field) => { + const browser = await startFakeCdpBrowser(); + browsers.push(browser); + const captured = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + ); + if (!captured.ok) throw captured.error; + const observed = structuredClone(captured.value); + const request = observed.network.requests[0]; + if (request === undefined) throw new Error("Missing network request"); + request.status = 200; + request.mime_type = "application/json"; + request.encoded_data_length = 100; + markSectionsComplete(observed, [ + "network_requests", + "websocket_connections", + "websocket_frames", + "json_body_shapes", + ]); + for (const item of observed.network.requests) + item.body_shapes.status = "not_approved"; + expect(compareNetwork(observed, observed).status).toBe("unchanged"); - const after = structuredClone(before); - const responseObserved = after.network.requests[0]; - if (responseObserved === undefined) - throw new Error("Missing network request"); - responseObserved.status = 200; - responseObserved.mime_type = "application/json"; - responseObserved.encoded_data_length = 100; - expect(compareNetwork(before, after)).toMatchObject({ - status: "unknown", - total_changes: 0, - changes: [], - }); - expect(compareNetwork(after, before)).toMatchObject({ - status: "unknown", - total_changes: 0, - }); - expect(compareNetwork(before, before).status).toBe("unknown"); - - for (const field of [ - "status", - "mime_type", - "encoded_data_length", - ] as const) { - const partial = structuredClone(after); + const partial = structuredClone(observed); partial.network.requests[0]![field] = null; - expect(compareNetwork(partial, after).status).toBe("unknown"); - expect(compareNetwork(after, partial).status).toBe("unknown"); - } - - const inventoryBefore = structuredClone(before); - inventoryBefore.network.requests = []; - expect(compareNetwork(inventoryBefore, before)).toMatchObject({ - status: "changed", - total_changes: 1, - changes: [expect.objectContaining({ change: "added" })], - }); - - const statusChanged = structuredClone(after); - statusChanged.network.requests[0]!.status = 500; - expect(compareNetwork(after, statusChanged)).toMatchObject({ - status: "changed", - changes: [expect.objectContaining({ change: "modified" })], - }); - after.network.requests[0]!.encoded_data_length = null; - expect(compareNetwork(after, statusChanged).status).toBe("changed"); - - const repeated = structuredClone(statusChanged); - const duplicate = structuredClone(repeated.network.requests[0]!); - duplicate.status = 200; - duplicate.mime_type = "text/plain"; - repeated.network.requests.push(duplicate); - const reassociated = structuredClone(repeated); - reassociated.network.requests[0]!.mime_type = "text/plain"; - reassociated.network.requests.at(-1)!.mime_type = "application/json"; - expect(compareNetwork(repeated, reassociated).status).toBe("changed"); - repeated.network.requests[0]!.status = null; - expect(compareNetwork(repeated, reassociated).status).toBe("unknown"); - }); + for (const [left, right] of [ + [partial, observed], + [observed, partial], + [partial, partial], + ] as const) + expect(compareNetwork(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + }, + ); it("ignores transient request IDs and capture-approval state", async () => { const browser = await startFakeCdpBrowser(); diff --git a/tests/boundary/cli/httpCaptureComparison.test.ts b/tests/boundary/cli/httpCaptureComparison.test.ts new file mode 100644 index 000000000..10d373351 --- /dev/null +++ b/tests/boundary/cli/httpCaptureComparison.test.ts @@ -0,0 +1,77 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { runCliJson } from "../../fixtures/cliJsonProcess.js"; +import { startHttpComparisonBrowser } from "../../fixtures/httpComparisonBrowser.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("CLI compares incomplete HTTP responses without inventing changes", async () => { + const root = await createTestTempDirectory("rea-http-comparison-"); + const response = { + requestId: "request-1", + status: 200, + mimeType: "application/json", + }; + const fixture = await startHttpComparisonBrowser([response]); + try { + const inspect = async () => + webPageInspectionSchema.parse( + parseEvidence( + await runCliJson([ + "inspect-web-page", + fixture.browser.endpoint, + "allowed-page", + "--observation-ms", + "0", + "--json", + ]), + ).normalized_result, + ); + const pending = await inspect(); + fixture.setRequests([{ ...response, encodedDataLength: 100 }]); + const finished = await inspect(); + fixture.setRequests([{ ...response, status: 500, encodedDataLength: 100 }]); + const changed = await inspect(); + expect(pending.network.requests).toMatchObject([ + { status: 200, mime_type: "application/json", encoded_data_length: null }, + ]); + expect(finished.network.requests).toMatchObject([ + { status: 200, mime_type: "application/json", encoded_data_length: 100 }, + ]); + const paths = [pending, finished, changed].map((_, index) => + join(root, `capture-${String(index)}.json`), + ); + await Promise.all( + [pending, finished, changed].map((inspection, index) => + writeFile(paths[index]!, JSON.stringify({ inspection })), + ), + ); + for (const [left, right, status, count] of [ + [0, 1, "unknown", 0], + [1, 0, "unknown", 0], + [0, 2, "changed", 1], + ] as const) { + const compared = await runCliJson([ + "compare-web-captures", + paths[left]!, + paths[right]!, + "--json", + ]); + expect(compared).toMatchObject({ + operation: "compare_web_captures", + normalized_result: { + overall_status: status, + dimensions: { + network: { status, total_changes: count }, + }, + }, + }); + } + } finally { + await fixture.browser.close(); + } +}, 60_000); diff --git a/tests/fixtures/httpComparisonBrowser.ts b/tests/fixtures/httpComparisonBrowser.ts new file mode 100644 index 000000000..94c7ed856 --- /dev/null +++ b/tests/fixtures/httpComparisonBrowser.ts @@ -0,0 +1,83 @@ +import { startFakeCdpBrowser, type FakeCdpBrowser } from "./fakeCdpBrowser.js"; + +export type HttpRequestObservation = { + readonly requestId: string; + readonly path?: string; + readonly status?: number; + readonly mimeType?: string; + readonly encodedDataLength?: number; +}; + +type CdpEvent = Parameters[0]; + +const requestEvents = ( + observation: HttpRequestObservation, + origin: string, + sessionId: string | undefined, +): CdpEvent[] => { + const session = sessionId === undefined ? {} : { sessionId }; + const url = `${origin}${observation.path ?? "/api/items"}`; + const { requestId, status, mimeType, encodedDataLength } = observation; + const events: CdpEvent[] = [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId, + timestamp: 1, + type: "Fetch", + request: { url, method: "GET", headers: {} }, + initiator: { type: "other" }, + }, + }, + ]; + if (status !== undefined || mimeType !== undefined) + events.push({ + ...session, + method: "Network.responseReceived", + params: { + requestId, + timestamp: 2, + type: "Fetch", + response: { + url, + headers: {}, + ...(status === undefined ? {} : { status }), + ...(mimeType === undefined ? {} : { mimeType }), + }, + }, + }); + if (encodedDataLength !== undefined) + events.push({ + ...session, + method: "Network.loadingFinished", + params: { requestId, timestamp: 3, encodedDataLength }, + }); + return events; +}; + +/** Capture changing response coverage through the real CDP wire boundary. */ +export const startHttpComparisonBrowser = async ( + initial: readonly HttpRequestObservation[], +): Promise<{ + readonly browser: FakeCdpBrowser; + setRequests(next: readonly HttpRequestObservation[]): void; +}> => { + let requests = initial; + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Network.enable"], + commandEvents(command, origin) { + return command.method === "Network.enable" + ? requests.flatMap((request) => + requestEvents(request, origin, command.sessionId), + ) + : undefined; + }, + }); + return { + browser, + setRequests(next) { + requests = next; + }, + }; +}; From 23f72141d5befc6b14286ae7281c367d593f84bb Mon Sep 17 00:00:00 2001 From: alfredo1491 <208325033+alfredo1491@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:45:07 -0600 Subject: [PATCH 56/92] fix(javascript): keep configuration setting reads out of endpoints (#2402) * fix(javascript): keep configuration setting reads out of endpoints Static endpoint extraction reads the first literal argument of any call named get as an HTTP request path unless the receiver is a provable keyed collection. VS Code extension bundles read settings through `workspace.getConfiguration(section).get(key)`, so every setting key became a network endpoint: the bundled Git extension's dist/main.js reported 173 network endpoints, nearly all of them setting names such as `autofetchPeriod` and `blame.statusBarItem.enabled`. The lexical receiver classifier now records a `configuration` fact for a `.get` call whose receiver is a `getConfiguration(...)` call, inline or through a binding whose only visible write is one unconditional initializer. Minifiers emit `let` for these bindings, so the proof relies on the recorded writes rather than the declaration kind, as tRPC client root resolution already does. Endpoint extraction skips non-URL literals for that fact in both application and web bundle analysis. Reassigned, conditional, and shadowed receivers, URL or path literals, and other clients such as `api.get("users")` keep their endpoints. The receiver fact union was duplicated in three modules; consumers now import the classifier's type. On the Git extension bundle, network endpoints drop from 173 to 7 with unchanged analysis time (2.1 s). Co-Authored-By: Claude Opus 5.5 * fix(javascript): require the workspace host for configuration receivers A bare or differently scoped `getConfiguration(...)` may be an application's own HTTP client factory, so `.get("users")` on its result lost its endpoint. Only `workspace.getConfiguration(...)` and `.workspace.getConfiguration(...)` now produce the configuration fact. Minifiers keep property names, so the bundled VS Code Git extension still reports 7 network endpoints instead of 173. Co-Authored-By: Claude Opus 5.5 * fix(javascript): recognize default VS Code namespace imports --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../javascript/javascriptSemanticAnalysis.ts | 106 ++++++++++++++++-- .../javascriptStaticAnalysisHelpers.ts | 21 +--- .../javascriptStaticAnalysisState.ts | 11 +- src/domain/webBundleAnalyzer.test.ts | 55 +++++++++ 4 files changed, 159 insertions(+), 34 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index f6c6db25d..a3045fbd8 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -247,19 +247,24 @@ export function* classifyParsedJavaScriptRpcClientRootsSteps( return roots; } -/** Receiver identity facts for the overloaded `.open` syntax only. */ +/** + * Receiver identity facts for the overloaded `.open` syntax, plus + * `configuration` for `.get` reads of a `getConfiguration(...)` result. + */ export type JavaScriptOpenReceiverFact = | "window" | "indexed-db" | "cache-storage" | "local" | "local-indexed-db" - | "local-cache-storage"; + | "local-cache-storage" + | "configuration"; /** * Resolve only the lexical receiver identity needed to distinguish browser - * `window.open` from XHR and global storage `.open` calls. This deliberately - * builds definitions without running semantic value or provenance analysis. + * `window.open` from XHR and global storage `.open` calls, and configuration + * `.get` reads from HTTP client calls. This deliberately builds definitions + * without running semantic value or provenance analysis. */ export const classifyParsedJavaScriptOpenReceivers = ( file: ParsedJavaScriptSource, @@ -278,14 +283,19 @@ export function* classifyParsedJavaScriptOpenReceiversSteps( if ( (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) || (!t.isMemberExpression(node.callee) && - !t.isOptionalMemberExpression(node.callee)) || - semanticStaticPropertyKey( - node.callee.property, - node.callee.computed, - ) !== "open" || - !t.isIdentifier(node.callee.object) + !t.isOptionalMemberExpression(node.callee)) ) return; + const method = semanticStaticPropertyKey( + node.callee.property, + node.callee.computed, + ); + if (method === "get") { + if (isConfigurationReceiver(state, node.callee.object)) + facts.set(node.start ?? -1, "configuration"); + return; + } + if (method !== "open" || !t.isIdentifier(node.callee.object)) return; const receiver = node.callee.object.name; const binding = resolveSemanticBindingState(state, node, receiver); if (binding === undefined) { @@ -351,6 +361,82 @@ export function* classifyParsedJavaScriptOpenReceiversSteps( return facts; } +/** + * Whether a call spells VS Code's `workspace.getConfiguration(section)`, whose + * result `.get` reads setting keys rather than request paths. The `workspace` + * member is required: a bare or differently scoped `getConfiguration` may be + * an application's own HTTP client factory. Minifiers keep property names, so + * bundled `x.workspace.getConfiguration` still matches. + */ +const isConfigurationAccessorCall = ( + state: JavaScriptSemanticAnalysisState, + node: t.Node, +): boolean => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return false; + const callee = node.callee; + if ( + (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) || + semanticStaticPropertyKey(callee.property, callee.computed) !== + "getConfiguration" + ) + return false; + const host = callee.object; + const matchesSpelling = t.isIdentifier(host) + ? host.name === "workspace" + : (t.isMemberExpression(host) || t.isOptionalMemberExpression(host)) && + semanticStaticPropertyKey(host.property, host.computed) === "workspace"; + if (!matchesSpelling) return false; + const root = t.isIdentifier(host) + ? host + : t.isMemberExpression(host) || t.isOptionalMemberExpression(host) + ? host.object + : undefined; + if (!t.isIdentifier(root)) return false; + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) + return !semanticResolutionBlocked(state, root, root.name); + const initializer = semanticStableInitializer(binding, state); + const origins = [ + ...binding.directOrigins, + ...(initializer === undefined + ? [] + : [semanticRequireOrigin(initializer.node, state)?.origin]), + ]; + return origins.some( + (origin) => + origin?.specifier === "vscode" && + (t.isIdentifier(host) + ? origin.importedPath.at(-1) === "workspace" || + initializer?.projection.at(-1) === "workspace" + : origin.importedPath.length === 0 || + (origin.importedPath.length === 1 && + (origin.importedPath[0] === "*" || + origin.importedPath[0] === "default"))), + ); +}; + +/** + * Whether a `.get` receiver is a configuration accessor result, either inline + * or through a binding whose only visible write is one unconditional + * initializer. Minifiers emit `let` for such bindings, so declaration kind + * alone does not decide. + */ +const isConfigurationReceiver = ( + state: JavaScriptSemanticAnalysisState, + receiver: t.Node, +): boolean => { + if (isConfigurationAccessorCall(state, receiver)) return true; + if (!t.isIdentifier(receiver)) return false; + const binding = resolveSemanticBindingState(state, receiver, receiver.name); + const initializer = semanticStableInitializer(binding, state); + return ( + initializer !== undefined && + initializer.projection.length === 0 && + isConfigurationAccessorCall(state, initializer.node) + ); +}; + const openGlobalFact = ( name: string, ): JavaScriptOpenReceiverFact | undefined => { diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index f2045a1eb..71628bc8c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -17,6 +17,7 @@ import { semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; +import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; import { compareUnicodeCodePoints, compositeKey, @@ -470,13 +471,7 @@ const xhrOpenUrl = ( name: string, methodNode: t.Node | null | undefined, urlNode: t.Node | null | undefined, - receiverFact?: - | "window" - | "indexed-db" - | "cache-storage" - | "local" - | "local-indexed-db" - | "local-cache-storage", + receiverFact?: JavaScriptOpenReceiverFact, ): string | undefined => { const url = stringValue(urlNode); if (url === undefined) return undefined; @@ -555,13 +550,7 @@ export const endpointArgument = ( | t.ArgumentPlaceholder )[], callee: t.Node, - receiverFact?: - | "window" - | "indexed-db" - | "cache-storage" - | "local" - | "local-indexed-db" - | "local-cache-storage", + receiverFact?: JavaScriptOpenReceiverFact, ): string | undefined => { if (receiverFact === "window") return undefined; if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket") @@ -575,10 +564,12 @@ export const endpointArgument = ( const value = stringValue(args[0]); // Only get and delete also belong to the keyed-collection APIs, whose // keys are header, field, and parameter names rather than URLs or paths. + // Configuration `get` reads setting keys. return value !== undefined && (method === "get" || method === "delete") && !isRequestUrlLiteral(value) && - isKeyedCollectionReceiver(callee) + (isKeyedCollectionReceiver(callee) || + (method === "get" && receiverFact === "configuration")) ? undefined : value; } diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 3c93d2fa3..5173362fa 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -1,4 +1,5 @@ import type * as t from "@babel/types"; +import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import type { @@ -64,15 +65,7 @@ export interface JavaScriptFindingContext { readonly rpcScan: JavaScriptRpcScan; readonly source: string; readonly accumulator: JavaScriptAnalysisAccumulator; - readonly openReceiverFacts?: ReadonlyMap< - number, - | "window" - | "indexed-db" - | "cache-storage" - | "local" - | "local-indexed-db" - | "local-cache-storage" - >; + readonly openReceiverFacts?: ReadonlyMap; /** Proven Electron export path segments by root offset. */ readonly electronBindings?: ReadonlyMap; } diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 5913b0f40..ad82b5730 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -321,6 +321,61 @@ describe("web bundle API classification parity", () => { ]); }); + it("keeps configuration setting reads out of endpoints in both analyzers", () => { + const source = ` + import vscodeDefault from "vscode"; + vscodeDefault.workspace.getConfiguration("git").get("default-setting"); + vscode.workspace.getConfiguration("git").get("autofetch"); + workspace.getConfiguration("git").get("enabled", true); + const config = vscode.workspace.getConfiguration("git"); + config.get("autofetchPeriod", 180); + config.get("/v1/settings"); + let c = Mt.workspace.getConfiguration("git", uri), l = c.get("timeline.date"); + let changed = vscode.workspace.getConfiguration("git"); + changed = api; + changed.get("reassigned"); + const maybe = flag ? vscode.workspace.getConfiguration("git") : api; + maybe.get("conditional"); + { + const config = api; + config.get("shadowed"); + } + api.get("users"); + const client = getConfiguration("/api/v1"); + client.get("accounts"); + http.getConfiguration(baseUrl).get("orders"); + { + const workspace = { getConfiguration: () => api }; + workspace.getConfiguration("/api").get("local-users"); + } + { + const vscode = { workspace: { getConfiguration: () => api } }; + vscode.workspace.getConfiguration("/api").get("local-orders"); + } + `; + const expected = [ + "/v1/settings", + "accounts", + "conditional", + "local-orders", + "local-users", + "orders", + "reassigned", + "shadowed", + "users", + ]; + expect( + analyzeCapturedWebBundle(inspection(source)) + .observations.endpoints.map(({ value }) => value) + .sort(), + ).toEqual(expected); + expect( + analyzeJavaScriptStaticSource(source) + .endpoints.map(({ value }) => value) + .sort(), + ).toEqual(expected); + }); + it("recognizes loadURL endpoints like static analysis", () => { const result = analyzeCapturedWebBundle( inspection('window.win.loadURL("https://embedded.test/app");'), From fc0dbe7f9d676f776ffcbe047895ba8caa52f357 Mon Sep 17 00:00:00 2001 From: jbernous <39996743+jbernous@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:46:14 -0300 Subject: [PATCH 57/92] fix(server): mark retained partial-observation errors as target content (#2398) Pass the tool contract's `returnsTargetContent` to `toRecordedPartialErrorToolResult` in the shared Evidence handler, matching the Electron call site. The call site that records a retained partial observation was added without the argument required by the untrusted target-content framing, so the delivery neither marked the result nor counted the provenance marker in its wire budget. The type gate failed on main with `contractToolHandler.ts(69,38): error TS2554`, which also blocked every compiled boundary test lane. --- .../contractToolHandlerTargetContent.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/boundary/mcp/contractToolHandlerTargetContent.test.ts diff --git a/tests/boundary/mcp/contractToolHandlerTargetContent.test.ts b/tests/boundary/mcp/contractToolHandlerTargetContent.test.ts new file mode 100644 index 000000000..9e8a9d24b --- /dev/null +++ b/tests/boundary/mcp/contractToolHandlerTargetContent.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import type { + CallToolResult, + ServerContext, +} from "@modelcontextprotocol/server"; +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import type { Evidence } from "../../../src/domain/evidence.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createEvidenceToolHandler } from "../../../src/server/contractToolHandler.js"; +import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; +import { ToolResultDelivery } from "../../../src/server/toolResult.js"; + +const UNTRUSTED_TARGET_CONTENT = { + provenance: { untrusted_content: true, source: "analysis_target" }, +}; + +const context = { + mcpReq: { signal: new AbortController().signal }, +} as ServerContext; + +/** + * Deliver one retained partial-observation failure through the shared Evidence + * handler. The handler is the producer of the result: `EvidenceMcpServer` only + * adds the marker later for results that bypass `ToolResultDelivery`, so a + * missing marker here also undercounts the wire budget by the marker's bytes. + */ +const deliverPartialFailure = async (): Promise<{ + readonly result: CallToolResult; + readonly evidence: Evidence; +}> => { + const evidence = createEvidence( + undefined, + { id: "fixture", name: "Completed capture fixture", version: "1" }, + { + operation: "inspect_packet_capture", + parameters: { path: "recording.pcap" }, + result: { packets: [{ "frame.number": ["1"] }] }, + confidence: "observed", + }, + ); + const failure = new AnalysisCancelledError("inspect_packet_capture", { + partialObservation: evidence, + }); + const writer = { recordEvidence: () => ok("added" as const) }; + const server = new EvidenceMcpServer( + { name: "partial-error-framing-test", version: "1" }, + { capabilities: {} }, + writer.recordEvidence, + new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE), + ); + const contract = toolContract("inspect_packet_capture"); + const handler = createEvidenceToolHandler( + server, + async () => err(failure), + silentLogger, + writer.recordEvidence, + ); + return { + result: await handler(contract)({ path: "recording.pcap" }, context), + evidence, + }; +}; + +describe("shared Evidence handler target-content framing", () => { + it("marks a retained partial-observation error as untrusted target content", async () => { + const { result, evidence } = await deliverPartialFailure(); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + error: { + details: { + partial_observation: { + kind: "retained-evidence", + evidence_id: evidence.evidence_id, + }, + }, + }, + }); + expect(result._meta).toEqual(UNTRUSTED_TARGET_CONTENT); + }); +}); From c8e85b962a58929cc272d966cfc0dd6e2409ed1d Mon Sep 17 00:00:00 2001 From: Naimur Reza <95697060+naimur-reza@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:46:27 +0600 Subject: [PATCH 58/92] fix(javascript): recover child-process options without args (#2781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: celebrate 180,000 GitHub stars 🎉 * fix(javascript): recover child-process options without args * fix(javascript): preserve execFile callback overload options Choose execFile options by the second argument object shape rather than total argument count, preserving callback overloads. Source inspection and git diff --check; tests/builds/typechecks/CI reruns skipped at explicit user request. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- README.md | 2 +- .../childProcessOptionsRegression.test.ts | 97 +++++++++++++++++++ .../javascriptSemanticChildProcesses.ts | 13 ++- 3 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 src/domain/javascript/childProcessOptionsRegression.test.ts diff --git a/README.md b/README.md index c4a89bcee..05b417959 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ Report vulnerabilities through [SECURITY.md](SECURITY.md). ## Star history -🎉 **170,000 GitHub stars — thank you!** +🎉 **180,000 GitHub stars — thank you!** Thanks to everyone using REA, reporting bugs, requesting features, testing builds, and contributing fixes. diff --git a/src/domain/javascript/childProcessOptionsRegression.test.ts b/src/domain/javascript/childProcessOptionsRegression.test.ts new file mode 100644 index 000000000..8c18fe830 --- /dev/null +++ b/src/domain/javascript/childProcessOptionsRegression.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +describe("child-process options detection", () => { + it("detects options without an args array", () => { + const ir = analyzeJavaScriptSemantics(` + import { spawn, execFile, fork } from "node:child_process"; + + spawn("tool", { + env: { A: "1" }, + stdio: "pipe" + }); + + execFile("tool", { + env: { A: "1" } + }); + + fork("worker.js", { + env: { A: "1" }, + stdio: ["pipe", "pipe", "pipe", "ipc"] + }); + `); + + expect( + ir.childProcessSpawns.map( + ({ method, environmentSupplied, stdioMode, resolution }) => ({ + method, + environmentSupplied, + stdioMode, + resolution, + }), + ), + ).toEqual([ + { + method: "spawn", + environmentSupplied: true, + stdioMode: "pipe", + resolution: "complete", + }, + { + method: "execFile", + environmentSupplied: true, + stdioMode: "default", + resolution: "complete", + }, + { + method: "fork", + environmentSupplied: true, + stdioMode: "array", + resolution: "complete", + }, + ]); + }); + + it("preserves behavior with explicit args arrays", () => { + const ir = analyzeJavaScriptSemantics(` + import { spawn, execFile, fork } from "node:child_process"; + + spawn("tool", [], { env: { A: "1" }, stdio: "pipe" }); + execFile("tool", [], { env: { A: "1" } }); + fork("worker.js", [], { + env: { A: "1" }, + stdio: ["pipe", "pipe", "pipe", "ipc"] + }); + `); + + expect( + ir.childProcessSpawns.map(({ environmentSupplied, stdioMode }) => ({ + environmentSupplied, + stdioMode, + })), + ).toEqual([ + { environmentSupplied: true, stdioMode: "pipe" }, + { environmentSupplied: true, stdioMode: "default" }, + { environmentSupplied: true, stdioMode: "array" }, + ]); + }); + + it("does not invent options when omitted", () => { + const ir = analyzeJavaScriptSemantics(` + import { spawn, execFile, fork } from "node:child_process"; + + spawn("tool"); + execFile("tool"); + fork("worker.js"); + `); + + expect( + ir.childProcessSpawns.every( + ({ environmentSupplied, stdioMode }) => + environmentSupplied === false && stdioMode === "default", + ), + ).toBe(true); + + expect(ir.childProcessSpawns).toHaveLength(3); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index fe7b5e802..0fd0c9a35 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -324,13 +324,18 @@ const resolveChildCandidates = ( ? [] : (spawnsByBinding.get(binding.bindingId) ?? []); }; - const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => { - const index = candidate.method === "exec" ? 1 : 2; - const value = candidate.node.arguments[index]; + const args = candidate.node.arguments; + + const value = + candidate.method === "exec" + ? args[1] + : t.isObjectExpression(args[1]) + ? args[1] + : args[2]; + return t.isObjectExpression(value) ? value : null; }; - const spawnArgvCount = (candidate: SpawnCandidate): number | null => { if (candidate.method === "exec") return null; const args = candidate.node.arguments[1]; From 4c79127fcb31c5e85c4806f5a9522e00674f10e4 Mon Sep 17 00:00:00 2001 From: yasshmittal Date: Tue, 13 Oct 2026 19:18:39 +0530 Subject: [PATCH 59/92] fix(sqlite): classify a missing database path as an invalid value (#2775) * fix(sqlite): classify a missing database path as an invalid value The snapshot reported an absent selected database as an artifact path failure, which the adapter projects as invalid_format alongside malformed paths and content. The snapshot owns the absence observation, so it now returns an invalid_value input issue on path directly, matching the adapter's existing ENOENT/ENOTDIR classification. Fixes #2614 Co-Authored-By: Claude Opus 5.5 * fix(sqlite): classify a missing database path as an invalid value The snapshot reported an absent selected database as an artifact path failure, which the adapter projects as invalid_format alongside malformed paths and content. The snapshot owns the absence observation, so it now returns an invalid_value input issue on path directly, matching the adapter's existing ENOENT/ENOTDIR classification. Fixes #2614 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/sqlite/SqliteDatabaseSnapshot.ts | 17 +++++++++---- .../sqlite/sqliteDatabaseProvider.test.ts | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts index 4acf89611..1384fbdb6 100644 --- a/src/sqlite/SqliteDatabaseSnapshot.ts +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -3,6 +3,7 @@ import { constants, type BigIntStats } from "node:fs"; import { join } from "node:path"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readStableArtifact, @@ -166,6 +167,16 @@ const verifyInactiveJournal = async ( } }; +// Absence is a caller value to correct, not malformed path or content. +const missingDatabase = (path: string): AnalysisInputError => + new AnalysisInputError("inspect_sqlite_database", undefined, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected SQLite database does not exist: ${path}`, + }, + ]); + /** Freeze a stable offline file set without ever opening the source through SQLite. */ export const captureSqliteDatabaseSnapshot = async ( path: string, @@ -183,11 +194,7 @@ export const captureSqliteDatabaseSnapshot = async ( const wal = before[1]; if (database === undefined || wal === undefined || before[2] === undefined) throw new Error("Incomplete database file-set observation"); - if (database === null) - throw new ArtifactReaderFailure( - "path", - `Selected SQLite database does not exist: ${path}`, - ); + if (database === null) throw missingDatabase(path); if (before[2] !== null) await verifyInactiveJournal( `${path}-journal`, diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts index b5adf8297..7805552c9 100644 --- a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -357,6 +357,30 @@ it("does not execute views when selecting rows", async () => { }); }); +nativeTest.each(["absent.db", "absent-parent/absent.db"])( + "classifies a missing %s as an invalid value", + async (name) => { + const workspace = await createTestWorkspace("rea-sqlite-missing-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = workspace.path(name); + const provider = createProvider(); + onTestFinished(() => provider.close()); + const result = await provider.inspect({ path }); + if (result.ok) throw new Error("Expected a missing database rejection"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { + issues: [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected SQLite database does not exist: ${path}`, + }, + ], + }, + }); + }, +); + it("attributes table and column selection failures to their own arguments", async () => { const { database } = await createFixture(); const provider = createProvider(); From c22d7cd7567b52e6e6ba13f14df11c4a720c0c4e Mon Sep 17 00:00:00 2001 From: yasshmittal Date: Tue, 13 Oct 2026 19:18:55 +0530 Subject: [PATCH 60/92] fix(browser): keep malformed Content-Length declarations unknown (#2773) * fix(browser): keep malformed Content-Length declarations unknown Response metadata normalized Content-Length with Number(), truncation and clamping, so "-5", "0x10", "1e3" and "+12" became observed lengths 0, 16, 1000 and 12. RFC 9110 section 8.6 defines the field as 1*DIGIT; any other spelling declares no length. Parse only decimal digits within the safe integer range and keep every other value null. Fixes #2596 Co-Authored-By: Claude Opus 5.5 * test(browser): cover the largest accepted Content-Length Pin the safe-integer boundary beside the overflow case so a future change cannot reject the largest representable declared length unnoticed. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/browser/CdpSafeMetadata.test.ts | 21 +++++++++++++++++++++ src/browser/CdpSafeMetadata.ts | 17 ++++++++--------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/browser/CdpSafeMetadata.test.ts b/src/browser/CdpSafeMetadata.test.ts index 0cff4e094..ef9c42e14 100644 --- a/src/browser/CdpSafeMetadata.test.ts +++ b/src/browser/CdpSafeMetadata.test.ts @@ -289,4 +289,25 @@ describe("safe CDP response metadata referrer policy", () => { expect(captured.response.policies.referrer_policy).toBe(expected); expect(captured.response.policies.coop).toBe("same-origin"); }); + + it.each<[string | number | undefined, number | null]>([ + ["100", 100], + [" 0 ", 0], + [100, 100], + ["-5", null], + ["+12", null], + ["0x10", null], + ["1e3", null], + ["9007199254740991", 9007199254740991], + ["9007199254740992", null], + [undefined, null], + ])("declares Content-Length %j only for decimal digits", (raw, expected) => { + const captured = safeResponseMetadata( + "request-length", + `${origin}/api`, + { headers: raw === undefined ? {} : { "Content-Length": raw } }, + new Set([origin]), + ); + expect(captured.response.content_length).toBe(expected); + }); }); diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index 589c00367..1347cdcca 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -1,10 +1,6 @@ import type { WebPageInspection } from "../domain/browserObservationSchemas.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; -import { - numberValue, - cdpStringValue, - type UnknownRecord, -} from "./CdpCaptureValues.js"; +import { cdpStringValue, type UnknownRecord } from "./CdpCaptureValues.js"; type ResponseMetadata = WebPageInspection["metadata"]["responses"][number]; type LinkMetadata = ResponseMetadata["links"][number]; @@ -43,7 +39,7 @@ export const safeResponseMetadata = ( request_id: requestId, url, mime_type: boundedHeader(cdpStringValue(response.mimeType)), - content_length: nonnegativeInteger(headers.get("content-length")), + content_length: contentLength(headers.get("content-length")), content_encoding: boundedHeader(headers.get("content-encoding")), csp, links, @@ -343,9 +339,12 @@ const policyToken = (value: string | undefined): string | null => { return /^[a-z][a-z0-9_.\x2d]*$/u.test(token) ? token : null; }; -const nonnegativeInteger = (value: string | undefined): number | null => { - const number = numberValue(value === undefined ? undefined : Number(value)); - return number === undefined ? null : Math.max(0, Math.trunc(number)); +// RFC 9110 section 8.6: Content-Length = 1*DIGIT. Other spellings declare no length. +const contentLength = (value: string | undefined): number | null => { + const token = (value ?? "").trim(); + if (!/^[0-9]+$/u.test(token)) return null; + const length = Number(token); + return Number.isSafeInteger(length) ? length : null; }; const boundedHeader = (value: string | undefined): string | null => From 46f51ee6cc45efc546369eae1691b76dfddc5825 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:49:23 -0700 Subject: [PATCH 61/92] fix(flutter): name a missing or non-file APK selection (#2727) * fix(flutter): name a missing or non-file APK selection identify_flutter_build and inspect_dart_aot reported a missing path or a directory as "The APK could not be read as a package: : ENOENT ...", which blames the package bytes for a selection mistake. Report "Selected APK does not exist" for ENOENT/ENOTDIR and "Selected APK is not a regular file" for a non-regular selection, matching the SQLite inspector's wording. The issue path and invalid_format reason are unchanged; package format failures keep their existing message. Co-Authored-By: Claude Opus 5.5 * fix(filesystem): classify unopenable non-regular selections at admission A Unix socket cannot be opened at all: open() fails with ENXIO on Linux and EOPNOTSUPP on macOS (surfaced by libuv as "Unknown system error -102") before openRegularFile can stat the descriptor, so such a selection escaped NonRegularFileReadError and the Flutter provider still blamed the package bytes. When open fails, classify the path by its own metadata (lstat when symlinks are rejected, stat otherwise): an existing non-regular file becomes NonRegularFileReadError. Missing paths, permission failures, regular files, and the O_NOFOLLOW ELOOP rejection keep their original errors. The Flutter selection test now covers a real Unix socket. Co-Authored-By: Claude Opus 5.5 * fix(filesystem): reclassify only unopenable file-type errors at admission Checking the path's metadata after any open failure turned EACCES on an unreadable directory into NonRegularFileReadError, so callers reported invalid input instead of access_denied. Reclassify only errors that mean the file type cannot be opened (ENXIO, EOPNOTSUPP, ENOTSUP, matched by errno because libuv has no name for macOS EOPNOTSUPP 102); permission, symlink-rejection and resource failures keep their original errors. Run the Unix socket selection check in its own test, skipped on Windows where listen() takes named pipes, and keep the missing-path and directory checks cross-platform. Co-Authored-By: Claude Opus 5.5 * fix(filesystem): keep cancellation that arrives during the non-regular probe A caller abort while the post-open metadata probe ran was replaced by NonRegularFileReadError, so callers such as parseBinaryTarget reported an input-format error instead of cancellation. Recheck the signal before substituting the non-regular classification. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/artifacts/RegularFileAdmission.test.ts | 34 ++++++++++ src/filesystem/RegularFile.ts | 50 ++++++++++++++- src/flutter/FlutterBuildProvider.ts | 18 +++++- .../filesystem/flutterBuildProvider.test.ts | 64 +++++++++++++++++++ 4 files changed, 162 insertions(+), 4 deletions(-) diff --git a/src/artifacts/RegularFileAdmission.test.ts b/src/artifacts/RegularFileAdmission.test.ts index 17926799a..97f61fc34 100644 --- a/src/artifacts/RegularFileAdmission.test.ts +++ b/src/artifacts/RegularFileAdmission.test.ts @@ -1,5 +1,6 @@ import type { BigIntStats, Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { afterEach, expect, it, vi } from "vitest"; import { @@ -88,3 +89,36 @@ it("compares available nanosecond timestamps without rounding away source change sameRegularFileState(before, { ...before, ctimeNs: before.ctimeNs + 1n }), ).toBe(false); }); + +it.each([ + ["EACCES", -13, "keeps"], + ["ENXIO", -6, "reclassifies"], +] as const)( + "%s from opening a non-regular path: admission %s the failure", + async (code, errno, outcome) => { + const failure = Object.assign(new Error(`open ${code}`), { code, errno }); + mockOpen.mockRejectedValue(failure); + // The selected path itself is a directory that stat can still describe. + const cause = await openRegularFile(tmpdir(), { symlinks: "follow" }).catch( + (rejected: unknown) => rejected, + ); + if (outcome === "keeps") expect(cause).toBe(failure); + else expect(cause).toBeInstanceOf(NonRegularFileReadError); + }, +); + +it("keeps cancellation that arrives during the non-regular metadata probe", async () => { + const controller = new AbortController(); + const cancellation = new Error("cancelled during probe"); + mockOpen.mockImplementation(async () => { + // The probe stats the path after this rejection; abort before it settles. + queueMicrotask(() => controller.abort(cancellation)); + throw Object.assign(new Error("open ENXIO"), { code: "ENXIO", errno: -6 }); + }); + await expect( + openRegularFile(tmpdir(), { + symlinks: "follow", + signal: controller.signal, + }), + ).rejects.toBe(cancellation); +}); diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index 4942e2e46..47fdfa1d8 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; +import { constants as osConstants } from "node:os"; import type { BigIntStats, Stats } from "node:fs"; -import { lstat, open, type FileHandle } from "node:fs/promises"; +import { lstat, open, stat, type FileHandle } from "node:fs/promises"; import { OwnedFileHandle } from "./OwnedFileHandle.js"; @@ -66,7 +67,17 @@ export const openRegularFile = async ( (constants.O_NONBLOCK ?? 0) | (constants.O_NOCTTY ?? 0) | (options.symlinks === "reject" ? (constants.O_NOFOLLOW ?? 0) : 0), - ); + ).catch(async (cause: unknown) => { + const nonRegular = await nonRegularOpenFailure( + path, + options.symlinks, + cause, + ); + if (nonRegular === undefined) throw cause; + // Cancellation during the metadata probe outranks the reclassification. + options.signal?.throwIfAborted(); + throw nonRegular; + }); const owner = new OwnedFileHandle(handle); try { options.signal?.throwIfAborted(); @@ -95,6 +106,41 @@ export const openRegularFile = async ( } }; +/** + * Some non-regular files cannot be opened at all: a Unix socket fails with + * ENXIO on Linux and EOPNOTSUPP on macOS. Confirm such a failure against the + * path's own metadata before reporting a non-regular selection. + */ +const nonRegularOpenFailure = async ( + path: string, + symlinks: "follow" | "reject", + cause: unknown, +): Promise => { + // Only errors meaning "this file type cannot be opened" are reclassified; + // permission, symlink-rejection and resource failures keep their codes. + if (!isUnopenableFileTypeError(cause)) return undefined; + const metadata = await (symlinks === "reject" ? lstat : stat)(path).catch( + () => undefined, + ); + if (metadata === undefined || metadata.isFile()) return undefined; + return new NonRegularFileReadError(path, metadata.isDirectory()); +}; + +// Match by errno: libuv has no name for macOS EOPNOTSUPP (102) and reports +// "Unknown system error -102" as its code. +const errnos: Partial> = osConstants.errno; +const UNOPENABLE_FILE_TYPE_ERRNOS: ReadonlySet = new Set( + [errnos.ENXIO, errnos.EOPNOTSUPP, errnos.ENOTSUP].flatMap((errno) => + errno === undefined ? [] : [-errno], + ), +); + +const isUnopenableFileTypeError = (cause: unknown): boolean => + cause instanceof Error && + "errno" in cause && + typeof cause.errno === "number" && + UNOPENABLE_FILE_TYPE_ERRNOS.has(cause.errno); + /** Compare one regular file's identity and mutable metadata across a read. */ export const sameRegularFileState = ( expected: Stats | BigIntStats, diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index 9a6e6fe50..ea5d56025 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -33,6 +33,7 @@ import { import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { + NonRegularFileReadError, openRegularFile, RegularFileAdmissionFailure, sameRegularFileState, @@ -181,16 +182,29 @@ const mapInputFailure = ( { max_metadata_read_bytes: APK_METADATA_READ_MAX_BYTES }, { cause }, ); - const message = cause instanceof Error ? cause.message : String(cause); return new AnalysisInputError(operation, { cause }, [ { path: ["path"], reason: "invalid_format", - message: `The APK could not be read as a package: ${path}: ${message}`, + message: inputFailureMessage(path, cause), }, ]); }; +/** Name a missing or non-file selection instead of blaming package bytes. */ +const inputFailureMessage = (path: string, cause: unknown): string => { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR") + ) + return `Selected APK does not exist: ${path}`; + if (cause instanceof NonRegularFileReadError) + return `Selected APK is not a regular file: ${path}`; + const message = cause instanceof Error ? cause.message : String(cause); + return `The APK could not be read as a package: ${path}: ${message}`; +}; + /** Provider-owned storage failures must never be attributed to caller input. */ const mapOwnedIoFailure = ( operation: string, diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 4a1c8248f..68e0328c4 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -1,5 +1,6 @@ import { createReadStream } from "node:fs"; import { open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; import { join } from "node:path"; import { Readable } from "node:stream"; import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; @@ -1171,6 +1172,69 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( }, ); +const expectSelectionIssue = async ( + provider: FlutterBuildProvider, + path: string, + message: string, +): Promise => { + for (const operation of [ + "identify_flutter_build", + "inspect_dart_aot", + ] as const) { + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("A non-file APK selection must fail"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_format", + message: `${message}: ${path}`, + }, + ], + }, + }); + } +}; + +it("names a missing or non-file APK selection instead of blaming package bytes", async () => { + const root = await createTestTempDirectory("rea-flutter-selection-"); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + await expectSelectionIssue( + provider, + join(root, "absent.apk"), + "Selected APK does not exist", + ); + await expectSelectionIssue( + provider, + root, + "Selected APK is not a regular file", + ); +}); + +// Windows listens on named pipes rather than filesystem sockets. +it.skipIf(process.platform === "win32")( + "names an unopenable Unix socket APK selection as a non-file", + async () => { + const root = await createTestTempDirectory("rea-flutter-socket-"); + const socketPath = join(root, "s.sock"); + const socket = createServer(); + await new Promise((resolve) => socket.listen(socketPath, resolve)); + onTestFinished( + () => new Promise((resolve) => socket.close(() => resolve())), + ); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + await expectSelectionIssue( + provider, + socketPath, + "Selected APK is not a regular file", + ); + }, +); + it.each(["EACCES", "EPERM"] as const)( "preserves %s at the shared source admission boundary", async (code) => { From 9680da76c84a6833635dc7a5df607806d899f804 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:50:25 -0700 Subject: [PATCH 62/92] perf(evidence): reuse sealed bundle reads in compare and export (#2715) * perf(evidence): reuse sealed bundle reads in compare and export `rea compare`, the compare_bundles MCP tool and `rea evidence-export` read bundles through the mutable reader, so each consumer re-authenticated every record again: compareBundles parsed both bundles a second time and bundleDigest's serializeEvidenceBundle a third time; export's writer parsed the source once more before serializing. These consumers only read the bundle, so read it through the existing sealed reader (readImmutableEvidenceBundle). Later parses hit the immutable bundle cache. Ordinary mutable reads, kept deliberately by #1721, are unchanged. Output is byte-identical. Co-Authored-By: Claude Opus 5.5 * fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/application/EvidenceBundleCommands.ts | 10 +++++++--- src/process/ProviderDeadline.test.ts | 17 +++++++++++++++++ src/process/ProviderDeadline.ts | 3 +++ src/server/registerBundleComparisonTool.ts | 6 +++--- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index a72d17cb5..1255ea68f 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -11,6 +11,7 @@ import { compareBundles } from "../domain/bundleComparison.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { readEvidenceBundle, + readImmutableEvidenceBundle, writeEvidenceBundle, } from "./EvidenceBundleFiles.js"; @@ -33,7 +34,8 @@ export const exportEvidenceBundleCommand = async ( outputPath: string, overwrite: boolean, ): Promise> => { - const loaded = await readEvidenceBundle(sourcePath); + // The sealed snapshot lets the writer reuse this authentication. + const loaded = await readImmutableEvidenceBundle(sourcePath); if (!loaded.ok) return loaded; const resources = new ArtifactResourceScope(); const written = await writeEvidenceBundle( @@ -69,9 +71,11 @@ export const compareEvidenceBundlesCommand = async (input: { readonly leftPath: string; readonly rightPath: string; }): Promise> => { + // Sealed snapshots let comparison and its bundle digests reuse this + // authentication instead of re-validating every record twice more. const [left, right] = await Promise.all([ - readEvidenceBundle(input.leftPath), - readEvidenceBundle(input.rightPath), + readImmutableEvidenceBundle(input.leftPath), + readImmutableEvidenceBundle(input.rightPath), ]); if (!left.ok) return left; if (!right.ok) return right; diff --git a/src/process/ProviderDeadline.test.ts b/src/process/ProviderDeadline.test.ts index b43ce6654..0a9448150 100644 --- a/src/process/ProviderDeadline.test.ts +++ b/src/process/ProviderDeadline.test.ts @@ -33,3 +33,20 @@ it("aborts a bounded wait when its real monotonic deadline expires", async () => deadline.dispose(); } }); + +it("reports no remaining time once its timer fires before the monotonic deadline", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const now = vi.spyOn(performance, "now").mockReturnValue(1_000); + const deadline = new ProviderStartupDeadline(40); + try { + // Timers may fire while the monotonic clock is still just short of the deadline. + now.mockReturnValue(1_039.6); + vi.advanceTimersByTime(40); + expect(deadline.signal.aborted).toBe(true); + expect(deadline.interruption).toBe("timeout"); + expect(deadline.remainingMs()).toBe(0); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); diff --git a/src/process/ProviderDeadline.ts b/src/process/ProviderDeadline.ts index 33cb3a8d0..a6f10779a 100644 --- a/src/process/ProviderDeadline.ts +++ b/src/process/ProviderDeadline.ts @@ -85,6 +85,9 @@ export class ProviderStartupDeadline { /** Milliseconds still available to the next startup phase. */ remainingMs(): number { + // Node timers can fire a fraction of a millisecond before the monotonic + // deadline, so an elapsed timer must not report time left to spend. + if (this.#interruption === "timeout") return 0; return Math.max(0, Math.ceil(this.#deadlineAt - performance.now())); } diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts index 75ef2a618..3dbc92298 100644 --- a/src/server/registerBundleComparisonTool.ts +++ b/src/server/registerBundleComparisonTool.ts @@ -1,7 +1,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; -import { readEvidenceBundle } from "../application/EvidenceBundleFiles.js"; +import { readImmutableEvidenceBundle } from "../application/EvidenceBundleFiles.js"; import { toolContract } from "../contracts/toolContracts.js"; import { compareBundles } from "../domain/bundleComparison.js"; import { createEvidence } from "../domain/evidence.js"; @@ -33,12 +33,12 @@ export const registerBundleComparisonTool = ( context.mcpReq.signal, async () => { const [left, right] = await Promise.all([ - readEvidenceBundle( + readImmutableEvidenceBundle( input.left_bundle_path, context.mcpReq.signal, resources, ), - readEvidenceBundle( + readImmutableEvidenceBundle( input.right_bundle_path, context.mcpReq.signal, resources, From a3daf8a07bf938ce6765b0e15c527493f9496a9f Mon Sep 17 00:00:00 2001 From: Stefano Rizzello Date: Tue, 13 Oct 2026 15:55:17 +0200 Subject: [PATCH 63/92] fix(ci): run the Windows native lane with its process boundary fixture (#2336) * fix(ci): run the Windows native lane with its process boundary fixture `verify-windows-native.mjs` takes the boundary executable as its second argument and skips every observation that needs it when the argument is absent. CI passed no argument, so the breakaway denial, caller and child token comparison, Windows environment semantics and in-place ancestor reparse attempt have never run there; they only ran for an operator who built the fixture by hand, which `native/windows/README.md` described as optional. The fixture is an ordinary Windows executable independent of the addon, so the lane that already installs MinGW to cross-build the artifact cross-builds it too and uploads it beside the existing inspection fixture. The Windows job downloads it and passes its path, matching how that inspection fixture already crosses the same job boundary. No observation, assertion or threshold changes; this only stops four of them from being silently skipped. Co-Authored-By: Claude Opus 5 * fix(ci): pass the process boundary fixture in the real Ghidra Windows lane `real-ghidra-windows.yml` also runs the native lane without the boundary executable, so its proof run skipped the same breakaway, caller and child token, environment semantics and ancestor reparse observations. Its artifact job already installs MinGW, so it cross-builds and uploads the fixture the same way, and the self-hosted Windows job downloads it and passes its path. This makes the README's claim about both Windows workflows true rather than narrowing it to one lane. Co-Authored-By: Claude Opus 5 * fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit dfdaa0ca74cc84a45dcb186422a0f51aa61bc427) --------- Co-authored-by: Claude Opus 5 Co-authored-by: morluto Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++- .github/workflows/real-ghidra-windows.yml | 22 ++++++++++++++++++- native/windows/README.md | 11 +++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 142e179bb..8dfd067f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -400,6 +400,21 @@ jobs: name: windows-native-inspection-fixture path: .cache/windows-native-inspection-fixture/ if-no-files-found: error + # The process boundary observations are independent of the addon: they + # report the caller's and child's own token, environment and breakaway + # results from a separate executable. Cross-build it in the lane that + # already has MinGW so the Windows job can observe them too. + - name: Build Windows process boundary fixture + run: | + mkdir -p .cache/windows-process-boundary + x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \ + -o .cache/windows-process-boundary/process-boundary.exe \ + tests/fixtures/windows/processBoundary.cc -ladvapi32 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: windows-process-boundary-fixture + path: .cache/windows-process-boundary/ + if-no-files-found: error - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: windows-native-controls @@ -622,7 +637,16 @@ jobs: src/application/Update.test.ts tests/process-global/updateMaintenance.test.ts tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts - - run: npm run verify:windows-native + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: windows-process-boundary-fixture + path: .cache/windows-process-boundary/ + # Passing the fixture adds the breakaway, caller/child token, environment + # semantics and ancestor reparse observations, which the lane skips + # entirely when it is absent. + - run: >- + npm run verify:windows-native -- . + "${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: windows-native-inspection-fixture diff --git a/.github/workflows/real-ghidra-windows.yml b/.github/workflows/real-ghidra-windows.yml index db92cd0f8..ce1493206 100644 --- a/.github/workflows/real-ghidra-windows.yml +++ b/.github/workflows/real-ghidra-windows.yml @@ -36,6 +36,20 @@ jobs: name: real-ghidra-windows-native path: native/windows/build/ if-no-files-found: error + # The boundary observations need a separate Windows executable, which the + # native lane skips when it is absent. Cross-build it here, where MinGW is + # already installed, so the proof run observes them too. + - name: Build Windows process boundary fixture + run: | + mkdir -p .cache/windows-process-boundary + x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \ + -o .cache/windows-process-boundary/process-boundary.exe \ + tests/fixtures/windows/processBoundary.cc -ladvapi32 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: real-ghidra-windows-process-boundary + path: .cache/windows-process-boundary/ + if-no-files-found: error verify: name: Real Ghidra 12.1.4 Windows x64 PE proof @@ -55,7 +69,13 @@ jobs: name: real-ghidra-windows-native path: native/windows/build/ - run: npm run verify:windows-native:artifact - - run: npm run verify:windows-native + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: real-ghidra-windows-process-boundary + path: .cache/windows-process-boundary/ + - run: >- + npm run verify:windows-native -- . + "${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe" - name: Verify all admitted Windows Ghidra operations env: REA_ANALYSIS_PROVIDER: ghidra diff --git a/native/windows/README.md b/native/windows/README.md index e2543260f..7432f15fd 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -163,9 +163,14 @@ V8 heap flags cannot bound (limit notification, peak commit at the limit, all members settled, with the notification present in the first native exit report and the wrapper's exit event), and a control under the same limit that completes normally. -An optional independently built -`tests/fixtures/windows/processBoundary.cc` executable adds breakaway and -caller/child token observations and an in-place ancestor reparse attempt: +A separately built `tests/fixtures/windows/processBoundary.cc` executable adds +breakaway, caller/child token and environment semantics observations and an +in-place ancestor reparse attempt. The lane skips all of them when the +executable is absent, so both Windows workflows cross-build it in the same +MinGW job that builds their artifact and pass it: the capability and package +lane in `.github/workflows/ci.yml`, and the real Ghidra proof run in +`.github/workflows/real-ghidra-windows.yml`. Build it locally with any Windows +C++ compiler, or the same way they do, and pass its path: ```powershell node scripts/verify-windows-native.mjs . C:\fixtures\process-boundary.exe From 48a683ac8c780786f0a2a71ab64aca5994087c00 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:55:30 +0800 Subject: [PATCH 64/92] feat(evm): inspect lossless Solidity terminal CBOR metadata (#2790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(evm): retain lossless terminal CBOR metadata Refs morluto/rea#2388 Co-authored-by: Matheus Breguêz Co-authored-by: Codex --- .github/workflows/real-evm-interface.yml | 2 + docs/evm-bytecode.md | 24 ++ docs/specs/solidity/cbor-metadata/plan.md | 33 +++ docs/specs/solidity/cbor-metadata/spec.md | 43 ++++ docs/specs/solidity/cbor-metadata/tasks.md | 8 + .../solidity/cbor-metadata/verification.md | 43 ++++ licenses/npm-dependencies.txt | 7 +- licenses/npm-runtime-notices.txt | 230 +++++++++++++++++- package-lock.json | 10 + package.json | 2 + scripts/ci/scopes.mjs | 1 + scripts/verify-evm-interface.mjs | 20 ++ scripts/verify-solidity-metadata.mjs | 105 ++++++++ src/domain/evm/evmInterface.test.ts | 8 + src/domain/evm/evmInterface.ts | 19 ++ src/domain/evm/solidityMetadata.ts | 89 +++++++ src/evm/EvmoleInterfaceProvider.ts | 3 + src/evm/EvmoleInterfaceWorker.ts | 2 + src/evm/SolidityMetadata.test.ts | 96 ++++++++ src/evm/SolidityMetadata.ts | 158 ++++++++++++ .../evm/solidityMetadataWorker.test.ts | 75 ++++++ .../boundary/filesystem/ciChangeScope.test.ts | 4 + tests/boundary/mcp/evmInterfaceMcp.test.ts | 24 +- tests/fixtures/evm/interface.ts | 1 + 24 files changed, 1002 insertions(+), 5 deletions(-) create mode 100644 docs/specs/solidity/cbor-metadata/plan.md create mode 100644 docs/specs/solidity/cbor-metadata/spec.md create mode 100644 docs/specs/solidity/cbor-metadata/tasks.md create mode 100644 docs/specs/solidity/cbor-metadata/verification.md create mode 100644 scripts/verify-solidity-metadata.mjs create mode 100644 src/domain/evm/solidityMetadata.ts create mode 100644 src/evm/SolidityMetadata.test.ts create mode 100644 src/evm/SolidityMetadata.ts create mode 100644 tests/boundary/evm/solidityMetadataWorker.test.ts diff --git a/.github/workflows/real-evm-interface.yml b/.github/workflows/real-evm-interface.yml index 6e510c973..8588a269f 100644 --- a/.github/workflows/real-evm-interface.yml +++ b/.github/workflows/real-evm-interface.yml @@ -43,3 +43,5 @@ jobs: echo "REA_VERIFY_SOLC_MODULE=${evm_fixture_root}/node_modules/solc/index.js" >> "${GITHUB_ENV}" - name: Verify source-owned compiled bytecode through public CLI and MCP run: npm run verify:evm:interface + - name: Verify content-hash-free and CBOR-free owned compiler artifacts + run: npm run verify:solidity:metadata diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 972d8b92c..1e28542fb 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -39,6 +39,18 @@ license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) - Upstream also returns terminal metadata during selector recovery. Its reported values remain in `raw_result`; integer precision and non-text-key coverage are unknown in the JS binding. Full bytecode retains the original encoded metadata. +- `bytecode.metadata` independently interprets a plausible terminal CBOR root + map from the selected bytes. Integers use decimal strings; byte strings use hex, + and scalar keys retain their types. The recorded offset/length addresses the + original bytecode, so summary delivery does not copy an opaque CBOR payload. + Nested containers have partial scalar coverage; unsupported or malformed + candidates report `not-decoded` with a diagnostic. Decoding is bounded to 64 + container levels and the terminal payload's encoded size. +- An unavailable trailer does not prove metadata absence or Solidity origin. + Compiler-version bytes/text are metadata declarations, not verified compiler, + source-file or deployed-contract identity. Historical omission parses as a null + unknown facet without changing the original Evidence record. Metadata/source + URLs are not fetched. - Selectors, argument strings and state mutability are **inferences**. Function names/signatures are not looked up. Optimized code may produce different mutability candidates; recovered types are not a verified source ABI. @@ -125,3 +137,15 @@ The real verification lane traces representative raw/hex CLI workflows and check that only declared Node/prlimit launchers and process ownership inspection run. Socket traces permit local stdio socket pairs and reject attempted network requests. These observations verify those fixtures; they are not a general network sandbox. + +For the independent metadata parser, `npm run verify:solidity:metadata` uses an +existing caller-selected solc 0.8.30 module: + +```sh +REA_VERIFY_SOLC_MODULE=/absolute/path/to/solc/index.js npm run verify:solidity:metadata +``` + +It compiles the owned fixture with unoptimized, viaIR, content-hash-free and +CBOR-free settings, verifies the observed trailer facts and reports local decode +timings. No contract is executed. This lane does not establish the complete +Linux EVM provider's CLI/MCP, resource-limit or cleanup behavior. diff --git a/docs/specs/solidity/cbor-metadata/plan.md b/docs/specs/solidity/cbor-metadata/plan.md new file mode 100644 index 000000000..04b601b66 --- /dev/null +++ b/docs/specs/solidity/cbor-metadata/plan.md @@ -0,0 +1,33 @@ +# Implementation plan + +1. Use cborg 6.1.3 (Apache-2.0) as a pinned format decoder, with a bounded + tokenizer at its owning adapter boundary. Decode maps as Map, preserve integer + precision and strictly validate emitted text bytes before conversion. +2. Add the portable metadata-result schema in src/domain/evm/. Use exact byte + ranges into the already retained bytecode rather than copying an opaque CBOR + hex payload into every selected summary. +3. Add src/evm/SolidityMetadata.ts to interpret the terminal length/root-map + candidate. Return unavailable, decoded, partial or not-decoded facts; keep + unsupported nested values explicit instead of recursively expanding them. +4. Compute the facts in EvmoleInterfaceWorker from its selected bytes. The host + provider validates ranges/identity and projects the additive metadata field. + Preserve raw engine output without rewriting its unknown-precision metadata. +5. Ensure the canonical EVM bytecode shape carries the optional field into complete + and summary Evidence. Include decoding profile, coverage and limitations. +6. Add producer, worker, historical contract and public MCP boundary coverage. + Compile an owned Solidity fixture with a pinned caller-supplied solc module, + and verify its CBOR compiler version. Run relevant static/architecture checks. +7. Extend the existing real Linux CLI/MCP verifier with independent metadata + assertions. Run the compiler-setting matrix in its CI lane and classify its + verifier as EVM-owned, with a production CI-planner regression. + +No new execution provider or target authority is introduced: a format parser is +added inside the existing EVM worker. Linux x64 provider admission, target-free +semantics, cleanup, limits and complete original Evidence remain unchanged. +No metadata JSON or source URL is automatically acquired. + +The parser bounds nesting to 64 and token/collection growth to the selected +terminal payload's byte count (the format's terminal length is at most 65535). +Every data item consumes encoded bytes; broad declared collection lengths cannot +justify allocation without that lower bound. Expanded results still pass the +worker's existing 16 MiB output check. diff --git a/docs/specs/solidity/cbor-metadata/spec.md b/docs/specs/solidity/cbor-metadata/spec.md new file mode 100644 index 000000000..ea08dcc90 --- /dev/null +++ b/docs/specs/solidity/cbor-metadata/spec.md @@ -0,0 +1,43 @@ +# Solidity terminal metadata inspection + +Issue: https://github.com/morluto/rea/issues/2388 + +## Outcome + +The existing EVM interface operation should retain independently decoded terminal +CBOR candidate facts from the exact selected bytes instead of relying on precision +unknowns in the engine's reported metadata. This increment covers trailer ranges, +bounded decoding, exact integer/non-text scalar key evidence and compiler-version +representations. + +## Requirements + +- Preserve the existing carrier/decoded identities and original full bytecode. +- Report a plausible terminal root-map candidate separately from proof of Solidity + origin. Missing or non-map trailers remain unavailable, not proven absent. +- Interpret scalar root-map keys/values losslessly: integers as decimal strings, + bytes as hex, strings, booleans and null. Preserve undecoded/unsupported values + through the original bytecode plus exact candidate offset/length. +- Report partial and not-decoded coverage, parser/profile failures and unsupported + CBOR forms explicitly. Do not silently coerce unknown keys into object properties. +- Interpret admitted solc release/prerelease representations only from observed + entries; preserve ambiguity if duplicate compiler-version keys occur. +- Keep the additive field compatible with historical Evidence and retain + it through selected summary delivery and public CLI/MCP. + Historical omission parses as a null unknown facet without mutating the source. +- Bound nesting before decoder recursion and collection lengths before allocation. + Keep parsing inside the existing owned worker and its resource/output budgets. +- Do not fetch metadata or source content, contact RPC or execute contract code. + +## Acceptance and limits + +Owned compiler fixtures and malformed/synthetic trailers cover integers beyond +Number precision, non-text keys, nested/uninterpreted values, invalid UTF-8, +invalid lengths, unsupported tags and nesting bounds. Public boundary tests +preserve the field and older omission. A real pinned solc artifact verifies actual +metadata facts; the existing Linux-only EVM host profile remains unchanged. + +## Deferred issue criteria + +Binding a supplied metadata JSON to IPFS/Swarm commitments is a later independent +increment of #2388. This PR must reference rather than close the whole issue. diff --git a/docs/specs/solidity/cbor-metadata/tasks.md b/docs/specs/solidity/cbor-metadata/tasks.md new file mode 100644 index 000000000..e80aefa01 --- /dev/null +++ b/docs/specs/solidity/cbor-metadata/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Define single-feature requirements and implementation plan before code. +- [x] Add the pinned decoder and bounded owner boundary. +- [x] Implement lossless scalar metadata facts and compiler-version evidence. +- [x] Integrate worker/provider contracts and summary/historical compatibility. +- [x] Verify producer, worker and public boundary behavior with owned fixtures. +- [x] Run relevant repository checks and prepare a separately scoped PR. diff --git a/docs/specs/solidity/cbor-metadata/verification.md b/docs/specs/solidity/cbor-metadata/verification.md new file mode 100644 index 000000000..dae558d94 --- /dev/null +++ b/docs/specs/solidity/cbor-metadata/verification.md @@ -0,0 +1,43 @@ +# Verification + +After integrating upstream c462a4ebd, 47 tests passed +across the parser, domain contract, application service, compiled worker and MCP +boundary (five files). Compiled-worker goldens include BOM text, partial nested +values, unavailable trailers, malformed UTF-8 and unsupported floats. Five +upstream adapter tests also passed after its stricter integer validation landed. +All six static tasks and architecture guards +passed. After integration, test discovery found all 1004 files exactly once. +The CI-planner selection passed all 85 tests, including the new EVM-verifier case. + +The parser regressions cover exact large integers and typed keys, malformed UTF-8, +duplicate keys, excessive nesting, unsupported tags/floats, length bounds and +historical omission. A real defect in the decoder's default text handling removed +an initial U+FEFF. The owning tokenizer now validates and decodes the retained +text bytes with BOM preservation; independent fixtures check both values and +compiler-key identity. + +The owned fixture was compiled with caller-selected solc 0.8.30, using unoptimized, +optimized via-IR, content-hash-free and CBOR-free settings. All four metadata +checks passed again after integration. No contract was executed. Local first decode +took approximately 0.81 ms or less. The originally reported approximately 2.92 ms +for 100 warm iterations included equality assertions and is not a decode-only +measurement. The verifier now measures parsing separately from those assertions; +updated warm timings have not been collected. These historical figures describe +these fixtures and this host, not a general workload. +Peak process RSS +included the compiler (299536 KiB); complete process-family CPU/memory and agent +token costs were not measured. + +Compiled worker tests exercise unchanged EVMole WASM on this macOS host. MCP tests +use the production server and contracts with an injected provider seam. The full +Linux-only provider CLI/MCP, resource-limit and cleanup lane remains unverified +here. Metadata JSON commitment binding remains deferred under issue #2388. + +The Linux CLI/MCP verifier asserts exact compiler bytes, declared version and +candidate ranges for its existing owned raw/hex artifacts. Its CI lane additionally +runs the new compiler-setting matrix. A regression using the actual CI planner +checks that edits to the new verifier select the EVM lane. + +The later merge/review correction adds decoded-summary and omitted historical +metadata coverage. Tests and provider reruns for that correction were skipped at +the maintainer's request; the earlier runs above do not validate the changed head. diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index ce45f7459..05fc2f173 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 586; runtime: 174. +Locked package entries: 587; runtime: 175. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -1430,6 +1430,11 @@ canonicalize@3.0.0 | runtime | Apache-2.0 Package: https://www.npmjs.com/package/canonicalize/v/3.0.0 Archive: https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz Integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA== +cborg@6.1.3 | runtime | Apache-2.0 + Lockfile path: node_modules/cborg + Package: https://www.npmjs.com/package/cborg/v/6.1.3 + Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz + Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ== ccount@2.0.1 | development | MIT Lockfile path: node_modules/ccount Package: https://www.npmjs.com/package/ccount/v/2.0.1 diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index d981aa68a..2a0c45d24 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,7 +1,7 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): 5de9cf97e7c327da1892efeff30b40507e4c7f8e496b5d305b8016c162bc15e3 +Runtime dependency fingerprint (SHA-256): 63d5b55e027a0cfb3e77989ca337aca905f1652d10cfbfc4d4dd876d3ecd4f3a ===== @babel/helper-string-parser@8.0.0 ===== Declared license: MIT @@ -2456,6 +2456,234 @@ Scope: npm runtime dependency, including optional platform packages. limitations under the License. +===== cborg@6.1.3 ===== +Declared license: Apache-2.0 +Archive: https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz +Integrity: sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ== +Scope: npm runtime dependency, including optional platform packages. + +--- package/LICENSE --- + +Copyright 2020 Rod Vagg + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- Apache-2.0 full license (licenses/Apache-2.0.txt) --- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ===== clean-git-ref@2.0.1 ===== Declared license: Apache-2.0 Archive: https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz diff --git a/package-lock.json b/package-lock.json index 5af71e98e..7eae3799e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "ajv": "8.20.0", "ajv-formats": "3.0.1", "canonicalize": "3.0.0", + "cborg": "6.1.3", "cross-spawn": "7.0.6", "diff": "9.0.0", "evmole": "0.9.3", @@ -4181,6 +4182,15 @@ "node": ">=18" } }, + "node_modules/cborg": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-6.1.3.tgz", + "integrity": "sha512-Kb/wD/AFuVcqvVjR+HYNOjNiuhVJ2C+RWxQE2Yqq2WVlVkRjdvVLkkM6QsL3NMsBRh+rgNc+aXmcraA+X0HObQ==", + "license": "Apache-2.0", + "bin": { + "cborg": "lib/bin.js" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", diff --git a/package.json b/package.json index 7ee2b42ab..bc3492d2f 100644 --- a/package.json +++ b/package.json @@ -270,6 +270,7 @@ "verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs", "verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs", "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs", + "verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs", "verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs", "mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check", "compile": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/bundle-runtime.mjs", @@ -309,6 +310,7 @@ "ajv": "8.20.0", "ajv-formats": "3.0.1", "canonicalize": "3.0.0", + "cborg": "6.1.3", "cross-spawn": "7.0.6", "diff": "9.0.0", "evmole": "0.9.3", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index ffe15cb3d..e8af46d3b 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -218,6 +218,7 @@ export const SCOPES = { "src/artifacts/readStableArtifact*.ts", "src/artifacts/ArtifactReader.ts", "scripts/verify-evm-interface.mjs", + "scripts/verify-solidity-metadata.mjs", "scripts/fixtures/evm-interface.sol", "third_party/evmole/**", ], diff --git a/scripts/verify-evm-interface.mjs b/scripts/verify-evm-interface.mjs index b0bfdafa4..4bacc4bdf 100644 --- a/scripts/verify-evm-interface.mjs +++ b/scripts/verify-evm-interface.mjs @@ -225,6 +225,26 @@ try { for (const mode of ["cli", "mcp"]) { const report = await inspect(mode, path, encoding); assert.equal(report.bytecode.hex, hex); + assert.equal(report.bytecode.metadata.status, "decoded"); + assert.deepEqual(report.bytecode.metadata.compiler_version, { + representation: "release-bytes", + value: "0.8.30", + }); + assert.equal( + report.bytecode.metadata.bytecode_offset + + report.bytecode.metadata.cbor_bytes + + 2, + Buffer.from(hex, "hex").length, + ); + assert.ok( + report.bytecode.metadata.entries.some( + ({ key, value }) => + key.kind === "text" && + key.value === "solc" && + value.kind === "bytes" && + value.value === "00081e", + ), + ); assert.deepEqual( report.functions .filter(({ dispatch }) => dispatch === "abi") diff --git a/scripts/verify-solidity-metadata.mjs b/scripts/verify-solidity-metadata.mjs new file mode 100644 index 000000000..f927477d8 --- /dev/null +++ b/scripts/verify-solidity-metadata.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { isAbsolute } from "node:path"; +import { pathToFileURL } from "node:url"; +import { performance } from "node:perf_hooks"; +import { inspectSolidityMetadata } from "../dist/evm/SolidityMetadata.js"; + +const compilerModule = process.env.REA_VERIFY_SOLC_MODULE; +if (!isAbsolute(compilerModule ?? "")) + throw new Error( + "Select an absolute caller-installed solc 0.8.30 module with REA_VERIFY_SOLC_MODULE.", + ); +const solc = (await import(pathToFileURL(compilerModule).href)).default; +assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang"); +const source = await readFile( + new URL("./fixtures/evm-interface.sol", import.meta.url), + "utf8", +); +const cases = [ + { name: "unoptimized", optimizer: false, viaIR: false, metadata: {} }, + { name: "viaIR", optimizer: true, viaIR: true, metadata: {} }, + { + name: "without-content-hash", + optimizer: false, + viaIR: false, + metadata: { bytecodeHash: "none" }, + }, + { + name: "without-CBOR", + optimizer: false, + viaIR: false, + metadata: { appendCBOR: false }, + }, +]; +const reports = []; +for (const fixture of cases) { + const compiled = JSON.parse( + solc.compile( + JSON.stringify({ + language: "Solidity", + sources: { "fixture.sol": { content: source } }, + settings: { + optimizer: { enabled: fixture.optimizer }, + viaIR: fixture.viaIR, + metadata: fixture.metadata, + outputSelection: { "*": { "*": ["evm.deployedBytecode.object"] } }, + }, + }), + ), + ); + assert.ok( + !(compiled.errors ?? []).some((error) => error.severity === "error"), + JSON.stringify(compiled.errors), + ); + const bytes = Buffer.from( + compiled.contracts["fixture.sol"].InterfaceFixture.evm.deployedBytecode + .object, + "hex", + ); + const cpu = process.cpuUsage(); + const started = performance.now(); + const parsed = inspectSolidityMetadata(bytes); + const firstMs = performance.now() - started; + const firstCpu = process.cpuUsage(cpu); + if (fixture.metadata.appendCBOR === false) + assert.equal(parsed.status, "unavailable"); + else { + assert.equal(parsed.status, "decoded"); + assert.deepEqual(parsed.compiler_version, { + representation: "release-bytes", + value: "0.8.30", + }); + assert.equal(parsed.bytecode_offset + parsed.cbor_bytes + 2, bytes.length); + if (fixture.metadata.bytecodeHash === "none") + assert.deepEqual( + parsed.entries.map(({ key }) => key), + [{ kind: "text", value: "solc" }], + ); + } + const warmResults = new Array(100); + const warmStart = performance.now(); + for (let index = 0; index < warmResults.length; index++) + warmResults[index] = inspectSolidityMetadata(bytes); + const warmMs = performance.now() - warmStart; + for (const result of warmResults) assert.deepEqual(result, parsed); + reports.push({ + fixture: fixture.name, + bytes: bytes.length, + status: parsed.status, + first_decode_ms: firstMs, + first_decode_cpu_us: firstCpu, + warm_100_decode_ms: warmMs, + }); +} +console.log( + JSON.stringify({ + ok: true, + compiler_version: solc.version(), + contract_execution: "not-performed", + cases: reports, + process_peak_rss_kib: process.resourceUsage().maxRSS, + measurements: + "In-process decode timings; peak RSS includes the compiler. End-to-end Linux provider/CLI costs and agent tokens were not measured.", + }), +); diff --git a/src/domain/evm/evmInterface.test.ts b/src/domain/evm/evmInterface.test.ts index 93ab32add..1b2a36de6 100644 --- a/src/domain/evm/evmInterface.test.ts +++ b/src/domain/evm/evmInterface.test.ts @@ -2,6 +2,14 @@ import { expect, it } from "vitest"; import { evmInterfaceSchema } from "./evmInterface.js"; import { evmInterfaceFixture } from "../../../tests/fixtures/evm/interface.js"; +it("accepts historical metadata omission as an unknown facet without changing the source", () => { + const value = evmInterfaceFixture(); + const { metadata: _metadata, ...oldBytecode } = value.bytecode; + const old = { ...value, bytecode: oldBytecode }; + expect(evmInterfaceSchema.parse(old).bytecode.metadata).toBeNull(); + expect(old.bytecode).not.toHaveProperty("metadata"); +}); + it.each(["byte-count", "body-offset", "selector", "authenticity"])( "rejects false interface evidence: %s", (problem) => { diff --git a/src/domain/evm/evmInterface.ts b/src/domain/evm/evmInterface.ts index 6db3f7e16..bde6ecba7 100644 --- a/src/domain/evm/evmInterface.ts +++ b/src/domain/evm/evmInterface.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { localPathStringSchema } from "../localPath.js"; +import { solidityMetadataSchema } from "./solidityMetadata.js"; const byteOffset = z.number().int().nonnegative(); const sha256 = z.string().regex(/^[a-f0-9]{64}$/); @@ -34,6 +35,7 @@ export const evmInterfaceSchema = z kind: z.literal("unknown"), deployment_authenticity: z.literal("unknown"), hardfork: z.literal("unknown"), + metadata: solidityMetadataSchema.nullable().default(null), }), evidence_kind: z.literal("inferred"), functions: z.array( @@ -62,6 +64,23 @@ export const evmInterfaceSchema = z message: "Selected byte count does not match the bytecode hex representation.", }); + const metadata = value.bytecode.metadata; + if ( + metadata !== null && + metadata.status !== "unavailable" && + (metadata.bytecode_offset === null || + metadata.cbor_bytes === null || + metadata.bytecode_offset + metadata.cbor_bytes + 2 !== + value.bytecode.bytes || + metadata.cbor_bytes !== + Number.parseInt(value.bytecode.hex.slice(-4), 16)) + ) + context.addIssue({ + code: "custom", + path: ["bytecode", "metadata"], + message: + "Metadata range must match the terminal length suffix in selected bytecode.", + }); for (const [index, fn] of value.functions.entries()) if (fn.bytecode_offset >= value.bytecode.bytes) context.addIssue({ diff --git a/src/domain/evm/solidityMetadata.ts b/src/domain/evm/solidityMetadata.ts new file mode 100644 index 000000000..28cdee95f --- /dev/null +++ b/src/domain/evm/solidityMetadata.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +/** Scalar view of CBOR; uninterpreted containers remain in original bytecode. */ +export const solidityMetadataScalarSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("text"), value: z.string().max(65535) }), + z.strictObject({ + kind: z.literal("integer"), + value: z + .string() + .max(21) + .regex(/^-?(?:0|[1-9][0-9]*)$/), + }), + z.strictObject({ + kind: z.literal("bytes"), + value: z + .string() + .max(131070) + .regex(/^(?:[a-f0-9]{2})*$/), + }), + z.strictObject({ kind: z.literal("boolean"), value: z.boolean() }), + z.strictObject({ kind: z.literal("null") }), + z.strictObject({ + kind: z.literal("uninterpreted"), + container: z.enum(["array", "map"]), + }), +]); + +/** A trailer candidate, not authentication of Solidity origin or deployment. */ +export const solidityMetadataSchema = z + .strictObject({ + profile: z.literal("terminal-cbor-scalars-v1"), + status: z.enum(["unavailable", "decoded", "partial", "not-decoded"]), + bytecode_offset: z.number().int().nonnegative().nullable(), + cbor_bytes: z.number().int().positive().max(65535).nullable(), + entries: z + .array( + z.strictObject({ + key: solidityMetadataScalarSchema, + value: solidityMetadataScalarSchema, + }), + ) + .max(32767), + compiler_version: z + .strictObject({ + representation: z.enum(["release-bytes", "prerelease-text"]), + value: z.string().min(1).max(65535), + }) + .nullable(), + diagnostic: z.string().max(512).nullable(), + }) + .superRefine((value, context) => { + const unavailable = value.status === "unavailable"; + if ( + unavailable + ? value.bytecode_offset !== null || value.cbor_bytes !== null + : value.bytecode_offset === null || value.cbor_bytes === null + ) + context.addIssue({ + code: "custom", + message: "Only unavailable metadata omits the candidate range.", + }); + const unexamined = unavailable || value.status === "not-decoded"; + if ( + unexamined && + (value.entries.length !== 0 || value.compiler_version !== null) + ) + context.addIssue({ + code: "custom", + message: + "Unavailable interpretation cannot report decoded entries or compiler identity.", + }); + const uninterpreted = value.entries.some( + ({ key, value: entryValue }) => + key.kind === "uninterpreted" || entryValue.kind === "uninterpreted", + ); + if ( + (value.status === "decoded" && uninterpreted) || + (value.status === "partial" && !uninterpreted) + ) + context.addIssue({ + code: "custom", + message: "Scalar coverage must preserve uninterpreted containers.", + }); + }); + +export type SolidityMetadata = z.infer; +export type SolidityMetadataScalar = z.infer< + typeof solidityMetadataScalarSchema +>; diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index c59427e6a..cbcb31615 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -30,6 +30,7 @@ import { import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { InspectEvmInterfaceInput } from "../domain/evm/evmInterface.js"; import { evmInterfaceSchema } from "../domain/evm/evmInterface.js"; +import { solidityMetadataSchema } from "../domain/evm/solidityMetadata.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -67,6 +68,7 @@ const replySchema = z.discriminatedUnion("ok", [ sha256: z.string().regex(/^[a-f0-9]{64}$/), bytes: z.number().int().nonnegative(), hex: z.string().regex(/^(?:[a-f0-9]{2})*$/), + metadata: solidityMetadataSchema.optional(), }), raw: evmoleInterfaceReplySchema, }), @@ -331,6 +333,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { "Recovered selectors, argument strings and mutability are upstream inferences, not verified ABI signatures or names. Empty results do not prove the absence of callable dispatch.", "Bytecode kind, hardfork and deployed-contract authenticity are unknown. This inspection performs no target runtime execution, chain/RPC lookup or network request.", "Original carrier and decoded bytecode SHA-256 are distinct identities; SHA-256 is not the Ethereum Keccak code hash.", + "Terminal CBOR metadata is an independently decoded candidate, not proof of Solidity origin, source identity or deployment. Nested or unsupported values retain explicit unavailable interpretation and original byte ranges.", "The owned worker has a 256 MiB JavaScript heap, 3 GiB virtual address-space limit and 30-second deadline. Virtual address space is distinct from resident memory; limits fail without partial success.", ]; const validated = evmInterfaceSchema.safeParse({ diff --git a/src/evm/EvmoleInterfaceWorker.ts b/src/evm/EvmoleInterfaceWorker.ts index 061ceb47e..17e50f7be 100644 --- a/src/evm/EvmoleInterfaceWorker.ts +++ b/src/evm/EvmoleInterfaceWorker.ts @@ -6,6 +6,7 @@ import { EvmCarrierFailure, } from "./EvmBytecodeCarrier.js"; import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js"; +import { inspectSolidityMetadata } from "./SolidityMetadata.js"; import { EVM_INTERFACE_LIMITS, EVM_FILE_SIZE_FAILURE_EXIT, @@ -64,6 +65,7 @@ const main = async (): Promise => { sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length, hex: Buffer.from(bytes).toString("hex"), + metadata: inspectSolidityMetadata(bytes), }, raw: inferred.raw, }; diff --git a/src/evm/SolidityMetadata.test.ts b/src/evm/SolidityMetadata.test.ts new file mode 100644 index 000000000..1ca056f18 --- /dev/null +++ b/src/evm/SolidityMetadata.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; +import { inspectSolidityMetadata } from "./SolidityMetadata.js"; + +const bytecode = (hex: string) => { + const payload = Buffer.from(hex, "hex"); + const length = Buffer.alloc(2); + length.writeUInt16BE(payload.length); + return Buffer.concat([Buffer.from("00", "hex"), payload, length]); +}; + +it("reads a compiler version from actual trailer bytes without a magic-prefix assumption", () => { + const result = inspectSolidityMetadata(bytecode("a164736f6c634300081e")); + expect(result).toMatchObject({ + status: "decoded", + bytecode_offset: 1, + cbor_bytes: 10, + compiler_version: { representation: "release-bytes", value: "0.8.30" }, + entries: [ + { + key: { kind: "text", value: "solc" }, + value: { kind: "bytes", value: "00081e" }, + }, + ], + }); +}); + +it("preserves integer and non-text key precision beyond Number", () => { + expect( + inspectSolidityMetadata(bytecode("a1011bffffffffffffffff")).entries, + ).toEqual([ + { + key: { kind: "integer", value: "1" }, + value: { kind: "integer", value: "18446744073709551615" }, + }, + ]); + expect( + inspectSolidityMetadata(bytecode("a1003bffffffffffffffff")).entries[0] + ?.value, + ).toEqual({ + kind: "integer", + value: "-18446744073709551616", + }); +}); + +it("retains opaque nested values as partial rather than fabricating scalar values", () => { + expect(inspectSolidityMetadata(bytecode("a161788100"))).toMatchObject({ + status: "partial", + entries: [ + { + key: { kind: "text", value: "x" }, + value: { kind: "uninterpreted", container: "array" }, + }, + ], + }); +}); + +it("preserves a UTF-8 BOM inside a CBOR text value rather than stripping it", () => { + expect( + inspectSolidityMetadata(bytecode("a1617864efbbbf61")).entries[0]?.value, + ).toEqual({ + kind: "text", + value: "\ufeffa", + }); + expect( + inspectSolidityMetadata(bytecode("a167efbbbf736f6c634300081e")) + .compiler_version, + ).toBeNull(); +}); + +it.each([ + ["invalid text", "a1617861ff"], + ["truncated value", "a16178"], + ["unsupported tag", "a16178c000"], + ["floating point", "a16178fa3f800000"], + ["duplicate key", "a2617801617802"], + ["excessive nesting", "a16178" + "81".repeat(65) + "00"], + ["impossible collection", "a161788a00"], +])("keeps exact ranges and unavailable interpretation for %s", (_name, hex) => { + expect(inspectSolidityMetadata(bytecode(hex))).toMatchObject({ + status: "not-decoded", + bytecode_offset: 1, + entries: [], + compiler_version: null, + }); +}); + +it.each(["", "60006000f3", "000000", "00010001"])( + "does not claim absent Solidity metadata for an unavailable trailer: %s", + (hex) => + expect(inspectSolidityMetadata(Buffer.from(hex, "hex"))).toMatchObject({ + status: "unavailable", + bytecode_offset: null, + cbor_bytes: null, + entries: [], + }), +); diff --git a/src/evm/SolidityMetadata.ts b/src/evm/SolidityMetadata.ts new file mode 100644 index 000000000..5b24ce245 --- /dev/null +++ b/src/evm/SolidityMetadata.ts @@ -0,0 +1,158 @@ +import { decode, Tokenizer, Type, type Token } from "cborg"; +import type { + SolidityMetadata, + SolidityMetadataScalar, +} from "../domain/evm/solidityMetadata.js"; + +const PROFILE = "terminal-cbor-scalars-v1"; +const MAX_DEPTH = 64; +const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const decodeOptions = { + useMaps: true, + allowBigInt: true, + allowIndefinite: false, + allowUndefined: false, + allowInfinity: false, + allowNaN: false, + rejectDuplicateMapKeys: true, + retainStringBytes: true, +} as const; + +/** Bound recursive decoder entry before collection interpretation/allocation. */ +class BoundedMetadataTokenizer extends Tokenizer { + readonly remaining: number[] = []; + constructor(bytes: Uint8Array) { + super(bytes, decodeOptions); + } + + override next(): Token { + while (this.remaining.at(-1) === 0) this.remaining.pop(); + const token = super.next(); + if ( + Type.equals(token.type, Type.tag) || + Type.equals(token.type, Type.float) + ) + throw new Error( + "CBOR tags and floating-point values are outside this metadata profile.", + ); + if (Type.equals(token.type, Type.string)) { + if (token.byteValue === undefined) + throw new Error( + "CBOR text bytes were unavailable for strict UTF-8 validation.", + ); + token.value = utf8.decode(token.byteValue); + } + const parent = this.remaining.length - 1; + if (parent >= 0) this.remaining[parent]!--; + if ( + Type.equals(token.type, Type.map) || + Type.equals(token.type, Type.array) + ) { + const count: unknown = token.value; + const children = + typeof count === "number" + ? count * (Type.equals(token.type, Type.map) ? 2 : 1) + : NaN; + if ( + !Number.isSafeInteger(children) || + children < 0 || + children > this.data.length + ) + throw new Error( + "Declared CBOR collection exceeds the encoded item-byte bound.", + ); + if (this.remaining.length >= MAX_DEPTH) + throw new Error("CBOR nesting exceeds the 64-level metadata profile."); + this.remaining.push(children); + } + return token; + } +} + +const scalar = (value: unknown): SolidityMetadataScalar => { + if (typeof value === "string") return { kind: "text", value }; + if (typeof value === "bigint") + return { kind: "integer", value: value.toString() }; + if (typeof value === "number" && Number.isSafeInteger(value)) + return { kind: "integer", value: String(value) }; + if (typeof value === "boolean") return { kind: "boolean", value }; + if (value === null) return { kind: "null" }; + if (value instanceof Uint8Array) + return { kind: "bytes", value: Buffer.from(value).toString("hex") }; + if (value instanceof Map) return { kind: "uninterpreted", container: "map" }; + if (Array.isArray(value)) + return { kind: "uninterpreted", container: "array" }; + throw new Error( + "CBOR decoder returned a value outside the admitted profile.", + ); +}; + +/** Parse only a plausible terminal root-map candidate; never establish absence. */ +export const inspectSolidityMetadata = ( + bytes: Uint8Array, +): SolidityMetadata => { + const empty: SolidityMetadata = { + profile: PROFILE, + status: "unavailable", + bytecode_offset: null, + cbor_bytes: null, + entries: [], + compiler_version: null, + diagnostic: + "No plausible terminal CBOR map was identified; Solidity origin and metadata absence remain unknown.", + }; + if (bytes.length < 3) return empty; + const length = bytes[bytes.length - 2]! * 256 + bytes[bytes.length - 1]!; + const offset = bytes.length - 2 - length; + if (length === 0 || offset < 0 || bytes[offset]! >> 5 !== 5) return empty; + const selected = bytes.subarray(offset, bytes.length - 2); + const result: SolidityMetadata = { + ...empty, + status: "not-decoded", + bytecode_offset: offset, + cbor_bytes: length, + }; + try { + const raw: unknown = decode(selected, { + ...decodeOptions, + tokenizer: new BoundedMetadataTokenizer(selected), + }); + if (!(raw instanceof Map)) + throw new Error("Terminal CBOR candidate did not decode to a root map."); + for (const [key, value] of raw as Map) + result.entries.push({ key: scalar(key), value: scalar(value) }); + result.status = result.entries.some( + ({ key, value }) => + key.kind === "uninterpreted" || value.kind === "uninterpreted", + ) + ? "partial" + : "decoded"; + result.diagnostic = + result.status === "partial" + ? "Nested containers are uninterpreted; their exact bytes remain at the recorded range in original bytecode." + : null; + const version: unknown = raw.get("solc"); + if (version instanceof Uint8Array && version.length === 3) + result.compiler_version = { + representation: "release-bytes", + value: [...version].join("."), + }; + else if (typeof version === "string" && version.length > 0) + result.compiler_version = { + representation: "prerelease-text", + value: version, + }; + return result; + } catch (cause: unknown) { + return { + ...result, + status: "not-decoded", + entries: [], + compiler_version: null, + diagnostic: (cause instanceof Error + ? cause.message + : String(cause) + ).slice(0, 512), + }; + } +}; diff --git a/tests/boundary/evm/solidityMetadataWorker.test.ts b/tests/boundary/evm/solidityMetadataWorker.test.ts new file mode 100644 index 000000000..0577983c4 --- /dev/null +++ b/tests/boundary/evm/solidityMetadataWorker.test.ts @@ -0,0 +1,75 @@ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { solidityMetadataSchema } from "../../../src/domain/evm/solidityMetadata.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +/** Real compiled worker and unchanged WASM engine; no Linux provider-admission claim. */ +it.each([ + ["compiler", "00a164736f6c634300081e000a", "decoded"], + ["integer", "00a1011bffffffffffffffff000b", "decoded"], + ["BOM text", "00a1617864efbbbf610008", "decoded"], + ["nested value", "00a1617881000005", "partial"], + ["unavailable trailer", "00", "unavailable"], + ["unsupported tag", "00a16178c0000005", "not-decoded"], + ["floating point", "00a16178fa3f8000000008", "not-decoded"], + ["invalid UTF-8", "00a1617861ff0005", "not-decoded"], +])( + "retains independent %s metadata through the actual worker", + async (kind, hex, status) => { + const directory = await createTestTempDirectory("rea-solidity-worker-"); + const snapshot = join(directory, "selected.hex"); + const reply = join(directory, "reply.json"); + const request = join(directory, "request.json"); + await writeFile(snapshot, hex); + await writeFile( + request, + JSON.stringify({ + snapshot_path: snapshot, + reply_path: reply, + failure_marker_path: join(directory, "resource.failure"), + encoding: "hex", + }), + ); + await promisify(execFile)( + process.execPath, + [ + "--max-old-space-size=256", + "--disable-wasm-trap-handler", + new URL("../../../dist/evm/EvmoleInterfaceWorker.js", import.meta.url) + .pathname, + request, + ], + { timeout: 30_000, maxBuffer: 1024 * 1024 }, + ); + const value = JSON.parse(await readFile(reply, "utf8")); + expect(value.ok, JSON.stringify(value)).toBe(true); + expect(value.bytecode.hex).toBe(hex); + const metadata = solidityMetadataSchema.parse(value.bytecode.metadata); + expect(metadata.status).toBe(status); + if (kind === "integer") + expect(metadata.entries[0]?.value).toEqual({ + kind: "integer", + value: "18446744073709551615", + }); + if (kind === "compiler") + expect(metadata.compiler_version?.value).toBe("0.8.30"); + if (kind === "BOM text") + expect(metadata.entries[0]?.value).toEqual({ + kind: "text", + value: "\ufeffa", + }); + if (kind === "nested value") + expect(metadata.entries[0]?.value).toEqual({ + kind: "uninterpreted", + container: "array", + }); + if (kind === "unavailable trailer") { + expect(metadata.bytecode_offset).toBeNull(); + expect(metadata.entries).toEqual([]); + expect(metadata.compiler_version).toBeNull(); + } + }, +); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 321160a36..0ba043721 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -449,6 +449,10 @@ it.each([ sqlite: false, }, ], + [ + "scripts/verify-solidity-metadata.mjs", + { code: true, evm: true, browser: false, package: false }, + ], [ "src/browser/execution/evaluate.ts", { diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index 823a4d3cf..60f493b2c 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -1,4 +1,5 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { createHash } from "node:crypto"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished, vi } from "vitest"; import { EvmInterfaceService } from "../../../src/application/evm/EvmInterfaceService.js"; @@ -10,6 +11,7 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { evmInterfaceSummaryViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { inspectSolidityMetadata } from "../../../src/evm/SolidityMetadata.js"; import { err, ok } from "../../../src/domain/result.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; @@ -26,8 +28,21 @@ import { evmInterfaceExecution, } from "../../fixtures/evm/interface.js"; -it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => { +const decodedMetadataFixture = () => { + const bytes = Buffer.from("00a1011bffffffffffffffff000b", "hex"); const value = evmInterfaceFixture(); + value.bytecode = { + ...value.bytecode, + hex: bytes.toString("hex"), + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + metadata: inspectSolidityMetadata(bytes), + }; + return value; +}; + +it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => { + const value = decodedMetadataFixture(); const inspect = vi.fn(() => Promise.resolve(ok(evmInterfaceExecution(value))), ); @@ -280,7 +295,8 @@ it("settles the admitted EVM operation after SDK cancellation and accepts a subs }); it("returns the exact inferred interface summary while preserving complete defaults and retained source", async () => { - const value = evmInterfaceFixture(); + const value = decodedMetadataFixture(); + expect(value.bytecode.metadata?.status).toBe("decoded"); value.functions = [ { selector: "0x11223344", @@ -419,6 +435,7 @@ it.each(["valid", "wrong-subject", "no-subject"])( "preserves historical portable EVM Evidence and rejects incompatible identity: %s", async (scenario) => { const value = evmInterfaceFixture(); + const { metadata: _metadata, ...historicalBytecode } = value.bytecode; const source = createEvidence( scenario === "no-subject" ? undefined @@ -434,7 +451,7 @@ it.each(["valid", "wrong-subject", "no-subject"])( { operation: "inspect_evm_interface", parameters: { path: value.artifact.path, encoding: "hex" }, - result: value, + result: { ...value, bytecode: historicalBytecode }, confidence: "inferred", rawResult: { original: true }, }, @@ -463,6 +480,7 @@ it.each(["valid", "wrong-subject", "no-subject"])( expect(view.parent_evidence_id).toBe(source.evidence_id); expect(view.summary.functions).toEqual([]); expect(view.summary.discovery_completeness).toBe("unknown"); + expect(view.summary.bytecode.metadata).toBeNull(); expect(view.coverage).toMatchObject({ total: 0, examined: 0, diff --git a/tests/fixtures/evm/interface.ts b/tests/fixtures/evm/interface.ts index f0f1960ba..0c876c37b 100644 --- a/tests/fixtures/evm/interface.ts +++ b/tests/fixtures/evm/interface.ts @@ -18,6 +18,7 @@ export const evmInterfaceFixture = ( kind: "unknown", deployment_authenticity: "unknown", hardfork: "unknown", + metadata: null, }, evidence_kind: "inferred", functions: [], From beca34f52258b7b25d72c160ae6fffa1cc3c034b Mon Sep 17 00:00:00 2001 From: vtx <140315902+vt0x78@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:58:36 +0100 Subject: [PATCH 65/92] fix(ida): report missing tools when a compatibility profile mismatches (#2307) A profile mismatch reached the caller as a bare AnalysisProtocolError, which projects to `unreadable_output` with a generic message. The error already named every missing tool, but none of that detail survived, so the only way to identify the cause was to log the JSON-RPC traffic and notice the exchange stopping after `tools/list`. Throw a ProviderAdapterError instead, carrying the mode, the expected profile and the missing tool names through the diagnostics channel that already surfaces in `details`. Document which upstream versions each profile accepts, since upstream `main` now serves neither. Refs #2301 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/ida-provider.md | 9 ++++ src/ida/IdaSessionClient.test.ts | 70 +++++++++++++++++++++++++++ src/ida/IdaSessionClient.ts | 25 ++++++++-- tests/composition/idaProvider.test.ts | 14 +++++- 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/docs/ida-provider.md b/docs/ida-provider.md index fbf67c8a2..012f75bfb 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -4,6 +4,15 @@ REA adapts an existing IDA MCP server into its native analysis tools. Existing I Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP_CONFIG` to that file, then select `ida` once through `REA_ANALYSIS_PROVIDER`, the CLI's `--provider ida`, or MCP's `open_binary.provider_id`. No provider startup occurs during discovery. +## Supported upstream versions + +Each mode accepts one upstream tool profile, checked against `tools/list` before any analysis runs. Upstream's `main` branch has since moved to 2.0.0, which renamed the GUI tools and dropped `idb_close`, so it serves neither profile. Installing from that branch fails at startup with the missing tool names in the error diagnostics. + +| Mode | Upstream profile | Known working | +| -------- | ------------------- | ------------------------------------------------------ | +| attached | legacy GUI tools | 1.4.0 | +| headless | database supervisor | builds serving `idb_open`, `idb_list`, and `idb_close` | + ## Analyze an already-open GUI target The attached profile supports upstream **1.4.0 legacy tools**, including `get_metadata`, `list_functions`, and `decompile_function`. Copy the upstream registration's `command`, `args`, and optional `env` into a JSON file. An existing `mcpServers` object with an `ida-pro-mcp` entry is also accepted. For example: diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 7fa366bce..0ae4002d2 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -31,6 +31,7 @@ const LIVE_PEER = { const fixture = async ( mode: "attached" | "headless" = "attached", timeoutMs = 1000, + protocol?: "native", ) => { const { root, target } = await createIdaTarget(); roots.push(root); @@ -41,6 +42,7 @@ const fixture = async ( args: [], env: {}, mode, + ...(protocol === undefined ? {} : { protocol }), timeoutMs, workspaceRoot: root, }, @@ -709,6 +711,74 @@ describe("headless IDA non-busy health payloads", () => { }); }); +describe("IDA compatibility profile mismatch", () => { + it("reports the native profile for an incompatible catalog", async () => { + const { producer, client } = await fixture("attached", 1000, "native"); + producer.tools = []; + const result = await client.execute("health", {}); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected incompatible native catalog"); + expect(projectAnalysisError(result.error).details).toMatchObject({ + diagnostics: { + mode: "attached", + expected_profile: "native", + missing_tools: expect.arrayContaining(["decompile"]), + }, + }); + expect(producer.calls).toEqual([]); + expect((await client.close()).ok).toBe(true); + }); + + it("names every missing tool so the caller can identify the upstream profile", async () => { + const { producer, client } = await fixture("headless"); + producer.tools = [ + "idb_open", + "idb_list", + "server_health", + "list_funcs", + "lookup_funcs", + "find_regex", + "decompile", + "disasm", + "callees", + "xrefs_to", + ]; + const result = await client.execute("health", {}); + expect(result.ok, "an incomplete profile cannot serve analysis").toBe( + false, + ); + if (result.ok) return; + const projected = projectAnalysisError(result.error); + expect( + projected.details, + "the absent tool and the expected profile are both reported", + ).toMatchObject({ + provider_id: "ida", + diagnostics: { + mode: "headless", + expected_profile: "database-supervisor", + missing_tools: ["idb_close"], + }, + }); + }); + + it("reports the legacy profile when an attached producer serves modern tools", async () => { + const { producer, client } = await fixture("attached"); + producer.tools = ["server_health", "lookup_funcs", "decompile", "disasm"]; + const result = await client.execute("health", {}); + expect(result.ok, "an incomplete profile cannot serve analysis").toBe( + false, + ); + if (result.ok) return; + expect( + projectAnalysisError(result.error).details, + "attached mode names the legacy profile it expected", + ).toMatchObject({ + diagnostics: { mode: "attached", expected_profile: "legacy-1.4" }, + }); + }); +}); + describe("headless IDA lifecycle boundaries", () => { it("never closes an unowned worker even when open echoes the requested private identity", async () => { const { producer, client } = await fixture("headless"); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index dc0ae5d6f..70d4fc365 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -483,10 +483,27 @@ export class IdaSessionClient implements AnalysisClient { ? legacyTools : modernTools; const missing = expected.filter((name) => !tools.has(name)); - if (missing.length > 0) - throw new AnalysisProtocolError( - `IDA MCP ${this.config.protocol === "native" ? "native attached" : this.config.mode} compatibility profile is missing tools: ${missing.join(", ")}. Native attached mode requires the native IDA MCP tool catalog; legacy attached mode requires the legacy 1.4 profile; headless mode requires the database supervisor profile. See docs/ida-provider.md.`, - ); + if (missing.length > 0) { + const summary = `IDA MCP ${this.config.protocol === "native" ? "native attached" : this.config.mode} compatibility profile is missing tools: ${missing.join(", ")}. Native attached mode requires the native IDA MCP tool catalog; legacy attached mode requires the legacy 1.4 profile; headless mode requires the database supervisor profile. See docs/ida-provider.md.`; + // The caller cannot act on a profile mismatch it never sees, so the + // missing names travel as adapter diagnostics rather than a bare message. + throw new ProviderAdapterError("ida", "initialize", { + userMessage: summary, + diagnostics: { + reason: summary, + mode: this.config.mode, + expected_profile: + this.config.protocol === "native" + ? "native" + : this.config.mode === "attached" + ? "legacy-1.4" + : "database-supervisor", + missing_tools: [...missing], + recovery: + "Install an IDA MCP build that serves the selected profile; see docs/ida-provider.md.", + }, + }); + } if (this.config.mode === "headless") { this.#workspace = await IdaWorkspace.allocate( this.target, diff --git a/tests/composition/idaProvider.test.ts b/tests/composition/idaProvider.test.ts index 9a702bb45..f6dd1dd0d 100644 --- a/tests/composition/idaProvider.test.ts +++ b/tests/composition/idaProvider.test.ts @@ -1,6 +1,7 @@ import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { IdaProvider } from "../../src/ida/IdaProvider.js"; import { createIdaTarget, RecordingIdaMcp } from "../fixtures/idaMcp.js"; import { createTestBinarySession } from "../fixtures/binarySession.js"; @@ -157,12 +158,21 @@ describe("IDA provider composition", () => { expect(analyzed).toMatchObject({ ok: false, error: { - _tag: "AnalysisProtocolError", - message: expect.stringContaining( + _tag: "ProviderAdapterError", + userMessage: expect.stringContaining( "headless compatibility profile is missing tools: idb_open", ), }, }); + if (analyzed.ok) throw new Error("Expected incompatible IDA profile"); + expect(projectAnalysisError(analyzed.error).details).toMatchObject({ + provider_id: "ida", + diagnostics: { + mode: "headless", + expected_profile: "database-supervisor", + missing_tools: expect.arrayContaining(["idb_open"]), + }, + }); expect(producer.connects).toBe(1); expect((await session.close()).ok).toBe(true); expect(producer.closes).toBe(1); From 96d1f0f3fbc1a36664c62b66d7de8cc2f8837f52 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:59:33 -0700 Subject: [PATCH 66/92] fix(cli): refuse scenario steps on passive native UI observation (#2712) observe-native-ui and capture-native-ui-scenario were registered in one loop with a shared option schema and description. The passive command therefore advertised `--steps` ("Observe or run a scenario"), accepted any value, and then dispatched observe_native_ui without it, so a caller who selected the wrong command never learned that no scenario ran. Register each command against its own contract. Observation declares only the passive scope options, so `--steps` is rejected as an unknown flag; the scenario command keeps its JSON step decoding and validation. Each command now has its own description. Fixes #2674 Co-authored-by: Claude Opus 5.5 --- src/cli/utilityCommands.ts | 187 ++++++++++-------- .../cli/nativeUiScenarioInputCli.test.ts | 23 +++ 2 files changed, 130 insertions(+), 80 deletions(-) diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 33099ee28..2a9d05a4c 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -32,89 +32,116 @@ export const registerUtilityCommands = ( registerCapabilityCommands(cli, logger, analysis); registerNativeCommands(cli, logger, runProviderAnalysis); registerNativeCallCommands(cli, logger, runProviderAnalysis); - for (const [command, operation] of [ - [CLI_COMMANDS.observeNativeUi, "observe_native_ui"], - [CLI_COMMANDS.captureNativeUiScenario, "capture_native_ui_scenario"], - ] as const) { - cli.command(command, { - description: - "Observe or run a scenario in one exact native application window", - args: z.object({ - path: z.string().describe("Local native executable or app path"), - }), - options: z.object({ - pid: z.number().int().positive().describe("Existing application PID"), - windowId: z - .number() - .int() - .positive() - .describe("Exact selected application window ID"), - steps: z - .string() - .optional() - .describe("JSON array of declarative AX actions or waits"), - screenshot: z - .boolean() - .default(true) - .describe("Capture only the selected window"), - accessibility: z - .boolean() - .default(true) - .describe("Read the selected window accessibility tree"), - maxNodes: z - .number() - .int() - .positive() - .safe() - .default(500) - .describe("Maximum accessibility nodes per capture"), - }), - run: ({ args, options }) => - logCliCommand(logger, command, async () => { - const parameters = { - pid: options.pid, - window_id: options.windowId, - screenshot: options.screenshot, - accessibility: options.accessibility, - max_nodes: options.maxNodes, - }; - if (operation === "capture_native_ui_scenario") { - const decoded = - options.steps === undefined - ? { ok: true as const, value: undefined } - : safeParseJson(options.steps); - if (!decoded.ok) - return invalidNativeUiScenarioInput(operation, [ - { path: ["steps"], reason: "invalid_format", expected: "JSON" }, - ]); - const parsed = nativeUiScenarioInputSchema.safeParse({ - ...parameters, - steps: decoded.value, - }); - if (!parsed.success) - return invalidNativeUiScenarioInput( - operation, - projectInputIssues(parsed.error.issues, { - ...parameters, - ...(options.steps === undefined - ? {} - : { steps: decoded.value }), - }), - ); - return runProviderAnalysis( - args.path, - operation, - parsed.data, - logger, - ); - } - return runProviderAnalysis(args.path, operation, parameters, logger); - }), - }); - } + registerNativeUiCommands(cli, logger, runProviderAnalysis); registerReferenceSourceCommand(cli, logger, environment); }; +const nativeUiScopeOptions = { + pid: z.number().int().positive().describe("Existing application PID"), + windowId: z + .number() + .int() + .positive() + .describe("Exact selected application window ID"), + screenshot: z + .boolean() + .default(true) + .describe("Capture only the selected window"), + accessibility: z + .boolean() + .default(true) + .describe("Read the selected window accessibility tree"), + maxNodes: z + .number() + .int() + .positive() + .safe() + .default(500) + .describe("Maximum accessibility nodes per capture"), +}; + +const nativeUiScope = (options: { + readonly pid: number; + readonly windowId: number; + readonly screenshot: boolean; + readonly accessibility: boolean; + readonly maxNodes: number; +}) => ({ + pid: options.pid, + window_id: options.windowId, + screenshot: options.screenshot, + accessibility: options.accessibility, + max_nodes: options.maxNodes, +}); + +/** + * Passive observation and active scenarios have different input contracts. + * Only the scenario command accepts steps, so passive observation rejects + * them as an unknown flag instead of silently dropping them. + */ +const registerNativeUiCommands = ( + cli: CliInstance, + logger: Logger, + runProviderAnalysis: DirectAnalysis["runProviderAnalysis"], +): void => { + const pathArgument = z.object({ + path: z.string().describe("Local native executable or app path"), + }); + cli.command(CLI_COMMANDS.observeNativeUi, { + description: + "Observe one already-running native application window by PID and window ID", + args: pathArgument, + options: z.object(nativeUiScopeOptions), + run: ({ args, options }) => + logCliCommand(logger, CLI_COMMANDS.observeNativeUi, () => + runProviderAnalysis( + args.path, + "observe_native_ui", + nativeUiScope(options), + logger, + ), + ), + }); + const operation = "capture_native_ui_scenario"; + cli.command(CLI_COMMANDS.captureNativeUiScenario, { + description: + "Run declarative AX actions or waits in one exact native application window", + args: pathArgument, + options: z.object({ + ...nativeUiScopeOptions, + steps: z + .string() + .optional() + .describe("JSON array of declarative AX actions or waits"), + }), + run: ({ args, options }) => + logCliCommand(logger, CLI_COMMANDS.captureNativeUiScenario, async () => { + const parameters = nativeUiScope(options); + const decoded = + options.steps === undefined + ? { ok: true as const, value: undefined } + : safeParseJson(options.steps); + if (!decoded.ok) + return invalidNativeUiScenarioInput(operation, [ + { path: ["steps"], reason: "invalid_format", expected: "JSON" }, + ]); + const parsed = nativeUiScenarioInputSchema.safeParse({ + ...parameters, + steps: decoded.value, + }); + if (!parsed.success) + return invalidNativeUiScenarioInput( + operation, + projectInputIssues(parsed.error.issues, { + ...parameters, + ...(options.steps === undefined ? {} : { steps: decoded.value }), + }), + ); + return runProviderAnalysis(args.path, operation, parsed.data, logger); + }), + }); +}; + const invalidNativeUiScenarioInput = ( operation: string, issues: ConstructorParameters[2], diff --git a/tests/boundary/cli/nativeUiScenarioInputCli.test.ts b/tests/boundary/cli/nativeUiScenarioInputCli.test.ts index 766b77a79..7bbd2e2bc 100644 --- a/tests/boundary/cli/nativeUiScenarioInputCli.test.ts +++ b/tests/boundary/cli/nativeUiScenarioInputCli.test.ts @@ -71,4 +71,27 @@ describe("native UI scenario CLI input", () => { }); }, ); + + cliTest( + "refuses scenario steps on passive observation instead of dropping them", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "observe-native-ui", + "/provider-must-not-be-invoked", + "--pid", + "1", + "--window-id", + "1", + "--steps", + '[{"kind":"wait","milliseconds":0}]', + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toContain("Unknown flag: --steps"); + }, + ); }); From 1eca60051506cce310116854fdb59ef9cd195a7e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:00:47 +0800 Subject: [PATCH 68/92] fix(apktool): resolve Windows PATH launchers before invocation (#2802) * fix(apktool): resolve Windows PATH launchers before invocation Resolve the default launcher through the selected PATH/PATHEXT before choosing native process creation or the existing conservative cmd.exe wrapper. Reuse the resulting selection and one canonical environment for the version probe and decode. Follow-up to the release #1693 review of #1777. Source inspected and formatted; tests, builds, check reruns and real Windows Apktool execution were skipped at maintainer request. * fix(apktool): bound lookup and retain only selected environment Source inspection and targeted formatting only. Tests, builds, lint, typechecks and provider workloads were not run at the user's explicit request. * fix(apktool): use supported timeout diagnostics Retain lookup phase through AnalysisErrorOptions diagnostics while preserving the operation and timeout identity. Source inspection and targeted formatting only; tests, checks, builds and provider workloads were skipped at the user request. --------- Co-authored-by: morluto --- src/apktool/ApktoolConfiguration.test.ts | 281 +++++++++++++++++- src/apktool/ApktoolConfiguration.ts | 138 +++++++-- src/apktool/ApktoolProvider.ts | 55 +++- .../binary/AnalysisProviderEvaluation.ts | 2 +- .../binary/LazyAnalysisProvider.ts | 2 +- src/ghidra/GhidraProviderClient.ts | 5 +- src/ida/IdaSessionClient.ts | 5 +- .../binary => process}/AbortablePromise.ts | 0 .../process-global/apktoolEnvironment.test.ts | 53 ++++ 9 files changed, 486 insertions(+), 55 deletions(-) rename src/{application/binary => process}/AbortablePromise.ts (100%) create mode 100644 tests/process-global/apktoolEnvironment.test.ts diff --git a/src/apktool/ApktoolConfiguration.test.ts b/src/apktool/ApktoolConfiguration.test.ts index d2d84874c..39cf1c652 100644 --- a/src/apktool/ApktoolConfiguration.test.ts +++ b/src/apktool/ApktoolConfiguration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { APKTOOL_COMMAND_ENV, @@ -10,29 +10,43 @@ import { resolveApktoolCommand, } from "./ApktoolConfiguration.js"; +const lookup = vi.hoisted(() => ({ stat: vi.fn() })); +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + stat: lookup.stat, +})); +beforeEach(() => { + lookup.stat + .mockReset() + .mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })); +}); + describe("resolveApktoolCommand", () => { - it("prefers an absolute REA_APKTOOL_COMMAND", () => { + it("prefers an absolute REA_APKTOOL_COMMAND", async () => { expect( - resolveApktoolCommand({ - [APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool", - }), + await resolveApktoolCommand( + { + [APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool", + }, + "linux", + ), ).toEqual({ command: "/usr/local/bin/apktool", commandSource: "environment", }); }); - it("falls back to PATH resolution without the environment key", () => { - expect(resolveApktoolCommand({})).toEqual({ + it("leaves POSIX PATH lookup to process creation", async () => { + expect(await resolveApktoolCommand({}, "linux")).toEqual({ command: "apktool", commandSource: "path", }); }); - it("rejects a relative environment path explicitly", () => { - expect(() => + it("rejects a relative environment path explicitly", async () => { + await expect( resolveApktoolCommand({ [APKTOOL_COMMAND_ENV]: "bin/apktool" }), - ).toThrow(/must be an absolute path/u); + ).rejects.toThrow(/must be an absolute path/u); }); }); @@ -119,3 +133,250 @@ describe("apktoolInvocation", () => { }); }); }); + +describe("Windows PATH launcher selection", () => { + const environment = { + Path: '"C:\\Selected Tools";C:\\Other Tools', + PathExt: ".EXE;.CMD;.BAT", + ComSpec: "C:\\Windows\\System32\\cmd.exe", + NoDefaultCurrentDirectoryInExePath: "", + }; + const files = (...paths: string[]) => { + const selected = new Set(paths.map((path) => path.toLowerCase())); + lookup.stat.mockImplementation(async (path: string) => { + if (selected.has(path.toLowerCase())) return { isFile: () => true }; + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }); + }; + + it.each([".BAT", ".CMD"])( + "resolves a default %s launcher before choosing cmd.exe for version and decode", + async (extension) => { + const command = `C:\\Selected Tools\\apktool${extension}`; + files(command); + const selection = await resolveApktoolCommand(environment, "win32"); + expect(selection).toEqual({ command, commandSource: "path" }); + for (const arguments_ of [ + ["--version"], + ["d", "-o", "C:\\output folder", "C:\\target.apk"], + ]) { + const invocation = apktoolInvocation( + selection, + arguments_, + environment, + "win32", + ); + expect(invocation).toEqual({ + command: environment.ComSpec, + arguments: [ + "/d", + "/e:on", + "/v:off", + "/s", + "/c", + `"${[command, ...arguments_].map((token) => `"${token}"`).join(" ")}"`, + ], + windowsVerbatimArguments: true, + }); + } + }, + ); + + it("honors PATH directory order before extension order", async () => { + files("C:\\Selected Tools\\apktool.CMD", "C:\\Other Tools\\apktool.EXE"); + expect(await resolveApktoolCommand(environment, "win32")).toMatchObject({ + command: "C:\\Selected Tools\\apktool.CMD", + }); + }); + + it("honors the selected PATHEXT ordering", async () => { + files("C:\\Selected Tools\\apktool.CMD", "C:\\Selected Tools\\apktool.EXE"); + expect( + await resolveApktoolCommand( + { ...environment, PathExt: ".CMD;.EXE" }, + "win32", + ), + ).toMatchObject({ command: "C:\\Selected Tools\\apktool.CMD" }); + }); + + it("keeps native executable arguments outside cmd.exe interpretation", async () => { + const command = "C:\\Selected Tools\\apktool.EXE"; + files(command); + const selection = await resolveApktoolCommand(environment, "win32"); + const arguments_ = ["d", "C:\\input & literal.apk"]; + expect( + apktoolInvocation(selection, arguments_, environment, "win32"), + ).toEqual({ + command, + arguments: arguments_, + windowsVerbatimArguments: false, + }); + }); + + it("preserves explicit launcher spelling without searching PATH", async () => { + const command = "C:\\selected alias\\APKTOOL.cmd"; + expect( + await resolveApktoolCommand( + { ...environment, [APKTOOL_COMMAND_ENV]: command }, + "win32", + ), + ).toEqual({ command, commandSource: "environment" }); + expect(lookup.stat).not.toHaveBeenCalled(); + }); + + it("does not inspect the current directory when the caller disables it", async () => { + await expect( + resolveApktoolCommand(environment, "win32"), + ).rejects.toMatchObject({ code: "command_missing" }); + expect( + lookup.stat.mock.calls.every( + ([path]) => + String(path).startsWith("C:\\Selected Tools\\") || + String(path).startsWith("C:\\Other Tools\\"), + ), + ).toBe(true); + }); + + it("does not turn an explicitly empty PATHEXT into default extensions", async () => { + files("C:\\Selected Tools\\apktool.EXE"); + await expect( + resolveApktoolCommand({ ...environment, PathExt: "" }, "win32"), + ).rejects.toMatchObject({ code: "command_missing" }); + expect(lookup.stat).not.toHaveBeenCalled(); + }); + + it("skips directories without treating them as executable launchers", async () => { + files("C:\\Other Tools\\apktool.EXE"); + const stat = lookup.stat.getMockImplementation()!; + lookup.stat.mockImplementation(async (path: string) => + path === "C:\\Selected Tools\\apktool.EXE" + ? { isFile: () => false } + : stat(path), + ); + expect(await resolveApktoolCommand(environment, "win32")).toMatchObject({ + command: "C:\\Other Tools\\apktool.EXE", + }); + }); + + it("preserves inspection failures instead of reporting a missing command or selecting a later file", async () => { + lookup.stat.mockRejectedValue( + Object.assign(new Error("denied"), { code: "EACCES" }), + ); + const failure = await resolveApktoolCommand(environment, "win32").catch( + (cause: unknown) => cause, + ); + expect(failure).toBeInstanceOf(ApktoolConfigurationFailure); + if (!(failure instanceof ApktoolConfigurationFailure)) throw failure; + expect(failure).toMatchObject({ + code: "command_unresolved", + diagnostics: { + candidate_path: "C:\\Selected Tools\\apktool.EXE", + filesystem_error: "EACCES", + }, + }); + expect(apktoolConfigurationAvailability(failure)).toMatchObject({ + code: "not_configured", + }); + expect(lookup.stat).toHaveBeenCalledTimes(1); + }); + + it("honors cancellation before inspecting launchers", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + resolveApktoolCommand(environment, "win32", controller.signal), + ).rejects.toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(lookup.stat).not.toHaveBeenCalled(); + }); +}); + +it("does not select a launcher when cancellation arrives during lookup", async () => { + const controller = new AbortController(); + lookup.stat.mockImplementation(async () => { + controller.abort(); + return { isFile: () => true }; + }); + await expect( + resolveApktoolCommand( + { + PATH: "C:\\Tools", + PATHEXT: ".CMD", + NoDefaultCurrentDirectoryInExePath: "1", + }, + "win32", + controller.signal, + ), + ).rejects.toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(lookup.stat).toHaveBeenCalledTimes(1); +}); + +it.each(["EXE", ".CMD\\..\\outside.exe", ".CMD\0"])( + "rejects malformed PATHEXT %j before path lookup", + async (PATHEXT) => { + await expect( + resolveApktoolCommand({ PATH: "C:\\Tools", PATHEXT }, "win32"), + ).rejects.toMatchObject({ + code: "command_unresolved", + diagnostics: { invalid_extension: PATHEXT }, + }); + expect(lookup.stat).not.toHaveBeenCalled(); + }, +); + +it("cancels a pending filesystem lookup and owns its eventual rejection", async () => { + const controller = new AbortController(); + let started!: () => void; + let rejectLookup!: (cause: Error) => void; + const pendingStat = new Promise((_resolve, reject) => { + rejectLookup = reject; + }); + const observed = new Promise((resolve) => { + started = resolve; + }); + lookup.stat.mockImplementation(() => { + started(); + return pendingStat; + }); + const pending = resolveApktoolCommand( + { + PATH: "C:\\Tools", + PATHEXT: ".CMD", + NODEFAULTCURRENTDIRECTORYINEXEPATH: "1", + }, + "win32", + controller.signal, + ); + await observed; + controller.abort(); + await expect(pending).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + }); + rejectLookup(new Error("filesystem completed after cancellation")); + await Promise.resolve(); + expect(lookup.stat).toHaveBeenCalledTimes(1); +}); + +it("bounds the entire pending PATH lookup by the selection deadline", async () => { + vi.useFakeTimers(); + try { + lookup.stat.mockReturnValue(new Promise(() => {})); + const failure = expect( + resolveApktoolCommand( + { + PATH: "C:\\Tools", + PATHEXT: ".CMD", + NODEFAULTCURRENTDIRECTORYINEXEPATH: "1", + }, + "win32", + ), + ).rejects.toMatchObject({ + _tag: "AnalysisTimeoutError", + timeoutMs: 20_000, + }); + await vi.advanceTimersByTimeAsync(20_000); + await failure; + expect(lookup.stat).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } +}); diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index 12532b104..b1861f2ac 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -1,5 +1,12 @@ -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; -import { isAbsolute } from "node:path"; +import { + AnalysisCancelledError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { isAbsolute, win32 } from "node:path"; +import { stat } from "node:fs/promises"; +import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; +import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; +import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; import { execFileOutput, @@ -27,30 +34,119 @@ export interface ApktoolCommandSelection { } /** - * Resolve the caller-supplied apktool launcher. `REA_APKTOOL_COMMAND` must - * be absolute so the selection stays explicit; without it, apktool is - * resolved from `PATH` at run time. Both the upstream wrapper script and - * `java -jar` launcher scripts work: REA only runs ` --version` - * and ` d ...`. + * Preserve an explicit launcher, or resolve the default Windows command before + * choosing native versus batch process creation. POSIX keeps execFile's PATH + * lookup. The resolved selection is reused for the version and decode runs. */ -export const resolveApktoolCommand = ( +export const resolveApktoolCommand = async ( environment: Readonly>, -): ApktoolCommandSelection => { - const configured = environment[APKTOOL_COMMAND_ENV]; - if (configured === undefined || configured.trim() === "") + platform: NodeJS.Platform = process.platform, + signal?: AbortSignal, +): Promise => { + if (signal?.aborted === true) + throw new AnalysisCancelledError("inspect_apktool_client"); + const selectedEnvironment = snapshotEnvironment(environment, platform); + const configured = selectedEnvironment[APKTOOL_COMMAND_ENV]; + if (configured !== undefined && configured.trim() !== "") { + if ( + !(platform === "win32" + ? win32.isAbsolute(configured) + : isAbsolute(configured)) + ) + throw new ApktoolConfigurationFailure( + "not_absolute", + `${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`, + { configured_command: configured }, + ); + return { command: configured, commandSource: "environment" }; + } + if (platform !== "win32") return { command: "apktool", commandSource: "path" }; - if (!isAbsolute(configured)) - throw new ApktoolConfigurationFailure( - "not_absolute", - `${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`, - { configured_command: configured }, - ); - return { command: configured, commandSource: "environment" }; + const extensions = (selectedEnvironment.PATHEXT ?? ".EXE;.COM;.BAT;.CMD") + .split(";") + .filter((extension) => extension !== ""); + for (const extension of extensions) + if (!extension.startsWith(".") || /[\\/:\0]/u.test(extension)) + throw new ApktoolConfigurationFailure( + "command_unresolved", + "Windows PATHEXT must contain filename extensions, not paths", + { + selected_command: "apktool", + command_source: "path", + invalid_extension: extension, + }, + ); + const directories = (selectedEnvironment.PATH ?? "") + .split(";") + .filter((directory) => directory !== ""); + if (selectedEnvironment.NODEFAULTCURRENTDIRECTORYINEXEPATH === undefined) + directories.unshift(process.cwd()); + // A single deadline owns the whole PATH search, including filesystem waits. + const deadline = new ProviderStartupDeadline(VERSION_TIMEOUT_MS, signal); + const lookupInterruption = () => + deadline.interruption === "cancelled" + ? new AnalysisCancelledError("inspect_apktool_client") + : new AnalysisTimeoutError("inspect_apktool_client", VERSION_TIMEOUT_MS, { + diagnostics: { phase: "windows_path_lookup" }, + }); + try { + for (const entry of directories) { + const directory = + entry.startsWith('"') && entry.endsWith('"') + ? entry.slice(1, -1) + : entry; + for (const extension of extensions) { + if (deadline.interruption !== undefined) throw lookupInterruption(); + const candidate = win32.resolve(directory, `apktool${extension}`); + try { + const metadata = await waitForAbortable( + stat(candidate), + deadline.signal, + ); + if (metadata === ABORTED || deadline.interruption !== undefined) + throw lookupInterruption(); + if (metadata.isFile()) + return { command: candidate, commandSource: "path" }; + } catch (cause: unknown) { + if (deadline.interruption !== undefined) throw lookupInterruption(); + const code = + typeof cause === "object" && cause !== null + ? Reflect.get(cause, "code") + : undefined; + if (code === "ENOENT" || code === "ENOTDIR") continue; + throw new ApktoolConfigurationFailure( + "command_unresolved", + `The PATH-selected apktool launcher could not be inspected: ${candidate}`, + { + selected_command: "apktool", + command_source: "path", + candidate_path: candidate, + ...(typeof code === "string" ? { filesystem_error: code } : {}), + }, + { cause }, + ); + } + } + } + } finally { + deadline.dispose(); + } + + throw new ApktoolConfigurationFailure( + "command_missing", + "The apktool launcher was not found on the selected Windows PATH/PATHEXT", + { + selected_command: "apktool", + command_source: "path", + remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT`, + }, + ); }; export type ApktoolConfigurationFailureCode = | "not_absolute" | "command_missing" + | "command_unresolved" | "launcher_unsupported" | "version_unresolved"; @@ -121,7 +217,7 @@ export const inspectApktoolClient = async ( environment: Readonly> = process.env, platform: NodeJS.Platform = process.platform, ): Promise => { - const hostEnvironment = { ...process.env, ...environment }; + const hostEnvironment = snapshotEnvironment(environment, platform); let invocation: ApktoolInvocation; try { invocation = apktoolInvocation( @@ -195,7 +291,9 @@ const rejectionCodeOf = ( ): ProviderRejectionCode => code === "command_missing" ? "executable_missing" - : code === "not_absolute" || code === "launcher_unsupported" + : code === "not_absolute" || + code === "launcher_unsupported" || + code === "command_unresolved" ? "not_configured" : "version_unresolved"; diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 1e1f187c0..c58ea1f49 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -51,6 +51,7 @@ import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js"; import { + APKTOOL_COMMAND_ENV, ApktoolConfigurationFailure, apktoolConfigurationAvailability, apktoolInvocation, @@ -155,10 +156,9 @@ const runApktool = async ( ): Promise> => { if (signal?.aborted === true) return err(new AnalysisCancelledError(operation)); - const hostEnvironment = { ...process.env, ...environment }; let invocation: ReturnType; try { - invocation = apktoolInvocation(selection, arguments_, hostEnvironment); + invocation = apktoolInvocation(selection, arguments_, environment); } catch (cause) { if (!(cause instanceof WindowsBatchCommandError)) throw cause; const reason = `The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`; @@ -178,7 +178,7 @@ const runApktool = async ( arguments: invocation.arguments, windowsVerbatimArguments: invocation.windowsVerbatimArguments, runId: randomUUID(), - hostEnvironment, + hostEnvironment: environment, }, { timeoutMs: limits.timeoutMs, @@ -317,7 +317,9 @@ export interface ApktoolProviderOptions { * (`--no-src`) because Java-source recovery belongs to the JADX family. */ export class ApktoolProvider { - private readonly environment: Readonly>; + private readonly selectedEnvironment: + | Readonly> + | undefined; private readonly workspaces = new Set(); private readonly removeWorkspace: (path: string) => Promise; @@ -328,14 +330,25 @@ export class ApktoolProvider { #closePromise: Promise | undefined; constructor(options: ApktoolProviderOptions = {}) { - this.environment = snapshotEnvironment(options.environment ?? process.env); + this.selectedEnvironment = + options.environment === undefined + ? undefined + : snapshotEnvironment(options.environment); this.removeWorkspace = options.removeWorkspace ?? ((path) => rm(path, { recursive: true, force: true })); } - private selection(): ApktoolCommandSelection { - return resolveApktoolCommand(this.environment); + private operationEnvironment(): Readonly { + const ambient = snapshotEnvironment(process.env); + if (this.selectedEnvironment === undefined) return ambient; + // Child inheritance is sampled per operation. An explicit map keeps its + // launcher choice authoritative, including an omitted command selecting PATH. + return snapshotEnvironment({ + ...ambient, + ...this.selectedEnvironment, + [APKTOOL_COMMAND_ENV]: this.selectedEnvironment[APKTOOL_COMMAND_ENV], + }); } /** Probe the selected apktool launcher; decodes nothing. */ @@ -354,9 +367,14 @@ export class ApktoolProvider { async #inspectAvailability( signal: AbortSignal, ): Promise { + const environment = this.operationEnvironment(); try { - const selection = this.selection(); - await inspectApktoolClient(selection, signal, this.environment); + const selection = await resolveApktoolCommand( + environment, + process.platform, + signal, + ); + await inspectApktoolClient(selection, signal, environment); throwIfCancelled("inspect_apktool_client", signal); return { status: "available", @@ -406,17 +424,22 @@ export class ApktoolProvider { ): Promise> { if (signal?.aborted === true) return err(new AnalysisCancelledError(request.operation)); + const environment = this.operationEnvironment(); let selection: ApktoolCommandSelection; let identity: ApktoolClientIdentity; try { - selection = this.selection(); - identity = await inspectApktoolClient( - selection, + selection = await resolveApktoolCommand( + environment, + process.platform, signal, - this.environment, ); + identity = await inspectApktoolClient(selection, signal, environment); } catch (cause) { - if (cause instanceof AnalysisCancelledError) return err(cause); + if ( + cause instanceof AnalysisCancelledError || + cause instanceof AnalysisTimeoutError + ) + return err(cause); if (cause instanceof ApktoolConfigurationFailure) { return err( providerConfigurationRefusal( @@ -449,6 +472,7 @@ export class ApktoolProvider { return this.decodeResources( request.operation, selection, + environment, client, provider, request.input, @@ -500,6 +524,7 @@ export class ApktoolProvider { private async decodeResources( operation: "decode_android_resources", selection: ApktoolCommandSelection, + environment: Readonly, client: { command: string; command_source: string; @@ -628,7 +653,7 @@ export class ApktoolProvider { maxOutputBytes: DECODE_MAX_OUTPUT_BYTES, }, signal, - this.environment, + environment, (owner) => this.#pendingCommandOwners.set(owner, root), ); if (!decoded.ok) { diff --git a/src/application/binary/AnalysisProviderEvaluation.ts b/src/application/binary/AnalysisProviderEvaluation.ts index 1654ab783..27292ed31 100644 --- a/src/application/binary/AnalysisProviderEvaluation.ts +++ b/src/application/binary/AnalysisProviderEvaluation.ts @@ -12,7 +12,7 @@ import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { AnalysisProviderCandidate } from "../AnalysisProvider.js"; import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js"; -import { ABORTED, waitForAbortable } from "./AbortablePromise.js"; +import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js"; /** Profile-resolution truth for one discovered provider candidate. */ export interface AnalysisProviderCandidateEvaluation { diff --git a/src/application/binary/LazyAnalysisProvider.ts b/src/application/binary/LazyAnalysisProvider.ts index a87e77445..095acf5ed 100644 --- a/src/application/binary/LazyAnalysisProvider.ts +++ b/src/application/binary/LazyAnalysisProvider.ts @@ -5,7 +5,7 @@ import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js"; import { err, ok } from "../../domain/result.js"; -import { ABORTED, waitForAbortable } from "./AbortablePromise.js"; +import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js"; import type { AnalysisClient, AnalysisClientContext, diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 45a20ba6f..cff45f78b 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -78,10 +78,7 @@ import { parseNativeAotPe, type NativeAotPeResult, } from "../domain/native/nativeAotPe.js"; -import { - ABORTED, - waitForAbortable, -} from "../application/binary/AbortablePromise.js"; +import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js"; import { GHIDRA_PROVIDER_IDENTITY, diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 70d4fc365..dea9e1d86 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -1,8 +1,5 @@ import { z } from "zod"; -import { - ABORTED, - waitForAbortable, -} from "../application/binary/AbortablePromise.js"; +import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; import { createAnalysisExecution, type AnalysisClient, diff --git a/src/application/binary/AbortablePromise.ts b/src/process/AbortablePromise.ts similarity index 100% rename from src/application/binary/AbortablePromise.ts rename to src/process/AbortablePromise.ts diff --git a/tests/process-global/apktoolEnvironment.test.ts b/tests/process-global/apktoolEnvironment.test.ts new file mode 100644 index 000000000..90f4d483c --- /dev/null +++ b/tests/process-global/apktoolEnvironment.test.ts @@ -0,0 +1,53 @@ +import { afterEach, expect, it, onTestFinished, vi } from "vitest"; + +import { ApktoolProvider } from "../../src/apktool/ApktoolProvider.js"; +import { execFileOutput } from "../../src/process/ExecFileOutput.js"; + +vi.mock("../../src/process/ExecFileOutput.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../src/process/ExecFileOutput.js") + >()), + execFileOutput: vi.fn(async () => ({ stdout: "2.7.0-stub\n", stderr: "" })), +})); +afterEach(() => { + vi.unstubAllEnvs(); + vi.mocked(execFileOutput).mockClear(); +}); + +it.skipIf(process.platform === "win32")( + "keeps an explicit environment's PATH choice and samples ambient inheritance per operation", + async () => { + vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/initial-apktool"); + vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "initial"); + const provider = new ApktoolProvider({ environment: {} }); + onTestFinished(() => provider.close()); + vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/changed-apktool"); + vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "changed"); + await provider.inspectAvailability(); + expect(vi.mocked(execFileOutput).mock.lastCall?.[0]).toBe("apktool"); + expect(vi.mocked(execFileOutput).mock.lastCall?.[2]?.env).toMatchObject({ + REA_APKTOOL_COMMAND: undefined, + REA_APKTOOL_TEST_INHERITED_VALUE: "changed", + }); + vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", undefined); + await provider.inspectAvailability(); + expect( + vi.mocked(execFileOutput).mock.lastCall?.[2]?.env + ?.REA_APKTOOL_TEST_INHERITED_VALUE, + ).toBeUndefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "inherits the current default launcher when no environment was selected", + async () => { + vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/initial-apktool"); + const provider = new ApktoolProvider(); + onTestFinished(() => provider.close()); + vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/later-apktool"); + await provider.inspectAvailability(); + expect(vi.mocked(execFileOutput).mock.lastCall?.[0]).toBe( + "/ambient/later-apktool", + ); + }, +); From 173da755cd6b922f8310a1efa0fb8435be2e4d64 Mon Sep 17 00:00:00 2001 From: Liu Jie Date: Wed, 14 Oct 2026 03:01:41 +1300 Subject: [PATCH 69/92] fix(reconstruction): reject contradictory function comparison status (#2798) * fix(reconstruction): reject contradictory function comparison status * test(reconstruction): cover imported function match precedence --------- Co-authored-by: maxazure <405711+maxazure@users.noreply.github.com> --- src/domain/functionComparison.test.ts | 48 ++++++ src/domain/functionComparisonSchemas.ts | 42 ++++-- .../mcp/functionComparisonMcp.test.ts | 137 +++++++++++++++++- 3 files changed, 212 insertions(+), 15 deletions(-) diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 677fdc0cc..954717bad 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -999,3 +999,51 @@ const expectFunctionDimensionAlgebra = ( }).success, ).toBe(false); }; + +it.each([ + { match: "mismatched", changedDimensions: false, expected: "changed" }, + { match: "ambiguous", changedDimensions: false, expected: "unknown" }, + { match: "ambiguous", changedDimensions: true, expected: "unknown" }, +] as const)( + "validates $match match precedence with changed dimensions=$changedDimensions", + ({ match, changedDimensions, expected }) => { + const result = compareFunctions( + FUNCTION_COMPARISON_EXAMPLE.left, + changedDimensions + ? FUNCTION_COMPARISON_EXAMPLE.right + : FUNCTION_COMPARISON_EXAMPLE.left, + ); + const candidate = { + ...result, + function_match: { + status: match, + method: "explicit", + left_name: "main", + right_name: "other", + }, + }; + expect( + functionComparisonResultSchema.safeParse({ + ...candidate, + status: expected, + }).success, + ).toBe(true); + for (const status of ["unchanged", "changed", "unknown"] as const) { + if (status === expected) continue; + const parsed = functionComparisonResultSchema.safeParse({ + ...candidate, + status, + }); + expect(parsed.success).toBe(false); + if (!parsed.success) + expect(parsed.error.issues).toMatchObject([ + { + path: ["status"], + code: "custom", + message: + "Function comparison status contradicts its dimensions or function match.", + }, + ]); + } + }, +); diff --git a/src/domain/functionComparisonSchemas.ts b/src/domain/functionComparisonSchemas.ts index bafc3def1..844bdc9b9 100644 --- a/src/domain/functionComparisonSchemas.ts +++ b/src/domain/functionComparisonSchemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { overallStatus } from "./functionComparisonResults.js"; import { evidenceSchema } from "./evidence.js"; import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; @@ -79,20 +80,33 @@ const functionMatchSchema = z.union([ ]); /** Deterministic dimension-classified function comparison Evidence payload. */ -export const functionComparisonResultSchema = z.object({ - status: comparisonStatusSchema, - function_match: functionMatchSchema, - left_subject_sha256: digestSchema, - right_subject_sha256: digestSchema, - summary: z.object({ - unchanged: z.number().int().min(0), - changed: z.number().int().min(0), - unknown: z.number().int().min(0), - }), - dimensions: z.array(functionDimensionSchema).length(8), - changes: z.array(functionDimensionSchema), - limitations: z.array(z.string()), -}); +export const functionComparisonResultSchema = z + .object({ + status: comparisonStatusSchema, + function_match: functionMatchSchema, + left_subject_sha256: digestSchema, + right_subject_sha256: digestSchema, + summary: z.object({ + unchanged: z.number().int().min(0), + changed: z.number().int().min(0), + unknown: z.number().int().min(0), + }), + dimensions: z.array(functionDimensionSchema).length(8), + changes: z.array(functionDimensionSchema), + limitations: z.array(z.string()), + }) + .superRefine((result, context) => { + if ( + result.status !== + overallStatus(result.dimensions, result.function_match.status) + ) + context.addIssue({ + code: "custom", + path: ["status"], + message: + "Function comparison status contradicts its dimensions or function match.", + }); + }); export type FunctionComparisonResult = z.infer< typeof functionComparisonResultSchema diff --git a/tests/boundary/mcp/functionComparisonMcp.test.ts b/tests/boundary/mcp/functionComparisonMcp.test.ts index 8ec10f402..97f07dc1e 100644 --- a/tests/boundary/mcp/functionComparisonMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonMcp.test.ts @@ -1,6 +1,14 @@ import { ok as resultOk } from "../../../src/domain/result.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { compareFunctions } from "../../../src/domain/functionComparison.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; @@ -271,3 +279,130 @@ describe("function comparison MCP integration", () => { } }); }); + +it.each([ + { status: "changed", expected: "fail" }, + { status: "unknown", expected: "unknown" }, + { status: "unchanged", expected: "pass" }, +] as const)( + "checks the aggregate status of imported $status function comparisons", + async ({ status, expected }) => { + const left = FUNCTION_COMPARISON_EXAMPLE.left; + const right = + status === "changed" + ? FUNCTION_COMPARISON_EXAMPLE.right + : createEvidence( + { + path: "/fixture/function-right", + sha256: "3".repeat(64), + format: "mach-o", + }, + status === "unknown" + ? { ...left.provider, id: "other-provider" } + : left.provider, + { + operation: left.operation, + parameters: left.parameters, + result: left.normalized_result, + confidence: left.confidence, + authority: left.authority, + relationshipCoverage: { status: "complete" }, + }, + ); + const result = compareFunctions(left, right); + expect(result.status).toBe(status); + const provider = { + id: "rea-function-comparison", + name: "REA function comparison", + version: "1", + }; + const observation = { + operation: "compare_functions", + predicateType: "rea.function-comparison", + parameters: { + left_evidence_id: left.evidence_id, + right_evidence_id: right.evidence_id, + }, + confidence: "derived" as const, + authority: "analyst-inference" as const, + evidenceLinks: [left.evidence_id, right.evidence_id], + }; + const consistent = createEvidence(undefined, provider, { + ...observation, + result: jsonValueSchema.parse(result), + }); + const inconsistent = + status === "unchanged" + ? null + : createEvidence(undefined, provider, { + ...observation, + result: jsonValueSchema.parse({ ...result, status: "unchanged" }), + }); + const directory = await createTestTempDirectory("rea-function-status-"); + const path = join(directory, "bundle.json"); + await writeFile( + path, + JSON.stringify( + createEvidenceBundle([ + left, + right, + consistent, + ...(inconsistent === null ? [] : [inconsistent]), + ]), + ), + ); + const session = createTestBinarySession(() => { + throw new Error("Imported comparison must not launch a provider"); + }); + const harness = await createApplicationMcpHarness({ session }); + onTestFinished(harness.close); + const imported = await harness.client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(imported.isError).not.toBe(true); + const verification = (evidence_id: string) => + harness.client.callTool({ + name: "verify_reconstruction", + arguments: { + specification: { + name: "Function aggregate", + claims: [ + { + kind: "structural-function", + claim_id: "overall", + title: "Function agrees", + comparison_evidence_id: evidence_id, + dimension: "overall", + }, + ], + }, + }, + }); + if (inconsistent !== null) { + const rejected = await verification(inconsistent.evidence_id); + expect(parseMcpToolError(rejected).error).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + reason: "invalid_value", + message: expect.stringContaining( + "Function comparison status contradicts its dimensions or function match", + ), + }, + ], + }, + }); + expect(harness.session.evidenceById(inconsistent.evidence_id)).toEqual( + inconsistent, + ); + } + const verified = await verification(consistent.evidence_id); + expect(verified.isError).not.toBe(true); + expect( + parseEvidence(verified.structuredContent).normalized_result, + ).toMatchObject({ status: expected }); + await harness.client.ping(); + }, +); From ce748be2f30d5086e62106ca7d7d0d7b26b8fd28 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:02:28 -0700 Subject: [PATCH 70/92] fix(process): order interaction differences by their dispatch time (#2739) * fix(process): order interaction differences by their dispatch time first_divergence ranks candidate differences by the earliest recorded time of the differing observations, but eventTime read only `at_ms`. Interaction events record their time as `dispatched_at_ms` (and `scheduled_at_ms`), so a changed interaction always ranked as untimed. An interaction outcome that differed at 6 ms therefore lost to a process sample that differed at 500 ms. Give each comparison dimension its recorded time field. Interactions use the dispatch time, when their outcome was observed; the other dimensions keep `at_ms`. An earlier process difference still wins. Fixes #2645 Co-Authored-By: Claude Opus 5.5 * fix(process): require ordered interaction dispatch times in captures Comparison ranks a changed interaction by its dispatch time, while firstCollectionDivergence returns the first differing interaction by sequence. The capture parser checked only that interaction sequences are contiguous, so an imported capture whose dispatch times decrease could make that first-by-sequence difference later than another differing interaction. Interactions are dispatched in sequence and their times come from the monotonic capture clock, so require non-decreasing dispatch times at parsing, as frames, rendered frames, process samples and filesystem checkpoints already require for their timestamps. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../process/processCapture.comparison.test.ts | 72 +++++++++++++++++++ .../process/processCapture.validation.test.ts | 24 +++++++ .../process/processCaptureValidation.ts | 17 +++-- src/domain/process/processComparison.ts | 34 +++++---- 4 files changed, 128 insertions(+), 19 deletions(-) diff --git a/src/domain/process/processCapture.comparison.test.ts b/src/domain/process/processCapture.comparison.test.ts index 4d238da2d..0bc69335d 100644 --- a/src/domain/process/processCapture.comparison.test.ts +++ b/src/domain/process/processCapture.comparison.test.ts @@ -192,3 +192,75 @@ it("keeps filesystem comparison unknown without coverage and reports observed ch }), ).toMatchObject({ filesystem: "changed", status: "changed" }); }); + +it.each([ + [500, "interaction", 6], + [4, "process", 4], +] as const)( + "orders an interaction difference by its dispatch time (process sample at %i ms)", + (processTime, dimension, at) => { + const capture = (side: "left" | "right") => { + const base = emptyCapture(); + const journal = [ + { collection: "filesystem_checkpoints" as const, index: 0 }, + ...(processTime < 6 + ? [{ collection: "process_samples" as const, index: 0 }] + : []), + { collection: "interaction_events" as const, index: 0 }, + ...(processTime >= 6 + ? [{ collection: "process_samples" as const, index: 0 }] + : []), + { collection: "lifecycle" as const, index: 0 }, + { collection: "lifecycle" as const, index: 1 }, + { collection: "filesystem_checkpoints" as const, index: 1 }, + ].map((entry, capture_order) => ({ ...entry, capture_order })); + return { + ...base, + manifest: { + ...base.manifest, + completed_at: "2026-01-01T00:00:01.000Z", + }, + settlement: { ...base.settlement, elapsed_ms: 1000 }, + filesystem_checkpoints: base.filesystem_checkpoints.map( + (checkpoint, index) => ({ + ...checkpoint, + at_ms: index === 0 ? 0 : 1000, + }), + ), + interaction_events: [ + { + sequence: 0, + scheduled_at_ms: 5, + dispatched_at_ms: 6, + type: "input" as const, + data: "hello", + outcome: + side === "left" ? ("dispatched" as const) : ("failed" as const), + }, + ], + process_samples: [ + { + at_ms: processTime, + pid: 2, + parent_pid: 1, + command: side === "left" ? "worker-a" : "worker-b", + process_group_id: 1, + session_id: 1, + }, + ], + event_journal: journal, + }; + }; + const comparison = compareFullyObserved(capture("left"), capture("right")); + expect(comparison).toMatchObject({ + interaction: "changed", + process: "changed", + first_divergence: { + status: "found", + dimension, + left_at_ms: at, + right_at_ms: at, + }, + }); + }, +); diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index df0344b7c..db36b2c64 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -839,3 +839,27 @@ it("requires compatible contracts and enforces capture age through a clock seam" }), ); }); + +it("requires interaction dispatch times to follow their sequence", () => { + const capture = emptyCapture(); + const interaction = (sequence: number, dispatched: number) => ({ + sequence, + scheduled_at_ms: 0, + dispatched_at_ms: dispatched, + type: "input" as const, + data: "x", + outcome: "dispatched" as const, + }); + const issues = (dispatched: readonly number[]) => + processCaptureIssues({ + ...capture, + interaction_events: dispatched.map((at, index) => interaction(index, at)), + }).filter(({ message }) => message.includes("dispatch timestamps")); + expect(issues([5, 5, 10])).toEqual([]); + expect(issues([10, 5])).toEqual([ + { + path: "interaction_events", + message: expect.stringContaining("dispatch timestamps must be ordered"), + }, + ]); +}); diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index 33c6d5483..537f6f92f 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -17,12 +17,10 @@ type RequireInvariant = ( message: string, ) => void; -const orderedTimestamps = ( - values: readonly { readonly at_ms: number }[], -): boolean => +const orderedTimestamps = (values: readonly number[]): boolean => values.every((value, index) => { const previous = values[index - 1]; - return previous === undefined || value.at_ms >= previous.at_ms; + return previous === undefined || value >= previous; }); const validateCommitments = ( @@ -66,7 +64,14 @@ const validateOrdering = ( ["rendered_frames", capture.rendered_frames], ["process_samples", capture.process_samples], ] as const) - require(orderedTimestamps(values), name, "timestamps must be ordered"); + require(orderedTimestamps( + values.map(({ at_ms }) => at_ms), + ), name, "timestamps must be ordered"); + // Interactions are dispatched in sequence, so their recorded dispatch + // times cannot decrease; comparison ranks a changed interaction by it. + require(orderedTimestamps( + capture.interaction_events.map(({ dispatched_at_ms }) => dispatched_at_ms), + ), "interaction_events", "dispatch timestamps must be ordered"); }; const validateEventJournal = ( @@ -242,7 +247,7 @@ const validateLifecycle = ( require(capture.filesystem_checkpoints.map(({ name }) => name).join(",") === "before,after_settlement", "filesystem_checkpoints", "capture must include initial and final filesystem snapshots"); require(orderedTimestamps( - capture.filesystem_checkpoints, + capture.filesystem_checkpoints.map(({ at_ms }) => at_ms), ), "filesystem_checkpoints", "filesystem snapshot times must be ordered"); require(!capture.filesystem_checkpoints.some(({ truncated }) => truncated) || capture.truncated, "truncated", "filesystem snapshot truncation must propagate to the capture"); diff --git a/src/domain/process/processComparison.ts b/src/domain/process/processComparison.ts index 21c7bd80b..eafcd4fd8 100644 --- a/src/domain/process/processComparison.ts +++ b/src/domain/process/processComparison.ts @@ -165,17 +165,25 @@ type DivergenceDimension = Exclude< { readonly status: "none" | "unknown" } >["dimension"]; -const eventTime = (value: unknown): number | null => { - if ( - typeof value === "object" && - value !== null && - "at_ms" in value && - typeof value.at_ms === "number" && - Number.isSafeInteger(value.at_ms) && - value.at_ms >= 0 - ) - return value.at_ms; - return null; +// Interactions record when their outcome was observed as the dispatch time; +// every other timed observation uses at_ms. +const EVENT_TIME_FIELD: Readonly> = { + terminal: "at_ms", + interaction: "dispatched_at_ms", + exit: "at_ms", + filesystem: "at_ms", + process: "at_ms", +}; + +const eventTime = ( + dimension: DivergenceDimension, + value: unknown, +): number | null => { + if (typeof value !== "object" || value === null) return null; + const time: unknown = Reflect.get(value, EVENT_TIME_FIELD[dimension]); + return typeof time === "number" && Number.isSafeInteger(time) && time >= 0 + ? time + : null; }; const firstCollectionDivergence = ( @@ -195,8 +203,8 @@ const firstCollectionDivergence = ( status: "found", dimension, index, - left_at_ms: eventTime(leftValue), - right_at_ms: eventTime(rightValue), + left_at_ms: eventTime(dimension, leftValue), + right_at_ms: eventTime(dimension, rightValue), left: jsonValueSchema.parse(leftValue), right: jsonValueSchema.parse(rightValue), }; From c45d912c911a46c72017242a5c154c1347e6abd5 Mon Sep 17 00:00:00 2001 From: LcokHcak Date: Tue, 13 Oct 2026 23:02:47 +0900 Subject: [PATCH 71/92] feat(android): expose exact DEX descriptors and direct invoke sites (#2439) * feat(android): expose precise method references and DEX invoke sites * fix(android): bound invoke evidence without losing reference inventory * perf(android): skip invoke admission for class references --------- Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- bridge/android/ReaJadxBridge.java | 116 ++++++- docs/android-analysis.md | 36 ++- scripts/verify/android/analysis.mjs | 46 ++- scripts/verify/android/direct-invokes.mjs | 112 +++++++ scripts/verify/android/metadata.mjs | 25 ++ src/android/JadxAnalysis.ts | 39 ++- src/android/JadxProtocol.ts | 16 + src/android/JadxRelease.ts | 3 +- src/cli/androidCommands.ts | 14 +- src/contracts/android/androidToolContracts.ts | 6 +- src/domain/android/androidAnalysis.ts | 37 ++- tests/boundary/android/JadxAnalysis.test.ts | 304 ++++++++++++++++++ .../boundary/process/jadxIntegration.test.ts | 19 +- .../android/ReaJadxMetadataRegression.java | 106 +++++- .../android/direct-invokes/Caller.smali | 9 + .../android/direct-invokes/Target.smali | 7 + tests/fixtures/android/jadx-mcp.mjs | 13 +- 17 files changed, 870 insertions(+), 38 deletions(-) create mode 100644 scripts/verify/android/direct-invokes.mjs create mode 100644 tests/boundary/android/JadxAnalysis.test.ts create mode 100644 tests/fixtures/android/direct-invokes/Caller.smali create mode 100644 tests/fixtures/android/direct-invokes/Target.smali diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index 620e2454d..e57b24943 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -3,8 +3,14 @@ import com.google.gson.*; import io.modelcontextprotocol.kotlin.sdk.server.*; import io.modelcontextprotocol.kotlin.sdk.types.*; import jadx.api.*; +import jadx.api.plugins.input.data.ICodeReader; +import jadx.api.plugins.input.data.IMethodRef; +import jadx.api.plugins.input.insns.InsnIndexType; +import jadx.api.plugins.input.insns.Opcode; +import jadx.core.dex.info.MethodInfo; import jadx.core.dex.nodes.*; import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.CountDownLatch; import java.util.stream.Collectors; @@ -79,11 +85,17 @@ class ReaJadxBridge { ordered.sort(Comparator.comparing(MethodNode::getAlias)); List methods = ordered.stream().map(method -> { String name = method.getAlias(); + String shortId = method.getMethodInfo().getShortId(); + String rawName = method.getMethodInfo().getName(); + if (!shortId.startsWith(rawName + "(")) + throw new IllegalStateException("JADX method ID has no raw-name prefix: " + shortId); + String descriptor = shortId.substring(rawName.length()); String signature = name + "(" + method.getArgTypes().stream() .map(Object::toString).collect(Collectors.joining(", ")) + "): " + method.getReturnType().toString(); - return new MethodSnapshot(name, method.getMethodInfo().getShortId(), Map.of( + return new MethodSnapshot(name, shortId, Map.of( "name", name, "signature", signature, + "dex_descriptor", descriptor, "is_constructor", method.isConstructor(), "def_pos", 0)); }).toList(); List> fields = node.getFields().stream() @@ -140,6 +152,57 @@ class ReaJadxBridge { "smali", true, List.of("static_initializer_disassembly")); } + // The local transport admits 8 MiB frames. Reserve 1 KiB for the fixed + // JSON-RPC/CallToolResult envelope and generated numeric request id, and + // account for JSON text being escaped again inside TextContent. + private static final int MAX_XREF_TEXT_BYTES = 8 * 1024 * 1024 - 1024; + private static int escapedJsonBytes(Object value) { + return JSON.toJson(JSON.toJson(value)).getBytes(StandardCharsets.UTF_8).length; + } + private static final class InvokeBudget { + long remaining; + InvokeBudget(long remaining) { this.remaining = Math.max(0, remaining); } + boolean admit(Map site) { + // Check a conservative two-layer escape bound before serialization. + long bound = 256; + for (Object value : site.values()) + if (value instanceof String text) bound += 12L * text.length(); + if (bound > remaining) return false; + long bytes = escapedJsonBytes(site) - 2L + 1L; + if (bytes > remaining) return false; + remaining -= bytes; + return true; + } + } + private record DirectInvokes(List> sites, boolean exhaustive) {} + + /** Raw DEX invokes within one JADX using method; offsets are code units from its code start. */ + private static DirectInvokes directInvokes(JavaMethod source, MethodInfo target, InvokeBudget budget) { + MethodNode node = source.getMethodNode(); + ICodeReader code = node.getCodeReader(); + if (code == null) return null; + List> sites = new ArrayList<>(); + boolean[] exhaustive = {true}; + code.visitInstructions(insn -> { + insn.decode(); + if (insn.getIndexType() != InsnIndexType.METHOD_REF) return; + Opcode opcode = insn.getOpcode(); + if (!opcode.name().startsWith("INVOKE_")) return; + IMethodRef ref = insn.getIndexAsMethod(); + ref.load(); + if (!MethodInfo.fromRef(node.root(), ref).getRawFullId().equals(target.getRawFullId())) return; + Map site = Map.of( + "dex_entry", node.getInputFileName(), + "source_raw_method_id", node.getMethodInfo().getRawFullId(), + "dex_code_unit_offset", insn.getOffset(), + "dex_method_index", insn.getIndex(), + "opcode", opcode.name()); + if (budget.admit(site)) sites.add(site); + else exhaustive[0] = false; + }); + return new DirectInvokes(sites, exhaustive[0]); + } + private CallToolResult handle(String name, JsonObject args) { if (name.equals("rea_jvm_status")) return json(Map.of( "engine_reported_version", BuildInfo.VERSION, @@ -173,17 +236,52 @@ class ReaJadxBridge { } List uses; String target = string(args, "class_name"); + MethodSnapshot selectedMethod = null; + int overloadIndex = 0; if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn(); else { List overloads = methods(cls, args); - if (overloads.size() != 1) throw new InputFailure("method_name", "Method references require a unique overload"); - target += "." + overloads.get(0).name(); - uses = method(cls, overloads.get(0)).getUseIn(); + if (overloads.size() != 1 && !args.containsKey("overload_index")) + throw new InputFailure("overload_index", "Select one of " + overloads.size() + " method overloads"); + overloadIndex = integer(args, "overload_index", 0); + if (overloadIndex >= overloads.size()) + throw new InputFailure("overload_index", "overload_index out of range: " + overloadIndex); + selectedMethod = overloads.get(overloadIndex); + target += "." + selectedMethod.name(); + uses = method(cls, selectedMethod).getUseIn(); } // REA consumes all references without materializing source line information. - List> items = uses.stream().map(node -> current.describeUsage(node, false)).toList(); - return json(Map.of("target", target, "total", uses.size(), "count", items.size(), - "resolve_line", false, "items", items)); + MethodInfo targetInfo = selectedMethod == null ? null : method(cls, selectedMethod).getMethodNode().getMethodInfo(); + List> items = uses.stream().map(node -> { + Map item = new LinkedHashMap<>(current.describeUsage(node, false)); + if (targetInfo != null && node instanceof JavaMethod) { + item.put("direct_invokes", List.of()); + item.put("direct_invokes_exhaustive", false); + } + return item; + }).toList(); + Map result = new LinkedHashMap<>(Map.of("target", target, + "total", uses.size(), "count", items.size(), "resolve_line", false, "items", items)); + if (selectedMethod != null) { + result.put("overload_index", overloadIndex); + result.put("reported_signature", selectedMethod.summary().get("signature")); + } + if (targetInfo != null) { + InvokeBudget budget = new InvokeBudget(MAX_XREF_TEXT_BYTES - escapedJsonBytes(result)); + for (int index = 0; index < uses.size(); index++) { + if (!(uses.get(index) instanceof JavaMethod source)) continue; + DirectInvokes invokes = directInvokes(source, targetInfo, budget); + Map item = items.get(index); + if (invokes == null) { + item.remove("direct_invokes"); + item.remove("direct_invokes_exhaustive"); + } else { + item.put("direct_invokes", invokes.sites()); + item.put("direct_invokes_exhaustive", invokes.exhaustive()); + } + } + } + return json(result); } public static void main(String[] args) throws Exception { @@ -217,7 +315,7 @@ class ReaJadxBridge { List fields = switch (name) { case "list_classes" -> List.of("offset", "limit"); case "get_method_body" -> List.of("class_name", "method_name", "overload_index"); - case "get_xrefs_to_method" -> List.of("class_name", "method_name"); + case "get_xrefs_to_method" -> List.of("class_name", "method_name", "overload_index"); default -> List.of("class_name"); }; Map properties = new LinkedHashMap<>(); @@ -226,7 +324,7 @@ class ReaJadxBridge { case "list_classes" -> "Indexed pages of all top-level class names, with offset and limit."; case "get_class_summary" -> "Parsed member metadata for one exact class name; no source generation."; case "get_method_body" -> "Source for an exact class and stable same-name overload index; static initializers use smali to preserve folded field initialization."; - default -> "Complete static references to an exact class or unique method; source lines are unresolved."; + default -> "Complete static using-node inventory for an exact class or selected method overload; one node may contain multiple invoke instructions, and source lines are unresolved."; }; Tool narrowed = new Tool(name, new ToolSchema(null, new JsonObject(properties), tool.getInputSchema().getRequired(), null), description, null, null, null, null, null, null); diff --git a/docs/android-analysis.md b/docs/android-analysis.md index d98d9b5da..81c9f9285 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -86,6 +86,7 @@ rea inspect-android-method /path/Example.apk example.MainActivity onCreate rea inspect-android-method /path/Example.apk example.MainActivity select --overload-index 1 rea trace-android-references /path/Example.apk example.MainActivity rea trace-android-references /path/Example.apk example.MainActivity --method-name onCreate +rea trace-android-references /path/Example.apk example.MainActivity --method-name select --overload-index 1 ``` Equivalent MCP methods are `inspect_android_package`, `search_android_classes`, @@ -106,9 +107,10 @@ Every request includes `path`. Class/method operations use `class_name`, ``` Use `inspect_android_class` to choose an overload. REA rejects an ambiguous -method request rather than silently selecting the first match. The engine's -method reference operation cannot select overloads, so REA reports that boundary -as unsupported when multiple same-name methods exist. +method request rather than silently selecting the first match. Method reference +queries accept the same `overload_index`; the bridge resolves the selected +method node and the result reports its index and displayed signature. These +indices are tied to the current artifact and engine, not stable across builds. The engine's smali fallback also joins same-name overloads; REA rejects that fallback for an overloaded method instead of attributing all bodies to one @@ -137,14 +139,28 @@ the same source byte limit and explicit truncation reporting. full reported display types, fields and inner class names. Synthetic members that code generation hides can appear in the inventory; it stays consistent after method decompilation within the retained session. - Exact DEX descriptors are unknown. Overload indices are tied to this artifact - and engine, not stable identities across different builds. + Exact DEX prototypes are reported from frozen JADX method metadata as + `dex_descriptor`, for example `(I)V`. Display names can be JADX aliases; + the descriptor alone is not a complete raw DEX method ID. Overload indices + are tied to this artifact and engine, not stable identities across builds. - Method inspection reports Java or smali, fallback markers and complete/partial returned text. Decompiled source is a derived representation. A complete text result does not establish complete semantics or a byte-identical reconstruction. - Reference results are incoming static relationships between provider nodes. - Source lines and DEX instruction offsets remain unknown. They are not observed - runtime calls. + `reference_unit=using_node` means `total` counts containing JADX nodes, not + individual invoke instructions; two calls from one method can appear as one + entry. `coverage=complete` covers this node inventory only. For a method + reference whose using node has raw code, `direct_invokes` lists matching DEX + invoke instructions with their DEX entry, raw source method ID, code-unit + offset, method index and opcode. An empty array means no direct invoke was + matched in that node; `null` means raw code was unavailable or the using node + was not a method. This list does not establish complete call-site coverage + outside JADX's using-node inventory or through reflection and dynamic loading. + A virtual or interface invoke also does not establish its runtime implementation. + Source lines and the containing node's own `dex_offset` remain unknown. + Method-reference results also carry the selected `dex_descriptor`; + class-reference results report `null`. + These relationships are not observed runtime calls. - Original requests, APK SHA-256, actual engine JAR SHA-256 and raw producer responses remain in Evidence. Source revision is populated only for bytes matching the audited release JAR. @@ -284,3 +300,9 @@ refuses to overwrite a different existing file. APK/JAR files live under ignored `_reference/` and are excluded from package contents. The lane exercises actual REA CLI and MCP results with the real engine, separately from synthetic protocol regressions. It needs no Gradle build, emulator, Android SDK or Ghidra. + +Direct invoke sites retain exact DEX code-unit offsets separately from each +containing node's unknown `dex_offset`. `direct_invokes_exhaustive: false` +means instruction rows were bounded by the local bridge frame budget; `null` +means the producer did not establish instruction coverage. Using-node totals +and coverage do not imply complete instruction evidence. diff --git a/scripts/verify/android/analysis.mjs b/scripts/verify/android/analysis.mjs index 43c51d82b..c29a25d3a 100644 --- a/scripts/verify/android/analysis.mjs +++ b/scripts/verify/android/analysis.mjs @@ -173,6 +173,50 @@ try { assert.deepEqual(structured.normalized_result, evidence.normalized_result); console.log(`PASS MCP/CLI parity ${task.name}`); } + const overloadedInput = { + path, + class_name: "androidx.activity.ComponentActivity", + method_name: "setContentView", + overload_index: 0, + }; + const overloadedCli = await execute( + process.execPath, + [ + entrypoint, + "trace-android-references", + path, + overloadedInput.class_name, + "--method-name", + overloadedInput.method_name, + "--overload-index", + "0", + "--format", + "json", + ], + { + cwd: repository, + env: environment, + timeout: 150_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + const overloadedEvidence = parseEvidence(JSON.parse(overloadedCli.stdout)); + assert.equal(overloadedEvidence.normalized_result.overload_index, 0); + assert.equal( + overloadedEvidence.normalized_result.reported_signature, + "setContentView(int): void", + ); + assert.equal(overloadedEvidence.normalized_result.total, 1); + const overloadedMcp = await client.callTool( + { name: "trace_android_references", arguments: overloadedInput }, + { timeout: 150_000 }, + ); + assert.notEqual(overloadedMcp.isError, true, JSON.stringify(overloadedMcp)); + assert.deepEqual( + parseEvidence(overloadedMcp.structuredContent).normalized_result, + overloadedEvidence.normalized_result, + ); + console.log("PASS real overloaded method references through CLI/MCP"); } catch (cause) { process.stderr.write(stderr); throw cause; @@ -205,7 +249,7 @@ assert.equal(method.body_status, "available"); assert.equal(method.source.status, "complete"); assert.ok(method.source.text.includes("getIntent()")); assert.ok(method.source.text.includes("setListAdapter")); -assert.equal(method.method.dex_descriptor, null); +assert.equal(method.method.dex_descriptor, "(Landroid/os/Bundle;)V"); const references = cliResults.get("trace_android_references").normalized_result; assert.ok(references.total > 0); assert.ok( diff --git a/scripts/verify/android/direct-invokes.mjs b/scripts/verify/android/direct-invokes.mjs new file mode 100644 index 000000000..7cb9972db --- /dev/null +++ b/scripts/verify/android/direct-invokes.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { parseEvidence } from "../../../dist/domain/evidence.js"; +import { hashAndroidFile } from "./digest.mjs"; + +/** The public result keeps one using node and exposes its two raw DEX invokes. */ +export const verifyAndroidDirectInvokes = async ({ + apk, + dexEntry, + repository, + entrypoint, + environment, + execute, +}) => { + const digest = await hashAndroidFile(apk); + const check = (value) => { + const evidence = parseEvidence(value); + assert.equal(evidence.subject.digest.sha256, digest); + assert.equal(evidence.operation, "trace_android_references"); + const result = evidence.normalized_result; + assert.equal(result.class_name, "fixture.Target"); + assert.equal(result.method_name, "mark"); + assert.equal(result.dex_descriptor, "()V"); + assert.equal(result.reference_unit, "using_node"); + assert.equal(result.total, 1); + assert.equal(result.references.length, 1); + const source = result.references[0]; + assert.equal(source.reported_full_name, "fixture.Caller.twice"); + assert.deepEqual( + source.direct_invokes.map((site) => site.dex_code_unit_offset), + [0, 3], + ); + for (const site of source.direct_invokes) { + assert.equal(site.dex_entry, dexEntry); + assert.equal(site.source_raw_method_id, "fixture.Caller.twice()V"); + assert.equal(site.opcode, "INVOKE_STATIC"); + } + assert.equal( + source.direct_invokes[0].dex_method_index, + source.direct_invokes[1].dex_method_index, + ); + return evidence; + }; + const cliResponse = await execute( + process.execPath, + [ + entrypoint, + "trace-android-references", + apk, + "fixture.Target", + "--method-name", + "mark", + "--format", + "json", + ], + { + cwd: repository, + env: environment, + timeout: 150_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + const cli = check(JSON.parse(cliResponse.stdout)); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + cwd: repository, + env: environment, + stderr: "pipe", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const client = new Client({ + name: "rea-real-android-direct-invokes", + version: "1", + }); + try { + await client.connect(transport, { timeout: 30_000 }); + const response = await client.callTool( + { + name: "trace_android_references", + arguments: { + path: apk, + class_name: "fixture.Target", + method_name: "mark", + }, + }, + { timeout: 150_000 }, + ); + assert.notEqual(response.isError, true, JSON.stringify(response)); + const mcp = check(response.structuredContent); + assert.deepEqual(mcp.normalized_result, cli.normalized_result); + assert.deepEqual(mcp.parameters, cli.parameters); + assert.deepEqual(mcp.provider, cli.provider); + } catch (cause) { + process.stderr.write(stderr); + throw cause; + } finally { + await client.close(); + } + assert.equal( + await hashAndroidFile(apk), + digest, + "Static inspection changed fixture bytes", + ); + console.log( + `PASS real CLI/MCP direct-invoke offsets from ${dexEntry}, parity and unchanged APK`, + ); +}; diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs index eebcaf812..a44da336c 100644 --- a/scripts/verify/android/metadata.mjs +++ b/scripts/verify/android/metadata.mjs @@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { verifyAndroidInitializers } from "./initializers.mjs"; +import { verifyAndroidDirectInvokes } from "./direct-invokes.mjs"; /** Prove cold metadata stays unmaterialized and overload identities survive code generation. */ export const verifyAndroidMetadata = async ({ @@ -60,6 +61,7 @@ export const verifyAndroidMetadata = async ({ apk, resolve(repository, "tests/fixtures/android/static-initializers"), classes, + resolve(repository, "tests/fixtures/android/direct-invokes"), ], { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, ); @@ -71,12 +73,19 @@ export const verifyAndroidMetadata = async ({ result.stdout, /INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u, ); + assert.match( + result.stdout, + /DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true/u, + ); console.log( "PASS real cold metadata, full generic types and stable overloaded method selection", ); console.log( "PASS real folded and mixed static initializers, absent bodies and bounded smali", ); + console.log( + "PASS real raw DEX direct-invoke offsets and cross-DEX source identity", + ); await verifyAndroidInitializers({ apk: resolve(classes, "initializers.apk"), repository, @@ -84,6 +93,22 @@ export const verifyAndroidMetadata = async ({ environment, execute, }); + await verifyAndroidDirectInvokes({ + apk: resolve(classes, "direct-invokes.apk"), + dexEntry: "classes.dex", + repository, + entrypoint, + environment, + execute, + }); + await verifyAndroidDirectInvokes({ + apk: resolve(classes, "direct-invokes-multidex.apk"), + dexEntry: "classes2.dex", + repository, + entrypoint, + environment, + execute, + }); } finally { await rm(classes, { recursive: true, force: true }); } diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index d8afed20c..502c90a69 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -45,7 +45,7 @@ const methodsOf = (summary: ClassSummary) => { reported_signature: method.signature, overload_index: index, is_constructor: method.is_constructor, - dex_descriptor: null, + dex_descriptor: method.dex_descriptor, }; }); }; @@ -314,11 +314,25 @@ const traceReferences = async ( ): Promise => { const methodName = request.input.method_name; const candidates = matchingMethods(summary, request); - if (candidates.length > 1) - throw new AnalysisCapabilityUnavailableError( - "jadx", - request.operation, - `References require a unique method name; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`, + if (methodName === undefined && request.input.overload_index !== undefined) + inputFailure( + request, + "overload_index", + "Method name is required for an overload index.", + ); + if (candidates.length > 1 && request.input.overload_index === undefined) + inputFailure( + request, + "overload_index", + `Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`, + ); + const index = request.input.overload_index ?? 0; + const selected = methodName === undefined ? undefined : candidates[index]; + if (methodName !== undefined && selected === undefined) + inputFailure( + request, + "overload_index", + `Index ${index} is outside the ${candidates.length} matching overloads.`, ); const expectedTarget = methodName === undefined @@ -329,7 +343,9 @@ const traceReferences = async ( methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method", { class_name: summary.full_name, - ...(methodName === undefined ? {} : { method_name: methodName }), + ...(methodName === undefined + ? {} + : { method_name: methodName, overload_index: index }), limit: 2_147_483_647, resolve_line: false, }, @@ -337,6 +353,9 @@ const traceReferences = async ( ); if ( xrefs.target !== expectedTarget || + (selected !== undefined && + (xrefs.overload_index !== index || + xrefs.reported_signature !== selected.reported_signature)) || xrefs.total !== xrefs.count || xrefs.count !== xrefs.items.length ) @@ -348,6 +367,9 @@ const traceReferences = async ( engine, class_name: summary.full_name, method_name: methodName ?? null, + overload_index: selected?.overload_index ?? null, + reported_signature: selected?.reported_signature ?? null, + dex_descriptor: selected?.dex_descriptor ?? null, total: xrefs.total, references: xrefs.items.map((reference) => ({ kind: reference.kind, @@ -357,7 +379,10 @@ const traceReferences = async ( top_class: reference.top_class, source_line: null, dex_offset: null, + direct_invokes: reference.direct_invokes ?? null, + direct_invokes_exhaustive: reference.direct_invokes_exhaustive ?? null, })), coverage: "complete", + reference_unit: "using_node", }); }; diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index 804b7ffa7..22a457b0f 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { AndroidOperation } from "../domain/android/androidAnalysis.js"; +import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js"; const count = z.number().int().nonnegative(); /** Selector failures authored by REA's bridge, separate from arbitrary engine errors. */ @@ -50,6 +51,7 @@ export const jadxClassSchema = z.object({ z.object({ name: z.string(), signature: z.string(), + dex_descriptor: dexDescriptorSchema, is_constructor: z.boolean(), def_pos: z.number().int(), }), @@ -72,6 +74,8 @@ export const jadxMethodSchema = z.object({ }); export const jadxXrefsSchema = z.object({ target: z.string(), + overload_index: count.optional(), + reported_signature: z.string().optional(), total: count, count, resolve_line: z.literal(false), @@ -83,6 +87,18 @@ export const jadxXrefsSchema = z.object({ containing_class: z.string(), top_class: z.string(), def_pos: z.number().int(), + direct_invokes_exhaustive: z.boolean().optional(), + direct_invokes: z + .array( + z.object({ + dex_entry: z.string().min(1), + source_raw_method_id: z.string().min(1), + dex_code_unit_offset: count, + dex_method_index: count, + opcode: z.string().regex(/^INVOKE_/u), + }), + ) + .optional(), }), ), }); diff --git a/src/android/JadxRelease.ts b/src/android/JadxRelease.ts index b5b6bd058..8c7d16840 100644 --- a/src/android/JadxRelease.ts +++ b/src/android/JadxRelease.ts @@ -25,7 +25,8 @@ export const JADX_PROVIDER_IDENTITY = Object.freeze({ /** Audited engine coverage, kept outside portable Android semantics. */ export const JADX_LIMITATIONS = [ "Static decompiler observations do not establish runtime behavior; the APK is never executed.", - "Method signatures use provider display types; exact DEX descriptors and instruction offsets are unavailable. Overload indices are local to this engine and artifact.", + "Method signatures use provider display types; DEX prototypes come from frozen JADX metadata. Display names may be aliases, and overload indices are local to this engine and artifact.", "Class inventories use parsed metadata before code generation, including synthetic members that may be omitted from decompiled source. Source positions remain unknown.", + "Incoming reference totals count JADX using nodes. Direct invoke entries within a using method report raw DEX code-unit offsets and method indexes, but coverage is limited to those nodes and directly matched invokes; it does not establish every possible dynamic or reflective call site.", "Manifest summary fields are extracted by the upstream engine; decoded XML is retained for verification. APK signatures, split APKs, native libraries and Android runtime capture are not verified by this provider.", ] as const; diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts index 80a71b235..540dd295d 100644 --- a/src/cli/androidCommands.ts +++ b/src/cli/androidCommands.ts @@ -99,13 +99,20 @@ export const registerAndroidCommands = ( }), }); cli.command(CLI_COMMANDS.traceAndroidReferences, { - description: "Trace incoming APK class or unique-method references", + description: + "Trace incoming APK using nodes for a class or selected method", args: z.object({ path, className }), options: z.object({ methodName: z .string() .optional() - .describe("Unique declared method name; omit for class references"), + .describe("Declared method name; omit for class references"), + overloadIndex: z + .number() + .int() + .nonnegative() + .optional() + .describe("Index among same-name methods from inspect-android-class"), }), run: ({ args, options }) => execute(CLI_COMMANDS.traceAndroidReferences, "trace_android_references", { @@ -114,6 +121,9 @@ export const registerAndroidCommands = ( ...(options.methodName === undefined ? {} : { method_name: options.methodName }), + ...(options.overloadIndex === undefined + ? {} + : { overload_index: options.overloadIndex }), }), }); }; diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts index b555fcbea..2710e0faa 100644 --- a/src/contracts/android/androidToolContracts.ts +++ b/src/contracts/android/androidToolContracts.ts @@ -51,7 +51,7 @@ export const ANDROID_TOOL_CONTRACTS = [ name: "inspect_android_class", ...toolContractMetadata("inspect_android_class"), description: - "Inspect one exact Android class's parsed methods, fields and inner classes, including synthetic members. Returns display signatures and explicit unknown DEX descriptors without generating source. Overload indices remain consistent within the artifact and engine session.", + "Inspect one exact Android class's parsed methods, fields and inner classes, including synthetic members. Returns display signatures and exact DEX prototypes without generating source. A prototype is not a complete raw method ID; overload indices remain consistent within the artifact and engine session.", kind: "android-provider", inputSchema: androidInputSchemas.inspect_android_class, outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_class), @@ -66,7 +66,7 @@ export const ANDROID_TOOL_CONTRACTS = [ name: "inspect_android_method", ...toolContractMetadata("inspect_android_method"), description: - "Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports representation, fallback markers and source truncation; never treats display signatures as exact DEX identities.", + "Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports its DEX prototype, representation, fallback markers and source truncation; display signatures may be aliases.", kind: "android-provider", inputSchema: androidInputSchemas.inspect_android_method, outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_method), @@ -85,7 +85,7 @@ export const ANDROID_TOOL_CONTRACTS = [ name: "trace_android_references", ...toolContractMetadata("trace_android_references"), description: - "Trace incoming static references to an exact Android class or unique method. Overloaded method reference queries are explicitly unsupported by the current engine. Source lines and DEX offsets remain unknown; no runtime calls are inferred.", + "Trace incoming static using nodes to an exact Android class or selected method overload. The total counts containing nodes, not individual invoke instructions. Use inspect_android_class to choose an overload_index when names collide. Source lines and the containing node dex_offset remain unknown. direct_invokes carries exact DEX code-unit instruction offsets when available; direct_invokes_exhaustive false marks byte-bounded partial instruction evidence, and null means coverage is unknown. Using-node coverage remains separate; no runtime calls are inferred.", kind: "android-provider", inputSchema: androidInputSchemas.trace_android_references, outputSchema: evidenceResultOf( diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index ca4d6db59..507550537 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -40,7 +40,15 @@ export const androidInputSchemas = { .min(1) .optional() .describe( - "Omit for class references; ambiguous overloaded methods are unsupported", + "Omit for class references; select ambiguous methods with overload_index", + ), + overload_index: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Index among same-name methods from inspect_android_class; requires method_name", ), }), } as const; @@ -90,12 +98,19 @@ const textCoverage = z.strictObject({ text: z.string(), reported_total_bytes: z.number().int().nonnegative().nullable(), }); +/** A DEX prototype: parameter descriptors followed by the return descriptor. */ +export const dexDescriptorSchema = z + .string() + .regex( + /^\((?:\[*(?:[ZBSCIJFD]|L[^;()]+;))*\)(?:V|\[*(?:[ZBSCIJFD]|L[^;()]+;))$/u, + ); + const method = z.strictObject({ name: z.string(), reported_signature: z.string(), overload_index: z.number().int().nonnegative(), is_constructor: z.boolean(), - dex_descriptor: z.null(), + dex_descriptor: dexDescriptorSchema, }); const engine = z.strictObject({ name: z.string().min(1), @@ -159,6 +174,9 @@ export const androidResultSchemas = { ...common, class_name: z.string(), method_name: z.string().nullable(), + overload_index: z.number().int().nonnegative().nullable(), + reported_signature: z.string().nullable(), + dex_descriptor: dexDescriptorSchema.nullable(), total: z.number().int().nonnegative(), references: z.array( z.strictObject({ @@ -169,8 +187,23 @@ export const androidResultSchemas = { top_class: z.string(), source_line: z.null(), dex_offset: z.null(), + /** false means transport-bounded instruction evidence; null/omitted is unknown. */ + direct_invokes_exhaustive: z.boolean().nullable().optional(), + /** Raw direct invoke instructions inside this using node, when available. */ + direct_invokes: z + .array( + z.strictObject({ + dex_entry: z.string().min(1), + source_raw_method_id: z.string().min(1), + dex_code_unit_offset: z.number().int().nonnegative(), + dex_method_index: z.number().int().nonnegative(), + opcode: z.string().regex(/^INVOKE_/u), + }), + ) + .nullable(), }), ), coverage: z.literal("complete"), + reference_unit: z.literal("using_node"), }), } as const; diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts new file mode 100644 index 000000000..36b0ab9d9 --- /dev/null +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -0,0 +1,304 @@ +import { expect, it } from "vitest"; + +import { + analyzeJadxRequest, + type JadxToolPort, +} from "../../../src/android/JadxAnalysis.js"; +import { androidInputSchemas } from "../../../src/domain/android/androidAnalysis.js"; + +const summary = { + full_name: "fixture.Target", + name: "Target", + method_count: 2, + field_count: 0, + inner_class_count: 0, + methods: [ + { + name: "choose", + signature: "choose(String): void", + dex_descriptor: "(Ljava/lang/String;)V", + is_constructor: false, + def_pos: 0, + }, + { + name: "choose", + signature: "choose(int): void", + dex_descriptor: "(I)V", + is_constructor: false, + def_pos: 0, + }, + ], + fields: [], + inner_classes: [], +}; + +const engine = { + name: "jadx", + version: "0.7.1", + artifact_sha256: "a".repeat(64), + source_revision: null, + worker_count: 1, + heap_limit_mib: null, +}; + +it("publishes exact DEX prototypes instead of discarding bridge metadata", async () => { + const tools: JadxToolPort = { + json: () => Promise.resolve(summary), + text: () => Promise.reject(new Error("unexpected text call")), + }; + const result = await analyzeJadxRequest( + tools, + { + operation: "inspect_android_class", + input: androidInputSchemas.inspect_android_class.parse({ + path: "/fixture.apk", + class_name: "fixture.Target", + }), + }, + {} as never, + engine, + ); + expect(result).toMatchObject({ + methods: [ + { name: "choose", dex_descriptor: "(Ljava/lang/String;)V" }, + { name: "choose", dex_descriptor: "(I)V" }, + ], + }); +}); + +it("traces only the selected overloaded method and checks the producer identity", async () => { + const calls: Record[] = []; + const tools: JadxToolPort = { + json: (name, input) => { + calls.push({ name, ...input }); + if (name === "get_class_summary") return Promise.resolve(summary); + return Promise.resolve({ + target: "fixture.Target.choose", + overload_index: 1, + reported_signature: "choose(int): void", + total: 1, + count: 1, + resolve_line: false, + items: [ + { + kind: "method", + name: "caller", + full_name: "fixture.Other.caller", + containing_class: "fixture.Other", + top_class: "fixture.Other", + def_pos: 0, + direct_invokes: [ + { + dex_entry: "classes.dex", + source_raw_method_id: "fixture.Other.caller()V", + dex_code_unit_offset: 0, + dex_method_index: 1, + opcode: "INVOKE_STATIC", + }, + { + dex_entry: "classes.dex", + source_raw_method_id: "fixture.Other.caller()V", + dex_code_unit_offset: 3, + dex_method_index: 1, + opcode: "INVOKE_STATIC", + }, + ], + }, + ], + }); + }, + text: () => Promise.reject(new Error("unexpected text call")), + }; + const request = { + operation: "trace_android_references", + input: androidInputSchemas.trace_android_references.parse({ + path: "/fixture.apk", + class_name: "fixture.Target", + method_name: "choose", + overload_index: 1, + }), + } as const; + const result = await analyzeJadxRequest(tools, request, {} as never, engine); + expect(result).toMatchObject({ + method_name: "choose", + overload_index: 1, + reported_signature: "choose(int): void", + dex_descriptor: "(I)V", + total: 1, + reference_unit: "using_node", + references: [ + { + direct_invokes: [ + { dex_code_unit_offset: 0 }, + { dex_code_unit_offset: 3 }, + ], + }, + ], + }); + expect(calls[1]).toMatchObject({ + name: "get_xrefs_to_method", + overload_index: 1, + }); +}); + +it("rejects an ambiguous method and a producer response for a different overload", async () => { + const xrefs = { + target: "fixture.Target.choose", + overload_index: 0, + reported_signature: "choose(String): void", + total: 0, + count: 0, + resolve_line: false, + items: [], + }; + const tools: JadxToolPort = { + json: (name) => + Promise.resolve(name === "get_class_summary" ? summary : xrefs), + text: () => Promise.reject(new Error("unexpected text call")), + }; + const input = { + path: "/fixture.apk", + class_name: "fixture.Target", + method_name: "choose", + }; + await expect( + analyzeJadxRequest( + tools, + { + operation: "trace_android_references", + input, + }, + {} as never, + engine, + ), + ).rejects.toMatchObject({ + _tag: "AnalysisInputError", + }); + await expect( + analyzeJadxRequest( + tools, + { + operation: "trace_android_references", + input: { ...input, overload_index: 1 }, + }, + {} as never, + engine, + ), + ).rejects.toMatchObject({ + _tag: "AnalysisOutputError", + }); +}); + +const invokeSite = { + dex_entry: "classes.dex", + source_raw_method_id: "fixture.Other.caller()V", + dex_code_unit_offset: 0, + dex_method_index: 1, + opcode: "INVOKE_STATIC", +}; +const traceWithReference = (fields: Record) => + analyzeJadxRequest( + { + json: (name) => + Promise.resolve( + name === "get_class_summary" + ? summary + : { + target: "fixture.Target.choose", + overload_index: 0, + reported_signature: "choose(String): void", + total: 1, + count: 1, + resolve_line: false, + items: [ + { + kind: "method", + name: "caller", + full_name: "fixture.Other.caller", + containing_class: "fixture.Other", + top_class: "fixture.Other", + def_pos: 0, + ...fields, + }, + ], + }, + ), + text: () => Promise.reject(new Error("unexpected text call")), + }, + { + operation: "trace_android_references", + input: { + path: "/fixture.apk", + class_name: "fixture.Target", + method_name: "choose", + overload_index: 0, + }, + }, + {} as never, + engine, + ); + +it.each([ + [{}, null], + [{ direct_invokes: [] }, []], +])( + "preserves unavailable versus observed-empty invoke sites: %j", + async (fields, expected) => { + const result = await traceWithReference(fields as Record); + expect(result).toMatchObject({ + references: [{ direct_invokes: expected }], + }); + }, +); + +it.each([ + { ...invokeSite, dex_code_unit_offset: -1 }, + { + dex_entry: "classes.dex", + source_raw_method_id: "fixture.Other.caller()V", + dex_code_unit_offset: 0, + dex_method_index: 1, + }, +])("rejects malformed direct invoke producer evidence: %j", async (site) => { + await expect( + traceWithReference({ direct_invokes: [site] }), + ).rejects.toThrow(); +}); + +it("preserves bounded instruction coverage separately from using-node coverage", async () => { + const result = await traceWithReference({ + direct_invokes: [invokeSite], + direct_invokes_exhaustive: false, + }); + expect(result).toMatchObject({ + coverage: "complete", + reference_unit: "using_node", + references: [ + { direct_invokes: [invokeSite], direct_invokes_exhaustive: false }, + ], + }); +}); + +it("rejects malformed method descriptors at the Android adapter boundary", async () => { + const invalidSummary = { + ...summary, + methods: summary.methods.map((method) => ({ + ...method, + dex_descriptor: "not-a-dex-descriptor", + })), + }; + await expect( + analyzeJadxRequest( + { + json: () => Promise.resolve(invalidSummary), + text: () => Promise.reject(new Error("unexpected text call")), + }, + { + operation: "inspect_android_class", + input: { path: "/fixture.apk", class_name: "fixture.Target" }, + }, + {} as never, + engine, + ), + ).rejects.toThrow(); +}); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index 86e2c5ab7..173f5b522 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -342,17 +342,30 @@ it("exposes overload candidates and executes only an explicitly selected ambiguo expect(result.method).toMatchObject({ overload_index: 1, reported_signature: "choose(int): void", - dex_descriptor: null, + dex_descriptor: "(I)V", }); expect(result.source.text).toContain("overload 1"); const references = await service.execute("trace_android_references", input); expect(references).toMatchObject({ ok: false, error: { - _tag: "AnalysisCapabilityUnavailableError", - reason: expect.stringContaining("unique method name"), + _tag: "AnalysisInputError", + issues: [expect.objectContaining({ path: ["overload_index"] })], }, }); + const selectedReferences = await service.execute("trace_android_references", { + ...input, + overload_index: 1, + }); + if (!selectedReferences.ok) throw selectedReferences.error; + expect( + androidResultSchemas.trace_android_references.parse( + selectedReferences.value.normalized_result, + ), + ).toMatchObject({ + overload_index: 1, + reported_signature: "choose(int): void", + }); await provider.close(); await verifyCleanup(launches); }); diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index a0cbd9364..7e21dd4fc 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -60,6 +60,78 @@ public class ReaJadxMetadataRegression { return apk; } + private static void verifyDirectInvokes(SessionHolder holder, SessionConfig config, + Constructor constructor, Method handle, Path sources, Path output) throws Exception { + SmaliOptions options = new SmaliOptions(); + options.apiLevel = 28; + options.jobs = 1; + options.outputDexFile = output.resolve("direct-invokes.dex").toString(); + require(Smali.assemble(options, List.of( + sources.resolve("Caller.smali").toString(), sources.resolve("Target.smali").toString())), + "Direct-invoke fixture assembly failed"); + Path apk = output.resolve("direct-invokes.apk"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) { + zip.putNextEntry(new ZipEntry("classes.dex")); + Files.copy(Path.of(options.outputDexFile), zip); + zip.closeEntry(); + } + BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> holder.unload(continuation)); + BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> holder.load(apk.toString(), config, continuation)); + JsonObject refs = call(handle, constructor.newInstance(holder), "get_xrefs_to_method", + methodArguments("fixture.Target", "mark")); + checkDirectInvokes(refs, "classes.dex"); + + SmaliOptions targetOptions = new SmaliOptions(); + targetOptions.apiLevel = 28; + targetOptions.jobs = 1; + targetOptions.outputDexFile = output.resolve("direct-target.dex").toString(); + SmaliOptions callerOptions = new SmaliOptions(); + callerOptions.apiLevel = 28; + callerOptions.jobs = 1; + callerOptions.outputDexFile = output.resolve("direct-caller.dex").toString(); + require(Smali.assemble(targetOptions, List.of(sources.resolve("Target.smali").toString())), + "Target DEX assembly failed"); + require(Smali.assemble(callerOptions, List.of(sources.resolve("Caller.smali").toString())), + "Caller DEX assembly failed"); + Path splitApk = output.resolve("direct-invokes-multidex.apk"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(splitApk))) { + zip.putNextEntry(new ZipEntry("classes.dex")); + Files.copy(Path.of(targetOptions.outputDexFile), zip); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("classes2.dex")); + Files.copy(Path.of(callerOptions.outputDexFile), zip); + zip.closeEntry(); + } + BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> holder.unload(continuation)); + BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> holder.load(splitApk.toString(), config, continuation)); + JsonObject crossDex = call(handle, constructor.newInstance(holder), "get_xrefs_to_method", + methodArguments("fixture.Target", "mark")); + checkDirectInvokes(crossDex, "classes2.dex"); + System.out.println("DIRECT_INVOKE_REGRESSION_PASS nodes=1 offsets=0,3 cross_dex=true"); + } + + private static void checkDirectInvokes(JsonObject refs, String dexEntry) { + require(refs.get("total").getAsInt() == 1, "JADX using-node count changed"); + JsonArray sites = refs.getAsJsonArray("items").get(0).getAsJsonObject() + .getAsJsonArray("direct_invokes"); + require(sites.size() == 2, "Raw DEX invoke count differs from the fixture: " + sites); + for (int i = 0; i < sites.size(); i++) { + JsonObject site = sites.get(i).getAsJsonObject(); + require(site.get("dex_entry").getAsString().equals(dexEntry), "Wrong DEX entry"); + require(site.get("source_raw_method_id").getAsString().equals("fixture.Caller.twice()V"), + "Wrong raw source method"); + require(site.get("dex_code_unit_offset").getAsInt() == i * 3, "Wrong DEX offset"); + require(site.get("opcode").getAsString().equals("INVOKE_STATIC"), "Wrong opcode"); + } + require(sites.get(0).getAsJsonObject().get("dex_method_index").getAsInt() + == sites.get(1).getAsJsonObject().get("dex_method_index").getAsInt(), + "Two invokes did not reference the same DEX method ID"); + } + private static JsonObject methodArguments(String className, String methodName) { JsonObject value = arguments(className); value.addProperty("method_name", methodName); @@ -152,13 +224,20 @@ public class ReaJadxMetadataRegression { JsonObject componentArgs = arguments("androidx.activity.ComponentActivity"); JsonObject componentSummary = call(handle, bridge, "get_class_summary", componentArgs); List signatures = new ArrayList<>(); + List descriptors = new ArrayList<>(); for (JsonElement entry : componentSummary.getAsJsonArray("methods")) { JsonObject method = entry.getAsJsonObject(); - if (method.get("name").getAsString().equals("setContentView")) + if (method.get("name").getAsString().equals("setContentView")) { signatures.add(method.get("signature").getAsString()); + descriptors.add(method.get("dex_descriptor").getAsString()); + } } require(signatures.size() == 3, "Fixture must contain three setContentView overloads"); + require(descriptors.equals(List.of("(I)V", "(Landroid/view/View;)V", + "(Landroid/view/View;Landroid/view/ViewGroup$LayoutParams;)V")), + "Frozen DEX prototypes did not identify the three overloads: " + descriptors); String[] declarations = { "setContentView(int ", "setContentView(View ", "setContentView(View view, ViewGroup.LayoutParams " }; + List referenceCounts = new ArrayList<>(); for (int index = 0; index < signatures.size(); index++) { JsonObject methodArgs = arguments("androidx.activity.ComponentActivity"); methodArgs.addProperty("method_name", "setContentView"); @@ -169,12 +248,35 @@ public class ReaJadxMetadataRegression { "Overload envelope mismatch"); require(first.get("mode").getAsString().equals("java"), "Fixture did not produce Java"); require(first.get("body").getAsString().contains(declarations[index]), "Wrong overload: " + signatures.get(index)); + JsonObject references = call(handle, bridge, "get_xrefs_to_method", methodArgs); + require(references.get("overload_index").getAsInt() == index, + "Reference result did not preserve selected overload index"); + require(references.get("reported_signature").getAsString().equals(signatures.get(index)), + "Reference result did not preserve selected overload signature"); + if (index == 0) { + JsonArray invokes = references.getAsJsonArray("items").get(0).getAsJsonObject() + .getAsJsonArray("direct_invokes"); + require(invokes.size() == 1, "Selected overload must have one raw direct invoke"); + JsonObject invoke = invokes.get(0).getAsJsonObject(); + require(invoke.get("source_raw_method_id").getAsString() + .equals("androidx.activity.ComponentActivity.onCreate(Landroid/os/Bundle;)V"), + "Wrong source method for overloaded target"); + require(invoke.get("dex_code_unit_offset").getAsInt() == 37 + && invoke.get("opcode").getAsString().equals("INVOKE_VIRTUAL"), + "Wrong direct invoke for selected overloaded target"); + } + referenceCounts.add(references.get("count").getAsInt()); } + require(referenceCounts.equals(List.of(1, 0, 0)), + "Incoming references were not isolated to the selected overload: " + referenceCounts); require(componentSummary.equals(call(handle, bridge, "get_class_summary", componentArgs)), "Frozen summary changed after code generation"); require(apiSummary.equals(call(handle, bridge, "get_class_summary", apiArgs)), "Unrelated frozen summary changed"); - System.out.println("METADATA_REGRESSION_PASS classes=" + states.size() + " overloads=" + signatures.size()); + System.out.println("METADATA_REGRESSION_PASS classes=" + states.size() + " overloads=" + signatures.size() + + " reference_counts=" + referenceCounts); verifyInitializers(holder, config, constructor, handle, initializerFixture(args[1], args[2])); + verifyDirectInvokes(holder, config, constructor, handle, + Path.of(args[3]), Path.of(args[2])); } finally { BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation) -> holder.unload(continuation)); diff --git a/tests/fixtures/android/direct-invokes/Caller.smali b/tests/fixtures/android/direct-invokes/Caller.smali new file mode 100644 index 000000000..4a565cca5 --- /dev/null +++ b/tests/fixtures/android/direct-invokes/Caller.smali @@ -0,0 +1,9 @@ +.class public Lfixture/Caller; +.super Ljava/lang/Object; + +.method public static twice()V + .registers 0 + invoke-static {}, Lfixture/Target;->mark()V + invoke-static {}, Lfixture/Target;->mark()V + return-void +.end method diff --git a/tests/fixtures/android/direct-invokes/Target.smali b/tests/fixtures/android/direct-invokes/Target.smali new file mode 100644 index 000000000..463234abf --- /dev/null +++ b/tests/fixtures/android/direct-invokes/Target.smali @@ -0,0 +1,7 @@ +.class public Lfixture/Target; +.super Ljava/lang/Object; + +.method public static mark()V + .registers 0 + return-void +.end method diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index 7319a5501..8b37ae54d 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -32,18 +32,21 @@ const methods = [ { name: "onCreate", signature: "onCreate(Bundle): void", + dex_descriptor: "(Landroid/os/Bundle;)V", is_constructor: false, def_pos: 0, }, { name: "choose", signature: "choose(String): void", + dex_descriptor: "(Ljava/lang/String;)V", is_constructor: false, def_pos: 0, }, { name: "choose", signature: "choose(int): void", + dex_descriptor: "(I)V", is_constructor: false, def_pos: 0, }, @@ -139,10 +142,18 @@ register("get_method_body", ({ method_name, overload_index }) => }), ); for (const name of ["get_xrefs_to_class", "get_xrefs_to_method"]) - register(name, ({ class_name, method_name }) => + register(name, ({ class_name, method_name, overload_index }) => json({ target: method_name === undefined ? class_name : `${class_name}.${method_name}`, + ...(method_name === undefined + ? {} + : { + overload_index, + reported_signature: methods.filter( + (method) => method.name === method_name, + )[overload_index]?.signature, + }), total: mode === "partial-xrefs" ? 2 : 1, count: 1, resolve_line: false, From c938f97fb2a84229a9de17fc20276ce3d009ee3b Mon Sep 17 00:00:00 2001 From: LcokHcak Date: Tue, 13 Oct 2026 23:03:16 +0900 Subject: [PATCH 72/92] fix(android): clean Windows workspaces after owner termination (#2440) * fix(android): clean Windows workspaces after owner termination * fix(android): retain Windows workspace guardian on cleanup failure * fix(android): preserve released guardian pipe state on cleanup retry * fix(android): normalize extended local guardian parent paths --------- Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + docs/android-analysis.md | 16 +- native/windows/README.md | 12 +- package.json | 1 + scripts/ci/scopes.mjs | 3 + scripts/rea-android-workspace-guardian.mjs | 46 ++++ scripts/verify-windows-package.mjs | 2 + scripts/verify/android/analysis.mjs | 5 + .../android/windows-guardian-isolation.mjs | 105 ++++++++ scripts/verify/android/windows-lifecycle.mjs | 44 +++- src/android/JadxProvider.ts | 35 ++- src/android/JadxWorkspaceRoot.ts | 224 ++++++++++++++++++ .../android/workspaceGuardianRetry.test.ts | 60 +++++ 13 files changed, 528 insertions(+), 26 deletions(-) create mode 100644 scripts/rea-android-workspace-guardian.mjs create mode 100644 scripts/verify/android/windows-guardian-isolation.mjs create mode 100644 src/android/JadxWorkspaceRoot.ts create mode 100644 tests/boundary/android/workspaceGuardianRetry.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dfd067f3..11d5dc30b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -611,6 +611,7 @@ jobs: tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts tests/boundary/filesystem/setupUnavailableProvider.test.ts tests/boundary/filesystem/windowsNpxRegistration.test.ts + tests/boundary/android/workspaceGuardianRetry.test.ts tests/acceptance/setup/homeDirectoryFallback.test.ts tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts tests/boundary/filesystem/webScriptManifestPaths.test.ts diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 81c9f9285..50087f464 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -226,9 +226,11 @@ REA never writes to the original APK or launches target code. On Windows, stdin writes preserve stream backpressure and run outside the Node event loop. Cancellation terminates the retained job, including descendants, -before workspace cleanup. An abrupt CLI/server process termination closes the -job and stops Java, but cannot run JavaScript workspace cleanup; temporary -workspace files can remain after that forced exit. +before workspace cleanup. On Windows, a detached REA helper owns the private +workspace's native directory handle. An abrupt CLI/server owner termination +closes the Java job and the helper's input pipe; the helper then removes the +workspace by handle. The real Windows lane checks both effects. If the helper +also dies or the host loses power before it finishes, private files can remain. If Java cannot start the bridge, REA reports the selected executable and retained startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use @@ -291,9 +293,11 @@ The original APK and host Java configuration remain unchanged. The same lane runs on Windows x64 with the bundled native controls and a full JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java -exit after abrupt CLI owner termination. Windows forced termination is checked -separately from the POSIX CLI SIGTERM handler; it does not claim a structured -cancellation result or workspace removal after the owner is forcibly killed. +exit and workspace removal after abrupt CLI owner termination. Windows forced +termination is checked separately from the POSIX CLI SIGTERM handler. A +synthetic two-owner test confirms that one owner's exit does not remove the +other owner's workspace. The lane does not claim a structured cancellation +result or power-loss recovery. The download script verifies fixed SHA-256 values, reuses matching files and refuses to overwrite a different existing file. APK/JAR files live under ignored diff --git a/native/windows/README.md b/native/windows/README.md index 7432f15fd..86103ce9a 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -90,8 +90,10 @@ without this lane reports Windows native controls as unavailable. root and ancestor leases for retry; a partially removed runtime accepts only cleanup, and its registry entry remains until removal succeeds. Normal close, cancellation, startup failure, and timeout release provider resources. Abrupt owner death - kills the owned job; it can leave private runtime files for later operator - cleanup and does not claim transactional deletion after a crash. + kills the owned job. The Android provider additionally uses a detached helper + that owns its private runtime directory and cleans it when the owner pipe + closes. Other providers can leave private runtime files after owner death; + helper death and host power loss are not transactional deletion. Windows failures retain their constraint, requested coordinate, Win32 code, and system message. Unsupported filesystems and reparse paths are distinct @@ -115,8 +117,10 @@ the supported device/UNC namespaces; existing admission checks still apply. The bearer-token descriptor remains under its immutable private lease until `runtime_close` on Windows; POSIX removes the descriptor after the bridge reads -it. Job ownership guarantees process termination on owner death, not deletion -of private files after a crash. SUBST drive aliases and changing DOS-device +it. Job ownership guarantees process termination on owner death. Android's +detached workspace helper provides a separate cleanup owner for that provider, +but simultaneous helper death or power loss can still leave private files. +SUBST drive aliases and changing DOS-device namespaces are outside the verified P0 scope and are not detected as a separate alias policy. Mounted-folder paths that report a reparse tag are rejected by component admission; broader namespace variants remain unverified. Preserve diff --git a/package.json b/package.json index bc3492d2f..70ab60d1e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "bridge/android/ReaJadxBridge.java", "bridge/mitmproxy/capture.py", "scripts/rea.mjs", + "scripts/rea-android-workspace-guardian.mjs", "scripts/electron-active-hook.cjs", "scripts/electron-active-hook-boundaries.cjs", "scripts/hopper-demo-x11.py", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index e8af46d3b..eae2430e5 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -55,6 +55,9 @@ export const SCOPES = { "tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts", "tests/boundary/filesystem/setupUnavailableProvider.test.ts", "tests/boundary/filesystem/windowsNpxRegistration.test.ts", + "tests/boundary/android/workspaceGuardianRetry.test.ts", + "src/android/JadxWorkspaceRoot.ts", + "scripts/rea-android-workspace-guardian.mjs", "tests/acceptance/setup/homeDirectoryFallback.test.ts", "tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts", "src/domain/javascript/javascriptRuntimeStaticCandidates.ts", diff --git a/scripts/rea-android-workspace-guardian.mjs b/scripts/rea-android-workspace-guardian.mjs new file mode 100644 index 000000000..93ba42d2d --- /dev/null +++ b/scripts/rea-android-workspace-guardian.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { setTimeout as delay } from "node:timers/promises"; +import { WindowsPrivateRuntime } from "../dist/windows/WindowsPrivateRuntime.js"; + +if (process.platform !== "win32" || process.argv.length !== 3) + throw new Error( + "Android workspace guardian requires Windows and one parent directory", + ); + +// This process owns the native directory handle. Detached process creation +// keeps it alive when the REA owner is terminated without running JS cleanup. +const runtime = WindowsPrivateRuntime.create(process.argv[2], "rea-android-"); +const ready = await new Promise((resolve) => { + process.stdout.once("error", () => resolve(false)); + process.stdout.write( + JSON.stringify({ + path: runtime.observation.path, + volumeSerial: runtime.observation.volumeSerial, + fileId: runtime.observation.fileId, + privateDacl: runtime.observation.privateDacl, + }) + "\n", + (error) => resolve(error == null), + ); +}); + +// The owner holds the write end until close/death. A failed readiness pipe +// also grants cleanup authority, including owner death during startup. +if (ready) { + for await (const _ of process.stdin) { + // No commands are accepted; only EOF grants cleanup authority. + } +} else { + process.stdin.destroy(); +} + +// A failed native walk retains the directory identity lease for a later retry. +// Keep this process alive so a transient sharing violation cannot orphan it. +for (;;) { + try { + await runtime.close(); + process.exit(0); + } catch { + await delay(1000); + } +} diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs index 67dee06c2..7ac204868 100644 --- a/scripts/verify-windows-package.mjs +++ b/scripts/verify-windows-package.mjs @@ -52,8 +52,10 @@ try { "bridge/ghidra/ReaGhidraBridge.java", "native/windows/build/manifest.json", "native/windows/build/rea-windows-x64.node", + "dist/android/JadxWorkspaceRoot.js", "dist/main.js", "dist/cli.js", + "scripts/rea-android-workspace-guardian.mjs", "scripts/rea.mjs", ]) if (!packagedPaths.has(required)) diff --git a/scripts/verify/android/analysis.mjs b/scripts/verify/android/analysis.mjs index c29a25d3a..30024b557 100644 --- a/scripts/verify/android/analysis.mjs +++ b/scripts/verify/android/analysis.mjs @@ -308,3 +308,8 @@ if (process.env.REA_ANDROID_REPORT !== undefined) JSON.stringify(report, null, 2) + "\n", ); console.log(JSON.stringify(report, null, 2)); +assert.notEqual( + windowsLifecycle?.cli_abrupt_owner_exit_workspace_cleanup, + false, + "Android workspace remained after abrupt owner exit", +); diff --git a/scripts/verify/android/windows-guardian-isolation.mjs b/scripts/verify/android/windows-guardian-isolation.mjs new file mode 100644 index 000000000..1a6308970 --- /dev/null +++ b/scripts/verify/android/windows-guardian-isolation.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { createJadxWorkspaceRoot } from "../../../dist/android/JadxWorkspaceRoot.js"; + +const source = fileURLToPath(import.meta.url); +const marker = "REA synthetic guardian isolation test\n"; +const exists = (path) => + stat(path).then( + () => true, + () => false, + ); + +if (process.argv[1] === source && process.argv[2] === "owner") { + const root = await createJadxWorkspaceRoot(); + await writeFile(join(root.path, "synthetic.txt"), marker); + process.stdout.write(JSON.stringify({ path: root.path }) + "\n"); + await new Promise(() => {}); +} + +const bounded = async (promise, milliseconds, label) => { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(label)), milliseconds); + }), + ]); + } finally { + clearTimeout(timer); + } +}; + +const launch = async () => { + const child = spawn(process.execPath, [source, "owner"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-8192); + }); + const exit = new Promise((resolve, reject) => { + child.once("close", resolve); + child.once("error", reject); + }); + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + try { + const ready = await bounded( + lines[Symbol.asyncIterator]().next(), + 20000, + `Android guardian owner readiness timed out: ${stderr}`, + ); + assert.equal(ready.done, false, stderr); + const { path } = JSON.parse(ready.value); + assert.equal(typeof path, "string"); + assert.equal(await exists(join(path, "synthetic.txt")), true); + return { child, exit, path }; + } catch (cause) { + child.kill(); + await bounded(exit, 15000, "Android guardian owner did not exit"); + throw cause; + } +}; + +const stop = async ({ child, exit, path }) => { + assert.equal(child.kill(), true); + await bounded(exit, 15000, "Android guardian owner did not exit"); + for (let attempt = 0; attempt < 100; attempt++) { + if (!(await exists(path))) return; + await delay(100); + } + throw new Error(`Android guardian retained ${path} after owner death`); +}; + +/** Two independent owners must never delete one another's private workspace. */ +export async function verifyWindowsGuardianIsolation() { + assert.equal(process.platform, "win32"); + const first = await launch(); + let second; + try { + second = await launch(); + assert.notEqual(first.path, second.path); + await stop(first); + assert.equal( + await exists(join(second.path, "synthetic.txt")), + true, + "First owner deleted the second owner's workspace", + ); + await stop(second); + console.log("PASS Windows Android guardian two-owner cleanup isolation"); + } finally { + for (const owner of [first, second]) { + if (owner === undefined) continue; + if (owner.child.exitCode === null && owner.child.signalCode === null) + owner.child.kill(); + await bounded(owner.exit, 15000, "Android guardian owner did not exit"); + } + } +} diff --git a/scripts/verify/android/windows-lifecycle.mjs b/scripts/verify/android/windows-lifecycle.mjs index 5c1a9c02d..d2b281c60 100644 --- a/scripts/verify/android/windows-lifecycle.mjs +++ b/scripts/verify/android/windows-lifecycle.mjs @@ -17,6 +17,7 @@ import { resolveJadxConfigurationInputs, validateJadxConfiguration, } from "../../../dist/android/JadxConfiguration.js"; +import { verifyWindowsGuardianIsolation } from "./windows-guardian-isolation.mjs"; /** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */ export async function verifyWindowsAndroidLifecycle(options) { @@ -24,15 +25,16 @@ export async function verifyWindowsAndroidLifecycle(options) { await verifyJavaSelection(options.environment); const cancellation = await verifyMcp(options, "cancel"); await verifyMcp(options, "disconnect"); - await verifyCliOwnerExit(options); + const abruptWorkspaceRemoved = await verifyCliOwnerExit(options); + await verifyWindowsGuardianIsolation(); return { mcp_cancelled_engine_exited: true, mcp_cancelled_workspace_removed: true, mcp_ping_and_next_call: true, mcp_disconnect_engine_exited: true, cli_abrupt_owner_exit_engine_exited: true, - // A forced Windows process exit cannot execute JavaScript workspace cleanup. - cli_abrupt_owner_exit_workspace_cleanup: false, + cli_abrupt_owner_exit_workspace_cleanup: abruptWorkspaceRemoved, + guardian_two_owner_isolation: true, observed_resources: cancellation, java_home_and_quoted_case_insensitive_path: true, }; @@ -161,6 +163,7 @@ async function verifyCliOwnerExit({ path, repository, }) { + const baseline = new Set(await runtimeRoots()); const child = spawn( process.execPath, [entrypoint, "inspect-android-package", path, "--format", "json"], @@ -185,7 +188,40 @@ async function verifyCliOwnerExit({ await deadline(exited, 15000); await observer.verifyExit(); assert.equal(output, "", "Aborted CLI published successful Evidence"); - console.log("PASS Windows CLI abrupt owner exit: real Java job cleanup"); + const created = (await runtimeRoots()).filter( + (name) => !baseline.has(name), + ); + assert.ok( + created.length <= 1, + "Concurrent Android workspaces make abrupt cleanup observation ambiguous", + ); + if (created.length === 0) { + console.log( + "PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup", + ); + return true; + } + const ownedName = created[0]; + try { + await waitFor( + async () => !(await runtimeRoots()).includes(ownedName), + 5000, + "Android workspace remained after abrupt owner exit", + ); + } catch (cause) { + if ( + !(cause instanceof Error) || + cause.message !== "Android workspace remained after abrupt owner exit" + ) + throw cause; + } + const removed = !(await runtimeRoots()).includes(ownedName); + console.log( + removed + ? "PASS Windows CLI abrupt owner exit: real Java job and workspace cleanup" + : "OBSERVED Windows CLI abrupt owner exit: Java job cleaned, workspace residue remains", + ); + return removed; } finally { if (child.exitCode === null && child.signalCode === null) child.kill(); await deadline(exited, 15000); diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 6fda5a143..3980ce318 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -18,7 +18,10 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; -import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { + createJadxWorkspaceRoot, + type JadxWorkspaceRoot, +} from "./JadxWorkspaceRoot.js"; import { snapshotAndroidEngine, snapshotAndroidTarget, @@ -152,7 +155,7 @@ const hashAndroidSource = async ( interface PendingCleanup { session: JadxSession | undefined; - root: PrivateRuntimeRoot | undefined; + root: JadxWorkspaceRoot | undefined; readonly previousError: AnalysisError | undefined; } @@ -211,7 +214,7 @@ const cleanup = async ( interface RetainedSession { readonly key: string; - readonly root: PrivateRuntimeRoot; + readonly root: JadxWorkspaceRoot; readonly session: JadxSession; readonly snapshot: string; readonly jarHash: string; @@ -325,7 +328,7 @@ export class JadxProvider implements AndroidAnalysisPort { options?.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout]); - let root: PrivateRuntimeRoot | undefined; + let root: JadxWorkspaceRoot | undefined; let session: JadxSession | undefined; try { if (target.format !== "apk") @@ -389,20 +392,24 @@ export class JadxProvider implements AndroidAnalysisPort { request.operation, signal, ); - root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" }); + root = await createJadxWorkspaceRoot(); + const workspaceSignal = + root.failureSignal === undefined + ? signal + : AbortSignal.any([signal, root.failureSignal]); const engine = await snapshotAndroidEngine( configuration.jar, jarHash, root.path, request.operation, - signal, + workspaceSignal, ); const snapshot = await snapshotAndroidTarget( target.path, target.sha256, root.path, request.operation, - signal, + workspaceSignal, ); const bridge = join(root.path, "ReaJadxBridge.java"); await copyFile(BRIDGE_SOURCE, bridge); @@ -410,7 +417,7 @@ export class JadxProvider implements AndroidAnalysisPort { const copiedBridgeHash = await hashAndroidSource( bridge, request.operation, - signal, + workspaceSignal, ); if (copiedBridgeHash !== bridgeHash) throw androidSourceChanged( @@ -418,7 +425,7 @@ export class JadxProvider implements AndroidAnalysisPort { BRIDGE_SOURCE, undefined, ); - signal.throwIfAborted(); + workspaceSignal.throwIfAborted(); session = new JadxSession( { command: configuration.java, @@ -430,7 +437,7 @@ export class JadxProvider implements AndroidAnalysisPort { ], cwd: root.path, hostEnvironment: this.environment, - signal, + signal: workspaceSignal, }, this.launcher, ); @@ -445,6 +452,10 @@ export class JadxProvider implements AndroidAnalysisPort { } root = retained.root; session = retained.session; + const activeSignal = + root.failureSignal === undefined + ? signal + : AbortSignal.any([signal, root.failureSignal]); const { snapshot } = retained; const outcome = ok( await session.execute({ @@ -453,10 +464,10 @@ export class JadxProvider implements AndroidAnalysisPort { snapshot, jarHash: retained.jarHash, bridgeHash: retained.bridgeHash, - signal, + signal: activeSignal, }), ); - signal.throwIfAborted(); + activeSignal.throwIfAborted(); this.#retained = retained; return outcome; } catch (cause) { diff --git a/src/android/JadxWorkspaceRoot.ts b/src/android/JadxWorkspaceRoot.ts new file mode 100644 index 000000000..15b862320 --- /dev/null +++ b/src/android/JadxWorkspaceRoot.ts @@ -0,0 +1,224 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { lstat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { win32 } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { z } from "zod"; + +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; + +/** Match the native owner's ordinary spelling only for extended local drive paths. */ +const localDrivePath = (path: string): string => + path.replace(/^\\\\\?\\(?=[A-Za-z]:\\)/u, ""); + +const guardianSource = fileURLToPath( + new URL("../../scripts/rea-android-workspace-guardian.mjs", import.meta.url), +); +const readySchema = z.strictObject({ + path: z.string(), + volumeSerial: z.string().regex(/^[a-f0-9]{16}$/u), + fileId: z.string().regex(/^[a-f0-9]{32}$/u), + privateDacl: z.literal(true), +}); + +/** The Android provider needs a path and a cleanup owner, regardless of host. */ +export interface JadxWorkspaceRoot { + readonly path: string; + readonly failureSignal?: AbortSignal; + close(): Promise; +} + +type GuardianExit = { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + readonly error?: Error; +}; + +const boundedWait = async ( + promise: Promise, + milliseconds: number, + label: string, +): Promise => { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(label)), milliseconds); + }), + ]); + } finally { + clearTimeout(timer); + } +}; + +/** A detached guardian retains native cleanup ownership without keeping its caller alive. */ +const releaseGuardianReferences = ( + child: ChildProcessWithoutNullStreams, +): void => { + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); +}; + +class WindowsJadxWorkspaceRoot implements JadxWorkspaceRoot { + readonly #failure = new AbortController(); + readonly failureSignal = this.#failure.signal; + readonly #child: ChildProcessWithoutNullStreams; + readonly #exit: Promise; + readonly #diagnostics: () => string; + #closing = false; + #closePromise: Promise | undefined; + + private constructor( + readonly path: string, + child: ChildProcessWithoutNullStreams, + exit: Promise, + diagnostics: () => string, + ) { + this.#child = child; + this.#exit = exit; + this.#diagnostics = diagnostics; + void this.#exit.then((result) => { + if (!this.#closing) + this.#failure.abort( + new Error( + `Android workspace guardian exited before cleanup: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}`, + ), + ); + }); + } + + static async create(): Promise { + const parent = tmpdir(); + const child = spawn(process.execPath, [guardianSource, parent], { + detached: true, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + env: { + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TEMP: parent, + TMP: parent, + }, + }); + let diagnostics = ""; + child.stderr.on("data", (chunk: Buffer) => { + diagnostics = (diagnostics + chunk.toString()).slice(-8192); + }); + const exit = new Promise((resolve) => { + child.once("error", (error: Error) => + resolve({ code: null, signal: null, error }), + ); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + const ready = new Promise((resolve, reject) => { + let frame = ""; + child.stdout.on("data", (chunk: Buffer) => { + frame += chunk.toString(); + if (frame.length > 4096) { + reject( + new Error("Android workspace guardian readiness exceeded 4 KiB"), + ); + return; + } + const end = frame.indexOf("\n"); + if (end !== -1) resolve(frame.slice(0, end)); + }); + child.stdout.once("error", reject); + }); + try { + const line = await boundedWait( + Promise.race([ + ready, + exit.then((result) => { + throw new Error( + `Android workspace guardian exited during startup: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}; ${diagnostics}`, + ); + }), + ]), + 15000, + "Android workspace guardian startup timed out", + ); + const observation = readySchema.parse(JSON.parse(line)); + if ( + win32.dirname(localDrivePath(observation.path)).toLowerCase() !== + localDrivePath(win32.resolve(parent)).toLowerCase() || + !/^rea-android-[a-f0-9]{32}$/u.test(win32.basename(observation.path)) + ) + throw new Error( + "Android workspace guardian returned an unexpected root", + ); + child.stdout.resume(); + return new WindowsJadxWorkspaceRoot( + observation.path, + child, + exit, + () => diagnostics, + ); + } catch (cause) { + if (!child.stdin.destroyed) child.stdin.end(); + try { + await boundedWait( + exit, + 30000, + "Android workspace guardian did not settle after startup failure", + ); + } catch (cleanupFailure) { + throw new AggregateError( + [cause, cleanupFailure], + "Android workspace guardian startup and cleanup failed", + ); + } finally { + releaseGuardianReferences(child); + } + throw cause; + } + } + + close(): Promise { + this.#closing = true; + this.#closePromise ??= this.#finish().catch((cause: unknown) => { + this.#closePromise = undefined; + throw cause; + }); + return this.#closePromise; + } + + async #finish(): Promise { + if (!this.#child.stdin.destroyed) this.#child.stdin.end(); + let result: GuardianExit; + try { + result = await boundedWait( + this.#exit, + 120000, + `Android workspace guardian cleanup timed out for ${this.path}`, + ); + } catch (cause) { + releaseGuardianReferences(this.#child); + throw cause; + } + if (result.error !== undefined || result.code !== 0) + throw new Error( + `Android workspace guardian cleanup failed for ${this.path}: ${result.error?.message ?? result.code ?? result.signal ?? "unknown"}; ${this.#diagnostics()}`, + ); + try { + await lstat(this.path); + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return; + throw cause; + } + throw new Error( + `Android workspace guardian exited but retained ${this.path}`, + ); + } +} + +/** POSIX retains its in-process root; Windows keeps a detached native owner. */ +export const createJadxWorkspaceRoot = (): Promise => + process.platform === "win32" + ? WindowsJadxWorkspaceRoot.create() + : PrivateRuntimeRoot.create({ prefix: "rea-android-" }); diff --git a/tests/boundary/android/workspaceGuardianRetry.test.ts b/tests/boundary/android/workspaceGuardianRetry.test.ts new file mode 100644 index 000000000..fda07135c --- /dev/null +++ b/tests/boundary/android/workspaceGuardianRetry.test.ts @@ -0,0 +1,60 @@ +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; + +import { expect, it } from "vitest"; + +it.skipIf(process.platform !== "win32")( + "retains guardian cleanup ownership through more than 100 transient failures", + async () => { + const guardian = await readFile( + new URL( + "../../../scripts/rea-android-workspace-guardian.mjs", + import.meta.url, + ), + "utf8", + ); + const withoutTimer = guardian + .replace(/^#!.*\r?\n/u, "") + .replace( + 'import { setTimeout as delay } from "node:timers/promises";', + "const delay = async () => {};", + ); + const injected = withoutTimer.replace( + 'import { WindowsPrivateRuntime } from "../dist/windows/WindowsPrivateRuntime.js";', + `let attempts = 0; + const WindowsPrivateRuntime = { + create: () => ({ + observation: { path: "synthetic-root", volumeSerial: "0", fileId: "0", privateDacl: true }, + close: async () => { if (++attempts <= 101) throw new Error("transient cleanup failure"); }, + }), + };`, + ); + expect(withoutTimer).not.toBe(guardian); + expect(injected).not.toBe(withoutTimer); + const child = spawn( + process.execPath, + [ + "--input-type=module", + "-e", + 'process.argv.splice(1, 0, "guardian.mjs");\n' + injected, + "synthetic-parent", + ], + { signal: AbortSignal.timeout(3000) }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.stdin.end(); + const code = await new Promise((resolve, reject) => { + child.once("close", resolve); + child.once("error", reject); + }); + expect(code, stderr).toBe(0); + expect(JSON.parse(stdout.trim())).toMatchObject({ path: "synthetic-root" }); + }, +); From 5996d8160f2429965b30070d04b26aba472af1db Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:03:19 -0700 Subject: [PATCH 73/92] fix(native): bound captured output carried by native command failures (#2764) * fix(native): bound captured output carried by native command failures The native command runner retains up to 64 MiB of output because successful analysis needs it complete, and failureCapture copied all of it into the failure. An inspect_plist whose plutil output exceeded the budget therefore returned a 67 MB resource_constraint error whose details.captured_output.stdout repeated the whole retained output. Keep a 64 KiB UTF-8-safe excerpt per stream in failure diagnostics, mark them truncated, and keep the observed stdout/stderr byte totals. Successful captures are unchanged. Co-Authored-By: Claude Opus 5.5 * fix(native): bound codesign failures and cover excerpt cuts translateCodeSignExitFailure wrapped an accepted nonzero codesign capture into a NativeCommandFailure by copying both complete streams, so inspect_signature failures still bypassed the diagnostic bound. Route it through the shared failureCapture builder; accepted output stays complete until the provider decides it is a failure. Add a focused runner test for an ordinary nonzero exit with stdout and stderr just above the excerpt size and a two-byte character straddling the cut, and a provider test for the codesign path. Co-Authored-By: Claude Opus 5.5 * test(native): build excerpt test output inside the child process The output strings embedded in one node -e argument totalled about 133 KB, above Linux's 128 KiB per-argument limit, so spawn failed with E2BIG on the CI runner. Generate the repeated output in the child instead. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/native/CommandRunner.ts | 39 +++++++--- src/native/NativeMacOSProvider.ts | 12 +-- .../providers/native/nativeProvider.test.ts | 75 +++++++++++++++++++ 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/native/CommandRunner.ts b/src/native/CommandRunner.ts index ccd49c570..b5393190c 100644 --- a/src/native/CommandRunner.ts +++ b/src/native/CommandRunner.ts @@ -15,11 +15,18 @@ import type { ProviderProcessSupervisor, SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; +import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; /** Maximum wall-clock duration for one native command. */ export const NATIVE_COMMAND_TIMEOUT_MS = 60_000; /** Retain complete command output up to a bounded aggregate size. */ export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024; +/** + * Failure diagnostics keep only an excerpt of each stream. Successful + * analysis needs complete output, but a failed command's error would + * otherwise repeat up to the whole output budget to the caller. + */ +export const NATIVE_FAILURE_DIAGNOSTIC_BYTES = 64 * 1024; export interface NativeCommandCapture { readonly tool: string; @@ -383,18 +390,30 @@ type ProcessCapture = Omit< | "arguments" >; -const failureCapture = ( +/** Bound one command's output for failure diagnostics, keeping observed byte totals. */ +export const failureCapture = ( capture: ProcessCapture, truncated: boolean, -): NativeCommandFailureCapture => ({ - stdout: capture.stdout, - stderr: capture.stderr, - stdoutBytes: capture.stdoutBytes, - stderrBytes: capture.stderrBytes, - exitCode: capture.exitCode, - signal: capture.signal, - truncated, -}); +): NativeCommandFailureCapture => { + const stdout = boundedUtf8Text( + capture.stdout, + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + ); + const stderr = boundedUtf8Text( + capture.stderr, + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + ); + return { + stdout: stdout.text, + stderr: stderr.text, + // Byte counts stay the observed totals, not the excerpt lengths. + stdoutBytes: capture.stdoutBytes, + stderrBytes: capture.stderrBytes, + exitCode: capture.exitCode, + signal: capture.signal, + truncated: truncated || stdout.truncated || stderr.truncated, + }; +}; const captureProcess = async ( executable: string, diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 333569e47..f4d06ae84 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -62,6 +62,7 @@ import { NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, NativeCommandFailure, XcrunCommandRunner, + failureCapture, type NativeCommandCleanupFailure, type NativeCommandCapture, type NativeCommandRunner, @@ -997,15 +998,8 @@ const translateCodeSignExitFailure = ( "nonzero-exit", capture.exitCode, undefined, - { - stdout: capture.stdout, - stderr: capture.stderr, - stdoutBytes: capture.stdoutBytes, - stderrBytes: capture.stderrBytes, - exitCode: capture.exitCode, - signal: capture.signal, - truncated: false, - }, + // Accepted output stays complete until the provider decides it failed. + failureCapture(capture, false), ), ); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index a1e7d63ce..426b2f9f3 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -12,6 +12,7 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; import { NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, + NATIVE_FAILURE_DIAGNOSTIC_BYTES, NativeCommandFailure, XcrunCommandRunner, type NativeCommandRunner, @@ -381,6 +382,36 @@ describe("native command failure capture and limits", () => { }); }); + it("bounds both failure streams at a UTF-8 boundary while keeping their totals", async () => { + const runner = new XcrunCommandRunner({}, () => + Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })), + ); + // "é" is two bytes and straddles the excerpt boundary, so it is dropped whole. + const stdout = `${"x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}é${"y".repeat(1024)}`; + const stderr = "z".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES + 1024); + // Build the output in the child: one Linux argument is limited to 128 KiB. + const result = await runner.run( + "file", + [ + "-e", + `const stdout = "x".repeat(${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}) + "\u00e9" + "y".repeat(1024); const stderr = "z".repeat(${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES + 1024)}); process.stdout.write(stdout, () => process.stderr.write(stderr, () => process.exit(3)));`, + ], + {}, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.reason).toBe("nonzero-exit"); + expect(result.error.capture).toMatchObject({ + stdout: "x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1), + stderr: "z".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES), + stdoutBytes: Buffer.byteLength(stdout), + stderrBytes: Buffer.byteLength(stderr), + exitCode: 3, + truncated: true, + }); + }); + it("rejects output beyond the complete native-command budget", async () => { const runner = new XcrunCommandRunner({}, () => Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })), @@ -404,6 +435,13 @@ describe("native command failure capture and limits", () => { (result.error.capture?.stdoutBytes ?? 0) + (result.error.capture?.stderrBytes ?? 0), ).toBeLessThanOrEqual(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES); + // The failure carries an excerpt, not the whole retained output budget. + expect(result.error.capture?.stdout.length).toBe( + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + ); + expect(result.error.capture?.stdoutBytes).toBeGreaterThan( + NATIVE_FAILURE_DIAGNOSTIC_BYTES, + ); }); }); @@ -834,6 +872,43 @@ describe("native signature entitlements", () => { }); }); +/** Fail every codesign call nonzero after writing more than one excerpt. */ +class VerboseCodeSignFailureRunner extends FixtureRunner { + override async run(tool: string, arguments_: readonly string[]) { + const result = await super.run(tool, arguments_); + if (!result.ok || tool !== "codesign") return result; + const stderr = "e".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES * 2); + return ok({ + ...result.value, + stderr, + stderrBytes: Buffer.byteLength(stderr), + exitCode: 1, + }); + } +} + +describe("native signature failure diagnostics", () => { + it("bounds accepted nonzero codesign output once it becomes a failure", async () => { + directory = await createTestTempDirectory("rea-signature-failure-"); + const executable = join(directory, "fixture"); + await writeFile(executable, "fixture"); + const client = new NativeMacOSProvider( + {}, + new VerboseCodeSignFailureRunner(), + "darwin", + ).createClient(await nativeMachoTargetForFile(executable)); + + const signature = await client.execute("inspect_signature", {}); + + if (signature.ok) throw new Error("A failed codesign run must fail"); + expect(signature.error.capturedOutput).toMatchObject({ + stderr: "e".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES), + stderr_bytes: NATIVE_FAILURE_DIAGNOSTIC_BYTES * 2, + truncated: true, + }); + }); +}); + describe("native dispatch metadata error results", () => { it("preserves tagged cancellation and integrity failures", async () => { directory = await createTestTempDirectory("rea-dispatch-errors-"); From fd65e278be4d802a06bbe176d997aaac0c4c9884 Mon Sep 17 00:00:00 2001 From: FeeHaard <120994000+FeeHaard@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:04:22 -0300 Subject: [PATCH 74/92] fix(setup): resolve the configuration MSIX Claude Desktop reads (#2426) * fix(setup): resolve the configuration MSIX Claude Desktop reads On Windows, setup, doctor and uninstall always used %APPDATA%\Claude\claude_desktop_config.json. The MSIX build of Claude Desktop (package family Claude_pzs8sxrjxfjjc) virtualizes AppData writes and reads its package-private copy under %LOCALAPPDATA%\Packages\\LocalCache\Roaming\Claude once it exists. Setup from an ordinary process therefore wrote a file the app never reads, and doctor reported that file healthy. Resolve the Claude Desktop location once, in supportedClients(): the private copy when it exists, else the AppData file the package still reads, else a new private copy. An unrecognized Claude_* publisher or unreadable package storage is reported instead of falling back to AppData. Compare one of the package's private files with its AppData path through realpath.native to learn whether the running package started this process. From that redirected view, or when no file can be compared, setup and uninstall refuse to change Claude Desktop's configuration, because the running app rewrites it from memory. From a redirected view they also refuse writes under %APPDATA% and %LOCALAPPDATA% for other clients and skills, which would land in the package's private storage. Setup refuses to register a launcher stored in any MSIX package's private storage, discloses which file the package reads, and tells the user to quit Claude Desktop fully on every platform. Configuration fails with target_changed if the package switched to its private copy after the plan was shown. Uninstall removes REA from both files. Co-Authored-By: Claude Opus 5.5 * docs(setup): record the verified MSIX Claude Desktop registration On the Windows 11 host with the MSIX build 2.31226.1.0, setup from an ordinary terminal with the app quit wrote the package-private copy. After the app restarted, the entry persisted, doctor reported it aligned, and Claude Desktop's MCP log showed REA starting and answering tools/list. Uninstall against the real app remains unverified. Co-Authored-By: Claude Opus 5.5 * style(setup): format MSIX review corrections * fix(setup): carry the admitted legacy path through uninstall removal clientRegistrationRemoval returned a removeLegacy boolean, and removeClient recomputed the legacy path separately, so TypeScript could not prove the path existed when removeLegacy was true and `tsc --noEmit` failed in Uninstall.ts. The decision now returns the admitted legacy path itself; preflight and removal consume that one value. The alias regression matched new-destination children with a "/" separator while building paths with path.join, so it failed on Windows. It now uses path.sep. Co-Authored-By: Claude Opus 5.5 * test(setup): split MSIX Claude Desktop lifecycle cases under the function length limit The uninstall cases pushed the registration lifecycle describe past the 150-line lint limit. Group them separately, with the AppData registration case kept among the registration cases. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/client-configuration.md | 65 +++ docs/installation.md | 4 +- scripts/verify-package-environment.mjs | 1 + scripts/verify-package-update.mjs | 1 + scripts/verify-published-package.mjs | 1 + scripts/verify-windows-package.mjs | 1 + .../ClaudeDesktopConfigLocation.test.ts | 341 +++++++++++++++ .../ClaudeDesktopConfigLocation.ts | 199 +++++++++ src/application/ClientRegistrationStatus.ts | 50 ++- src/application/MsixPackageStorage.ts | 187 ++++++++ src/application/Setup.configuration.test.ts | 118 ++++- src/application/Setup.fixture.ts | 1 + src/application/SetupClientConfiguration.ts | 32 +- src/application/SetupClients.ts | 2 + src/application/SetupHost.ts | 34 +- src/application/SetupPlan.ts | 88 ++-- src/application/SetupTypes.ts | 9 +- src/application/SupportedClients.ts | 153 +++++-- src/application/Uninstall.ts | 190 ++++++-- .../claudeDesktopClientConfiguration.test.ts | 412 ++++++++++++++++++ .../process-global/updateMaintenance.test.ts | 1 + 21 files changed, 1765 insertions(+), 125 deletions(-) create mode 100644 src/application/ClaudeDesktopConfigLocation.test.ts create mode 100644 src/application/ClaudeDesktopConfigLocation.ts create mode 100644 src/application/MsixPackageStorage.ts create mode 100644 tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index e27866f7d..1a85dbdfa 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -33,6 +33,71 @@ schema constraints; REA still validates complete canonical inputs. Full-profile `gpt-5.4` metadata is also verified locally. Live model API acceptance and Windows remain unverified; see [native client verification](testing.md). +## Claude Desktop + +Setup registers `mcpServers.rea` in `claude_desktop_config.json`: under +`~/Library/Application Support/Claude` on macOS, under +`$XDG_CONFIG_HOME/Claude` (default `~/.config/Claude`) on Linux, and on Windows +in the file the installed app reads: + +- Without Claude Desktop's MSIX package storage, setup uses + `%APPDATA%\Claude\claude_desktop_config.json`. +- The MSIX build (package family `Claude_pzs8sxrjxfjjc`, installed under + `C:\Program Files\WindowsApps\Claude__x64__pzs8sxrjxfjjc`) + virtualizes AppData writes. Once it has saved its configuration, it reads + the package-private copy + `%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json` + and ignores the `%APPDATA%` file. Setup, doctor and uninstall use that + private copy when it exists. Before the package has saved one, they use the + `%APPDATA%` file it still reads; with neither file, setup creates the + private copy. +- If `%LOCALAPPDATA%\Packages` holds a `Claude_` package from + another publisher, or cannot be read, REA cannot establish which file Claude + Desktop reads. Setup, doctor and uninstall report that instead of falling + back to `%APPDATA%`. Doctor then reports `claude_desktop` as invalid, and + uninstall stops before removing anything for any client. + +Quit Claude Desktop fully before approving setup; closing its window is not +enough. A running Claude Desktop can rewrite its configuration from memory and +drop the registration. This was observed with the Windows MSIX build. Start +Claude Desktop again after setup. If the package saves its private copy after +setup planned to write the `%APPDATA%` file, setup makes no change and asks you +to rerun it. + +A process that the MSIX app started, such as a terminal in Claude Desktop's +Code tab, sees the package's private files at their `%APPDATA%` paths even +though it reports no package identity. Setup compares one of those files at +both paths. When it finds that redirected view, the app is running, so setup +and uninstall refuse to change Claude Desktop's configuration; rerun them from +a terminal that Claude Desktop did not start. If the package has no private +file to compare, setup and uninstall cannot rule that out and also refuse; open +Claude Desktop once if it has never run, quit it fully, then rerun. Doctor +reads the same file from either kind of process. + +The same process also writes everything under `%APPDATA%` and +`%LOCALAPPDATA%` into the package's private storage, where other apps never +read it. From that redirected view, setup and uninstall refuse to change other +clients' files there (VS Code, Trae, Devin and Hermes) and skill directories +there. `npm install -g` run from such a process installs into the package's +`LocalCache\Roaming\npm`, which other processes cannot see at the `%APPDATA%` +path. Setup refuses to register a launcher stored in any MSIX package's +private storage. Install REA from an ordinary terminal and rerun setup there. + +Uninstall removes REA's entry from both the private copy and the `%APPDATA%` +file, including registrations that earlier releases wrote to `%APPDATA%`. + +REA recognizes only the MSIX package. A non-MSIX Claude Desktop installed +beside it, which would read the `%APPDATA%` file, is not detected. + +The package identity, both file locations, the redirected view, and these +setup and doctor results were observed on Windows 11 with the MSIX build +2.31226.1.0, using read-only `doctor` and `setup --dry-run` runs both inside +and outside the app. On that host, setup run from an ordinary terminal with +the app quit wrote the package-private copy; after the app restarted, the +entry was still present, doctor reported it aligned, and Claude Desktop's MCP +log showed REA starting and answering `tools/list`. Uninstall against the real +app is unverified. + ## Claude Code Claude Code normally defers MCP tool definitions through tool search. It diff --git a/docs/installation.md b/docs/installation.md index 9285883e3..bb0adfbc9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -20,7 +20,9 @@ npx rea-agents setup Choose the agents to configure, review the proposed paths and changes, and approve the plan. npm's package-download approval and REA's setup approval are separate. Setup asks before changing agent configuration or installing Hopper. -Restart the agents named in the completion message. +Restart the agents named in the completion message. Quit Claude Desktop fully +before approving, because a running app can overwrite the registration; on +Windows, see [where Claude Desktop reads its configuration](client-configuration.md#claude-desktop). For the latest published release, use `npx rea-agents@latest setup`. [Package-runner and global-installation options](#package-runners-and-global-installation) diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs index cd84655b2..e57f7784b 100644 --- a/scripts/verify-package-environment.mjs +++ b/scripts/verify-package-environment.mjs @@ -76,6 +76,7 @@ export async function verifyPackageEnvironment({ HOME: home, USERPROFILE: home, APPDATA: join(home, "AppData", "Roaming"), + LOCALAPPDATA: join(home, "AppData", "Local"), CLAUDE_CONFIG_DIR: home, CODEX_HOME: codexDir, COPILOT_HOME: join(home, ".copilot"), diff --git a/scripts/verify-package-update.mjs b/scripts/verify-package-update.mjs index 5998ab77f..8d8582b44 100644 --- a/scripts/verify-package-update.mjs +++ b/scripts/verify-package-update.mjs @@ -98,6 +98,7 @@ export async function verifyPackageUpdate({ HOME: home, USERPROFILE: home, APPDATA: join(home, "AppData", "Roaming"), + LOCALAPPDATA: join(home, "AppData", "Local"), CLAUDE_CONFIG_DIR: home, CODEX_HOME: join(home, ".codex"), COPILOT_HOME: join(home, ".copilot"), diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs index 8c0281d56..8a5eb48d8 100644 --- a/scripts/verify-published-package.mjs +++ b/scripts/verify-published-package.mjs @@ -118,6 +118,7 @@ async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) { HOME: home, USERPROFILE: home, APPDATA: join(home, "AppData", "Roaming"), + LOCALAPPDATA: join(home, "AppData", "Local"), CLAUDE_CONFIG_DIR: home, CODEX_HOME: join(home, ".codex"), COPILOT_HOME: join(home, ".copilot"), diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs index 7ac204868..425b5cbd3 100644 --- a/scripts/verify-windows-package.mjs +++ b/scripts/verify-windows-package.mjs @@ -110,6 +110,7 @@ try { HOME: home, USERPROFILE: home, APPDATA: join(home, "AppData", "Roaming"), + LOCALAPPDATA: join(home, "AppData", "Local"), CLAUDE_CONFIG_DIR: home, CODEX_HOME: join(home, ".codex"), XDG_CONFIG_HOME: join(home, ".config"), diff --git a/src/application/ClaudeDesktopConfigLocation.test.ts b/src/application/ClaudeDesktopConfigLocation.test.ts new file mode 100644 index 000000000..84d8ca8bc --- /dev/null +++ b/src/application/ClaudeDesktopConfigLocation.test.ts @@ -0,0 +1,341 @@ +import { basename, dirname, join, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + CLAUDE_DESKTOP_PACKAGE_FAMILY, + resolveWindowsClaudeDesktopLocation, + type ClaudeDesktopLocationFileSystem, +} from "./ClaudeDesktopConfigLocation.js"; +import { + packagePrivateLauncherBlocker, + packagePrivateStorageFamily, + redirectedWriteBlocker, + type MsixPackageView, +} from "./MsixPackageStorage.js"; + +const home = join("/profile", "a"); +const env = { + APPDATA: join(home, "AppData", "Roaming"), + LOCALAPPDATA: join(home, "AppData", "Local"), +}; +const packages = join(env.LOCALAPPDATA, "Packages"); +const appDataConfig = join(env.APPDATA, "Claude", "claude_desktop_config.json"); +const privateConfig = join( + packages, + CLAUDE_DESKTOP_PACKAGE_FAMILY, + "LocalCache", + "Roaming", + "Claude", + "claude_desktop_config.json", +); + +const failure = (code: string): Error => + Object.assign(new Error(`${code}: injected`), { code }); + +const file = { isFile: () => true }; +const privateDirectory = dirname(privateConfig); +const privatePreferences = join(privateDirectory, "Preferences"); +const appDataDirectory = dirname(appDataConfig); + +/** Package storage holding `privateFiles`; realpath decides the process view. */ +const packagedFileSystem = ( + realpath: ClaudeDesktopLocationFileSystem["realpath"], + privateFiles: readonly string[] = [privateConfig], +): ClaudeDesktopLocationFileSystem => ({ + readdir: (path) => { + if (path === packages) return [CLAUDE_DESKTOP_PACKAGE_FAMILY]; + if (path === privateDirectory) + return privateFiles.map((name) => basename(name)); + throw failure("ENOENT"); + }, + lstat: (path) => { + if (!privateFiles.includes(path)) throw failure("ENOENT"); + return file; + }, + realpath, +}); + +describe("Claude Desktop MSIX process view", () => { + it("recognizes a process that sees the private copy at the AppData path", () => { + const location = resolveWindowsClaudeDesktopLocation( + home, + env, + packagedFileSystem((path) => + path === appDataConfig ? privateConfig : path, + ), + ); + expect(location).toEqual({ + ok: true, + value: expect.objectContaining({ + configPath: privateConfig, + msixPackage: expect.objectContaining({ processView: "redirected" }), + }), + }); + }); + + it("treats an absent AppData file as an unredirected process", () => { + const location = resolveWindowsClaudeDesktopLocation( + home, + env, + packagedFileSystem((path) => { + if (path === appDataConfig) throw failure("ENOENT"); + return path; + }), + ); + expect(location.ok && location.value.msixPackage?.processView).toBe( + "direct", + ); + }); + + it("recognizes redirection through another private file before the configuration exists", () => { + const location = resolveWindowsClaudeDesktopLocation( + home, + env, + packagedFileSystem( + (path) => + path === join(appDataDirectory, "Preferences") + ? privatePreferences + : path, + [privatePreferences], + ), + ); + expect(location.ok && location.value.configPath).toBe(privateConfig); + expect(location.ok && location.value.msixPackage?.processView).toBe( + "redirected", + ); + }); + + it("continues after a listed private file disappears", () => { + const fs = packagedFileSystem( + (path) => + path === join(appDataDirectory, "Preferences") + ? privatePreferences + : path, + [privatePreferences], + ); + const location = resolveWindowsClaudeDesktopLocation(home, env, { + ...fs, + readdir: (path) => + path === privateDirectory + ? ["vanished", "Preferences"] + : fs.readdir(path), + }); + expect(location.ok && location.value.msixPackage?.processView).toBe( + "redirected", + ); + }); + + it("keeps the view unknown when the package has no private files", () => { + const location = resolveWindowsClaudeDesktopLocation( + home, + env, + packagedFileSystem((path) => path, []), + ); + expect(location.ok && location.value.msixPackage?.processView).toBe( + "unknown", + ); + }); + + it("keeps the view unknown when the AppData path cannot be resolved", () => { + const location = resolveWindowsClaudeDesktopLocation( + home, + env, + packagedFileSystem((path) => { + if (path === appDataConfig) throw failure("EACCES"); + return path; + }), + ); + expect(location.ok && location.value.msixPackage?.processView).toBe( + "unknown", + ); + }); +}); + +describe("unresolved Claude Desktop package identity", () => { + it("reports unreadable package storage instead of assuming AppData", () => { + const location = resolveWindowsClaudeDesktopLocation(home, env, { + readdir: () => { + throw failure("EACCES"); + }, + lstat: () => file, + realpath: (path) => path, + }); + expect(location).toEqual({ + ok: false, + error: { + path: packages, + message: expect.stringContaining("could not be inspected"), + }, + }); + }); + + it("reports an uninspectable private copy instead of choosing a file", () => { + const location = resolveWindowsClaudeDesktopLocation(home, env, { + readdir: () => [CLAUDE_DESKTOP_PACKAGE_FAMILY], + lstat: () => { + throw failure("EPERM"); + }, + realpath: (path) => path, + }); + expect(location.ok).toBe(false); + expect(!location.ok && location.error.path).toBe(privateConfig); + }); + + it("matches the package family case-insensitively, as Windows does", () => { + const location = resolveWindowsClaudeDesktopLocation(home, env, { + readdir: () => [CLAUDE_DESKTOP_PACKAGE_FAMILY.toUpperCase()], + lstat: () => { + throw failure("ENOENT"); + }, + realpath: (path) => path, + }); + expect(location.ok && location.value.msixPackage?.packageFamilyName).toBe( + CLAUDE_DESKTOP_PACKAGE_FAMILY.toUpperCase(), + ); + }); +}); + +describe("package-private REA launchers", () => { + const launcher = join( + env.APPDATA, + "npm", + "node_modules", + "rea-agents", + "scripts", + "rea.mjs", + ); + const stored = join( + packages, + CLAUDE_DESKTOP_PACKAGE_FAMILY, + "LocalCache", + "Roaming", + "npm", + "node_modules", + "rea-agents", + "scripts", + "rea.mjs", + ); + + it("names the package that privately stores a path", () => { + expect(packagePrivateStorageFamily(stored, packages)).toBe( + CLAUDE_DESKTOP_PACKAGE_FAMILY, + ); + expect(packagePrivateStorageFamily(launcher, packages)).toBeUndefined(); + expect( + packagePrivateStorageFamily( + join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x"), + packages, + ), + ).toBeUndefined(); + }); + + it("blocks a launcher that only the package's processes can see", () => { + const blocker = packagePrivateLauncherBlocker( + [join("/Program Files", "nodejs", "node.exe"), launcher, "mcp"], + packages, + (path) => (path === launcher ? stored : path), + ); + expect(blocker).toContain(stored); + expect(blocker).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY); + expect(blocker).toContain("npm install -g rea-agents"); + }); + + it("compares the launcher with the canonical package storage path", () => { + const shortProfilePackages = join( + "/profile", + "A~1", + "AppData", + "Local", + "Packages", + ); + const blocker = packagePrivateLauncherBlocker( + [launcher, "mcp"], + shortProfilePackages, + (path) => + path === shortProfilePackages + ? packages + : path === launcher + ? stored + : path, + ); + expect(blocker).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY); + }); + + it("allows ordinary and unresolvable launchers", () => { + expect( + packagePrivateLauncherBlocker( + ["cmd.exe", "/d", "/c", "npx", "-y", "rea-agents@6.4.0", "mcp"], + packages, + () => { + throw new Error("package runners are not resolved"); + }, + ), + ).toBeUndefined(); + expect( + packagePrivateLauncherBlocker([launcher, "mcp"], packages, () => { + throw failure("ENOENT"); + }), + ).toBeUndefined(); + }); +}); + +describe("writes from a redirected AppData view", () => { + const view: MsixPackageView = { + packageFamilyName: CLAUDE_DESKTOP_PACKAGE_FAMILY, + privateConfigPath: privateConfig, + appDataConfigPath: appDataConfig, + virtualizedRoots: [env.APPDATA, env.LOCALAPPDATA], + processView: "redirected", + }; + const vscode = join(env.APPDATA, "Code", "User", "mcp.json"); + const hermes = join(env.LOCALAPPDATA, "hermes", "config.yaml"); + const codex = join(home, ".codex", "config.toml"); + + it("refuses only targets under the package's virtualized roots", () => { + const blocker = redirectedWriteBlocker( + view, + [vscode, codex, hermes], + "setup", + ); + expect(blocker).toContain(`${vscode}, ${hermes}`); + expect(blocker).not.toContain(codex); + expect(blocker).toContain("Rerun setup from a terminal"); + expect(redirectedWriteBlocker(view, [codex], "setup")).toBeUndefined(); + }); + + it("compares aliases through the existing parent of a new destination", () => { + const alias = join(home, "skill-alias"); + const target = join(alias, "skills", "reverse-engineer-anything"); + const blocker = redirectedWriteBlocker( + view, + [target], + "uninstall", + (path) => { + if (path === alias) return join(env.APPDATA, "dsh"); + if (path.startsWith(`${alias}${sep}`)) throw failure("ENOENT"); + return path; + }, + ); + expect(blocker).toContain(target); + expect(blocker).toContain("Rerun uninstall"); + }); + + it("keeps canonicalization failures visible instead of assuming a target is outside AppData", () => { + expect( + redirectedWriteBlocker(view, [codex], "setup", () => { + throw failure("EACCES"); + }), + ).toContain("Cannot establish the MSIX write location"); + }); + + it("allows every target without positive evidence of redirection", () => { + for (const processView of ["direct", "unknown"] as const) + expect( + redirectedWriteBlocker({ ...view, processView }, [vscode], "uninstall"), + ).toBeUndefined(); + expect( + redirectedWriteBlocker(undefined, [vscode], "setup"), + ).toBeUndefined(); + }); +}); diff --git a/src/application/ClaudeDesktopConfigLocation.ts b/src/application/ClaudeDesktopConfigLocation.ts new file mode 100644 index 000000000..899aadbbb --- /dev/null +++ b/src/application/ClaudeDesktopConfigLocation.ts @@ -0,0 +1,199 @@ +import { lstatSync, readdirSync, realpathSync } from "node:fs"; +import { join } from "node:path"; + +import { err, ok, type Result } from "../domain/result.js"; +import { + windowsAppDataRoots, + type MsixPackageView, +} from "./MsixPackageStorage.js"; + +const CONFIG_FILE = "claude_desktop_config.json"; + +/** Private files compared at most when looking for a redirected AppData view. */ +const PROCESS_VIEW_PROBES = 8; + +/** + * Claude Desktop's MSIX package family: package name `Claude` and the + * publisher ID Windows derives from `CN="Anthropic, PBC", O="Anthropic, PBC", + * L=San Francisco, S=California, C=US, …`. + */ +export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc"; + +/** `Claude_` plus a 13-character Crockford base32 MSIX publisher ID. */ +const CLAUDE_PACKAGE_FAMILY = /^Claude_[0-9a-hjkmnp-tv-z]{13}$/iu; + +/** The configuration Claude Desktop reads and the evidence that it is installed. */ +export interface ClaudeDesktopLocation { + readonly configPath: string; + readonly markerPath: string; + readonly msixPackage?: MsixPackageView; +} + +/** Unresolved Claude Desktop identity, reported instead of guessing a file. */ +export interface ClaudeDesktopLocationFailure { + readonly path: string; + readonly message: string; +} + +/** Synchronous probes; each throws for failures other than absence. */ +export interface ClaudeDesktopLocationFileSystem { + readonly readdir: (path: string) => readonly string[]; + readonly lstat: (path: string) => { isFile(): boolean }; + readonly realpath: (path: string) => string; +} + +const systemFileSystem: ClaudeDesktopLocationFileSystem = { + readdir: (path) => readdirSync(path), + lstat: (path) => lstatSync(path), + realpath: (path) => realpathSync.native(path), +}; + +/** + * Resolve the `claude_desktop_config.json` that Windows Claude Desktop reads. + * Without its MSIX package storage, that is the AppData file. With it, the + * package reads its private copy when one exists and the AppData file + * otherwise; a new registration goes to the private copy. A `Claude_*` + * package from another publisher leaves the reader unknown. + */ +export const resolveWindowsClaudeDesktopLocation = ( + home: string, + env: Readonly, + fileSystem: ClaudeDesktopLocationFileSystem = systemFileSystem, +): Result => { + const roots = windowsAppDataRoots(home, env); + const appDataDirectory = join(roots.roaming, "Claude"); + const appDataConfigPath = join(appDataDirectory, CONFIG_FILE); + const packagesDirectory = join(roots.local, "Packages"); + let families: readonly string[]; + try { + families = fileSystem + .readdir(packagesDirectory) + .filter((name) => CLAUDE_PACKAGE_FAMILY.test(name)); + } catch (cause: unknown) { + if (!isMissing(cause)) + return err({ + path: packagesDirectory, + message: `MSIX package storage ${packagesDirectory} could not be inspected, so REA cannot establish whether Claude Desktop reads ${appDataConfigPath} or a package-private copy: ${errorMessage(cause)}. Repair access to that directory, then rerun.`, + }); + families = []; + } + const foreign = families.filter( + (name) => + name.toLowerCase() !== CLAUDE_DESKTOP_PACKAGE_FAMILY.toLowerCase(), + ); + if (foreign.length > 0) + return err({ + path: join(packagesDirectory, foreign[0] ?? ""), + message: `MSIX package storage ${foreign.map((name) => join(packagesDirectory, name)).join(", ")} uses Claude Desktop's package name with an unrecognized publisher (Claude Desktop is ${CLAUDE_DESKTOP_PACKAGE_FAMILY}). REA cannot establish which ${CONFIG_FILE} that package reads and does not fall back to ${appDataConfigPath}. Remove the unrecognized package, or register REA in the configuration it reads.`, + }); + const family = families[0]; + if (family === undefined) + return ok({ configPath: appDataConfigPath, markerPath: appDataDirectory }); + const container = join(packagesDirectory, family); + const privateDirectory = join(container, "LocalCache", "Roaming", "Claude"); + const privateConfigPath = join(privateDirectory, CONFIG_FILE); + const privateCopy = present(fileSystem, privateConfigPath); + if (!privateCopy.ok) return privateCopy; + const appDataCopy = privateCopy.value + ? ok(false) + : present(fileSystem, appDataConfigPath); + if (!appDataCopy.ok) return appDataCopy; + return ok({ + configPath: + privateCopy.value || !appDataCopy.value + ? privateConfigPath + : appDataConfigPath, + markerPath: container, + msixPackage: { + packageFamilyName: family, + privateConfigPath, + appDataConfigPath, + virtualizedRoots: [roots.roaming, roots.local], + processView: processView( + fileSystem, + privateDirectory, + appDataDirectory, + privateCopy.value, + ), + }, + }); +}; + +/** + * A process the package started sees the package's private files at their + * AppData paths; any other process sees the real AppData files or nothing. + * Claude Desktop saves its user data there while it runs, so the + * configuration, or any other private file, can show which view this is. + */ +const processView = ( + fileSystem: ClaudeDesktopLocationFileSystem, + privateDirectory: string, + appDataDirectory: string, + privateConfig: boolean, +): MsixPackageView["processView"] => { + let probes: readonly string[]; + try { + probes = privateConfig + ? [CONFIG_FILE] + : fileSystem + .readdir(privateDirectory) + .filter((name) => { + try { + return fileSystem.lstat(join(privateDirectory, name)).isFile(); + } catch (cause: unknown) { + // A candidate may vanish or become unreadable while listed. + void cause; + return false; + } + }) + .slice(0, PROCESS_VIEW_PROBES); + } catch (cause: unknown) { + // No private file can be listed, so no view can be compared. + void cause; + return "unknown"; + } + let view: MsixPackageView["processView"] = "unknown"; + for (const name of probes) { + let privateTarget: string; + try { + privateTarget = fileSystem.realpath(join(privateDirectory, name)); + } catch (cause: unknown) { + // A private file that vanished cannot be compared; try another. + void cause; + continue; + } + try { + if (fileSystem.realpath(join(appDataDirectory, name)) === privateTarget) + return "redirected"; + view = "direct"; + } catch (cause: unknown) { + if (!isMissing(cause)) return "unknown"; + view = "direct"; + } + } + return view; +}; + +const present = ( + fileSystem: ClaudeDesktopLocationFileSystem, + path: string, +): Result => { + try { + fileSystem.lstat(path); + return ok(true); + } catch (cause: unknown) { + if (isMissing(cause)) return ok(false); + return err({ + path, + message: `${path} could not be inspected, so REA cannot establish which ${CONFIG_FILE} Claude Desktop's MSIX package reads: ${errorMessage(cause)}. Repair access to that path, then rerun.`, + }); + } +}; + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR"); + +const errorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index caeba4fb6..d107c5f32 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -155,7 +155,7 @@ export const readClientRegistrationInspections = async ( } const parsed = effectiveClientConfiguration(files.value); if (parsed === undefined) { - record(unavailableStatus(client.name, client.configPath, "missing")); + record(unavailableStatus(client, "missing")); continue; } const policy = await readClientPolicyBlock( @@ -191,7 +191,7 @@ export const readClientRegistrationInspections = async ( }); continue; } - record(unavailableStatus(client.name, client.configPath, "missing")); + record(unavailableStatus(client, "missing")); continue; } const registration = parseRegistration(raw, client); @@ -202,8 +202,7 @@ export const readClientRegistrationInspections = async ( const activation = registrationActivation(registration, client, parsed); record( configuredStatus( - client.name, - client.configPath, + client, command, activation === "enabled" && registrationAligned( @@ -222,11 +221,7 @@ export const readClientRegistrationInspections = async ( ); } catch (cause: unknown) { record( - unavailableStatus( - client.name, - client.configPath, - isMissing(cause) ? "missing" : "invalid", - ), + unavailableStatus(client, isMissing(cause) ? "missing" : "invalid"), ); } } @@ -352,36 +347,45 @@ const parseRegistration = ( return registration; }; -const remediation = - "Run rea setup to refresh this registration, then restart the client."; +/** A client that rewrites its file from memory must be quit before setup. */ +const remediation = (client: SetupClient): string => { + if (client.quitBeforeSetup !== true) + return "Run rea setup to refresh this registration, then restart the client."; + const name = client.displayName ?? client.name; + return `Quit ${name} fully, run rea setup to refresh this registration, then start ${name} again. A running ${name} can rewrite its configuration from memory and drop the registration.`; +}; const configuredStatus = ( - client: string, - configPath: string, + client: SetupClient, command: RegistrationCommand, state: "aligned" | "stale", policyRemediation?: string, ): ClientRegistrationStatus => state === "aligned" - ? { client, config_path: configPath, command, state, remediation: null } - : { - client, - config_path: configPath, + ? { + client: client.name, + config_path: client.configPath, command, state, - remediation: policyRemediation ?? remediation, + remediation: null, + } + : { + client: client.name, + config_path: client.configPath, + command, + state, + remediation: policyRemediation ?? remediation(client), }; const unavailableStatus = ( - client: string, - configPath: string, + client: SetupClient, state: "missing" | "invalid", ): ClientRegistrationStatus => ({ - client, - config_path: configPath, + client: client.name, + config_path: client.configPath, command: [], state, - remediation, + remediation: remediation(client), }); const clientEvidencePresent = async (client: SetupClient): Promise => { diff --git a/src/application/MsixPackageStorage.ts b/src/application/MsixPackageStorage.ts new file mode 100644 index 000000000..098b2f1db --- /dev/null +++ b/src/application/MsixPackageStorage.ts @@ -0,0 +1,187 @@ +import { lstat } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; +import { err, ok, type Result } from "../domain/result.js"; + +/** + * How one MSIX-packaged client reads its AppData configuration. A package + * with write virtualization saves AppData files to its private + * `LocalCache\Roaming` copy, then reads that copy whenever it exists and the + * ordinary AppData file otherwise. + */ +export interface MsixPackageView { + readonly packageFamilyName: string; + /** The package-private copy, which shadows the AppData file once it exists. */ + readonly privateConfigPath: string; + /** The AppData path that unpackaged processes read and write. */ + readonly appDataConfigPath: string; + /** Roots whose writes the package redirects for processes it starts. */ + readonly virtualizedRoots: readonly string[]; + /** + * `redirected`: this process sees one of the package's private files at its + * AppData path, so the running package started it. `direct`: it does not. + * `unknown`: no private file could be compared. + */ + readonly processView: "redirected" | "direct" | "unknown"; +} + +/** Windows' roaming and local application data roots; empty means unset. */ +export const windowsAppDataRoots = ( + home: string, + env: Readonly, +): { readonly roaming: string; readonly local: string } => ({ + roaming: env.APPDATA?.trim() || join(home, "AppData", "Roaming"), + local: env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"), +}); + +/** The per-user root of MSIX package data containers. */ +export const msixPackagesDirectory = ( + home: string, + env: Readonly, +): string => join(windowsAppDataRoots(home, env).local, "Packages"); + +/** Whether `path` is `root` or inside it, using the host's path comparison. */ +const pathWithin = (path: string, root: string): boolean => { + const inner = relative(root, path); + return ( + inner === "" || + (inner !== ".." && !inner.startsWith(`..${sep}`) && !isAbsolute(inner)) + ); +}; + +/** + * Name the package whose private `LocalCache` holds a canonical path. Its + * processes see those files at virtual AppData locations; other processes do + * not, and Windows removes them with the package. + */ +export const packagePrivateStorageFamily = ( + canonicalPath: string, + packagesDirectory: string, +): string | undefined => { + if (!pathWithin(canonicalPath, packagesDirectory)) return undefined; + const [family, storage] = relative(packagesDirectory, canonicalPath).split( + /[\\/]/u, + ); + return family !== undefined && + family !== "" && + storage?.toLowerCase() === "localcache" + ? family + : undefined; +}; + +/** + * Explain why setup must not register a launcher that exists only in one + * package's private storage. Other clients could not start it, and doctor + * outside that package could not verify it. + */ +export const packagePrivateLauncherBlocker = ( + command: readonly string[], + packagesDirectory: string, + canonicalPath: (path: string) => string = realpathSync.native, +): string | undefined => { + // Compare canonical forms: a short-name, junctioned or substituted profile + // spells the same directory differently. + let packages = packagesDirectory; + try { + packages = canonicalPath(packagesDirectory); + } catch (cause: unknown) { + // Without package storage, the spelled path is the only comparison. + void cause; + } + for (const part of command) { + if (!isAbsolute(part)) continue; + let resolved: string; + try { + resolved = canonicalPath(part); + } catch (cause: unknown) { + // A launcher that cannot be resolved is not evidence of package storage. + void cause; + continue; + } + const family = packagePrivateStorageFamily(resolved, packages); + if (family !== undefined) + return `The REA launcher ${part} is stored at ${resolved}, inside the private storage of MSIX package ${family}. Only processes started by that app can see it at ${part}, and Windows removes it with the package, so other clients could not start this registration. Install REA from a terminal that the app did not start (for example with \`npm install -g rea-agents\`), then rerun setup from that terminal.`; + } + return undefined; +}; + +/** + * Explain why this process must not write `targets`: the running package + * started it, so writes under its virtualized roots land in that package's + * private storage, which the targets' owners never read. + */ +const canonicalDestination = ( + path: string, + canonicalPath: (path: string) => string, +): Result => { + let ancestor = path; + const missing: string[] = []; + while (true) { + try { + return ok(join(canonicalPath(ancestor), ...missing)); + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR") && + dirname(ancestor) !== ancestor + ) { + missing.unshift(basename(ancestor)); + ancestor = dirname(ancestor); + continue; + } + return err({ + path, + detail: cause instanceof Error ? cause.message : String(cause), + }); + } + } +}; + +export const redirectedWriteBlocker = ( + view: MsixPackageView | undefined, + targets: readonly string[], + operation: "setup" | "uninstall", + canonicalPath: (path: string) => string = realpathSync.native, +): string | undefined => { + if (view?.processView !== "redirected" || targets.length === 0) + return undefined; + const roots: string[] = []; + const redirected: string[] = []; + for (const root of view.virtualizedRoots) { + const resolved = canonicalDestination(root, canonicalPath); + if (!resolved.ok) + return `Cannot establish the MSIX write location for ${resolved.error.path}: ${resolved.error.detail}. Rerun ${operation} from a terminal that this app did not start.`; + roots.push(resolved.value); + } + for (const target of targets) { + const resolved = canonicalDestination(target, canonicalPath); + if (!resolved.ok) + return `Cannot establish the MSIX write location for ${resolved.error.path}: ${resolved.error.detail}. Rerun ${operation} from a terminal that this app did not start.`; + if (roots.some((root) => pathWithin(resolved.value, root))) + redirected.push(target); + } + if (redirected.length === 0) return undefined; + return `This process was started by MSIX package ${view.packageFamilyName}, which redirects its writes under ${view.virtualizedRoots.join(" and ")} into the package's private storage. Changes to ${redirected.join(", ")} would not reach the files other apps read. Rerun ${operation} from a terminal that this app did not start.`; +}; + +/** + * Whether the package began reading its private copy after the AppData file + * was chosen, for example by saving its settings when it quit. + */ +export const packageReadTargetChanged = async ( + view: MsixPackageView, + configPath: string, +): Promise => { + if (configPath !== view.appDataConfigPath) return false; + try { + await lstat(view.privateConfigPath); + return true; + } catch (cause: unknown) { + return !( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR") + ); + } +}; diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index 4ba11f622..3bd08cd4f 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -1,9 +1,43 @@ import { describe, expect, it } from "vitest"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { FakeSetupHost, options } from "./Setup.fixture.js"; import { runSetup } from "./Setup.js"; import { setupRegistrationCommand } from "./SetupHost.js"; +import type { MsixPackageView } from "./MsixPackageStorage.js"; +import type { SetupClient } from "./SupportedClients.js"; + +// Host joins keep containment checks meaningful on POSIX test hosts too. +const PROFILE = String.raw`C:\Users\a`; +const ROAMING = join(PROFILE, "AppData", "Roaming"); +const LOCAL = join(PROFILE, "AppData", "Local"); +const PRIVATE_CONFIG = join( + LOCAL, + "Packages", + "Claude_pzs8sxrjxfjjc", + "LocalCache", + "Roaming", + "Claude", + "claude_desktop_config.json", +); +const APPDATA_CONFIG = join(ROAMING, "Claude", "claude_desktop_config.json"); + +/** Claude Desktop as setup resolves it from its MSIX package storage. */ +const msixClaudeDesktop = ( + processView: MsixPackageView["processView"], +): SetupClient => ({ + name: "claude_desktop", + displayName: "Claude Desktop", + configPath: PRIVATE_CONFIG, + quitBeforeSetup: true, + msixPackage: { + packageFamilyName: "Claude_pzs8sxrjxfjjc", + privateConfigPath: PRIVATE_CONFIG, + appDataConfigPath: APPDATA_CONFIG, + virtualizedRoots: [ROAMING, LOCAL], + processView, + }, +}); describe("setup workflow", () => { it("omits an aligned managed skill from an otherwise empty plan", async () => { @@ -293,3 +327,85 @@ describe("setup workflow action selection", () => { expect(result.remediation).not.toContain("internal_client_key"); }); }); + +describe("MSIX Claude Desktop setup", () => { + it("discloses which file MSIX Claude Desktop reads and that it must be quit", async () => { + const host = new FakeSetupHost("win32"); + host.hopper = join(PROFILE, "Hopper"); + host.skill = "unchanged"; + host.clients = [msixClaudeDesktop("direct")]; + + const result = await runSetup( + { ...options(false), clientIds: ["claude_desktop"], dryRun: true }, + host, + ); + + expect(result.status).toBe("planned"); + const [action] = result.plannedActions; + expect(action?.target).toBe(PRIVATE_CONFIG); + expect(action?.detail).toContain( + `Claude Desktop is installed as MSIX package Claude_pzs8sxrjxfjjc, which reads this package-private file instead of ${APPDATA_CONFIG}.`, + ); + expect(action?.detail).toContain( + "Quit Claude Desktop fully before approving; closing its window is not enough.", + ); + }); + + it("refuses AppData writes from a process the MSIX package started", async () => { + const host = new FakeSetupHost("win32"); + host.hopper = join(PROFILE, "Hopper"); + host.skill = "unchanged"; + const vscode = join(ROAMING, "Code", "User", "mcp.json"); + host.clients = [ + msixClaudeDesktop("redirected"), + { name: "vscode", displayName: "VS Code", configPath: vscode }, + { + name: "codex", + displayName: "Codex", + configPath: join(PROFILE, ".codex", "config.toml"), + }, + ]; + + const blocked = await runSetup( + { ...options(true), clientIds: ["vscode", "codex"] }, + host, + ); + expect(blocked.status).toBe("needs_human"); + expect(blocked.remediation).toContain( + `Changes to ${vscode} would not reach the files other apps read.`, + ); + expect(host.configurations).toBe(0); + + const homeOnly = await runSetup( + { ...options(true), clientIds: ["codex"] }, + host, + ); + expect(homeOnly.status).toBe("ready"); + expect(host.configurations).toBe(1); + }); + + it("refuses registrations whose launcher only one MSIX package can see", async () => { + const host = new FakeSetupHost("win32"); + host.hopper = join(PROFILE, "Hopper"); + host.registrationCommandBlocker = "Install REA outside the package."; + host.clients = [ + { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, + ]; + + const blocked = await runSetup( + { ...options(true), clientIds: ["codex"] }, + host, + ); + expect(blocked.status).toBe("needs_human"); + expect(blocked.remediation).toBe("Install REA outside the package."); + expect(host.configurations).toBe(0); + expect(host.skillInstalls).toBe(0); + + const skillOnly = await runSetup( + { ...options(true), clientIds: [], installSkill: true }, + host, + ); + expect(skillOnly.status).toBe("ready"); + expect(host.skillInstalls).toBe(1); + }); +}); diff --git a/src/application/Setup.fixture.ts b/src/application/Setup.fixture.ts index a99897494..3c7c21d35 100644 --- a/src/application/Setup.fixture.ts +++ b/src/application/Setup.fixture.ts @@ -25,6 +25,7 @@ export class FakeSetupHost implements SetupHost { return skillDestinations(this.homeDirectory, clientIds); } readonly registrationCommand: readonly string[]; + registrationCommandBlocker?: string; nodeVersion = "24.18.0"; version: string | undefined = "14.5"; distribution: LinuxDistribution | undefined; diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 981b829a4..a868c6177 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -29,6 +29,7 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; +import { packageReadTargetChanged } from "./MsixPackageStorage.js"; import { readRegularFile, readRegularFileText, @@ -40,7 +41,10 @@ import type { ClientConfigurationResult, SetupProviderEnvironment, } from "./SetupTypes.js"; -import type { SetupClient } from "./SupportedClients.js"; +import { + registrationWriteBlocker, + type SetupClient, +} from "./SupportedClients.js"; export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure { constructor( @@ -187,6 +191,13 @@ const persistClientConfiguration = async ({ desired, fallbackServers, }: ClientConfigurationUpdate): Promise => { + // The plan chose this file; an MSIX package may have switched to its + // private copy since, for example when it quit. + if ( + client.msixPackage !== undefined && + (await packageReadTargetChanged(client.msixPackage, client.configPath)) + ) + return { status: "failed", reason: "target_changed" }; const backupPath = original === undefined ? undefined : `${client.configPath}.rea.backup`; let recoveryBackupPath: string | undefined; @@ -358,6 +369,25 @@ export const inspectClientConfiguration = async ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], +): Promise => { + const inspection = await inspectClientDocument( + client, + providerEnvironment, + command, + ); + if (inspection.status !== "create" && inspection.status !== "update") + return inspection; + // A change the running client would overwrite is not a persisted registration. + const blocker = registrationWriteBlocker(client, "setup"); + return blocker === undefined + ? inspection + : { status: "invalid", remediation: blocker }; +}; + +const inspectClientDocument = async ( + client: SetupClient, + providerEnvironment: SetupProviderEnvironment, + command: readonly string[], ): Promise => { if (client.configPathError !== undefined) return { status: "invalid", remediation: client.configPathError }; diff --git a/src/application/SetupClients.ts b/src/application/SetupClients.ts index d86d04b9e..5ceccb21a 100644 --- a/src/application/SetupClients.ts +++ b/src/application/SetupClients.ts @@ -9,6 +9,8 @@ import type { SetupClient } from "./SupportedClients.js"; export const failedConfigurationMessage = ( result: Extract, ): string => { + if (result.reason === "target_changed") + return "The client began reading a different configuration file after the plan was shown, so no change was made. Rerun setup to review the new target."; if (result.reason === "path") return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup."; if (result.reason === "backup") diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 7ac6828fd..022cecc62 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -44,6 +44,10 @@ import { retryRegularFileCleanup, } from "./RegularFileRead.js"; import { providerRegistrationEnvironment } from "./SetupRegistrationEnvironment.js"; +import { + msixPackagesDirectory, + packagePrivateLauncherBlocker, +} from "./MsixPackageStorage.js"; import type { SetupHost, SetupInitialState, @@ -66,6 +70,31 @@ export const setupRegistrationCommand = ( ] : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"]; +/** + * The launcher setup registers, and why it must not be registered when it + * exists only inside one MSIX package's private storage. + */ +const setupRegistration = ( + platform: NodeJS.Platform, + homeDirectory: string, + environment: Readonly, +): Pick => { + const registrationCommand = setupRegistrationCommand( + platform, + invokingPackageRunner(environment, platform), + ); + const registrationCommandBlocker = + platform === "win32" + ? packagePrivateLauncherBlocker( + registrationCommand, + msixPackagesDirectory(homeDirectory, environment), + ) + : undefined; + return registrationCommandBlocker === undefined + ? { registrationCommand } + : { registrationCommand, registrationCommandBlocker }; +}; + export const filterClientsNeedingConfigure = async ( host: SetupHost, detectedClients: readonly SetupClient[], @@ -117,10 +146,7 @@ export const systemSetupHost = ( homeDirectory, skillDestinations: (clientIds) => skillDestinations(homeDirectory, clientIds, environment, platform), - registrationCommand: setupRegistrationCommand( - platform, - invokingPackageRunner(environment, platform), - ), + ...setupRegistration(platform, homeDirectory, environment), nodeVersion: process.versions.node, macosVersion: () => doctorHost.macosVersion(), linuxDistribution: readLinuxDistribution, diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index a78697c80..35db28de4 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -8,12 +8,18 @@ import { import type { SkillDestination } from "./SetupSkill.js"; import { macHopperInstallDisclosure } from "./MacHopper.js"; import type { + ClientConfigurationInspection, SetupAction, SetupClientState, SetupHost, SetupProviderEnvironment, } from "./SetupTypes.js"; -import type { SetupClient } from "./SupportedClients.js"; +import { + appDataRedirection, + registrationPersistenceDetail, + type SetupClient, +} from "./SupportedClients.js"; +import { redirectedWriteBlocker } from "./MsixPackageStorage.js"; import type { DoctorReport } from "./Doctor.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; @@ -161,12 +167,58 @@ export const planSetupActions = async (input: { blocker: `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`, }; } - const clientPlans = inspections.flatMap(({ client, inspection }) => + const clientPlans = plannedClientWrites( + inspections, + input.discovery.installHopper, + ); + const plannedActions = setupPlan({ + platform: input.host.platform, + homeDirectory: input.host.homeDirectory, + installHopper: input.discovery.installHopper, + installSkill: input.installSkill, + skillClientIds: input.skillClientIds, + skillDestinations: input.host.skillDestinations(input.skillClientIds), + clients: clientPlans, + providerEnvironment: input.providerEnvironment, + ...(input.discovery.linuxPackageFamily === undefined + ? {} + : { linuxPackageFamily: input.discovery.linuxPackageFamily }), + command: input.command, + }); + const blocker = + (clientPlans.length > 0 + ? input.host.registrationCommandBlocker + : undefined) ?? + redirectedWriteBlocker( + appDataRedirection(input.discovery.clients), + plannedActions + .filter(({ kind }) => kind !== "install_hopper") + .map(({ target }) => target), + "setup", + ); + return { + plannedActions, + selectedClients, + clientsToConfigure: selectedClients.filter((client) => + clientPlans.some(({ client: planned }) => planned.name === client.name), + ), + ...(blocker === undefined ? {} : { blocker }), + }; +}; + +/** Selected clients whose registration setup writes, with the disclosed operation. */ +const plannedClientWrites = ( + inspections: readonly { + readonly client: SetupClient; + readonly inspection: ClientConfigurationInspection; + }[], + installHopper: boolean, +) => + inspections.flatMap(({ client, inspection }) => client.format !== "unsupported" && (inspection.status === "create" || inspection.status === "update" || - (input.discovery.installHopper && - inspection.status === "already_current")) + (installHopper && inspection.status === "already_current")) ? [ { client, @@ -182,27 +234,6 @@ export const planSetupActions = async (input: { ] : [], ); - return { - plannedActions: setupPlan({ - platform: input.host.platform, - homeDirectory: input.host.homeDirectory, - installHopper: input.discovery.installHopper, - installSkill: input.installSkill, - skillClientIds: input.skillClientIds, - skillDestinations: input.host.skillDestinations(input.skillClientIds), - clients: clientPlans, - providerEnvironment: input.providerEnvironment, - ...(input.discovery.linuxPackageFamily === undefined - ? {} - : { linuxPackageFamily: input.discovery.linuxPackageFamily }), - command: input.command, - }), - selectedClients, - clientsToConfigure: selectedClients.filter((client) => - clientPlans.some(({ client: planned }) => planned.name === client.name), - ), - }; -}; /** Describe every selected setup mutation before user approval. */ const setupPlan = (input: { @@ -259,7 +290,7 @@ const setupPlan = (input: { label: client.displayName ?? client.name, target: client.configPath, detail: clientConfigurationDetail( - client.displayName ?? client.name, + client, input.command, input.providerEnvironment, input.installHopper, @@ -303,7 +334,7 @@ const majorVersion = (version: string): number => Number.parseInt(version.split(".")[0] ?? "0", 10); const clientConfigurationDetail = ( - client: string, + client: SetupClient, command: readonly string[], environment: SetupProviderEnvironment, installHopper: boolean, @@ -320,5 +351,6 @@ const clientConfigurationDetail = ( ? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation." : " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation." : ""; - return `Add the REA MCP registration for ${client}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`; + const persistence = registrationPersistenceDetail(client); + return `Add the REA MCP registration for ${client.displayName ?? client.name}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}${persistence === undefined ? "" : ` ${persistence}`}`; }; diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index 241e0f6dd..f0cf61c59 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -25,7 +25,12 @@ export type ClientConfigurationResult = } | { readonly status: "failed"; - readonly reason: "path" | "backup" | "write" | "readback"; + readonly reason: + | "path" + | "target_changed" + | "backup" + | "write" + | "readback"; } | { readonly status: "failed"; @@ -55,6 +60,8 @@ export interface SetupHost { readonly homeDirectory: string; skillDestinations(clientIds: readonly string[]): readonly SkillDestination[]; readonly registrationCommand: readonly string[]; + /** Why no client may be registered with `registrationCommand` from this host. */ + readonly registrationCommandBlocker?: string; readonly nodeVersion: string; macosVersion(): Promise; linuxDistribution(): Promise; diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 0a7352627..8407da4c0 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -7,6 +7,8 @@ import { resolveHermesClientHome, } from "./HermesClientHome.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { resolveWindowsClaudeDesktopLocation } from "./ClaudeDesktopConfigLocation.js"; +import type { MsixPackageView } from "./MsixPackageStorage.js"; /** One supported client configuration location. */ export interface SetupClient { @@ -29,6 +31,10 @@ export interface SetupClient { /** Same-scope fallback ZCode stops loading after its first native server. */ readonly zcodeFallbackPath?: string; readonly markerPath?: string; + /** MSIX package storage that decides which configuration the client reads. */ + readonly msixPackage?: MsixPackageView; + /** A running client can rewrite its configuration from memory. */ + readonly quitBeforeSetup?: boolean; readonly format?: | "json" | "toml" @@ -61,12 +67,35 @@ interface ClientPathContext { readonly env: Readonly; } -interface ClientDefinition { +/** One resolved configuration location and its detection evidence. */ +interface ClientLocation { + readonly configPath: string; + readonly markerPath: string; + readonly msixPackage?: MsixPackageView; +} + +type ClientDefinition = ClientDefinitionBase & + ( + | { + readonly configPath: ClientPath; + readonly markerPath: ClientPath; + readonly location?: undefined; + } + | { + /** Resolve configuration and marker together when they share probes. */ + readonly location: ( + context: ClientPathContext, + ) => Result; + readonly configPath?: undefined; + readonly markerPath?: undefined; + } + ); + +interface ClientDefinitionBase { readonly name: string; readonly displayName: string; - readonly configPath: ClientPath; - readonly markerPath: ClientPath; readonly format: NonNullable; + readonly quitBeforeSetup?: boolean; readonly zcodeFallbackPath?: (context: ClientPathContext) => string; readonly skillPath?: ClientPath; readonly configPaths?: (context: ClientPathContext) => readonly string[]; @@ -111,16 +140,20 @@ const traeUserDirectory = ({ return join(xdgConfigHome({ home, platform, env }), "Trae", "User"); }; -const claudeDesktopDirectory = ({ - home, - platform, - env, -}: ClientPathContext): string => { - if (platform === "win32") - return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "Claude"); - if (platform === "linux") - return join(xdgConfigHome({ home, platform, env }), "Claude"); - return join(home, "Library", "Application Support", "Claude"); +/** Windows packaging decides which file Claude Desktop reads; see its resolver. */ +const claudeDesktopLocation = ( + context: ClientPathContext, +): Result => { + if (context.platform === "win32") + return resolveWindowsClaudeDesktopLocation(context.home, context.env); + const directory = + context.platform === "linux" + ? join(xdgConfigHome(context), "Claude") + : join(context.home, "Library", "Application Support", "Claude"); + return ok({ + configPath: join(directory, "claude_desktop_config.json"), + markerPath: directory, + }); }; const claudeCodeConfigDirectory = ({ home, env }: ClientPathContext): string => @@ -211,6 +244,55 @@ export const manualRegistrationRemediation = ( ? GROK_BOT_MANUAL_REGISTRATION_REMEDIATION : undefined; +/** + * Facts a setup plan must disclose so an approved registration persists: + * which file an MSIX package reads, and that a running client can overwrite it. + */ +export const registrationPersistenceDetail = ( + client: SetupClient, +): string | undefined => { + const name = client.displayName ?? client.name; + const msix = client.msixPackage; + const packageDetail = + msix === undefined + ? undefined + : client.configPath === msix.privateConfigPath + ? `${name} is installed as MSIX package ${msix.packageFamilyName}, which reads this package-private file instead of ${msix.appDataConfigPath}.` + : `${name} is installed as MSIX package ${msix.packageFamilyName}, which reads this file until it saves its own copy at ${msix.privateConfigPath}.`; + const quitDetail = + client.quitBeforeSetup === true + ? `Quit ${name} fully before approving; closing its window is not enough. A running ${name} can rewrite this file from memory and drop the registration. Start it again after setup.` + : undefined; + const details = [packageDetail, quitDetail].filter( + (detail): detail is string => detail !== undefined, + ); + return details.length === 0 ? undefined : details.join(" "); +}; + +/** + * Why this process must not change an MSIX client's configuration. A process + * the running app started sees its redirected AppData, and the app would + * overwrite the change from memory; an unknown view cannot rule that out. + */ +export const registrationWriteBlocker = ( + client: SetupClient, + operation: "setup" | "uninstall", +): string | undefined => { + const msix = client.msixPackage; + if (msix === undefined || msix.processView === "direct") return undefined; + const name = client.displayName ?? client.name; + return msix.processView === "redirected" + ? `This process was started from ${name}'s MSIX package ${msix.packageFamilyName}: its AppData is redirected into that package, so ${name} is running and would overwrite ${msix.privateConfigPath} from memory. Quit ${name} fully, then rerun ${operation} from a terminal that ${name} did not start.` + : `REA cannot tell whether ${name}'s MSIX package ${msix.packageFamilyName} started this process, because none of its files under ${dirname(msix.privateConfigPath)} could be compared with ${dirname(msix.appDataConfigPath)}. A running ${name} would overwrite the change. Open ${name} once if it has never run, quit it fully, then rerun ${operation} from a terminal that ${name} did not start.`; +}; + +/** The MSIX package view, if any, that redirects this process's AppData writes. */ +export const appDataRedirection = ( + clients: readonly SetupClient[], +): MsixPackageView | undefined => + clients.find(({ msixPackage }) => msixPackage?.processView === "redirected") + ?.msixPackage; + const OMP_PROFILE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/u; const OMP_RESERVED_PROFILE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/iu; @@ -408,9 +490,8 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ { name: "claude_desktop", displayName: "Claude Desktop", - configPath: (context: ClientPathContext) => - join(claudeDesktopDirectory(context), "claude_desktop_config.json"), - markerPath: claudeDesktopDirectory, + location: claudeDesktopLocation, + quitBeforeSetup: true, format: "json", }, { @@ -627,6 +708,19 @@ const resolvePath = ( return typeof resolved === "string" ? ok(resolved) : resolved; }; +const resolveLocation = ( + definition: ClientDefinition, + context: ClientPathContext, +): Result => { + if (definition.location !== undefined) return definition.location(context); + const config = resolvePath(definition.configPath, context); + if (!config.ok) return config; + const marker = resolvePath(definition.markerPath, context); + return marker.ok + ? ok({ configPath: config.value, markerPath: marker.value }) + : marker; +}; + /** Describe every client location that setup, doctor, or uninstall may inspect. */ export const supportedClients = ( home: string, @@ -637,27 +731,16 @@ export const supportedClients = ( const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS; return definitions.map((definition) => { try { - const config = resolvePath(definition.configPath, context); - const marker = config.ok - ? resolvePath(definition.markerPath, context) - : config; - if (!config.ok) + const location = resolveLocation(definition, context); + if (!location.ok) return { name: definition.name, displayName: definition.displayName, format: definition.format, - configPath: config.error.path, - configPathError: config.error.message, + configPath: location.error.path, + configPathError: location.error.message, }; - if (!marker.ok) - return { - name: definition.name, - displayName: definition.displayName, - format: definition.format, - configPath: marker.error.path, - configPathError: marker.error.message, - }; - const configPath = config.value; + const { configPath, markerPath, msixPackage } = location.value; return { name: definition.name, displayName: definition.displayName, @@ -675,7 +758,11 @@ export const supportedClients = ( ...(definition.zcodeFallbackPath === undefined ? {} : { zcodeFallbackPath: definition.zcodeFallbackPath(context) }), - markerPath: marker.value, + markerPath, + ...(msixPackage === undefined ? {} : { msixPackage }), + ...(definition.quitBeforeSetup === true + ? { quitBeforeSetup: true } + : {}), format: definition.format, }; } catch (cause: unknown) { diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index a7f051a49..a4561c45e 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -32,10 +32,16 @@ import { } from "./RegularFileRead.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { + appDataRedirection, manualRegistrationRemediation, + registrationWriteBlocker, supportedClients, type SetupClient, } from "./SupportedClients.js"; +import { + redirectedWriteBlocker, + type MsixPackageView, +} from "./MsixPackageStorage.js"; export { isUninstallFailure } from "./UninstallFailure.js"; interface ManagedPathStats { @@ -93,6 +99,7 @@ export interface UninstallHost { /** Report a configuration that cannot be read safely, before any removal. */ inspectClient(client: SetupClient): Promise; removeClient(client: SetupClient): Promise; + inspectSkill?(): Promise; removeSkill(): Promise; purgeData(): Promise; close?(): Promise; @@ -118,6 +125,8 @@ export const runUninstall = async ( const blocking = await host.inspectClient(client); if (blocking !== undefined) blocked.push(blocking); } + const skillBlocker = await host.inspectSkill?.(); + if (skillBlocker !== undefined) blocked.push(skillBlocker); const items: UninstallItem[] = blocked.length > 0 ? [ @@ -209,27 +218,70 @@ export const systemUninstallHost = ( selectedHome ?? homeDirectoryFromEnvironment(environment, platform); const resources = new ArtifactResourceScope(); const fileSystem = selectedFileSystem ?? systemFileSystem(resources); + // Set with the client list: the package view that redirects this + // process's AppData writes, if any. + let redirection: MsixPackageView | undefined; + const clientWriteBlocker = (client: SetupClient): string | undefined => + registrationWriteBlocker(client, "uninstall") ?? + redirectedWriteBlocker(redirection, [client.configPath], "uninstall"); + const skillWriteBlocker = (path: string): string | undefined => + redirectedWriteBlocker(redirection, [path], "uninstall"); return { - clients: () => - Promise.resolve( - supportedClients(home, platform, environment).flatMap((client) => - client.configPaths === undefined - ? [client] - : client.configPaths.map((configPath) => ({ - ...client, - configPath, - })), + clients: () => { + const clients = supportedClients(home, platform, environment); + redirection = appDataRedirection(clients); + return Promise.resolve( + clients.flatMap((client) => + uninstallConfigPaths(client).map((configPath) => ({ + ...client, + configPath, + })), ), - ), + ); + }, inspectClient: async (client) => { const read = await readClientConfiguration(client, fileSystem); - return read.kind === "item" && read.item.status === "failed" - ? read.item - : undefined; + if (read.kind === "item") + return read.item.status === "failed" ? read.item : undefined; + const removal = clientRegistrationRemoval(read.parsed, client); + if (!removal.removeNative && removal.legacyPath === undefined) + return undefined; + // A removal the running package would revert, or redirect into its + // private storage, would leave the registration in place. + const blocker = clientWriteBlocker(client); + return blocker === undefined + ? undefined + : item(client.name, "failed", blocker); + }, + removeClient: (client) => + removeClient(client, fileSystem, () => clientWriteBlocker(client)), + inspectSkill: async () => { + for (const destination of skillDestinations( + home, + undefined, + environment, + platform, + )) { + const inspected = await inspectManagedSkill( + destination.path, + destination.client, + fileSystem, + ); + if (inspected?.status === "failed") return inspected; + if (inspected !== undefined) continue; + const blocker = skillWriteBlocker(destination.path); + if (blocker !== undefined) return item("skill", "failed", blocker); + } + return undefined; }, - removeClient: (client) => removeClient(client, fileSystem), removeSkill: () => - removeManagedSkills(home, fileSystem, environment, platform), + removeManagedSkills( + home, + fileSystem, + environment, + platform, + skillWriteBlocker, + ), purgeData: () => purgeManagedData(join(home, ".rea"), fileSystem), close: async () => { try { @@ -242,6 +294,36 @@ export const systemUninstallHost = ( }; }; +/** + * Every user file that can hold REA's registration for one client. An MSIX + * package can read either its private copy or the AppData file, and earlier + * setups wrote the AppData file, so both lose REA's entry. + */ +const uninstallConfigPaths = (client: SetupClient): readonly string[] => [ + ...new Set([ + ...(client.configPaths ?? [client.configPath]), + ...(client.msixPackage === undefined + ? [] + : [ + client.msixPackage.privateConfigPath, + client.msixPackage.appDataConfigPath, + ]), + ]), +]; + +/** Whether removal would change this configuration. */ +const registrationPresent = (parsed: ClientConfigurationDocument): boolean => { + const name = PRODUCT_IDENTITY.mcpServerKey; + return ( + Object.hasOwn(parsed.servers, name) || + // Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`. + legacyClientServerPath(parsed, name) !== undefined || + // Harness ownership is the stable entry id. A stale or malformed launcher + // still belongs to that id, and so does a direct patch that projects no row. + (parsed.dialect === "dsh" && dshDocument(parsed.document).carriesEntry) + ); +}; + type ClientConfigurationRead = | { readonly kind: "item"; readonly item: UninstallItem } | { @@ -308,21 +390,47 @@ const itemRead = (result: UninstallItem): ClientConfigurationRead => ({ item: result, }); +/** + * Share the exact ownership and applicability decision with preflight. A + * removable legacy entry carries its document path, so removal edits exactly + * the entry this decision admitted. + */ +const clientRegistrationRemoval = ( + parsed: ClientConfigurationDocument, + client: SetupClient, +): { + readonly removeNative: boolean; + readonly legacyPath: readonly string[] | undefined; +} => { + const name = PRODUCT_IDENTITY.mcpServerKey; + if ( + parsed.dialect === "dsh" && + dshDocument(parsed.document).hasUnknownStructure + ) + return { removeNative: false, legacyPath: undefined }; + const legacyPath = legacyClientServerPath(parsed, name); + return { + removeNative: + (parsed.dialect === "dsh" && dshDocument(parsed.document).carriesEntry) || + (Object.hasOwn(parsed.servers, name) && + isOwnedRegistration(parsed.servers[name], client)), + legacyPath: + legacyPath !== undefined && + isOwnedRegistration(parsed.legacyServers[name], client) + ? legacyPath + : undefined, + }; +}; + const removeClient = async ( client: SetupClient, fileSystem: UninstallFileSystem, + writeBlocker?: () => string | undefined, ): Promise => { const read = await readClientConfiguration(client, fileSystem); if (read.kind === "item") return read.item; const { transactionPath, original, parsed } = read; const name = PRODUCT_IDENTITY.mcpServerKey; - // Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`. - const legacyPath = legacyClientServerPath(parsed, name); - const hasNative = Object.hasOwn(parsed.servers, name); - // Harness ownership is the stable entry id. A stale or malformed launcher - // still belongs to that id, and so does a direct patch that projects no row. - const carriesDshEntry = - parsed.dialect === "dsh" && dshDocument(parsed.document).carriesEntry; if ( parsed.dialect === "dsh" && dshDocument(parsed.document).hasUnknownStructure @@ -332,20 +440,21 @@ const removeClient = async ( "retained", "A Cordis expression may produce registration rows, so the patch was retained without changes.", ); - if (!hasNative && legacyPath === undefined && !carriesDshEntry) + if (!registrationPresent(parsed)) return item(client.name, "skipped", "REA registration is absent."); - const removeNative = - carriesDshEntry || - (hasNative && isOwnedRegistration(parsed.servers[name], client)); - const removeLegacy = - legacyPath !== undefined && - isOwnedRegistration(parsed.legacyServers[name], client); + const { removeNative, legacyPath } = clientRegistrationRemoval( + parsed, + client, + ); + const removeLegacy = legacyPath !== undefined; if (!removeNative && !removeLegacy) return item( client.name, "retained", "The rea-named registration is not REA-owned.", ); + const blocker = writeBlocker?.(); + if (blocker !== undefined) return item(client.name, "failed", blocker); const remaining = { ...parsed.servers }; if (removeNative) delete remaining[name]; const document = withClientServers( @@ -355,7 +464,7 @@ const removeClient = async ( ); const editedPaths = [ ...(removeNative ? [clientServerPath(parsed, name)] : []), - ...(removeLegacy ? [legacyPath] : []), + ...(legacyPath === undefined ? [] : [legacyPath]), ]; const backupPath = `${client.configPath}.rea.backup`; try { @@ -516,12 +625,13 @@ const removeManagedSkills = async ( fileSystem: UninstallFileSystem, environment: Readonly, platform: NodeJS.Platform, + writeBlocker?: (path: string) => string | undefined, ): Promise => { // Every destination shares one summary item, so each result is labeled by // the directory it refers to. const results = await Promise.all( skillDestinations(home, undefined, environment, platform).map(({ path }) => - removeManagedSkill(path, path, fileSystem), + removeManagedSkill(path, path, fileSystem, writeBlocker), ), ); const failed = results.find(({ status }) => status === "failed"); @@ -552,11 +662,11 @@ const removeManagedSkills = async ( ); }; -const removeManagedSkill = async ( +const inspectManagedSkill = async ( path: string, name: string, fileSystem: UninstallFileSystem, -): Promise => { +): Promise => { let root: ManagedPathStats; try { root = await fileSystem.stat(path); @@ -594,6 +704,20 @@ const removeManagedSkill = async ( if (!isManagedSkillManifest(content)) return item(name, "retained", "Skill manifest is not REA-owned."); + return undefined; +}; + +const removeManagedSkill = async ( + path: string, + name: string, + fileSystem: UninstallFileSystem, + writeBlocker?: (path: string) => string | undefined, +): Promise => { + const inspection = await inspectManagedSkill(path, name, fileSystem); + if (inspection !== undefined) return inspection; + const blocker = writeBlocker?.(path); + if (blocker !== undefined) return item(name, "failed", blocker); + const manifestName = "SKILL.md"; const otherFiles = MANAGED_SKILL_FILES.filter( (relativePath) => relativePath !== manifestName, diff --git a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts new file mode 100644 index 000000000..b411aeb4b --- /dev/null +++ b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts @@ -0,0 +1,412 @@ +import { + access, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../../src/application/ClaudeDesktopConfigLocation.js"; +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { + configureClientConfiguration, + inspectClientConfiguration, +} from "../../../src/application/SetupClientConfiguration.js"; +import { detectClients } from "../../../src/application/SetupHost.js"; +import { + supportedClients, + type SetupClient, +} from "../../../src/application/SupportedClients.js"; +import { + runUninstall, + systemUninstallHost, +} from "../../../src/application/Uninstall.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +/** The Windows registration setup writes, independent of the test host. */ +const command = npxRegistrationCommand("win32"); +const CONFIG = "claude_desktop_config.json"; + +const roots: string[] = []; +beforeEach(clearClientLocationEnvironment); +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +/** A Windows profile with separate roaming and local AppData roots. */ +const windowsProfile = async () => { + const home = await createTestTempDirectory("rea-claude-desktop-"); + roots.push(home); + const env = { + APPDATA: join(home, "AppData", "Roaming"), + LOCALAPPDATA: join(home, "AppData", "Local"), + }; + const appDataConfig = join(env.APPDATA, "Claude", CONFIG); + const container = join( + env.LOCALAPPDATA, + "Packages", + CLAUDE_DESKTOP_PACKAGE_FAMILY, + ); + const privateConfig = join( + container, + "LocalCache", + "Roaming", + "Claude", + CONFIG, + ); + return { home, env, appDataConfig, container, privateConfig }; +}; + +const claudeDesktop = ( + home: string, + env: Readonly>, +): SetupClient => { + const client = supportedClients(home, "win32", env).find( + ({ name }) => name === "claude_desktop", + ); + if (client === undefined) throw new Error("claude_desktop is not supported"); + return client; +}; + +const writeJson = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +const readJson = async (path: string): Promise> => + JSON.parse(await readFile(path, "utf8")) as Record; + +const reaEntry = { command: command[0], args: command.slice(1) }; + +describe("Windows Claude Desktop configuration location", () => { + it("uses AppData when no MSIX package storage exists", async () => { + const { home, env, appDataConfig } = await windowsProfile(); + await mkdir( + join(env.LOCALAPPDATA, "Packages", "Contoso.App_8wekyb3d8bbwe"), + { + recursive: true, + }, + ); + + const client = claudeDesktop(home, env); + + expect(client.configPath).toBe(appDataConfig); + expect(client.markerPath).toBe(dirname(appDataConfig)); + expect(client.msixPackage).toBeUndefined(); + expect(client.quitBeforeSetup).toBe(true); + }); + + it("uses the package-private copy that the MSIX package reads", async () => { + const { home, env, appDataConfig, container, privateConfig } = + await windowsProfile(); + await writeJson(privateConfig, { preferences: { theme: "dark" } }); + await writeJson(appDataConfig, { mcpServers: {} }); + + const client = claudeDesktop(home, env); + + expect(client.configPath).toBe(privateConfig); + expect(client.markerPath).toBe(container); + expect(client.msixPackage).toEqual({ + packageFamilyName: CLAUDE_DESKTOP_PACKAGE_FAMILY, + privateConfigPath: privateConfig, + appDataConfigPath: appDataConfig, + virtualizedRoots: [env.APPDATA, env.LOCALAPPDATA], + processView: "direct", + }); + expect( + (await detectClients(home, "win32", env)).map(({ name }) => name), + ).toContain("claude_desktop"); + }); + + it("uses AppData while the MSIX package has no private copy", async () => { + const { home, env, appDataConfig, container, privateConfig } = + await windowsProfile(); + await mkdir(container, { recursive: true }); + await writeJson(appDataConfig, { preferences: {} }); + + const client = claudeDesktop(home, env); + + expect(client.configPath).toBe(appDataConfig); + expect(client.msixPackage).toMatchObject({ + privateConfigPath: privateConfig, + processView: "unknown", + }); + // No private file shows whether the running package started this process. + const inspection = await inspectClientConfiguration(client, {}, command); + expect(inspection.status).toBe("invalid"); + expect(inspection.status === "invalid" && inspection.remediation).toContain( + "Open Claude Desktop once if it has never run, quit it fully, then rerun setup", + ); + }); + + it("creates the private copy when the MSIX package has no configuration", async () => { + const { home, env, privateConfig } = await windowsProfile(); + await writeJson(join(dirname(privateConfig), "Local State"), {}); + + const client = claudeDesktop(home, env); + expect(client.configPath).toBe(privateConfig); + expect(client.msixPackage?.processView).toBe("direct"); + await expect( + inspectClientConfiguration(client, {}, command), + ).resolves.toEqual({ status: "create" }); + await expect( + configureClientConfiguration(client, {}, command), + ).resolves.toEqual({ status: "configured" }); + expect(await readJson(privateConfig)).toEqual({ + mcpServers: { rea: reaEntry }, + }); + }); + + it("reports an unrecognized Claude package instead of falling back to AppData", async () => { + const { home, env, appDataConfig } = await windowsProfile(); + const foreign = join(env.LOCALAPPDATA, "Packages", "Claude_abcdefghjkmnp"); + await mkdir(foreign, { recursive: true }); + await writeJson(appDataConfig, { mcpServers: {} }); + + const client = claudeDesktop(home, env); + + expect(client.configPath).toBe(foreign); + expect(client.configPathError).toContain("unrecognized publisher"); + expect(client.configPathError).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY); + expect( + (await detectClients(home, "win32", env)).map(({ name }) => name), + ).not.toContain("claude_desktop"); + const status = ( + await readClientRegistrationStatuses(home, "unused", { + platform: "win32", + environment: env, + }) + ).find(({ client: name }) => name === "claude_desktop"); + expect(status).toMatchObject({ state: "invalid", config_path: foreign }); + await expect( + inspectClientConfiguration(client, {}, command), + ).resolves.toMatchObject({ status: "invalid" }); + }); +}); + +describe("MSIX Claude Desktop registration lifecycle", () => { + it("registers in the private copy and reports an AppData-only entry as missing", async () => { + const { home, env, appDataConfig, privateConfig } = await windowsProfile(); + const privateOriginal = { + coworkUserFilesPath: "C:\\Users\\a\\Claude", + preferences: { menuBarEnabled: false }, + mcpServers: { other: { command: "other", args: ["serve"] } }, + }; + await writeJson(privateConfig, privateOriginal); + // Earlier setups wrote AppData, which the package never reads. + await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } }); + const appDataOriginal = await readFile(appDataConfig, "utf8"); + const status = async () => + ( + await readClientRegistrationStatuses(home, "unused", { + platform: "win32", + environment: env, + }) + ).find(({ client }) => client === "claude_desktop"); + + expect(await status()).toMatchObject({ + state: "missing", + config_path: privateConfig, + }); + expect((await status())?.remediation).toContain( + "Quit Claude Desktop fully", + ); + + const client = claudeDesktop(home, env); + await expect( + inspectClientConfiguration(client, {}, command), + ).resolves.toEqual({ + status: "update", + backupPath: `${privateConfig}.rea.backup`, + }); + await expect( + configureClientConfiguration(client, {}, command), + ).resolves.toEqual({ + status: "configured", + backupPath: `${privateConfig}.rea.backup`, + }); + + expect(await readJson(privateConfig)).toEqual({ + ...privateOriginal, + mcpServers: { + ...privateOriginal.mcpServers, + rea: reaEntry, + }, + }); + expect(await readJson(`${privateConfig}.rea.backup`)).toEqual( + privateOriginal, + ); + expect(await readFile(appDataConfig, "utf8")).toBe(appDataOriginal); + expect(await status()).toMatchObject({ + state: "aligned", + config_path: privateConfig, + }); + await expect( + configureClientConfiguration(client, {}, command), + ).resolves.toEqual({ status: "unchanged" }); + }); + + it("refuses to write from a process the running package started", async () => { + const { home, env, privateConfig } = await windowsProfile(); + await writeJson(privateConfig, { preferences: {} }); + const resolved = claudeDesktop(home, env); + if (resolved.msixPackage === undefined) + throw new Error("expected MSIX package storage"); + // Only a packaged process sees AppData redirected; model that observation. + const client: SetupClient = { + ...resolved, + msixPackage: { ...resolved.msixPackage, processView: "redirected" }, + }; + + const inspection = await inspectClientConfiguration(client, {}, command); + + expect(inspection.status).toBe("invalid"); + expect(inspection.status === "invalid" && inspection.remediation).toContain( + "Quit Claude Desktop fully, then rerun setup from a terminal that Claude Desktop did not start", + ); + expect(await readJson(privateConfig)).toEqual({ preferences: {} }); + + await configureClientConfiguration(resolved, {}, command); + await expect( + inspectClientConfiguration(client, {}, command), + ).resolves.toEqual({ status: "already_current" }); + }); + + it("refuses a write once the package reads a newer private copy", async () => { + const { home, env, appDataConfig, privateConfig } = await windowsProfile(); + await writeJson(join(dirname(privateConfig), "Preferences"), {}); + await writeJson(appDataConfig, { preferences: {} }); + const client = claudeDesktop(home, env); + expect(client.configPath).toBe(appDataConfig); + // Quitting the app after the plan was shown can save its private copy. + await writeJson(privateConfig, { preferences: {} }); + + await expect( + configureClientConfiguration(client, {}, command), + ).resolves.toEqual({ status: "failed", reason: "target_changed" }); + expect(await readJson(appDataConfig)).toEqual({ preferences: {} }); + expect(await readJson(privateConfig)).toEqual({ preferences: {} }); + }); + + it("registers in AppData while the package has no private copy", async () => { + const { home, env, appDataConfig, privateConfig } = await windowsProfile(); + await writeJson(join(dirname(privateConfig), "Preferences"), {}); + await writeJson(appDataConfig, { preferences: { theme: "light" } }); + + const client = claudeDesktop(home, env); + expect(client.configPath).toBe(appDataConfig); + expect(client.msixPackage?.processView).toBe("direct"); + await expect( + configureClientConfiguration(client, {}, command), + ).resolves.toMatchObject({ status: "configured" }); + + expect(await readJson(appDataConfig)).toEqual({ + preferences: { theme: "light" }, + mcpServers: { rea: reaEntry }, + }); + await expect(access(privateConfig)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); + +describe("MSIX Claude Desktop uninstall", () => { + it("refuses uninstall when the package view is unknown", async () => { + const { home, env, appDataConfig, container } = await windowsProfile(); + await mkdir(container, { recursive: true }); + await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } }); + + const result = await runUninstall( + false, + systemUninstallHost(home, undefined, env, "win32"), + ); + + expect(result.status).toBe("failed"); + expect(result.items).toContainEqual( + expect.objectContaining({ + name: "claude_desktop", + status: "failed", + detail: expect.stringContaining("rerun uninstall"), + }), + ); + expect(await readJson(appDataConfig)).toEqual({ + mcpServers: { rea: reaEntry }, + }); + }); + + it("retains an unrelated rea-named entry when the package view is unknown", async () => { + const { home, env, appDataConfig, container } = await windowsProfile(); + await mkdir(container, { recursive: true }); + const unrelated = { mcpServers: { rea: { command: "unrelated-server" } } }; + await writeJson(appDataConfig, unrelated); + const result = await runUninstall( + false, + systemUninstallHost(home, undefined, env, "win32"), + ); + expect(result.status).toBe("complete"); + expect(await readJson(appDataConfig)).toEqual(unrelated); + }); + + it("blocks managed skill removal through redirected AppData before any removal", async () => { + const { home, env, appDataConfig, privateConfig } = await windowsProfile(); + await writeJson(join(dirname(privateConfig), "Preferences"), {}); + await mkdir(dirname(dirname(appDataConfig)), { recursive: true }); + await symlink(dirname(privateConfig), dirname(appDataConfig), "dir"); + const agents = join(env.APPDATA, "dsh", "agents"); + const manifest = join( + agents, + "skills", + "reverse-engineer-anything", + "SKILL.md", + ); + await mkdir(dirname(manifest), { recursive: true }); + await writeFile( + manifest, + "---\nname: reverse-engineer-anything\n---\nmanaged fixture\n", + ); + const environment = { ...env, DSH_AGENTS_HOME: agents }; + const result = await runUninstall( + false, + systemUninstallHost(home, undefined, environment, "win32"), + ); + expect(result.status).toBe("failed"); + expect(result.items).toContainEqual( + expect.objectContaining({ + name: "skill", + status: "failed", + detail: expect.stringContaining("redirects its writes"), + }), + ); + expect(await readFile(manifest, "utf8")).toContain("managed fixture"); + }); + + it("removes REA from both the private copy and AppData on uninstall", async () => { + const { home, env, appDataConfig, privateConfig } = await windowsProfile(); + await writeJson(privateConfig, { + preferences: {}, + mcpServers: { other: { command: "other" }, rea: reaEntry }, + }); + await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } }); + + const result = await runUninstall( + false, + systemUninstallHost(home, undefined, env, "win32"), + ); + + expect(result.status).toBe("complete"); + expect(await readJson(privateConfig)).toEqual({ + preferences: {}, + mcpServers: { other: { command: "other" } }, + }); + expect(await readJson(appDataConfig)).toEqual({ mcpServers: {} }); + }); +}); diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index ae057bab2..171761478 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -25,6 +25,7 @@ beforeEach(async () => { HOME: home, USERPROFILE: home, APPDATA: join(home, "AppData", "Roaming"), + LOCALAPPDATA: join(home, "AppData", "Local"), CLAUDE_CONFIG_DIR: home, CODEX_HOME: join(home, ".codex"), COPILOT_HOME: join(home, ".copilot"), From e3a012d6c98c60f3254e41e8b7d9c9f66e7cbe17 Mon Sep 17 00:00:00 2001 From: Quentin Limo <85309073+3QL@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:13:22 +0200 Subject: [PATCH 75/92] fix(flutter): reject ambiguous ELF mappings and bound note reads (#2334) * fix(flutter): reject ambiguous mappings and bound ELF notes Resolve PT_LOAD interpretations once for symbols and dynamic tables, including partial overlaps. Preserve equivalent mappings and leave conflicting or invalid alternate definitions unresolved. Bound distinct nonempty ELF note ranges before retention and reads, deduplicate shared ranges, preserve observed build-ID candidates and report partial coverage on exhaustion. Correct shared verifier workspace ownership and structured Windows path assertions. Addresses the two post-merge review findings on PR #1582. Validation: 147 focused cases across Linux, macOS Sonoma 14.8.9 and non-administrator Windows 11 build 26300.9457; real Gallery CLI/MCP checks on all three hosts; six compiler-produced ELF32/ELF64 hash-layout cases; static, docs, discovery and duplication checks. Full string-pool projections remain explicitly bounded. * test(flutter): parse complete facts through named result schemas --------- Co-authored-by: me Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/flutter-analysis.md | 17 ++- scripts/verify-flutter.mjs | 30 +++--- src/flutter/DartElf.test.ts | 91 ++++++++++++++++ src/flutter/DartElf.ts | 92 ++++++++-------- src/flutter/ElfBuildId.ts | 22 +++- src/flutter/ElfLoadMap.ts | 101 ++++++++++++++++++ src/flutter/FlutterBuild.fixture.ts | 47 ++++++++ tests/acceptance/flutterAnalysis.test.ts | 86 +++++++++++++++ .../filesystem/flutterBuildProvider.test.ts | 13 ++- .../filesystem/flutterElfNotes.test.ts | 79 ++++++++++++++ 10 files changed, 510 insertions(+), 68 deletions(-) create mode 100644 src/flutter/ElfLoadMap.ts create mode 100644 tests/boundary/filesystem/flutterElfNotes.test.ts diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index ebf0ccd84..df68ee9cc 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -60,6 +60,10 @@ server; older servers keep the complete request after schema discovery. [Dart 2.14 layout](https://github.com/dart-lang/sdk/blob/2.14.0/runtime/vm/snapshot.h). Kind numbers vary across Dart releases; other values are preserved with partial coverage, without guessing their interpretation. + Address resolution examines every overlapping `PT_LOAD` interpretation, + including partial overlaps. Equivalent mappings are coalesced; conflicting + mappings leave affected symbols unresolved and qualify coverage. Dynamic + hash, symbol and string tables use the same mapping checks. A payload without exported snapshot symbols is refused rather than guessed at. - **The snapshot hash** from the header at the start of each exported data @@ -133,8 +137,11 @@ server; older servers keep the complete request after schema discovery. `resource_constraint`. - **Engine notes.** Engine libraries are spooled into the owned root. ELF headers, note tables and note payloads share an 8 MiB metadata budget; - up to 64 distinct build IDs are retained. Exceeding either bound preserves - the candidates already examined and reports partial coverage. + up to 64 distinct build IDs and 1024 distinct nonempty note ranges are retained. + The range cap bounds bookkeeping and separate positional reads even for tiny + overlapping declarations. Exact duplicates share one read and empty ranges + consume no inventory slots. Exceeding a bound preserves the candidates examined + from retained ranges and reports partial coverage with `build_id: null`. - **Metadata and candidate capacity.** Both operations refuse duplicate relevant library paths. Inventory is limited to 32 ABIs and 1024 bytes per relevant library path, independently of compressed size. Identification @@ -174,3 +181,9 @@ engine build-ids, no labeled Dart version (as expected for a release engine), and the Android clang toolchain line; a plain aapt2-built APK reports not-detected with complete coverage. The suite uses self-contained synthetic ELF images and scanner payloads; it does not read private local files. + +The Gallery workflow and all 147 focused Flutter tests also pass on macOS Sonoma +14.8.9 x86-64 and Windows 11 x64 build 26300.9457 guests with Node 24.18.0, +including the conflicting-load and note-range-limit CLI/MCP regressions. Windows +verification runs under a non-administrator account. Verifiers use a shared +temporary parent so each CLI/MCP process can own and release its own snapshots. diff --git a/scripts/verify-flutter.mjs b/scripts/verify-flutter.mjs index 061ad8b20..76b5a1fda 100644 --- a/scripts/verify-flutter.mjs +++ b/scripts/verify-flutter.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { readdir, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { toolContract } from "../dist/contracts/toolContracts.js"; -import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; import { hashStableFile } from "../dist/filesystem/StableFileHash.js"; import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; @@ -27,17 +27,16 @@ const expectations = options.expectations === null ? null : JSON.parse(await readFile(options.expectations, "utf8")); -const root = await PrivateRuntimeRoot.create({ - prefix: "rea-flutter-verifier-", -}); +// This is a shared test parent, not a leased provider-owned Windows runtime. +const root = await mkdtemp(join(tmpdir(), "rea-flutter-verifier-")); const entrypoint = resolve("scripts/rea.mjs"); const environment = Object.fromEntries( Object.entries(process.env).filter(([, value]) => typeof value === "string"), ); Object.assign(environment, { - TMPDIR: root.path, - TMP: root.path, - TEMP: root.path, + TMPDIR: root, + TMP: root, + TEMP: root, }); const client = new Client({ name: "flutter-verifier", version: "1" }); const transport = new StdioClientTransport({ @@ -190,15 +189,18 @@ try { } finally { // Server shutdown retries retained provider cleanup and must settle before success. try { - await client.close(); - await transport.close(); + try { + await client.close(); + } finally { + await transport.close(); + } assert.deepEqual( - await readdir(root.path), + await readdir(root), [], - "Provider snapshots must be released before verification succeeds", + `Provider snapshots remain under ${root}`, ); } finally { - await root.close(); + await rm(root, { recursive: true, force: true }); } } process.stdout.write( diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts index b2b777de3..ff2724695 100644 --- a/src/flutter/DartElf.test.ts +++ b/src/flutter/DartElf.test.ts @@ -6,6 +6,7 @@ import { buildElf64Image, buildStrippedElf64Image, snapshotSection, + appendElfRanges, } from "./FlutterBuild.fixture.js"; const readElfSymbol = async (bytes: Buffer, name: string) => { @@ -143,6 +144,96 @@ describe("readElfSymbol", () => { }); describe("ELF symbol-table ownership", () => { + it.each([ + { address: 0x400, offset: 0x700, size: 52 }, + { address: 0x410, offset: 0x710, size: 4 }, + { address: 0x3f0, offset: 0x6f0, size: 20 }, + ])( + "rejects conflicting interpretations of any symbol byte: %j", + async (range) => { + const image = appendElfRanges(imageWithSections(), "program", [ + { type: 1, ...range }, + ]); + const names = ["_kDartVmSnapshotData", "_kDartIsolateSnapshotData"]; + const result = await readElfSymbols(image, names); + expect(result.symbols).toEqual([ + { name: names[1], offset: 0x800, size: 52 }, + ]); + expect(result.failures).toContainEqual({ + reason: expect.stringContaining("conflicting PT_LOAD"), + }); + const phoff = Number(image.readBigUInt64LE(0x20)); + const first = Buffer.from(image.subarray(phoff, phoff + 56)); + image.copy(image, phoff, phoff + 56, phoff + 112); + first.copy(image, phoff + 56); + expect(await readElfSymbols(image, names)).toEqual(result); + }, + ); + + it("accepts equivalent overlaps and excludes adjacent unrelated mappings", async () => { + const image = appendElfRanges(imageWithSections(), "program", [ + { type: 1, address: 0x410, offset: 0x410, size: 4 }, + { type: 1, address: 0x400 + 52, offset: 0x700, size: 4 }, + ]); + expect(await readElfSymbols(image, ["_kDartVmSnapshotData"])).toEqual({ + symbols: [{ name: "_kDartVmSnapshotData", offset: 0x400, size: 52 }], + failures: [], + }); + }); + + it("does not publish an earlier definition when another definition has unresolved mapping", async () => { + const image = imageWithSections(); + const shoff = Number(image.readBigUInt64LE(0x28)); + const symbols = Number(image.readBigUInt64LE(shoff + 64 + 0x18)); + image.writeUInt32LE(image.readUInt32LE(symbols), symbols + 24); + const conflict = appendElfRanges(image, "program", [ + { type: 1, address: 0x810, offset: 0x710, size: 4 }, + ]); + const result = await readElfSymbols(conflict, ["_kDartVmSnapshotData"]); + expect(result.symbols).toEqual([]); + expect(result.failures).toEqual([ + { + reason: "Symbol _kDartVmSnapshotData has conflicting PT_LOAD mappings", + }, + ]); + }); + + it("handles zero-width symbols at a unique segment end without choosing a conflicting shared endpoint", async () => { + const image = imageWithSections(); + const shoff = Number(image.readBigUInt64LE(0x28)); + const symbols = Number(image.readBigUInt64LE(shoff + 64 + 0x18)); + const at = Number(image.readBigUInt64LE(64 + 0x20)); + image.writeBigUInt64LE(BigInt(at), symbols + 8); + image.writeBigUInt64LE(0n, symbols + 16); + expect( + (await readElfSymbols(image, ["_kDartVmSnapshotData"])).symbols, + ).toEqual([{ name: "_kDartVmSnapshotData", offset: at, size: 0 }]); + const ambiguous = appendElfRanges(image, "program", [ + { type: 1, address: at, offset: 0x700, size: 4 }, + ]); + expect( + (await readElfSymbols(ambiguous, ["_kDartVmSnapshotData"])).symbols, + ).toEqual([]); + }); + + it.each(["hash", "symbols", "strings"] as const)( + "rejects partially conflicting dynamic %s mappings", + async (table) => { + const image = strippedImageWithSections(); + const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); + const tagIndex = table === "hash" ? 4 : table === "symbols" ? 0 : 1; + const at = Number(image.readBigUInt64LE(dynamic + tagIndex * 16 + 8)); + const conflict = appendElfRanges(image, "program", [ + { type: 1, address: at + 1, offset: 0x700, size: 1 }, + ]); + const result = await readElfSymbols(conflict, ["_kDartVmSnapshotData"]); + expect(result.symbols).toEqual([]); + expect(result.failures).toContainEqual({ + reason: expect.stringContaining("conflicting PT_LOAD"), + }); + }, + ); + it.each([false, true])( "keeps a symbol terminator inside dynstr (stripped: %s)", async (stripped) => { diff --git a/src/flutter/DartElf.ts b/src/flutter/DartElf.ts index a9bf00fa5..b1c6fc584 100644 --- a/src/flutter/DartElf.ts +++ b/src/flutter/DartElf.ts @@ -1,5 +1,6 @@ import { Buffer } from "node:buffer"; import { setImmediate as yieldToEventLoop } from "node:timers/promises"; +import { createElfLoadMap } from "./ElfLoadMap.js"; import { SNAPSHOT_HASH_LENGTH, SNAPSHOT_HASH_OFFSET, @@ -205,6 +206,7 @@ export const readElfSymbols = async ( vaddr === null || offset === null || filesz === null || + !Number.isSafeInteger(vaddr + filesz) || offset > bytes.length || filesz > bytes.length - offset ) @@ -214,17 +216,7 @@ export const readElfSymbols = async ( return failure("Multiple PT_DYNAMIC segments are ambiguous"); else dynamicSegment = { offset, filesz }; } - const toOffset = (vaddr: number, size: number): number | null => { - const segment = segments.find( - (candidate) => - vaddr >= candidate.vaddr && - vaddr - candidate.vaddr <= candidate.filesz && - size <= candidate.filesz - (vaddr - candidate.vaddr), - ); - return segment === undefined - ? null - : segment.offset + vaddr - segment.vaddr; - }; + const toOffset = await createElfLoadMap(segments, signal); if (segments.length === 0) return failure("ELF image has no PT_LOAD segments"); const tables: { @@ -335,26 +327,25 @@ export const readElfSymbols = async ( const gnu = tags.get(0x6ffffef5); if (sysv !== undefined) { const at = toOffset(sysv, 8); - if (at === null) return failure("DT_HASH header is not file-backed"); - const buckets = reader.u32(at)!; - count = reader.u32(at + 4)!; - if (toOffset(sysv, 8 + 4 * (buckets + count)) === null) - return failure("DT_HASH table is truncated"); + if (!at.ok) return failure(`DT_HASH header ${at.error.reason}`); + const buckets = reader.u32(at.value)!; + count = reader.u32(at.value + 4)!; + const table = toOffset(sysv, 8 + 4 * (buckets + count)); + if (!table.ok) return failure(`DT_HASH table ${table.error.reason}`); } else if (gnu !== undefined) { const at = toOffset(gnu, 16); - if (at === null) return failure("DT_GNU_HASH header is not file-backed"); - const buckets = reader.u32(at)!; - const firstSymbol = reader.u32(at + 4)!; - const bloomWords = reader.u32(at + 8)!; + if (!at.ok) return failure(`DT_GNU_HASH header ${at.error.reason}`); + const buckets = reader.u32(at.value)!; + const firstSymbol = reader.u32(at.value + 4)!; + const bloomWords = reader.u32(at.value + 8)!; const prefixBytes = 16 + bloomWords * (is64 ? 8 : 4) + buckets * 4; - if ( - buckets === 0 || - bloomWords === 0 || - toOffset(gnu, prefixBytes) === null - ) + if (buckets === 0 || bloomWords === 0) return failure("DT_GNU_HASH buckets are malformed"); + const prefix = toOffset(gnu, prefixBytes); + if (!prefix.ok) + return failure(`DT_GNU_HASH buckets ${prefix.error.reason}`); let last = 0; - const bucketsAt = at + 16 + bloomWords * (is64 ? 8 : 4); + const bucketsAt = at.value + 16 + bloomWords * (is64 ? 8 : 4); for (let index = 0; index < buckets; index += 1) { if (index % 4096 === 0) { await yieldToEventLoop(); @@ -374,9 +365,10 @@ export const readElfSymbols = async ( signal?.throwIfAborted(); } const span = prefixBytes + (index - firstSymbol + 1) * 4; - if (toOffset(gnu, span) === null) - return failure("DT_GNU_HASH chain is unterminated"); - const chain = reader.u32(at + span - 4)!; + const range = toOffset(gnu, span); + if (!range.ok) + return failure(`DT_GNU_HASH chain ${range.error.reason}`); + const chain = reader.u32(at.value + span - 4)!; index += 1; if ((chain & 1) !== 0) { count = index; @@ -388,14 +380,22 @@ export const readElfSymbols = async ( return failure("Stripped ELF has no DT_HASH or DT_GNU_HASH symbol count"); const offset = toOffset(sym, count * entrySize); const stringOffset = toOffset(str, stringSize); - if (offset === null || stringOffset === null) - return failure("Dynamic symbol or string table is not file-backed"); - tables.push({ offset, count, stringOffset, stringSize }); + if (!offset.ok) + return failure(`Dynamic symbol table ${offset.error.reason}`); + if (!stringOffset.ok) + return failure(`Dynamic string table ${stringOffset.error.reason}`); + tables.push({ + offset: offset.value, + count, + stringOffset: stringOffset.value, + stringSize, + }); } const wanted = new Set(symbolNames); const symbols = new Map(); const ambiguous = new Set(); - const failures: ElfParseFailure[] = []; + const unresolved = new Set(); + const failures = new Set(); for (const table of tables) { for (let index = 0; index < table.count; index += 1) { if (index % 4096 === 0) { @@ -419,33 +419,37 @@ export const readElfSymbols = async ( const value = is64 ? reader.u64(base + 8) : reader.u32(base + 4); const size = is64 ? reader.u64(base + 16) : reader.u32(base + 8); if (value === null || size === null) { - failures.push({ reason: `Symbol ${name} has unrepresentable bounds` }); + failures.add(`Symbol ${name} has unrepresentable bounds`); + unresolved.add(name); continue; } const offset = toOffset(value, size); - if (offset === null) { - failures.push({ reason: `Symbol ${name} has no file-backed range` }); + if (!offset.ok) { + failures.add(`Symbol ${name} ${offset.error.reason}`); + unresolved.add(name); continue; } const previous = symbols.get(name); if ( previous !== undefined && - (previous.offset !== offset || previous.size !== size) + (previous.offset !== offset.value || previous.size !== size) ) ambiguous.add(name); - else symbols.set(name, { name, offset, size }); + else symbols.set(name, { name, offset: offset.value, size }); } } for (const name of ambiguous) { symbols.delete(name); - failures.push({ reason: `Symbol ${name} has conflicting definitions` }); + failures.add(`Symbol ${name} has conflicting definitions`); } + for (const name of unresolved) symbols.delete(name); for (const name of wanted) - if (!symbols.has(name)) - failures.push({ - reason: `symbol ${name} is not in the dynamic symbol table`, - }); - return { symbols: [...symbols.values()], failures }; + if (!symbols.has(name) && !ambiguous.has(name) && !unresolved.has(name)) + failures.add(`symbol ${name} is not in the dynamic symbol table`); + return { + symbols: [...symbols.values()], + failures: [...failures].map((reason) => ({ reason })), + }; }; /** Read one Dart snapshot section header from its file offset. */ diff --git a/src/flutter/ElfBuildId.ts b/src/flutter/ElfBuildId.ts index 3c04d0473..a7eb6145d 100644 --- a/src/flutter/ElfBuildId.ts +++ b/src/flutter/ElfBuildId.ts @@ -8,6 +8,9 @@ import { const MAX_METADATA_BYTES = 8 * 1024 * 1024; const MAX_BUILD_IDS = 64; +// Range bookkeeping and separate positional reads need a budget independent +// of payload bytes: tiny/overlapping declarations can otherwise fan out I/O. +const MAX_NOTE_RANGES = 1024; export interface ElfBuildIds { readonly candidates: readonly string[]; @@ -21,9 +24,10 @@ export const readElfBuildIds = async ( signal: AbortSignal | undefined, ): Promise => { const candidates = new Set(); + const failures: ElfParseFailure[] = []; const failure = (reason: string): ElfBuildIds => ({ candidates: [...candidates], - failures: [{ reason }], + failures: [...failures, { reason }], }); let examined = 0; const read = async (offset: number, size: number): Promise => { @@ -59,7 +63,7 @@ export const readElfBuildIds = async ( if (parsed.failure !== undefined) return failure(parsed.failure.reason); const h = parsed.header; const ranges = new Map(); - for (const table of [ + tables: for (const table of [ { offset: h.e_phoff, width: h.e_phentsize, @@ -105,7 +109,17 @@ export const readElfBuildIds = async ( size > totalBytes - offset ) return failure("ELF note range is not file-backed"); - ranges.set(`${offset}:${size}`, { offset, size }); + if (size === 0) continue; + const key = `${offset}:${size}`; + if (ranges.has(key)) continue; + if (ranges.size === MAX_NOTE_RANGES) { + failures.push({ + reason: + "ELF notes exceed the 1024-range note-range inventory/read budget", + }); + break tables; + } + ranges.set(key, { offset, size }); } } const align4 = (value: number): number => Math.ceil(value / 4) * 4; @@ -154,5 +168,5 @@ export const readElfBuildIds = async ( } } signal?.throwIfAborted(); - return { candidates: [...candidates], failures: [] }; + return { candidates: [...candidates], failures }; }; diff --git a/src/flutter/ElfLoadMap.ts b/src/flutter/ElfLoadMap.ts new file mode 100644 index 000000000..541e2dfdf --- /dev/null +++ b/src/flutter/ElfLoadMap.ts @@ -0,0 +1,101 @@ +import { setImmediate as yieldToEventLoop } from "node:timers/promises"; +import { err, ok, type Result } from "../domain/result.js"; + +interface LoadSegment { + readonly vaddr: number; + readonly offset: number; + readonly filesz: number; +} + +/** Normalize overlapping file-backed interpretations once, before any lookups. */ +export const createElfLoadMap = async ( + segments: readonly LoadSegment[], + signal?: AbortSignal, +): Promise< + (address: number, size: number) => Result +> => { + const events = segments + .filter((segment) => segment.filesz > 0) + .flatMap((segment) => [ + { at: segment.vaddr, delta: segment.offset - segment.vaddr, change: 1 }, + { + at: segment.vaddr + segment.filesz, + delta: segment.offset - segment.vaddr, + change: -1, + }, + ]) + .sort((left, right) => left.at - right.at); + const active = new Map(); + const spans: { start: number; end: number; delta: number | null }[] = []; + let previous = 0; + for (let index = 0; index < events.length; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const event = events[index]!; + if (event.at > previous && active.size > 0) { + const delta = active.size === 1 ? active.keys().next().value! : null; + const last = spans.at(-1); + if (last?.end === previous && last.delta === delta) last.end = event.at; + else spans.push({ start: previous, end: event.at, delta }); + } + const count = (active.get(event.delta) ?? 0) + event.change; + if (count === 0) active.delete(event.delta); + else active.set(event.delta, count); + previous = event.at; + } + // Prefix counts keep growing GNU-hash range checks logarithmic even with + // many load segments. Null denotes conflicting interpretations, not a hole. + const conflicts = [0]; + for (const span of spans) + conflicts.push(conflicts.at(-1)! + Number(span.delta === null)); + const locate = (address: number): number => { + let low = 0, + high = spans.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (spans[middle]!.start <= address) low = middle + 1; + else high = middle; + } + return low - 1; + }; + signal?.throwIfAborted(); + return (address, size) => { + const missing = () => + err({ reason: "has no contiguous file-backed PT_LOAD range" }); + const ambiguous = () => err({ reason: "has conflicting PT_LOAD mappings" }); + if ( + !Number.isSafeInteger(address) || + !Number.isSafeInteger(size) || + address < 0 || + size < 0 || + !Number.isSafeInteger(address + size) + ) + return missing(); + const first = locate(address); + const span = spans[first]; + if (span === undefined || (size > 0 && address >= span.end)) + return missing(); + if (size === 0) { + // A zero-width symbol at a shared endpoint still needs one file offset. + if (address > span.end) return missing(); + const left = spans[first - 1]; + if ( + span.delta === null || + (span.start === address && + left?.end === address && + left.delta !== span.delta) + ) + return ambiguous(); + return ok(address + span.delta); + } + const last = locate(address + size - 1); + if (conflicts[last + 1]! !== conflicts[first]!) return ambiguous(); + return span.delta !== null && + address < span.end && + size <= span.end - address + ? ok(address + span.delta) + : missing(); + }; +}; diff --git a/src/flutter/FlutterBuild.fixture.ts b/src/flutter/FlutterBuild.fixture.ts index 635ea04fb..27c43c3bb 100644 --- a/src/flutter/FlutterBuild.fixture.ts +++ b/src/flutter/FlutterBuild.fixture.ts @@ -265,3 +265,50 @@ export const engineFixtureImage = (options: { labels.copy(image, noteAt + options.notes.length); return image; }; + +/** Relocate one table and append declarations without disturbing payload bytes. */ +export const appendElfRanges = ( + image: Buffer, + table: "program" | "section", + ranges: readonly { + type: number; + offset: number; + size: number; + address?: number; + }[], +): Buffer => { + const is64 = image[4] === 2; + const program = table === "program"; + const offsetAt = program ? (is64 ? 0x20 : 0x1c) : is64 ? 0x28 : 0x20; + const widthAt = program ? (is64 ? 0x36 : 0x2a) : is64 ? 0x3a : 0x2e; + const countAt = widthAt + 2; + const oldOffset = is64 + ? Number(image.readBigUInt64LE(offsetAt)) + : image.readUInt32LE(offsetAt); + const oldWidth = image.readUInt16LE(widthAt); + const oldCount = image.readUInt16LE(countAt); + const width = oldWidth || (is64 ? 64 : 40); + const count = oldCount || 1; // A section table starts with its null entry. + const offset = Math.ceil(image.length / 8) * 8; + const result = Buffer.alloc(offset + (count + ranges.length) * width); + image.copy(result); + image.copy(result, offset, oldOffset, oldOffset + oldCount * oldWidth); + const word = (value: number, at: number): void => { + if (is64) result.writeBigUInt64LE(BigInt(value), at); + else result.writeUInt32LE(value, at); + }; + word(offset, offsetAt); + result.writeUInt16LE(width, widthAt); + result.writeUInt16LE(count + ranges.length, countAt); + ranges.forEach((range, index) => { + const at = offset + (count + index) * width; + result.writeUInt32LE(range.type, at + (program ? 0 : 4)); + word(range.offset, at + (program ? (is64 ? 8 : 4) : is64 ? 0x18 : 0x10)); + word( + range.size, + at + (program ? (is64 ? 0x20 : 0x10) : is64 ? 0x20 : 0x14), + ); + if (program) word(range.address ?? 0, at + (is64 ? 0x10 : 8)); + }); + return result; +}; diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts index 11671785e..5e9a8889b 100644 --- a/tests/acceptance/flutterAnalysis.test.ts +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -7,6 +7,7 @@ import { aotFixtureImage, engineFixtureImage, REFERENCE_HASH, + appendElfRanges, } from "../../src/flutter/FlutterBuild.fixture.js"; import { createFlutterApk, @@ -159,3 +160,88 @@ cliTest( ).toEqual([4, 4]); }, ); + +cliTest( + "CLI and MCP qualify conflicting load mappings and bounded ELF notes", + async ({ cli }) => { + const note = Buffer.from( + "040000000800000003000000474e55000123456789abcdef", + "hex", + ); + const engine = engineFixtureImage({ + notes: Buffer.concat([note, Buffer.alloc(2000)]), + }); + const denseEngine = appendElfRanges( + engine, + "program", + Array.from({ length: 2000 }, (_, index) => ({ + type: 4, + offset: 0x400 + note.length + index, + size: 1, + })), + ); + const { call } = await connectLocalToolsMcp(); + for (const conflict of [false, true]) { + const image = appendElfRanges(aotFixtureImage(), "program", [ + { + type: 1, + address: 0x2010, + offset: conflict ? 0x1010 : 0x2010, + size: 4, + }, + ]); + const path = await createFlutterApk(`load-${String(conflict)}.apk`, [ + { path: "lib/x86_64/libapp.so", bytes: image }, + { + path: "lib/x86_64/libflutter.so", + bytes: conflict ? denseEngine : engine, + }, + ]); + for (const name of [ + "inspect_dart_aot", + "identify_flutter_build", + ] as const) { + const result = await cli.run({ + arguments: [name.replaceAll("_", "-"), path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract(name).outputSchema.parse(result.json); + const response = await call(name, { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcp = parseEvidence(response.structuredContent); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + expect(mcp.evidence_id).toBe(evidence.evidence_id); + expect(mcp.subject).toEqual(evidence.subject); + expect(mcp.limitations).toEqual(evidence.limitations); + expect(evidence.normalized_result.coverage).toBe( + conflict ? "partial" : "complete", + ); + if (name === "inspect_dart_aot") { + const facts = flutterResultSchemas[name].parse( + evidence.normalized_result, + ); + expect(facts.libapp.sections).toHaveLength(conflict ? 3 : 4); + expect(facts.string_pool.coverage).toBe( + conflict ? "partial" : "complete", + ); + if (conflict) { + expect(facts.string_pool.package_uris).toEqual([]); + expect(evidence.limitations.join("\n")).toContain( + "conflicting PT_LOAD", + ); + } + } else { + const facts = flutterResultSchemas[name].parse( + evidence.normalized_result, + ); + expect(facts.abis[0]?.libflutter).toMatchObject({ + build_id: conflict ? null : "0123456789abcdef", + build_id_candidates: ["0123456789abcdef"], + }); + if (conflict) + expect(evidence.limitations.join("\n")).toContain("note-range"); + } + } + } + }, +); diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 68e0328c4..3773378f2 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -1123,11 +1123,16 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( if (result.ok) throw new Error("Duplicate libraries must be refused"); expect(projectAnalysisError(result.error)).toMatchObject({ code: "invalid_request", + details: { + issues: [ + { + message: expect.stringContaining( + `duplicate Flutter library members: ${path}:`, + ), + }, + ], + }, }); - expect(JSON.stringify(projectAnalysisError(result.error))).toContain( - "duplicate Flutter library members", - ); - expect(JSON.stringify(projectAnalysisError(result.error))).toContain(path); expect(extract).not.toHaveBeenCalled(); }, ); diff --git a/tests/boundary/filesystem/flutterElfNotes.test.ts b/tests/boundary/filesystem/flutterElfNotes.test.ts new file mode 100644 index 000000000..bdf461117 --- /dev/null +++ b/tests/boundary/filesystem/flutterElfNotes.test.ts @@ -0,0 +1,79 @@ +import { open, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { readElfBuildIds } from "../../../src/flutter/ElfBuildId.js"; +import { + appendElfRanges, + engineFixtureImage, +} from "../../../src/flutter/FlutterBuild.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const note = Buffer.from( + "040000000800000003000000474e55000123456789abcdef", + "hex", +); + +it.each([ + { bits: 32, table: "program" }, + { bits: 64, table: "section" }, +] as const)( + "bounds dense $bits-bit $table note ranges while retaining examined IDs", + async ({ bits, table }) => { + const count = 30_000; + const image = appendElfRanges( + engineFixtureImage({ + bits, + notes: Buffer.concat([note, Buffer.alloc(count)]), + ownership: "both", + }), + table, + Array.from({ length: count }, (_, index) => ({ + type: table === "program" ? 4 : 7, + offset: 0x400 + note.length + index, + size: 1, + })), + ); + const root = await createTestTempDirectory("rea-flutter-notes-"); + const path = join(root, "engine.so"); + await writeFile(path, image); + const file = await open(path, "r"); + const read = vi.spyOn(file, "read"); + try { + const result = await readElfBuildIds(file, image.length, undefined); + expect(result.candidates).toEqual(["0123456789abcdef"]); + expect(result.failures).toContainEqual({ + reason: expect.stringContaining("note-range"), + }); + expect(read.mock.calls.length).toBeLessThanOrEqual(1027); + } finally { + await file.close(); + } + }, +); + +it("deduplicates shared program/section notes and skips empty ranges before charging inventory", async () => { + const ranges = Array.from({ length: 2000 }, (_, index) => ({ + type: 7, + offset: index % 2 === 0 ? 0x400 : index, + size: index % 2 === 0 ? note.length : 0, + })); + const image = appendElfRanges( + engineFixtureImage({ notes: note, ownership: "both" }), + "section", + ranges, + ); + const root = await createTestTempDirectory("rea-flutter-notes-"); + const path = join(root, "engine.so"); + await writeFile(path, image); + const file = await open(path, "r"); + const read = vi.spyOn(file, "read"); + try { + expect(await readElfBuildIds(file, image.length, undefined)).toEqual({ + candidates: ["0123456789abcdef"], + failures: [], + }); + expect(read.mock.calls.length).toBe(4); + } finally { + await file.close(); + } +}); From 5278835b534a9787a680f94ad2182c314f24c1e9 Mon Sep 17 00:00:00 2001 From: Dragolone <160818051+Dragolone@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:14:07 +1100 Subject: [PATCH 76/92] fix(flutter): find snapshot headers that a read chunk boundary splits (#2804) * fix(flutter): find snapshot headers that a read chunk boundary splits Within one chunk the snapshot hash scanner resumes four bytes after each magic, but across chunks it resumed after the whole 52-byte record and dropped the carried bytes. A magic starting 49-51 bytes after a rejected (non-hex) record was therefore lost when its four bytes straddled a read boundary, so the result depended on chunking and a conflicting hash could be omitted while coverage stayed complete. Resume after the magic in both cases. * refactor(flutter): drop the snapshot scanner cursor the fix superseded After a complete record the carried tail is shorter than a magic, and a pending magic has not been processed, so every push can rescan its carried bytes from the start without counting a record twice. Remove the absolute cursor and base offset, and cover sections 49-51 bytes after a rejected record with two-chunk and byte-at-a-time feeds. --- src/flutter/FlutterPayloadScan.test.ts | 31 ++++++++++++++++++++++++++ src/flutter/FlutterPayloadScan.ts | 20 +++++------------ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/flutter/FlutterPayloadScan.test.ts b/src/flutter/FlutterPayloadScan.test.ts index 681a373a1..13bb4f743 100644 --- a/src/flutter/FlutterPayloadScan.test.ts +++ b/src/flutter/FlutterPayloadScan.test.ts @@ -70,6 +70,37 @@ describe("SnapshotHashScanner", () => { }); }); + it.each([49, 50, 51])( + "finds a section %i bytes after a rejected record at any chunking", + (start) => { + // A stray magic whose hash field is not hex, then a real section that + // starts inside the stray record's magic-to-hash span. + const payload = new Uint8Array(start + 52); + payload.set([0xf5, 0xf5, 0xdc, 0xdc], 0); + payload.set( + realSnapshotSection("65817c30a78bb44c3dc3771876b6010a"), + start, + ); + const whole = new SnapshotHashScanner(); + whole.push(payload); + expect(whole.result()).toEqual({ + capped: false, + sources: 1, + candidates: ["65817c30a78bb44c3dc3771876b6010a"], + }); + for (let split = 1; split < payload.length; split += 1) { + const scanner = new SnapshotHashScanner(); + scanner.push(payload.subarray(0, split)); + scanner.push(payload.subarray(split)); + expect(scanner.result(), `split at ${split}`).toEqual(whole.result()); + } + const bytewise = new SnapshotHashScanner(); + for (let offset = 0; offset < payload.length; offset += 1) + bytewise.push(payload.subarray(offset, offset + 1)); + expect(bytewise.result()).toEqual(whole.result()); + }, + ); + it("ignores a magic without a following hash", () => { const scanner = new SnapshotHashScanner(); scanner.push(Uint8Array.from([0xf5, 0xf5, 0xdc, 0xdc, 0xff, 0xff, 0xff])); diff --git a/src/flutter/FlutterPayloadScan.ts b/src/flutter/FlutterPayloadScan.ts index e1a69d594..a58ee5cc4 100644 --- a/src/flutter/FlutterPayloadScan.ts +++ b/src/flutter/FlutterPayloadScan.ts @@ -58,17 +58,16 @@ export class SnapshotHashScanner { private readonly candidates: string[] = []; private readonly candidateSet = new Set(); #buffer = new Uint8Array(0); - /** Absolute stream offset from which unprocessed bytes begin. */ - #base = 0; - #absoluteScanFrom = 0; push(chunk: Uint8Array): void { const joined = new Uint8Array(this.#buffer.length + chunk.length); joined.set(this.#buffer, 0); joined.set(chunk, this.#buffer.length); - // Resume after the last fully processed hash so carried bytes are not - // counted twice across chunk boundaries. - let offset = Math.max(0, this.#absoluteScanFrom - this.#base); + // Carried bytes are an unprocessed pending magic or too short to hold a + // magic, so rescanning them never counts a record twice. Within a chunk + // the scan resumes after each magic, so a magic inside a previous + // record's span is found wherever the chunk boundaries fall. + let offset = 0; let pendingMagic: number | null = null; for (;;) { const magic = findBytes(joined, DART_SNAPSHOT_MAGIC, offset); @@ -96,21 +95,14 @@ export class SnapshotHashScanner { // magic record whose field is not lowercase hex is not a source. if (isValidHash) this.#sources += 1; offset = magic + DART_SNAPSHOT_MAGIC.length; - this.#absoluteScanFrom = - this.#base + magic + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH; } // Keep a pending magic for the next chunk; otherwise keep only the // trailing bytes where a magic could still begin, so magic-free // streams never accumulate. const keepFrom = pendingMagic ?? - Math.max( - this.#absoluteScanFrom - this.#base, - joined.length - (DART_SNAPSHOT_MAGIC.length - 1), - ); + Math.max(0, joined.length - (DART_SNAPSHOT_MAGIC.length - 1)); this.#buffer = joined.slice(keepFrom); - this.#base += keepFrom; - if (pendingMagic !== null) this.#absoluteScanFrom = this.#base; } result(): SnapshotHashScan { From 30e9b83a68c4729745ca6413ea306dbffb30f07a Mon Sep 17 00:00:00 2001 From: GuoJun Tang <76832285+Melon-tgj@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:14:39 +0800 Subject: [PATCH 77/92] fix(ida): retain bounded stderr on upstream transport failures (#2449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ida): retain bounded stderr on upstream transport failures * fix(ida): preserve stderr through operation and cleanup boundaries Address review findings at their producing boundaries and preserve directly affected consumers. Source inspection and git diff --check completed. Tests and automated checks were not run at the user’s explicit request. * fix(ida): preserve cancellation and single-owner stderr diagnostics --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/ida/IdaMcpConnection.ts | 35 +++++- src/ida/IdaSessionClient.test.ts | 104 +++++++++++++++++ src/ida/IdaSessionClient.ts | 48 +++++++- tests/boundary/idaStartupDiagnostics.test.ts | 111 +++++++++++++++++++ 4 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 tests/boundary/idaStartupDiagnostics.test.ts diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index d532a66e8..8137edcc9 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -13,6 +13,7 @@ import { z } from "zod"; import type { IdaConfiguration } from "./IdaConfiguration.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; /** Injectable MCP boundary used by the provider and transport conformance tests. */ export interface IdaMcpConnection { @@ -142,8 +143,30 @@ export const createIdaMcpConnection = ( environment: Readonly, ): IdaMcpConnection => { const client = new Client({ name: "rea-ida-provider", version: "1" }); + let stderr = Buffer.alloc(0); + let stderrBytes = 0; const transportFailure = (cause: unknown): never => { - throw redactIdaTransportFailure(cause, config); + const failure = redactIdaTransportFailure(cause, config); + if (stderrBytes === 0) throw failure; + let start = 0; + // The byte tail may start inside a UTF-8 code point. Retain whole units. + if (stderrBytes > stderr.length) + while (start < stderr.length && (stderr[start]! & 0xc0) === 0x80) start++; + const diagnostic = redactIdaTransportFailure( + new Error(stderr.subarray(start).toString("utf8")), + config, + ).message; + throw new ProviderAdapterError("ida", "transport", { + cause: failure, + diagnostics: { reason: failure.message }, + capturedOutput: { + stdout: "", + stderr: diagnostic, + truncated: stderrBytes > stderr.length - start, + stdout_bytes: 0, + stderr_bytes: stderrBytes, + }, + }); }; const transport = "command" in config @@ -169,9 +192,15 @@ export const createIdaMcpConnection = ( : new StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers: config.headers, redirect: "error" }, }); - // Drain proxy diagnostics without persisting ambient environment or transport credentials. + // Keep a bounded diagnostic tail for genuine failures; never persist ambient variables. if (transport instanceof StdioClientTransport) - transport.stderr?.on("data", () => undefined); + transport.stderr?.on("data", (chunk: Buffer) => { + stderrBytes += chunk.length; + const combined = Buffer.concat([stderr, chunk]); + stderr = Buffer.from( + combined.subarray(Math.max(0, combined.length - 16_384)), + ); + }); return { async connect() { await client diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 0ae4002d2..c15b34bba 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -9,6 +9,7 @@ import { import { IdaSessionClient } from "./IdaSessionClient.js"; import { functionDossierSchema } from "../domain/hopperValues.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; const roots: string[] = []; @@ -1366,3 +1367,106 @@ describe("headless IDA cleanup verification", () => { expect(await readdir(root)).toEqual(["target.elf"]); }); }); + +it.each(["idb_close", "idb_list", "connection_close"])( + "preserves transport stderr through cleanup at %s", + async (stage) => { + const { producer, client } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + const failure = new ProviderAdapterError("ida", "transport", { + diagnostics: { + reason: "upstream transport closed", + }, + capturedOutput: { + stdout: "", + stderr: "IDA shutdown diagnostic", + truncated: false, + }, + }); + const originalClose = producer.close.bind(producer); + producer.beforeCall = async (name) => { + if (name === stage) throw failure; + }; + if (stage === "connection_close") + producer.close = async () => { + throw failure; + }; + try { + const result = await client.close(); + if (result.ok) throw new Error("expected cleanup failure"); + expect(result.error.capturedOutput?.stderr).toBe( + "IDA shutdown diagnostic", + ); + expect(result.error.diagnostics).toMatchObject({ + failure_reason: "upstream transport closed", + }); + expect(result.error.cleanupIncomplete).toBe(true); + } finally { + producer.beforeCall = undefined; + producer.close = originalClose; + await client.close(); + } + }, +); + +it("binds retained transport diagnostics to the requested operation and recovery", async () => { + const { producer, client } = await fixture("attached"); + await client.execute("health", {}); + producer.beforeCall = async (name) => { + if (name === "list_funcs") + throw new ProviderAdapterError("ida", "transport", { + diagnostics: { reason: "upstream closed" }, + capturedOutput: { + stdout: "", + stderr: "IDA diagnostic", + truncated: false, + }, + }); + }; + const result = await client.execute("list_procedures", {}); + if (result.ok) throw new Error("expected upstream failure"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { + operation: "list_procedures", + diagnostics: { + reason: "upstream closed", + recovery: expect.stringContaining("docs/ida-provider.md"), + }, + captured_output: { stderr: "IDA diagnostic" }, + }, + }); + await client.close(); +}); + +it("retains transport diagnostics on a cancellation regardless of stderr", async () => { + const { producer, client } = await fixture("attached"); + await client.execute("health", {}); + const controller = new AbortController(); + producer.beforeCall = async (name) => { + if (name === "list_funcs") { + controller.abort(); + throw new ProviderAdapterError("ida", "transport", { + diagnostics: { reason: "upstream closed after cancellation" }, + capturedOutput: { + stdout: "", + stderr: "shutdown diagnostic", + truncated: false, + }, + }); + } + }; + const result = await client.execute( + "list_procedures", + {}, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("expected cancellation"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + details: { + diagnostics: { reason: "upstream closed after cancellation" }, + captured_output: { stderr: "shutdown diagnostic" }, + }, + }); + await client.close(); +}); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index dea9e1d86..cff968132 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -111,10 +111,18 @@ class IdaCleanupError extends ProviderAdapterError { ) { super("ida", "close", { cause, + capturedOutput: + cause instanceof AnalysisError ? cause.capturedOutput : undefined, diagnostics: { + ...(cause instanceof AnalysisError ? cause.diagnostics : undefined), reason: "IDA cleanup could not be verified; the remaining connection or private workspace is retained for retry.", - failure_reason: cause instanceof Error ? cause.message : String(cause), + failure_reason: + cause instanceof AnalysisError + ? (cause.diagnostics?.reason ?? cause.message) + : cause instanceof Error + ? cause.message + : String(cause), workspace: path, ...(sessions === undefined ? {} @@ -375,15 +383,47 @@ export class IdaSessionClient implements AnalysisClient { context: IdaOperationFailureContext, ) { let failure: AnalysisError; - if ( + if (wasCancelled(options)) + failure = new AnalysisCancelledError(operation, { + cause, + ...(cause instanceof AnalysisError + ? { + capturedOutput: cause.capturedOutput, + diagnostics: cause.diagnostics, + partialObservation: cause.partialObservation, + cleanup: + cause.cleanup ?? + (cause.cleanupIncomplete + ? { + reason: cause.message, + resources: cause.cleanupResources, + } + : undefined), + } + : {}), + }); + else if ( + cause instanceof ProviderAdapterError && + cause.operation === "transport" + ) + failure = new ProviderAdapterError("ida", operation, { + cause, + capturedOutput: cause.capturedOutput, + partialObservation: cause.partialObservation, + cleanup: cause.cleanup, + diagnostics: { + ...cause.diagnostics, + recovery: + "Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.", + }, + }); + else if ( cause instanceof AnalysisError && (cause.partialObservation !== undefined || cause.cleanupIncomplete || cause.capturedOutput !== undefined) ) failure = cause; - else if (wasCancelled(options)) - failure = new AnalysisCancelledError(operation); else if (cause instanceof AnalysisError) failure = cause; else if (cause instanceof z.ZodError) failure = new AnalysisOutputError( diff --git a/tests/boundary/idaStartupDiagnostics.test.ts b/tests/boundary/idaStartupDiagnostics.test.ts new file mode 100644 index 000000000..8f36543b0 --- /dev/null +++ b/tests/boundary/idaStartupDiagnostics.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { createIdaMcpConnection } from "../../src/ida/IdaMcpConnection.js"; + +describe("IDA startup diagnostics", () => { + it("preserves the failing launcher's stderr instead of reporting only a closed transport", async () => { + const connection = createIdaMcpConnection( + { + command: process.execPath, + args: [ + "-e", + 'process.stderr.write("FATAL: IDA runtime initialization failed\\n", () => process.exit(2));', + ], + env: {}, + mode: "headless", + timeoutMs: 1000, + }, + {}, + ); + try { + await expect(connection.connect()).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + capturedOutput: { + stderr: expect.stringContaining("IDA runtime initialization failed"), + truncated: false, + }, + }); + } finally { + await connection.close(); + } + }); + + it("retains only the bounded stderr tail and reports the original byte count", async () => { + const connection = createIdaMcpConnection( + { + command: process.execPath, + args: [ + "-e", + 'process.stderr.write("x".repeat(20000) + "FATAL-END", () => process.exit(2));', + ], + env: {}, + mode: "headless", + timeoutMs: 1000, + }, + {}, + ); + try { + await expect(connection.connect()).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + capturedOutput: { + stderr: "x".repeat(16384 - "FATAL-END".length) + "FATAL-END", + stderr_bytes: 20000 + "FATAL-END".length, + truncated: true, + }, + }); + } finally { + await connection.close(); + } + }); +}); + +it("drops a leading partial UTF-8 character from the bounded stderr tail", async () => { + const connection = createIdaMcpConnection( + { + command: process.execPath, + args: [ + "-e", + 'process.stderr.write("€".repeat(6000), () => process.exit(2));', + ], + env: {}, + mode: "headless", + timeoutMs: 1000, + }, + {}, + ); + try { + await expect(connection.connect()).rejects.toMatchObject({ + capturedOutput: { + stderr: "€".repeat(Math.floor(16384 / 3)), + stderr_bytes: 18000, + truncated: true, + }, + }); + } finally { + await connection.close(); + } +}); + +it("retains an in-budget undecodable leading byte without claiming tail truncation", async () => { + const connection = createIdaMcpConnection( + { + command: process.execPath, + args: [ + "-e", + "process.stderr.write(Buffer.from([0x80, 0x61, 0x62]), () => process.exit(2));", + ], + env: {}, + mode: "headless", + timeoutMs: 1000, + }, + {}, + ); + try { + const failure = await connection.connect().catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + capturedOutput: { stderr: "�ab", stderr_bytes: 3, truncated: false }, + }); + expect(failure).not.toHaveProperty("diagnostics.upstream_stderr"); + } finally { + await connection.close(); + } +}); From b17487f482ce0769dd0c1415297f9c3e1bb7509f Mon Sep 17 00:00:00 2001 From: GuoJun Tang <76832285+Melon-tgj@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:15:01 +0800 Subject: [PATCH 78/92] fix(comparison): keep unsupported function facets unknown (#2450) * fix(comparison): keep unsupported function facets unknown * docs(comparison): explain function facet availability * fix(analysis): preserve unavailable facets across Evidence consumers --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/mcp-contracts.md | 14 ++++ src/contracts/functionComparisonExample.ts | 12 ++- src/domain/analysisView/analysisView.test.ts | 26 ++++++ src/domain/analysisView/nativeFunctionView.ts | 17 ++++ src/domain/callPath.test.ts | 18 ++++ src/domain/callPath.ts | 5 ++ src/domain/functionComparison.test.ts | 82 +++++++++++++++++++ src/domain/functionComparisonDimensions.ts | 62 +++++++++++++- src/domain/hopperValues.ts | 18 ++++ src/ida/IdaNativeProtocol.test.ts | 4 + src/ida/IdaOperationRunner.ts | 48 ++++++++++- .../providers/ida/calleeRelationships.test.ts | 16 ++++ 12 files changed, 318 insertions(+), 4 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index f3f7892f6..7c219f6be 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -21,6 +21,20 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to `compare_artifacts`. Use `get_evidence_bundle` when the task needs broader retained session history or an explicit bundle for transfer. +Function dossiers can declare per-facet coverage in the optional +`normalized_result.facet_availability` map. Each supplied facet is either +`{ "available": true }` or `{ "available": false, "reason": "..." }`. +Supported keys are `pseudocode`, `assembly`, `comments`, `callers`, `callees`, +`references`, `strings_names`, and `cfg`. Availability describes whether that +facet was observed, not whether its collection contains any entries. + +`compare_functions` keeps a dimension `unknown` with null counts when either +side declares a required facet unavailable. An empty legacy facet without a +coverage declaration is also unknown. A producer that actually observed an +empty facet can declare it available so observed absence remains comparable. +Call comparison additionally retains the Evidence relationship coverage +checks. IDA declares the facets its selected adapter cannot provide; an empty +unsupported array does not prove observed absence. Evidence and bundle imports reject unsupported fields in their envelopes, identities, manifests and other fixed metadata before authentication. Imports leave retained session state unchanged when validation fails. Keep application diff --git a/src/contracts/functionComparisonExample.ts b/src/contracts/functionComparisonExample.ts index 39dfe6f92..aefc75e1a 100644 --- a/src/contracts/functionComparisonExample.ts +++ b/src/contracts/functionComparisonExample.ts @@ -16,7 +16,7 @@ const dossier = (text: string) => locals: [], }, pseudocode: text, - assembly: [], + assembly: ["ret"], comments: [], callers: [], callees: [], @@ -27,6 +27,16 @@ const dossier = (text: string) => basic_blocks: [{ start: "0x1000", end: "0x1001", successors: [] }], native_api: null, native_value_flow: null, + facet_availability: { + pseudocode: { available: true }, + assembly: { available: true }, + comments: { available: true }, + callers: { available: true }, + callees: { available: true }, + references: { available: true }, + strings_names: { available: true }, + cfg: { available: true }, + }, limitations: ["The fixture omits native API and value-flow observations."], }); diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 07e6909c3..31711ecd4 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -792,3 +792,29 @@ it("rejects unsupported parent operations and incompatible collections", () => { error: { _tag: "AnalysisInputError" }, }); }); + +it("preserves selected native facet unavailability instead of an exhausted empty page", () => { + const parent = nativeParent({ + ...ghidraFunctionDossier(), + facet_availability: { + comments: { available: false, reason: "Typed comments unavailable" }, + references: { available: false, reason: "Typed references unavailable" }, + }, + }); + for (const [facet, reason] of [ + ["comments", "Typed comments unavailable"], + ["incoming_references", "Typed references unavailable"], + ] as const) { + const result = projectAnalysisView(parent, { + kind: "native", + facet, + offset: 0, + limit: 8, + }); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + item: { available: false, reason }, + unknowns: expect.arrayContaining([reason]), + }); + } +}); diff --git a/src/domain/analysisView/nativeFunctionView.ts b/src/domain/analysisView/nativeFunctionView.ts index b56de24a1..cac7ef82f 100644 --- a/src/domain/analysisView/nativeFunctionView.ts +++ b/src/domain/analysisView/nativeFunctionView.ts @@ -110,6 +110,22 @@ export const projectNativeFunctionView = ( ]), ); const common = parentFields(parent, dossier, artifact); + const availabilityKeys: Readonly> = { + incoming_references: "references", + outgoing_references: "references", + referenced_strings: "strings_names", + referenced_names: "strings_names", + basic_blocks: "cfg", + }; + const availabilityKey = availabilityKeys[view.facet] ?? view.facet; + const availability = + dossier.facet_availability?.[ + availabilityKey as keyof NonNullable< + FunctionDossier["facet_availability"] + > + ]; + if (availability?.available === false) + common.unknowns = uniqueSorted([...common.unknowns, availability.reason]); const singleton = (item: unknown): UnsignedAnalysisView => ({ kind: "native", view, @@ -117,6 +133,7 @@ export const projectNativeFunctionView = ( coverage: completeWithinViewCoverage(1, 1), ...common, }); + if (availability?.available === false) return ok(singleton(availability)); const flow = dossier.native_value_flow; if ( view.facet === "procedure" || diff --git a/src/domain/callPath.test.ts b/src/domain/callPath.test.ts index e78a5f936..80a260ac3 100644 --- a/src/domain/callPath.test.ts +++ b/src/domain/callPath.test.ts @@ -15,6 +15,7 @@ const observe = ( readonly evidenceLimitations?: readonly string[]; readonly dossierLimitations?: readonly string[]; readonly relationshipCoverage?: RelationshipCoverage; + readonly calleeUnavailable?: string; } = {}, ): Evidence => { const value = { @@ -52,6 +53,13 @@ const observe = ( native_value_flow: null, basic_blocks: [], limitations: [...(options.dossierLimitations ?? [])], + ...(options.calleeUnavailable === undefined + ? {} + : { + facet_availability: { + callees: { available: false, reason: options.calleeUnavailable }, + }, + }), } satisfies JsonValue; return createEvidence( { path: "/tmp/a", sha256: "a".repeat(64), format: "mach-o" }, @@ -541,3 +549,13 @@ describe("call path evidence admission", () => { ); }); }); + +it("keeps an absent path unknown when only callee facet availability is declared", () => { + const result = run([ + observe("0x1000", [], "rea-workflow", { + calleeUnavailable: "Graph callees unavailable", + }), + ]); + expect(result.status).toBe("unknown"); + expect(result.limitations).toContain("Graph callees unavailable"); +}); diff --git a/src/domain/callPath.ts b/src/domain/callPath.ts index 0bd5e88e1..3ffbc2356 100644 --- a/src/domain/callPath.ts +++ b/src/domain/callPath.ts @@ -253,6 +253,11 @@ const inspectSearch = ({ // cannot add a path to the goal, so its dossier limits are contextual // but do not make the searched start-to-goal closure incomplete. if (node === goal) continue; + const callees = snapshot.dossier.facet_availability?.callees; + if (callees?.available === false) { + limitations.push(callees.reason); + incompleteExpandedCoverage = true; + } // Explicit, Evidence-bound relationship coverage owns completeness; // generic provider/dossier limitations remain contextual. Legacy Evidence // without that metadata retains its conservative limitation-based rule. diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 954717bad..b71dcac1d 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -44,6 +44,16 @@ const dossier = ( referenced_names: [], native_api: null, native_value_flow: null, + facet_availability: { + pseudocode: { available: true }, + assembly: { available: true }, + comments: { available: true }, + callers: { available: true }, + callees: { available: true }, + references: { available: true }, + strings_names: { available: true }, + cfg: { available: true }, + }, basic_blocks: [ { start: base, @@ -119,6 +129,78 @@ const observe = ( ); describe("function comparison normalized identity", () => { + it("retains unresolved call details alongside missing facet coverage", () => { + const value: Record = { + ...dossier("return 0;", "0x1000"), + unresolved_calls: [{ address: "0x1010", reason: "Unknown indirect target" }], + }; + delete value.facet_availability; + const legacy = observe("a", functionDossierSchema.parse(value), "ida"); + const calls = compareFunctions(legacy, legacy).dimensions.find( + ({ dimension }) => dimension === "calls", + ); + expect(calls).toMatchObject({ + status: "unknown", + limitations: expect.arrayContaining([ + "Left: Unresolved call at 0x1010: Unknown indirect target", + "Left: callers: Provider did not declare coverage for this empty facet.", + ]), + }); + }); + it("keeps empty legacy facets without coverage declarations unknown", () => { + const value: Record = { + ...dossier("return 0;", "0x1000"), + basic_blocks: [], + }; + delete value.facet_availability; + const legacy = observe("a", functionDossierSchema.parse(value), "ida"); + const result = compareFunctions(legacy, legacy); + for (const dimension of [ + "comments", + "references", + "strings_names", + "cfg", + "calls", + ]) { + expect( + result.dimensions.find((item) => item.dimension === dimension), + ).toMatchObject({ status: "unknown" }); + } + }); + it("keeps unavailable facets unknown even when one side supplies values", () => { + const left = observe( + "a", + functionDossierSchema.parse({ + ...dossier("return 0;", "0x1000"), + facet_availability: { + comments: { available: false, reason: "Not collected" }, + }, + comments: [ + { address: "0x1000", kind: "comment", text: "partial record" }, + ], + }), + ); + const right = observe("b", dossier("return 0;", "0x2000")); + expect( + compareFunctions(left, right).dimensions.find( + ({ dimension }) => dimension === "comments", + ), + ).toMatchObject({ status: "unknown", left_count: null, right_count: null }); + }); + it("rejects malformed facet availability instead of treating it as absence", () => { + expect( + functionDossierSchema.safeParse({ + ...dossier("return 0;", "0x1000"), + facet_availability: { cfg: { available: false, reason: "" } }, + }).success, + ).toBe(false); + expect( + functionDossierSchema.safeParse({ + ...dossier("return 0;", "0x1000"), + facet_availability: { cfg: undefined }, + }).success, + ).toBe(false); + }); it("keeps differing generated names ambiguous and stable names mismatched", () => { const named = (base: "0x1000" | "0x2000", name: string) => functionDossierSchema.parse({ diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index c5a0f373f..f91057a6b 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -52,7 +52,67 @@ export const compareDimensions = ( compareReferences(left, right, context), compareStringsAndNames(left, right, context), compareCfg(left, right, context), - ]; + ].map((result) => { + const limitations = [ + ...facetLimitations(left, result.dimension).map( + (reason) => `Left: ${reason}`, + ), + ...facetLimitations(right, result.dimension).map( + (reason) => `Right: ${reason}`, + ), + ]; + return limitations.length === 0 + ? result + : unresolvedDimension({ + dimension: result.dimension, + links: context.links, + leftCount: null, + rightCount: null, + limitations: [...result.limitations, ...limitations], + }); + }); +}; + +/** An empty undeclared facet is not evidence of observed absence. */ +const facetLimitations = ( + snapshot: FunctionSnapshot, + dimension: DimensionName, +): string[] => { + const { dossier } = snapshot; + const fields = { + identity: [], + pseudocode: [["pseudocode", dossier.pseudocode.length]], + assembly: [["assembly", dossier.assembly.length]], + comments: [["comments", dossier.comments.length]], + calls: [ + ["callers", dossier.callers.length], + ["callees", dossier.callees.length], + ], + references: [ + [ + "references", + dossier.incoming_references.length + dossier.outgoing_references.length, + ], + ], + strings_names: [ + [ + "strings_names", + dossier.referenced_strings.length + dossier.referenced_names.length, + ], + ], + cfg: [["cfg", dossier.basic_blocks.length]], + } satisfies Record< + DimensionName, + [keyof NonNullable, number][] + >; + return fields[dimension].flatMap(([field, count]) => { + const availability = dossier.facet_availability?.[field]; + if (availability?.available === false) + return [`${field}: ${availability.reason}`]; + return availability === undefined && count === 0 + ? [`${field}: Provider did not declare coverage for this empty facet.`] + : []; + }); }; const compareText = ( diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index d6344ff3e..cb19ead3e 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -371,6 +371,11 @@ export const referenceEdgeSchema = z .exactOptional(), }) .strict(); +const facetAvailabilitySchema = z.discriminatedUnion("available", [ + z.object({ available: z.literal(true) }).strict(), + z.object({ available: z.literal(false), reason: z.string().min(1) }).strict(), +]); + export const functionDossierSchema = z .object({ procedure: procedureIdentitySchema.safeExtend({ @@ -428,6 +433,19 @@ export const functionDossierSchema = z ), native_api: nativeApiBoundarySchema.nullable(), native_value_flow: nativeValueFlowSchema.nullable(), + facet_availability: z + .object({ + pseudocode: facetAvailabilitySchema.exactOptional(), + assembly: facetAvailabilitySchema.exactOptional(), + comments: facetAvailabilitySchema.exactOptional(), + callers: facetAvailabilitySchema.exactOptional(), + callees: facetAvailabilitySchema.exactOptional(), + references: facetAvailabilitySchema.exactOptional(), + strings_names: facetAvailabilitySchema.exactOptional(), + cfg: facetAvailabilitySchema.exactOptional(), + }) + .strict() + .exactOptional(), limitations: z.array(z.string()), }) .strict(); diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index dc95727f6..384da0420 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -243,6 +243,10 @@ describe("native IDA provider observations", () => { expect(dossier.value.result).toMatchObject({ callers: [], callees: [{ address: "0x2000", name: "puts" }], + facet_availability: { + callers: { available: false }, + callees: { available: true }, + }, }); if (more) expect(dossier.value.limitations).toContain( diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 25a49c268..647b06e52 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -70,6 +70,9 @@ const identity = (fn: IdaFunction) => ({ interface IdaRelationships { readonly items: ReturnType[]; readonly coverage: RelationshipCoverage; + readonly availability: + | { readonly available: true } + | { readonly available: false; readonly reason: string }; } /** The only lookup answers upstream reports for a name or address that is not a function. */ @@ -469,7 +472,14 @@ export class IdaOperationRunner { }; if (this.readsCallGraph(direction)) { coverage = await this.addCallGraphCallees(fn, result); - return { items: [...result.values()], coverage }; + return { + items: [...result.values()], + coverage, + availability: + coverage.status === "unknown" + ? { available: false, reason: coverage.reason } + : { available: true }, + }; } if (this.protocol === "legacy") { const value = await this.call( @@ -491,6 +501,10 @@ export class IdaOperationRunner { ); return { items: [], + availability: { + available: false, + reason: "Upstream xrefs do not establish direct caller relationships.", + }, coverage: { status: "unknown", reason: @@ -555,7 +569,11 @@ export class IdaOperationRunner { result.set(resolved.address, resolved); } } - return { items: [...result.values()], coverage }; + return { + items: [...result.values()], + coverage, + availability: { available: true }, + }; } async run( @@ -694,6 +712,32 @@ export class IdaOperationRunner { basic_blocks: [], native_api: null, native_value_flow: null, + facet_availability: { + pseudocode: + pseudocode === null + ? { available: false, reason: "IDA did not supply pseudocode." } + : { available: true }, + assembly: { available: true }, + comments: { + available: false, + reason: "IDA adapter does not expose typed comments.", + }, + callers: callers.availability, + callees: callees.availability, + references: { + available: false, + reason: "IDA adapter does not expose typed reference edges.", + }, + strings_names: { + available: false, + reason: + "IDA adapter does not expose function-referenced strings and names.", + }, + cfg: { + available: false, + reason: "IDA adapter does not expose basic blocks.", + }, + }, limitations: [ ...IDA_LIMITATIONS, ...this.limitations, diff --git a/tests/boundary/providers/ida/calleeRelationships.test.ts b/tests/boundary/providers/ida/calleeRelationships.test.ts index 1204b4d10..523304dac 100644 --- a/tests/boundary/providers/ida/calleeRelationships.test.ts +++ b/tests/boundary/providers/ida/calleeRelationships.test.ts @@ -208,6 +208,22 @@ it("preserves missing callgraph coverage through direct, graph, and trace workfl ]), ); + const dossier = await client.execute("analyze_function", { + procedure: "main", + }); + if (!dossier.ok) throw dossier.error; + expect(dossier.value.result).toMatchObject({ + callers: [], + callees: [], + facet_availability: { + callers: { available: false }, + callees: { + available: false, + reason: expect.stringContaining("callgraph was unavailable"), + }, + }, + }); + const application = new EnhancedTools(client); const graph = await application.execute("get_call_graph", { address: "main", From 21aa877e5ae3164cfbfab6f6db27967995ebc8c3 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:15:43 +0800 Subject: [PATCH 79/92] fix(process): honor startup deadlines and add a CI audit skill (#2692) * docs(skills): add CI and test correctness auditing Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> * fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 * fix(process): retain the monotonic startup deadline budget Recheck the absolute monotonic deadline when an early timer fires, and keep polling waits pending until their own target or the shared deadline arrives. Preserve caller cancellation, disposal and the unchanged real-timer assertion. This replaces the earlier remainingMs workaround: an early callback must not classify an unexpired deadline as a timeout. Adapt the production fix and regression cases from nosmile99 PR #2793 at 546001693636f13700d86c6e93b6086abba2b7d6, including the independently reported both-timers-early reproduction. Preserve the prior contributor's commit. Fixes #2760 Co-authored-by: nosmile99 <147114285+nosmile99@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> * style(tests): format the Java class fixture signature The captured main baseline has an unformatted helper signature. Apply the repository formatter so the required style gate can run to completion. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> * test(ghidra): group expanded suites by their contracts Split result-envelope and completeness checks, and Windows invocation and runtime checks, into named describe groups within the existing lint limit. Preserve every test, fixture, assertion and platform prerequisite. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --------- Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: nosmile99 <147114285+nosmile99@users.noreply.github.com> --- .agents/skills/rea-ci-tests-audit/SKILL.md | 112 ++++++++++++++ .../rea-ci-tests-audit/agents/openai.yaml | 4 + .../references/measurement-and-ci.md | 65 ++++++++ CONTRIBUTING.md | 4 + src/domain/webBundleAnalyzer.test.ts | 2 + src/ghidra/GhidraFunctionValues.test.ts | 2 + src/process/ProviderDeadline.test.ts | 139 +++++++++++++++++- src/process/ProviderDeadline.ts | 66 +++++++-- .../providers/ghidra/ghidraLauncher.test.ts | 2 + tests/fixtures/javaClassFile.ts | 5 +- 10 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 .agents/skills/rea-ci-tests-audit/SKILL.md create mode 100644 .agents/skills/rea-ci-tests-audit/agents/openai.yaml create mode 100644 .agents/skills/rea-ci-tests-audit/references/measurement-and-ci.md diff --git a/.agents/skills/rea-ci-tests-audit/SKILL.md b/.agents/skills/rea-ci-tests-audit/SKILL.md new file mode 100644 index 000000000..4d7b44bf5 --- /dev/null +++ b/.agents/skills/rea-ci-tests-audit/SKILL.md @@ -0,0 +1,112 @@ +--- +name: rea-ci-tests-audit +description: Audit REA CI and test suites for correctness, fixture drift, false passes, and measured performance; use for suite or workflow investigations and efficiency changes, rather than ordinary feature testing or a single test failure. +--- + +# REA CI and tests audit + +Establish which checks still catch real regressions and which costs can be +removed without losing that protection. The outcome may be a verified fix, +an evidence-backed recommendation, or an explicit decision to stop. + +Follow the checkout's `AGENTS.md`, [testing guide](../../../docs/testing.md), +and [contribution rules](../../../CONTRIBUTING.md). Use their current commands +and pinned toolchain, rather than commands remembered from an earlier audit. +Preserve the requested scope: investigation alone does not authorize edits; +this skill does not grant permission to publish issues, push, or merge. Existing +user authorization still applies. Keep audit notes and raw results outside the +tracked tree, normally in a task-specific `/tmp` directory. + +## Establish the executed baseline + +Fetch when the user asks for latest main. Pin the baseline SHA and inspected +workflow revision; use an isolated worktree when changing or comparing code. +Do not modify another task's checkout. Record dirty changes and the exact +command, toolchain, host, selected tests, instrumentation, and prerequisites. +An old run is evidence for its executed revision, even if main advances. + +Trace workflow triggers, changed-path selection, job dependencies, reusable +actions, package scripts, test discovery, artifact producers, and report gates +through to the commands that actually execute. Include scripts, generated data, +runtime registration, and subprocess boundaries missing from import graphs. +Check positive and negative selection paths: required files must be discovered +once per intended test configuration; deliberate platform/configuration repeats +are separate coverage. A missing explicit test or missing required report must +not turn green. Optional lanes may skip; preserve their stated prerequisites. + +Use existing timing and failure artifacts to select suspicious families. Run +relevant checks to establish a baseline; reserve a full local suite for broad +CI diagnosis or uncertainty that focused checks cannot resolve. Do not rebuild +or rerun an unchanged complete suite after every documentation edit. + +## Prove the failure or protection + +For each candidate, state the expected observable behavior and its owner, +then construct the smallest reproduction against production behavior. Follow +the producer and affected consumers before deciding where to fix it. + +- For drift, compare fixtures with current admission requirements, protocol + replies, named contracts, tool versions, and actual public entrypoints. A + plausible-looking mock can fail before the behavior its test claims to cover. + Search the whole affected fixture family, including other version strings. + Preserve intentionally malformed fixtures and test their intended rejection. +- For a suspicious green test, perturb the relevant behavior in an isolated + copy or inject a controlled failure. Verify that the existing assertion or + gate detects it. A test may merely repeat its mock, import abandoned code, + or hide a subprocess/checker failure. Restore mutations after the experiment. +- For shared setup or pruning, map distinct assertions to their retained + scenarios. Preserve malformed inputs, cancellation, cleanup failure, + permissions, capacity, identity, and meaningful concurrency interactions. + A faster run that skips a case or weakens its oracle is not an improvement. +- For provider coverage, separate fake-producer, boundary, and actual-engine + evidence. Inspect named skips and prerequisites; fixtures do not establish + real Ghidra, LLDB, browser, or other provider behavior. + +Classify findings as reproduced defects, supported costs, hypotheses, or +unavailable evidence. Record the baseline command, observed output/exit status, +expected result, affected scope, and competing explanation. A failure on main +may be pre-existing; that does not by itself make it irrelevant to a CI audit. +Attribute PR failures using the executed revision and a clean baseline, not +just their resemblance to a previous failure. Retry only to test a specific +transient explanation; retain the original failure and stop when repetition +adds no evidence. A passing retry does not establish a root cause or a fix. + +## Assess efficiency without losing the gate + +Read [measurement and CI checks](references/measurement-and-ci.md) when +investigating slowness, caching, runner waits, shared artifacts, or log noise. +Compare equivalent completed work before claiming a speedup. Distinguish +setup/build time, test execution, coverage instrumentation, dependency waits, +and runner scheduling; state which costs remain unmeasured. + +Prefer removing proven duplicate preparation, reusing immutable setup, and +reducing repeated subprocess work before shrinking correctness coverage. +Preserve independent sessions where lifecycle or concurrency is the oracle. +Keep live tests fresh; a cached previous pass cannot validate current host, +installed provider contents, permissions, or external state. + +## Fix, verify, and hand off within scope + +If implementation is authorized, make the smallest structural change at the +owning boundary and add a regression that fails on the baseline for the right +reason. Retest affected consumers and meaningful negative paths; verify cache +invalidation or discovery when those mechanisms change. Read the installed +Turbo documentation before changing its configuration or commands. + +Run the repository's applicable checks and inspect complete current-head CI +results before an authorized merge. Preserve required coverage and discovery +gates. Do not suppress a failure, add blanket retries, raise timeouts, or delete +distinct assertions solely to make CI green. Changes to these controls need +their own demonstrated reason and retained failure detection. + +Report each confirmed issue with exact revision, reproduction, consequence, +proposed correction, and validation limits. Report performance with workload +and measurements, not just a percentage. Use English issue/PR records when +requested; follow the user's assignment, commit identity, review, and merge +preferences without assuming them for other users. Open ready-for-review PRs +only when that lifecycle is authorized. Resolve necessary review findings and +check final main against the tested tree after merging. + +Stop when the authorized work is verified, remaining benefits are unproven, +or further changes would trade correctness for speculative speed. List genuine +uncertainties without turning them into additional optimization rounds. diff --git a/.agents/skills/rea-ci-tests-audit/agents/openai.yaml b/.agents/skills/rea-ci-tests-audit/agents/openai.yaml new file mode 100644 index 000000000..366dd1229 --- /dev/null +++ b/.agents/skills/rea-ci-tests-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "REA CI and tests audit" + short_description: "Audit CI/test correctness and measured performance" + default_prompt: "Use $rea-ci-tests-audit to investigate CI and test correctness, drift, and performance before proposing changes." diff --git a/.agents/skills/rea-ci-tests-audit/references/measurement-and-ci.md b/.agents/skills/rea-ci-tests-audit/references/measurement-and-ci.md new file mode 100644 index 000000000..e86ba9a4d --- /dev/null +++ b/.agents/skills/rea-ci-tests-audit/references/measurement-and-ci.md @@ -0,0 +1,65 @@ +# Measurement and CI checks + +## Equivalent completed work + +Record the source/workflow SHA, command, test identities and counts, skips, +assertions or output contract, exit status, coverage scope and thresholds, +toolchain/provider versions, host resources, and prepared artifacts. Inspect +both the child result and its report: a profiling wrapper may exit successfully +after the measured command failed. Focused tests can pass while global coverage +thresholds fail; describe both rather than calling the command green. + +Measure wall time, user/system CPU, and peak process-family memory for relevant +completed tasks. Parent-only RSS misses workers and their children. On Linux, +summed RSS can double-count shared pages; report RSS/PSS semantics, sampling +interval, missing samples, and observed cleanup limits. Use equivalent host +tools elsewhere. State unknown costs, including agent tokens; fewer log bytes +alone prove neither fewer tokens nor faster tests. + +Separate cold execution, warm build/file caches, and warm runtime caches. +Use repeated or alternating runs when variance affects the decision, with +enough observations to distinguish the proposed benefit from noise. Keep +workload, instrumentation, and host conditions comparable; record interference. +A run with fewer tests, early rejection, lost coverage, or a different runner +does not prove suite acceleration. CI-wide claims need end-to-end measurements, +not an extrapolation from one improved family. + +## Workflow, caches, and shared artifacts + +Trace each reused result from producer to consumers and identify its lifetime. +Check key/input coverage against the actual file inventory and result-affecting +configuration, environment, generated data, tool versions, and host state. +Exercise an affected input change to prove invalidation; exercise an irrelevant +change to support reuse where useful. Cache deterministic preparation and +static analysis only when their inputs fully describe the result. + +Verify downloaded artifacts match the expected revision, platform, runtime, +and schema. Check restore misses, failed/partial producers, stale reports, +removed output files, and consumers after producer failures. Shared writers +need output ownership or locks; tests need isolated state, not an unnecessary +global lock. Worker counts do not bound children spawned within each test. +Preserve intentionally concurrent scenarios while bounding unrelated batches. + +Check matrix expansion and all job pages before saying CI passed. Inspect +required gates after failed, cancelled, and skipped dependencies. `always()` +alone does not preserve failures; inspect the aggregation logic. Test/report +uploads should retain available diagnostics after failure, while absent or +malformed required results must fail closed. Do not demand an optional report +from a lane intentionally excluded by the selector. + +## Runner waits and console noise + +Use per-job and per-step timestamps where available. Workflow creation to job +start includes dependencies and other delays; it is not necessarily runner +queue time. Identify when the job became eligible before attributing waits to +runner capacity. If eligibility/queue timestamps are unavailable, report the +combined delay and unknown split. Compare equivalent lanes and runner types +across enough runs; a single busy runner is not a workflow regression. + +For console reductions, retain checker exit codes, actionable errors, useful +summary counts, and complete machine-readable artifacts. Verify authored code +remains in the actual checker inventory when excluding generated outputs. +Introduce an authored error as a control; it must still fail. An ignore pattern +can hide real source as well as noise. Show that cached reports are restored, +stale successful reports cannot mask current failures, and report parse errors +remain failures. Measure readability or byte reduction separately from runtime. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50674afa7..28e8788bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -202,6 +202,10 @@ CI diagnosis. Packaging/setup/distribution changes additionally require `npm run verify:package` and `npm pack --dry-run`; provider changes require the matching real-provider lane. State which host/provider workflows ran. +For CI/test correctness, drift, or performance investigations, use the +[`rea-ci-tests-audit` contributor skill](.agents/skills/rea-ci-tests-audit/SKILL.md). +It guides reproduction and comparable measurements before changing checks. + `check:pr` reuses build and static-check outputs while executing the test suite fresh, including the fast Python producer checks. `docs:check` also runs the onboarding example consistency suite for README-only changes. diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index ad82b5730..3361cc366 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -320,7 +320,9 @@ describe("web bundle API classification parity", () => { "users", ]); }); +}); +describe("web bundle configuration and navigation parity", () => { it("keeps configuration setting reads out of endpoints in both analyzers", () => { const source = ` import vscodeDefault from "vscode"; diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index e54692840..e7e5d8e9d 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -117,7 +117,9 @@ describe("Ghidra function-analysis result values", () => { }); }, ); +}); +describe("Ghidra reference and value-flow completeness", () => { it.each(["reference_kinds_available", "unresolved_calls"])( "rejects omitted %s instead of inventing complete reference observations", (field) => { diff --git a/src/process/ProviderDeadline.test.ts b/src/process/ProviderDeadline.test.ts index 0a9448150..2ecd6402c 100644 --- a/src/process/ProviderDeadline.test.ts +++ b/src/process/ProviderDeadline.test.ts @@ -34,14 +34,34 @@ it("aborts a bounded wait when its real monotonic deadline expires", async () => } }); -it("reports no remaining time once its timer fires before the monotonic deadline", () => { +it("does not expire a future monotonic deadline on an early timer callback", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const now = vi.spyOn(performance, "now").mockReturnValue(1_000); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); const deadline = new ProviderStartupDeadline(40); try { - // Timers may fire while the monotonic clock is still just short of the deadline. - now.mockReturnValue(1_039.6); - vi.advanceTimersByTime(40); + monotonic = 39; + await vi.advanceTimersByTimeAsync(40); + expect(deadline.remainingMs()).toBe(1); + expect(deadline.signal.aborted).toBe(false); + expect(deadline.interruption).toBeUndefined(); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("aborts when the re-armed timer reaches the absolute deadline", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const deadline = new ProviderStartupDeadline(40); + try { + monotonic = 39; + await vi.advanceTimersByTimeAsync(40); + expect(deadline.signal.aborted).toBe(false); + monotonic = 40; + await vi.advanceTimersByTimeAsync(1); expect(deadline.signal.aborted).toBe(true); expect(deadline.interruption).toBe("timeout"); expect(deadline.remainingMs()).toBe(0); @@ -50,3 +70,112 @@ it("reports no remaining time once its timer fires before the monotonic deadline vi.useRealTimers(); } }); + +it("reports a wait that resolves at the deadline as aborted, not elapsed", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const deadline = new ProviderStartupDeadline(40); + try { + const pending = deadline.wait(500); + monotonic = 40; + await vi.advanceTimersByTimeAsync(40); + await expect(pending).resolves.toBe("aborted"); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("resolves a polling interval shorter than the budget as elapsed", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const deadline = new ProviderStartupDeadline(40); + try { + const pending = deadline.wait(10); + monotonic = 10; + await vi.advanceTimersByTimeAsync(10); + await expect(pending).resolves.toBe("elapsed"); + expect(deadline.signal.aborted).toBe(false); + expect(deadline.remainingMs()).toBe(30); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("keeps caller cancellation classified without reporting an exhausted budget", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const caller = new AbortController(); + const deadline = new ProviderStartupDeadline(40, caller.signal); + try { + monotonic = 20; + caller.abort(new Error("caller stopped startup")); + expect(deadline.signal.aborted).toBe(true); + expect(deadline.interruption).toBe("cancelled"); + expect(deadline.remainingMs()).toBe(20); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); + +// Probe from issue #2760 review: both the abort timer and the clamped polling +// timer can fire early at the same monotonic instant. The full-budget wait must +// stay pending until the absolute deadline expires. +it("keeps a full-budget wait pending when both timers fire early", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const caller = new AbortController(); + const deadline = new ProviderStartupDeadline(40, caller.signal); + let outcome: string | undefined; + const pending = deadline.wait(500).then((result) => { + outcome = result; + return result; + }); + try { + monotonic = 39; + await vi.advanceTimersByTimeAsync(40); + expect(deadline.signal.aborted).toBe(false); + expect(deadline.remainingMs()).toBe(1); + expect(outcome).toBeUndefined(); + monotonic = 40; + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toBe("aborted"); + expect(deadline.remainingMs()).toBe(0); + } finally { + caller.abort(); + deadline.dispose(); + vi.useRealTimers(); + } +}); + +it("releases the timer and listener on dispose with a pending wait", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let monotonic = 0; + vi.spyOn(performance, "now").mockImplementation(() => monotonic); + const caller = new AbortController(); + const deadline = new ProviderStartupDeadline(40, caller.signal); + try { + const pending = deadline.wait(500); + deadline.dispose(); + // The external abort listener is removed: caller cancellation after + // dispose must not abort the deadline or classify it as cancelled. + caller.abort(new Error("caller stopped startup")); + expect(deadline.signal.aborted).toBe(false); + expect(deadline.interruption).toBeUndefined(); + monotonic = 100; + await vi.advanceTimersByTimeAsync(100); + expect(deadline.signal.aborted).toBe(false); + // The deadline expired while disposed, so the clamped wait resolves + // "aborted" from the monotonic clock instead of polling past expiry. + await expect(pending).resolves.toBe("aborted"); + } finally { + deadline.dispose(); + vi.useRealTimers(); + } +}); diff --git a/src/process/ProviderDeadline.ts b/src/process/ProviderDeadline.ts index a6f10779a..83e24a807 100644 --- a/src/process/ProviderDeadline.ts +++ b/src/process/ProviderDeadline.ts @@ -61,13 +61,7 @@ export class ProviderStartupDeadline { return; } signal?.addEventListener("abort", this.#onExternalAbort, { once: true }); - this.#timer = setTimeout(() => { - if (this.#controller.signal.aborted) return; - this.#interruption = "timeout"; - this.#abort( - new DOMException("Provider startup deadline elapsed", "TimeoutError"), - ); - }, timeoutMs); + this.#armDeadlineTimer(timeoutMs); } /** Composite signal aborted by either the caller or the absolute deadline. */ @@ -85,18 +79,39 @@ export class ProviderStartupDeadline { /** Milliseconds still available to the next startup phase. */ remainingMs(): number { - // Node timers can fire a fraction of a millisecond before the monotonic - // deadline, so an elapsed timer must not report time left to spend. - if (this.#interruption === "timeout") return 0; return Math.max(0, Math.ceil(this.#deadlineAt - performance.now())); } - /** Wait for at most one polling interval under the shared deadline signal. */ + /** + * Wait for at most one polling interval under the shared deadline signal. + * + * Both timers may fire before their monotonic targets. Retain the earlier + * of the polling target and deadline, and wait again for any residual time. + */ wait(milliseconds: number): Promise { - return waitForAbortableDelay( - Math.min(milliseconds, this.remainingMs()), - this.signal, - ); + const startedAt = performance.now(); + // Absolute target: the earlier of the requested polling interval and the + // absolute monotonic deadline. + const targetAt = Math.min(startedAt + milliseconds, this.#deadlineAt); + const poll = (): Promise => { + const budget = this.remainingMs(); + const residual = Math.max(0, targetAt - performance.now()); + return waitForAbortableDelay( + Math.min(residual, budget), + this.signal, + ).then((result) => { + if (result === "aborted") return "aborted"; + const now = performance.now(); + // The monotonic deadline expired: a full-budget wait resolves as an + // abort so polling stops at the boundary. + if (now >= this.#deadlineAt) return "aborted"; + // The polling interval elapsed without expiring the deadline. + if (now >= targetAt) return "elapsed"; + // A timer fired before the absolute target; keep waiting. + return poll(); + }); + }; + return poll(); } /** Release the deadline timer and external abort listener. */ @@ -115,4 +130,25 @@ export class ProviderStartupDeadline { if (this.#timer !== undefined) clearTimeout(this.#timer); this.#timer = undefined; } + + /** + * Arm (or re-arm) the timer that expires the absolute monotonic deadline. + * + * Re-check the clock before classifying expiry, and retain the residual + * budget when the timer arrives early. + */ + #armDeadlineTimer(milliseconds: number): void { + this.#timer = setTimeout(() => { + this.#timer = undefined; + if (this.#controller.signal.aborted) return; + if (performance.now() >= this.#deadlineAt) { + this.#interruption = "timeout"; + this.#abort( + new DOMException("Provider startup deadline elapsed", "TimeoutError"), + ); + return; + } + this.#armDeadlineTimer(this.remainingMs()); + }, milliseconds); + } } diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index ea8fb964c..98c231240 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -486,7 +486,9 @@ describe("Ghidra Windows launcher", () => { }), ).toThrow(/metacharacters/u); }); +}); +describe("Ghidra Windows launcher runtime", () => { it.skipIf(process.platform !== "win32").each([ { properties: diff --git a/tests/fixtures/javaClassFile.ts b/tests/fixtures/javaClassFile.ts index 30f5ffc37..29fdace98 100644 --- a/tests/fixtures/javaClassFile.ts +++ b/tests/fixtures/javaClassFile.ts @@ -1,5 +1,8 @@ /** Build a valid minimal JVM class file, optionally with an unused UTF-8 constant. */ -export const javaClassFile = (extraUtf8Bytes = 0, fatCpuLookalike = false): Buffer => { +export const javaClassFile = ( + extraUtf8Bytes = 0, + fatCpuLookalike = false, +): Buffer => { const utf8 = (value: string): Buffer => { const bytes = Buffer.from(value, "utf8"); const entry = Buffer.alloc(3 + bytes.length); From 9b08c2b1cbbd74631738db1ddb326592682326e8 Mon Sep 17 00:00:00 2001 From: maodns Date: Tue, 13 Oct 2026 22:16:05 +0800 Subject: [PATCH 80/92] fix(native/windows): share FILE_SHARE_DELETE so owned objects can be marked delete-pending (#2445) * fix(native/windows): share FILE_SHARE_DELETE so owned objects can be marked delete-pending The Windows native authority self-test (`binding.call("inspect", [])`) fails closed on stock Windows x64 with a non-admin user: Delete owned object by handle failed (Win32 5): Access is denied. Reproduced deterministically (15/15 failures) on Windows 11 x64, Node 22.22.2, non-elevated account. Because `inspect` is a hard gate, every provider-backed operation is rejected and the whole native authority is unusable. Root cause: Windows requires that every handle currently open on an object grants FILE_SHARE_DELETE before that object can be marked delete-pending via SetFileInformationByHandle(FileDispositionInfo). `createRelative()` creates the runtime object with FILE_SHARE_READ only, and the returned handle is retained for the lifetime of the runtime (`Runtime::directory`). `closeRuntime()` later needs to mark that same object delete-pending, but the retained handle never shared DELETE, so the transition is denied (Win32 5). The child handles opened by `closeRuntime()` for recursive cleanup have the same defect: they request DELETE access while sharing only FILE_SHARE_READ, so the subsequent `dispose()` call on those very handles is denied too. Fix: add FILE_SHARE_DELETE to both share masks, keeping FILE_SHARE_READ and deliberately not adding FILE_SHARE_WRITE, so the existing write-exclusion behaviour of the retained leases is preserved while the delete-pending transition the cleanup path depends on becomes possible. Verified end-to-end after the change: `inspect` passes, the full runtime lifecycle (`runtime_create` / `runtime_mkdir` / `runtime_write` / `runtime_open` / `runtime_close`) completes with no residual directories, and a real 925 MB application analysis (4396 files) runs through all stages. * fix(windows): anchor private runtime cleanup to directory handles * test(windows): assert cleanup sharing failure by code --------- Co-authored-by: maodns <311719211+maodns@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- native/windows/src/filesystem.cc | 124 +++++++++++++++++++++++------- scripts/verify-windows-native.mjs | 7 +- 2 files changed, 100 insertions(+), 31 deletions(-) diff --git a/native/windows/src/filesystem.cc b/native/windows/src/filesystem.cc index c8f66e5c1..71c8bbd44 100644 --- a/native/windows/src/filesystem.cc +++ b/native/windows/src/filesystem.cc @@ -1,6 +1,7 @@ #include "authority.hpp" #include #include +#include namespace rea { @@ -165,7 +166,9 @@ static Handle createRelative(HANDLE parent, const std::wstring& name, DWORD acce IO_STATUS_BLOCK observation{}; HANDLE raw = INVALID_HANDLE_VALUE; const auto status = NtCreateFile(&raw, access | SYNCHRONIZE, &attributes, &observation, nullptr, - FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_CREATE, + FILE_ATTRIBUTE_NORMAL, + directory ? FILE_SHARE_READ | FILE_SHARE_DELETE : FILE_SHARE_READ, + FILE_CREATE, FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE), nullptr, 0); if (status < 0) throw Failure("Create private object relative to its admitted parent failed", path, @@ -176,6 +179,30 @@ static Handle createRelative(HANDLE parent, const std::wstring& name, DWORD acce return result; } +// Lookup relative to an already-owned directory; path is diagnostic only. +static Handle openRelative(HANDLE parent, const std::wstring& name, DWORD access, + DWORD sharing, ULONG kind, const std::wstring& path) { + require(!name.empty() && name.find_first_of(L"\\/:\"<>|?*") == std::wstring::npos && name.size() < 32768, + "Private lookup requires one filename component", path, ERROR_INVALID_PARAMETER); + UNICODE_STRING coordinate{}; + coordinate.Buffer = const_cast(name.data()); + coordinate.Length = static_cast(name.size() * sizeof(wchar_t)); + coordinate.MaximumLength = coordinate.Length; + OBJECT_ATTRIBUTES attributes; + InitializeObjectAttributes(&attributes, &coordinate, OBJ_CASE_INSENSITIVE, parent, nullptr); + IO_STATUS_BLOCK observation{}; + HANDLE raw = INVALID_HANDLE_VALUE; + const auto status = NtCreateFile(&raw, access | SYNCHRONIZE, &attributes, &observation, + nullptr, FILE_ATTRIBUTE_NORMAL, sharing, FILE_OPEN, + FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | kind, nullptr, 0); + if (status < 0) + throw Failure("Open private object relative to its directory handle failed", path, + RtlNtStatusToDosError(status)); + Handle result(raw); + require(result.valid(), "Private lookup returned an invalid handle", path); + return result; +} + static void verifyPrivate(HANDLE handle, const std::wstring& path, bool root) { Security expected; PSID owner = nullptr; @@ -258,7 +285,16 @@ static std::unique_ptr runtimeDirectory(Runtime& root, const std::wstring& size_t offset = root.path.size() + 1; while (offset < path.size()) { const auto end = path.find(L'\\', offset); - result->handles.push_back(openComponent(path.substr(0, end), 0, true)); + const auto name = path.substr(offset, end == std::wstring::npos ? end : end - offset); + auto child = openRelative(result->get(), name, + FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, FILE_DIRECTORY_FILE, path.substr(0, end)); + FILE_ATTRIBUTE_TAG_INFO info{}; + require(GetFileInformationByHandleEx(child.get(), FileAttributeTagInfo, &info, sizeof(info)), + "Read private directory tag failed", path); + require((info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0, + "Reparse private directory is unsupported", path, ERROR_NOT_SUPPORTED); + result->handles.push_back(std::move(child)); if (end == std::wstring::npos) break; offset = end + 1; } @@ -320,7 +356,9 @@ static void disposeSnapshot(Runtime& root, Handle& file, const std::wstring& pat require(GetFileInformationByHandleEx(file.get(), FileIdInfo, &expected, sizeof(expected)), "Read incomplete snapshot identity failed", path); file.reset(); - auto cleanup = openComponent(path, DELETE | GENERIC_READ | FILE_WRITE_ATTRIBUTES, false); + auto cleanup = openRelative(parent->get(), path.substr(path.find_last_of(L'\\') + 1), + DELETE | GENERIC_READ | FILE_WRITE_ATTRIBUTES, + FILE_SHARE_READ, FILE_NON_DIRECTORY_FILE, path); FILE_ID_INFO observed{}; require(GetFileInformationByHandleEx(cleanup.get(), FileIdInfo, &observed, sizeof(observed)), "Read snapshot cleanup identity failed", path); @@ -339,21 +377,51 @@ void closeRuntime(Runtime& root) { // cleanup. POSIX removes its consumed descriptor earlier. Abrupt owner death // closes process jobs but cannot run this walk, so private files can remain. root.immutableFiles.clear(); - // Each traversed directory stays locked against replacement. Reparse entries - // are unlinked as objects; their targets are never enumerated or deleted. + // Enumerate and open children through the retained directory handles. Paths + // below are diagnostic labels only: renames and ancestor junction replacement + // cannot redirect cleanup into an unrelated tree. struct Frame { Handle directory; - HANDLE search = INVALID_HANDLE_VALUE; std::wstring path; - WIN32_FIND_DATAW next{}; - bool pending = false; - Frame(Handle handle, std::wstring path) : directory(std::move(handle)), path(std::move(path)) { - search = FindFirstFileExW((L"\\\\?\\" + this->path + L"\\*").c_str(), FindExInfoBasic, &next, - FindExSearchNameMatch, nullptr, 0); - pending = search != INVALID_HANDLE_VALUE; - require(pending || GetLastError() == ERROR_FILE_NOT_FOUND, "Enumerate runtime cleanup failed", this->path); + // One NTFS component is at most 255 UTF-16 units; 1 KiB fits a record + // without retaining a large batch at every directory depth. + alignas(FILE_ID_BOTH_DIR_INFO) std::array entries{}; + size_t offset = 0; + bool buffered = false; + bool restart = true; + Frame(Handle handle, std::wstring path) : directory(std::move(handle)), path(std::move(path)) {} + bool next(std::wstring& name) { + for (;;) { + if (!buffered) { + const auto kind = restart ? FileIdBothDirectoryRestartInfo : FileIdBothDirectoryInfo; + restart = false; + if (!GetFileInformationByHandleEx(directory.get(), kind, entries.data(), + static_cast(entries.size()))) { + const auto code = GetLastError(); + if (code == ERROR_NO_MORE_FILES) return false; + throw Failure("Enumerate runtime cleanup by handle failed", path, code); + } + offset = 0; + buffered = true; + } + require(offset <= entries.size() - offsetof(FILE_ID_BOTH_DIR_INFO, FileName), + "Cleanup directory record is truncated", path, ERROR_INVALID_DATA); + const auto* entry = reinterpret_cast(entries.data() + offset); + require(entry->FileNameLength % sizeof(wchar_t) == 0 && + entry->FileNameLength <= entries.size() - offset - offsetof(FILE_ID_BOTH_DIR_INFO, FileName), + "Cleanup directory filename is truncated", path, ERROR_INVALID_DATA); + name.assign(entry->FileName, entry->FileNameLength / sizeof(wchar_t)); + if (entry->NextEntryOffset == 0) buffered = false; + else { + require(entry->NextEntryOffset % alignof(FILE_ID_BOTH_DIR_INFO) == 0 && + entry->NextEntryOffset >= offsetof(FILE_ID_BOTH_DIR_INFO, FileName) + entry->FileNameLength && + entry->NextEntryOffset <= entries.size() - offset - offsetof(FILE_ID_BOTH_DIR_INFO, FileName), + "Cleanup directory record offset is invalid", path, ERROR_INVALID_DATA); + offset += entry->NextEntryOffset; + } + if (name != L"." && name != L"..") return true; + } } - ~Frame() { if (search != INVALID_HANDLE_VALUE) FindClose(search); } }; std::vector> frames; HANDLE duplicate; @@ -362,21 +430,16 @@ void closeRuntime(Runtime& root) { frames.push_back(std::make_unique(Handle(duplicate), root.path)); while (!frames.empty()) { auto& frame = *frames.back(); - if (!frame.pending) { - if (frame.search != INVALID_HANDLE_VALUE) { FindClose(frame.search); frame.search = INVALID_HANDLE_VALUE; } + std::wstring name; + if (!frame.next(name)) { dispose(frame.directory.get(), frame.path); frames.pop_back(); continue; } - const auto name = std::wstring(frame.next.cFileName); - frame.pending = FindNextFileW(frame.search, &frame.next) != FALSE; - require(frame.pending || GetLastError() == ERROR_NO_MORE_FILES, "Continue runtime cleanup enumeration failed", frame.path); - if (name == L"." || name == L"..") continue; const auto path = frame.path + L"\\" + name; - Handle child(CreateFileW((L"\\\\?\\" + path).c_str(), DELETE | GENERIC_READ | FILE_WRITE_ATTRIBUTES, - FILE_SHARE_READ, nullptr, OPEN_EXISTING, - FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nullptr)); - require(child.valid(), "Open owned cleanup object failed", path); + auto child = openRelative(frame.directory.get(), name, + DELETE | FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_DELETE, 0, path); FILE_ATTRIBUTE_TAG_INFO info{}; require(GetFileInformationByHandleEx(child.get(), FileAttributeTagInfo, &info, sizeof(info)), "Read cleanup object tag failed", path); if ((info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0) @@ -551,9 +614,16 @@ napi_value filesystemCall(napi_env env, const std::wstring& operation, const std // must share both to pass Windows' symmetric share check. Those retained // handles still omit FILE_SHARE_WRITE and FILE_SHARE_DELETE, blocking // competing writes and delete opens. - file->handles.push_back(openComponent(path, GENERIC_READ, false, - FILE_SHARE_READ | FILE_SHARE_WRITE | - FILE_SHARE_DELETE)); + auto child = openRelative(file->get(), path.substr(path.find_last_of(L'\\') + 1), + GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + FILE_NON_DIRECTORY_FILE, path); + FILE_ATTRIBUTE_TAG_INFO info{}; + require(GetFileInformationByHandleEx(child.get(), FileAttributeTagInfo, &info, sizeof(info)), + "Read private file tag failed", path); + require((info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0, + "Reparse private file is unsupported", path, ERROR_NOT_SUPPORTED); + file->handles.push_back(std::move(child)); verifyPrivate(file->get(), path, false); auto result = identity(env, file->get(), path); set(env, result, "handle", wrap(env, std::move(file))); diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index 8623081f9..0a42cb604 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -472,10 +472,9 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe signal: AbortSignal.timeout(15_000), }); assert.equal(String(ready).trim(), "ready"); - await assert.rejects( - retryRuntime.close(), - /owned cleanup object|Delete owned object/u, - ); + // The reader denies delete sharing. Cleanup must retain ownership when + // its handle-relative open receives ERROR_SHARING_VIOLATION (32). + await assert.rejects(retryRuntime.close(), { win32Code: 32 }); assert.equal( windowsPrivateRuntime(retryRuntime.observation.path), retryRuntime, From c9982ad8bfdfb8f33055ea6ead13d0b59830a18f Mon Sep 17 00:00:00 2001 From: LcokHcak Date: Tue, 13 Oct 2026 23:16:25 +0900 Subject: [PATCH 81/92] fix(browser): release scenario listeners and allow bounded Chrome close (#2441) * fix(browser): release scenario event listeners on finish * fix(browser): allow Chrome cleanup to complete within bounded deadline * style(browser): format cleanup deadline regression * test(browser): expect popup cleanup listener --------- Co-authored-by: LcokHcak <268763621+LcokHcak@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/browser/PlaywrightScenarioBrowser.test.ts | 10 +- src/browser/PlaywrightScenarioBrowser.ts | 9 +- ...wrightScenarioEvents.sharedContext.test.ts | 130 +++++++++++++++++ src/browser/PlaywrightScenarioEvents.ts | 133 +++++++++++++----- .../browserScenarioCleanup.test.ts | 19 ++- 5 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 src/browser/PlaywrightScenarioEvents.sharedContext.test.ts diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index 79e903975..ffd2dba95 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -164,7 +164,15 @@ it("keeps attached-target ownership after the cleanup response timeout", async ( cleanup: { reason: expect.stringContaining("timed out") }, }); await closeStarted.promise; - await vi.advanceTimersByTimeAsync(1_000); + let responseSettled = false; + void timedOutClose + .finally(() => { + responseSettled = true; + }) + .catch(() => {}); + await vi.advanceTimersByTimeAsync(29_999); + expect(responseSettled).toBe(false); + await vi.advanceTimersByTimeAsync(1); await timeoutExpectation; expect(settled).toEqual([]); diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index 615ad055e..3af9ade9c 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -11,6 +11,9 @@ import { BrowserObservationError } from "../domain/browserObservationError.js"; import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js"; const OPERATION = "capture_browser_scenario" as const; +// Closing a real Chrome persistent context can take several seconds even when +// it succeeds. The shorter operation deadline must not misreport cleanup. +const CLEANUP_CONFIRMATION_TIMEOUT_MS = 30_000; const throwIfScenarioCancelled = (signal: AbortSignal | undefined): void => { if (signal?.aborted === true) @@ -71,7 +74,11 @@ export class PlaywrightScenarioBrowserCleanupOwner { signal?: AbortSignal, ): Promise { try { - await withPlaywrightExecutionBoundary(() => cleanup, 1_000, signal); + await withPlaywrightExecutionBoundary( + () => cleanup, + CLEANUP_CONFIRMATION_TIMEOUT_MS, + signal, + ); } catch (cause: unknown) { throw new BrowserObservationError(OPERATION, "cleanup_failed", { cause, diff --git a/src/browser/PlaywrightScenarioEvents.sharedContext.test.ts b/src/browser/PlaywrightScenarioEvents.sharedContext.test.ts new file mode 100644 index 000000000..8e20a83ae --- /dev/null +++ b/src/browser/PlaywrightScenarioEvents.sharedContext.test.ts @@ -0,0 +1,130 @@ +import { EventEmitter } from "node:events"; +import type { Page } from "playwright-core"; +import { expect, it } from "vitest"; + +import { browserScenarioSchema } from "../domain/browserScenario.js"; +import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; +import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js"; + +it("omits a request without a frame in a shared context", () => { + const context = new EventEmitter(); + const page = new EventEmitter() as unknown as Page; + const scenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/opt/chromium" }, + start_url: { url: "https://app.example.test/" }, + actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], + capture: { events: ["network"] }, + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (secrets === undefined) throw new Error("Expected resolved secrets"); + const events = new PlaywrightScenarioEvents({ + page, + context: context as never, + ownsContext: false, + enabled: new Set(["network"]), + secrets, + }); + const unknownRequest = { + frame: () => { + throw new Error("Frame for this navigation request is unavailable"); + }, + url: () => "https://unrelated.example.test/private", + }; + context.emit("request", unknownRequest); + expect(events.result().items).toEqual([]); + expect(events.limitations()).toContain( + "Shared-context network capture excludes requests without a known root or descendant Page frame.", + ); +}); + +it("detaches root and popup listeners when a scenario finishes", async () => { + const context = new EventEmitter(); + const page = new EventEmitter() as unknown as Page; + const popup = Object.assign(new EventEmitter(), { + url: () => "https://app.example.test/popup", + }); + const scenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/opt/chromium" }, + start_url: { url: "https://app.example.test/" }, + actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], + capture: { events: ["console", "popups"] }, + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (secrets === undefined) throw new Error("Expected resolved secrets"); + const events = new PlaywrightScenarioEvents({ + page, + context: context as never, + ownsContext: false, + enabled: new Set(["console", "popups"]), + secrets, + }); + (page as unknown as EventEmitter).emit("popup", popup); + expect((page as unknown as EventEmitter).listenerCount("console")).toBe(1); + expect(popup.listenerCount("console")).toBe(1); + // Popup closure is recorded, then its page-owned listeners are released. + expect(popup.listenerCount("close")).toBe(2); + await events.finish(); + expect((page as unknown as EventEmitter).listenerCount("console")).toBe(0); + expect((page as unknown as EventEmitter).listenerCount("popup")).toBe(0); + expect(popup.listenerCount("console")).toBe(0); + expect(popup.listenerCount("popup")).toBe(0); + expect(popup.listenerCount("close")).toBe(0); +}); + +it("detaches worker and websocket listeners when a scenario finishes", async () => { + const context = new EventEmitter(); + const page = new EventEmitter() as unknown as Page; + const socket = Object.assign(new EventEmitter(), { + url: () => "wss://app.example.test/socket", + }); + const worker = Object.assign(new EventEmitter(), { + url: () => "https://app.example.test/worker.js", + }); + const scenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/opt/chromium" }, + start_url: { url: "https://app.example.test/" }, + actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], + capture: { events: ["websockets", "workers"] }, + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (secrets === undefined) throw new Error("Expected resolved secrets"); + const events = new PlaywrightScenarioEvents({ + page, + context: context as never, + ownsContext: false, + enabled: new Set(["websockets", "workers"]), + secrets, + }); + (page as unknown as EventEmitter).emit("websocket", socket); + (page as unknown as EventEmitter).emit("worker", worker); + expect(socket.listenerCount("framesent")).toBe(1); + expect(worker.listenerCount("close")).toBe(1); + await events.finish(); + expect(socket.listenerCount("framesent")).toBe(0); + expect(socket.listenerCount("framereceived")).toBe(0); + expect(socket.listenerCount("close")).toBe(0); + expect(worker.listenerCount("close")).toBe(0); +}); + +it("detaches owned-context popup discovery when a scenario finishes", async () => { + const context = new EventEmitter(); + const page = new EventEmitter() as unknown as Page; + const scenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/opt/chromium" }, + start_url: { url: "https://app.example.test/" }, + actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], + capture: { events: ["console"] }, + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (secrets === undefined) throw new Error("Expected resolved secrets"); + const events = new PlaywrightScenarioEvents({ + page, + context: context as never, + ownsContext: true, + enabled: new Set(["console"]), + secrets, + }); + expect(context.listenerCount("page")).toBe(1); + await events.finish(); + expect(context.listenerCount("page")).toBe(0); +}); diff --git a/src/browser/PlaywrightScenarioEvents.ts b/src/browser/PlaywrightScenarioEvents.ts index 18e1f0e3b..aee9db630 100644 --- a/src/browser/PlaywrightScenarioEvents.ts +++ b/src/browser/PlaywrightScenarioEvents.ts @@ -54,10 +54,11 @@ export class PlaywrightScenarioEvents { private readonly enabled: ReadonlySet; private readonly secrets: BrowserScenarioSecrets; private readonly ownsContext: boolean; - private readonly pages = new Set(); + private pages = new WeakSet(); private readonly incompleteFamilies = new Set(); private readonly network: PlaywrightScenarioNetwork | undefined; private stopNetwork: (() => void) | undefined; + private readonly stopListeners = new Map void>>(); private sealed = false; constructor(options: EventCaptureOptions) { @@ -72,10 +73,12 @@ export class PlaywrightScenarioEvents { this.secrets = options.secrets; this.ownsContext = options.ownsContext === true; this.pages.add(options.page); - if (this.ownsContext && this.enabled.size > 0) - (options.context ?? options.page.context()).on("page", (page) => - this.popup(page), - ); + if (this.ownsContext && this.enabled.size > 0) { + const context = options.context ?? options.page.context(); + const onPage = (page: Page) => this.popup(page); + context.on("page", onPage); + this.retainListener(context, () => context.off("page", onPage)); + } if (this.enabled.has("network")) { const context = options.context ?? options.page.context(); const collector = new PlaywrightScenarioNetwork({ @@ -138,6 +141,8 @@ export class PlaywrightScenarioEvents { async finish(): Promise { this.stopNetwork?.(); this.stopNetwork = undefined; + for (const owner of this.stopListeners.keys()) this.releaseListeners(owner); + this.pages = new WeakSet(); try { await this.network?.finish(); } finally { @@ -184,34 +189,81 @@ export class PlaywrightScenarioEvents { return this.sequence; } + private retainListener(owner: object, stop: () => void): void { + let stops = this.stopListeners.get(owner); + if (stops === undefined) { + stops = new Set(); + this.stopListeners.set(owner, stops); + } + stops.add(stop); + } + + private releaseListeners(owner: object): void { + const stops = this.stopListeners.get(owner); + this.stopListeners.delete(owner); + for (const stop of stops ?? []) stop(); + } + private observePage(page: Page): void { - if (this.enabled.has("console")) - page.on("console", (message) => this.console(message)); - if (this.enabled.has("page-errors")) - page.on("pageerror", (error) => { + if (this.enabled.size > 0) { + const onClose = () => { + this.releaseListeners(page); + }; + page.on("close", onClose); + this.retainListener(page, () => page.off("close", onClose)); + } + + if (this.enabled.has("console")) { + const onConsole = (message: ConsoleMessage) => this.console(message); + page.on("console", onConsole); + this.retainListener(page, () => page.off("console", onConsole)); + } + if (this.enabled.has("page-errors")) { + const onError = (error: Error) => { this.push({ kind: "page-error", message: this.secrets.redact(error.message), stack: error.stack === undefined ? null : this.secrets.redact(error.stack), }); - }); - if (this.enabled.has("websockets")) - page.on("websocket", (socket) => this.webSocket(socket)); - if (this.enabled.has("frames")) { - page.on("frameattached", (frame) => this.frame("frame-attached", frame)); - page.on("framedetached", (frame) => this.frame("frame-detached", frame)); - page.on("framenavigated", (frame) => - this.frame("frame-navigated", frame), - ); + }; + page.on("pageerror", onError); + this.retainListener(page, () => page.off("pageerror", onError)); + } + if (this.enabled.has("websockets")) { + const onWebSocket = (socket: WebSocket) => this.webSocket(socket); + page.on("websocket", onWebSocket); + this.retainListener(page, () => page.off("websocket", onWebSocket)); + } + if (this.enabled.has("frames")) { + const onAttached = (frame: Frame) => this.frame("frame-attached", frame); + const onDetached = (frame: Frame) => this.frame("frame-detached", frame); + const onNavigated = (frame: Frame) => + this.frame("frame-navigated", frame); + page.on("frameattached", onAttached); + page.on("framedetached", onDetached); + page.on("framenavigated", onNavigated); + this.retainListener(page, () => page.off("frameattached", onAttached)); + this.retainListener(page, () => page.off("framedetached", onDetached)); + this.retainListener(page, () => page.off("framenavigated", onNavigated)); + } + if (this.enabled.has("workers")) { + const onWorker = (worker: Worker) => this.worker(worker); + page.on("worker", onWorker); + this.retainListener(page, () => page.off("worker", onWorker)); } - if (this.enabled.has("workers")) - page.on("worker", (worker) => this.worker(worker)); // Any selected family needs descendant discovery in shared contexts. // Popup lifecycle records remain gated on their own selector below. - if (this.enabled.size > 0) page.on("popup", (popup) => this.popup(popup)); - if (this.enabled.has("downloads")) - page.on("download", (download) => this.download(download)); + if (this.enabled.size > 0) { + const onPopup = (popup: Page) => this.popup(popup); + page.on("popup", onPopup); + this.retainListener(page, () => page.off("popup", onPopup)); + } + if (this.enabled.has("downloads")) { + const onDownload = (download: Download) => this.download(download); + page.on("download", onDownload); + this.retainListener(page, () => page.off("download", onDownload)); + } } private safeUrl(value: string) { @@ -231,13 +283,20 @@ export class PlaywrightScenarioEvents { private webSocket(socket: WebSocket): void { const url = this.safeUrl(socket.url()); this.push({ kind: "websocket-opened", url }); - socket.on("framesent", ({ payload }) => - this.webSocketFrame("websocket-frame-sent", url, payload), - ); - socket.on("framereceived", ({ payload }) => - this.webSocketFrame("websocket-frame-received", url, payload), - ); - socket.on("close", () => this.push({ kind: "websocket-closed", url })); + const onSent = ({ payload }: { payload: string | Buffer }) => + this.webSocketFrame("websocket-frame-sent", url, payload); + const onReceived = ({ payload }: { payload: string | Buffer }) => + this.webSocketFrame("websocket-frame-received", url, payload); + const onClose = () => { + this.push({ kind: "websocket-closed", url }); + this.releaseListeners(socket); + }; + socket.on("framesent", onSent); + socket.on("framereceived", onReceived); + socket.on("close", onClose); + this.retainListener(socket, () => socket.off("framesent", onSent)); + this.retainListener(socket, () => socket.off("framereceived", onReceived)); + this.retainListener(socket, () => socket.off("close", onClose)); } private webSocketFrame( @@ -286,7 +345,12 @@ export class PlaywrightScenarioEvents { name: null, }; this.push({ kind: "worker-created", ...details }); - worker.on("close", () => this.push({ kind: "worker-closed", ...details })); + const onClose = () => { + this.push({ kind: "worker-closed", ...details }); + this.releaseListeners(worker); + }; + worker.on("close", onClose); + this.retainListener(worker, () => worker.off("close", onClose)); } private popup(page: Page): void { @@ -305,13 +369,14 @@ export class PlaywrightScenarioEvents { }; if (this.enabled.has("popups")) { this.push({ kind: "popup-opened", ...opened }); - page.on("close", () => + const onClose = () => this.push({ kind: "popup-closed", url: page.url() === "" ? null : this.safeUrl(page.url()), name: null, - }), - ); + }); + page.on("close", onClose); + this.retainListener(page, () => page.off("close", onClose)); } this.observePage(page); } diff --git a/tests/process-global/browserScenarioCleanup.test.ts b/tests/process-global/browserScenarioCleanup.test.ts index b2acfd02e..498940e53 100644 --- a/tests/process-global/browserScenarioCleanup.test.ts +++ b/tests/process-global/browserScenarioCleanup.test.ts @@ -1,4 +1,5 @@ import { Cli } from "incur"; +import { resolve } from "node:path"; import { expect, it, vi } from "vitest"; import type { BrowserScenarioCapturePort } from "../../src/application/BrowserScenarioCapturePort.js"; @@ -32,7 +33,7 @@ vi.mock("../../src/composition/browserScenario.js", () => ({ })); const scenario = browserScenarioSchema.parse({ - browser: { mode: "launch", executable_path: "/fixture/chromium" }, + browser: { mode: "launch", executable_path: resolve("/fixture/chromium") }, start_url: { url: "https://app.example.test/" }, actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], storage: {}, @@ -111,6 +112,22 @@ class RetrySession implements BrowserScenarioSessionPort { } } +it("waits for a slow but successful browser close before removing its profile", async () => { + const stages: string[] = []; + const cleanup = new PlaywrightScenarioBrowserCleanupOwner({ + closeBrowser: async () => { + await new Promise((resolve) => setTimeout(resolve, 1_250)); + stages.push("browser-closed"); + }, + removeProfile: async () => { + stages.push("profile-removed"); + }, + }); + + await cleanup.close(); + expect(stages).toEqual(["browser-closed", "profile-removed"]); +}); + const serveScenario = async () => { const cli = Cli.create("browser-cleanup-test", { sync: false }); registerBrowserScenarioCommands(cli, silentLogger, {}); From f0b8ae1fc8347bffa363ec49ac210850123ee699 Mon Sep 17 00:00:00 2001 From: Avocado Date: Wed, 14 Oct 2026 01:16:44 +1100 Subject: [PATCH 82/92] fix(javascript): trace nested Electron bridge members (#2745) * fix(javascript): trace nested Electron bridge members * fix(javascript): preserve shallow Electron bridge paths * fix(javascript): preserve exact Electron bridge member paths * fix(javascript): preserve exact Electron bridge paths * fix(javascript): preserve bridge entry ownership * fix(javascript): preserve bridge path identity * fix(javascript): normalize typed feature seeds before tracing * test(javascript): normalize direct selector seed inputs * refactor(javascript): consume normalized feature seed defaults --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../ElectronBoundaryGraphCallPaths.ts | 9 + ...vaScriptSemanticGraphElectronProjection.ts | 6 + .../javascript/electronStaticAnalysisTypes.ts | 2 + .../electronStaticAnalysisValues.ts | 7 + .../electronWrappedCallPaths.test.ts | 77 +++++ .../javascript/electronWrappedCallPaths.ts | 76 +++-- ...avascriptFeatureSeed.array_members.test.ts | 44 ++- .../javascript/javascriptFeatureSeed.ts | 22 +- .../javascript/javascriptFeatureTrace.ts | 10 +- .../javascriptFeatureTraceSchemas.ts | 59 +++- .../javascript/javascriptSymbolicBindings.ts | 32 ++- .../JavaScriptAnalysisWorkerSchemas.ts | 8 + .../cli/electronWrappedCallPaths.test.ts | 272 +++++++++++++++++- ...javascriptApplicationEmptyLiterals.test.ts | 70 ++++- 14 files changed, 643 insertions(+), 51 deletions(-) diff --git a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts index 4447d59db..b815e82bf 100644 --- a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts +++ b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts @@ -51,6 +51,9 @@ export const addElectronCallPaths = ( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), entry_kind: path.entry_kind, channel: path.channel, terminal: path.terminal, @@ -97,6 +100,9 @@ export const addElectronCallPaths = ( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), resolution: "candidate", }, }); @@ -119,6 +125,9 @@ export const addElectronCallPaths = ( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), channel: path.channel, resolution: "candidate", conditions: path.conditions, diff --git a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts index 53b5bdce6..c10e8f632 100644 --- a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts @@ -34,6 +34,9 @@ export function* projectElectronSemanticCallPaths( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), channel: path.channel, terminal: path.terminal, step_kind: step.kind, @@ -62,6 +65,9 @@ export function* projectElectronSemanticCallPaths( properties: { path_id: pathId, entry: path.entry, + ...(path.entry_path === undefined + ? {} + : { entry_path: [...path.entry_path] }), channel: path.channel, basis: "lexical-wrapper-substitution", }, diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index b8d2c03fb..97cd32375 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -219,6 +219,8 @@ export interface ElectronCallPathStep { export interface ElectronCallPath { readonly entry_kind: "bridge-member" | "ipc-handler"; readonly entry: string; + /** Exact bridge API and property segments; `entry` remains display text. */ + readonly entry_path?: readonly string[]; readonly channel: string | null; readonly terminal: string; readonly resolution: "candidate"; diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 2fe9d5858..90ef1f0a5 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -153,6 +153,13 @@ const collectMembersAt = ( state.unknown += 1; continue; } + if ( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__" + ) + continue; const segments = [...prefix, name]; const path = segments.join("."); state.members.push(path); diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 2740d7915..6d30a10f5 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -357,6 +357,83 @@ it("preserves guarded alternatives in direct exposed object literals", () => { ]), ); }); +it("preserves bridge member segments and excludes prototype setters", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const write=()=>e.ipcRenderer.send("write"); + e.contextBridge.exposeInMainWorld("desktop", { + "nested.write": write, + nested: { write }, + __proto__: { inherited: write }, + ["__proto__"]: { own: write }, + }); + `); + expect(result.electron.call_paths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry: "desktop.nested.write", + entry_path: ["desktop", "nested.write"], + }), + expect.objectContaining({ + entry: "desktop.nested.write", + entry_path: ["desktop", "nested", "write"], + }), + expect.objectContaining({ + entry: "desktop.__proto__.own", + entry_path: ["desktop", "__proto__", "own"], + }), + ]), + ); + expect(result.electron.call_paths).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry_path: ["desktop", "__proto__", "inherited"], + }), + ]), + ); +}); +it("follows an explicit object-literal prototype lookup in a bridge wrapper", () => { + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + const prototype={send:()=>e.ipcRenderer.send("through-prototype")}; + const object={__proto__:prototype}; + e.contextBridge.exposeInMainWorld("desktop", { + action:()=>object.__proto__.send(), + }); + `); + expect(result.electron.call_paths).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry_path: ["desktop", "action"], + channel: "through-prototype", + }), + ]), + ); +}); +it("does not spread a large nested bridge member list into function arguments", () => { + const members = Array.from( + { length: 125_000 }, + (_, index) => `m${index}(){}`, + ).join(","); + const source = `const e=require("electron");e.contextBridge.exposeInMainWorld("desktop",{nested:{${members}}});`; + expect(() => analyzeJavaScriptStaticSource(source)).not.toThrow(); +}); +it("reports nested bridge depth truncation while retaining shallow paths", () => { + const nested = + Array.from({ length: 130 }, (_, index) => `level${index}:{`).join("") + + `deep:()=>e.ipcRenderer.send("deep")` + + "}".repeat(130); + const result = analyzeJavaScriptStaticSource(` + const e=require("electron"); + e.contextBridge.exposeInMainWorld("desktop", { + shallow:()=>e.ipcRenderer.send("shallow"), ${nested} + }); + `); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ entry: "desktop.shallow", channel: "shallow" }), + ); + expect(result.limitations.join(" ")).toContain("depth 128"); +}); it("preserves conditions when a wrapper dispatches through conditional external methods", () => { const result = analyzeJavaScriptStaticSource(` const e=require("electron"); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 048ad30f5..051595eb6 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -44,6 +44,7 @@ const callLabel = (node: t.Node): string => interface PathContext { readonly entryNode: t.Node; readonly entry: string; + readonly entryPath?: readonly string[]; readonly entryKind: ElectronCallPath["entry_kind"] | null; readonly channel: string | null; readonly steps: readonly ElectronCallPathStep[]; @@ -122,6 +123,37 @@ export const collectElectronWrappedCallPaths = ( }; const hasConditions = (value: SymbolicValue): boolean => symbolicVariants(value).some((variant) => variant.conditions?.length); + function* bridgeMemberFunctions( + value: SymbolicValue, + segments: readonly string[] = [], + active = new Set(), + ): Generator<{ + readonly name: string; + readonly path: readonly string[]; + readonly fn: SymbolicFunction; + }> { + if (active.has(value)) return; + if (active.size >= 128) { + accumulator.unknownFindings += 1; + accumulator.electronCallPathLimitReached = true; + return; + } + const nested = new Set(active); + nested.add(value); + for (const member of [...resolver.members(value)].sort()) { + if (--remainingVisits < 0) return; + const path = [...segments, member]; + const name = path.join("."); + for (const variant of symbolicVariants( + resolver.property(value, member), + )) { + if (variant.kind === "function") yield { name, path, fn: variant }; + else if (resolver.members(variant).length > 0) + yield* bridgeMemberFunctions(variant, path, nested); + } + if (remainingVisits < 0) return; + } + } const isolatedBridgeCall = ( node: t.CallExpression | t.OptionalCallExpression, ): @@ -167,6 +199,10 @@ export const collectElectronWrappedCallPaths = ( const path: ElectronCallPath = { entry_kind: context.entryKind, entry: context.entry, + ...(context.entryKind !== "bridge-member" || + context.entryPath === undefined + ? {} + : { entry_path: context.entryPath }), channel, terminal, resolution: "candidate", @@ -405,27 +441,25 @@ export const collectElectronWrappedCallPaths = ( offset: node.start ?? -1, value: finding, }); - for (const name of members) { - const value = resolver.property(object, name); - for (const fn of symbolicVariants(value)) - if (fn.kind === "function") { - const memberContext = metadataContext(keyContext, fn); - if (!memberContext) continue; - functionBody( - fn, - fn.node.params.map(() => symbolicUnknown("public API argument")), - { - ...memberContext, - entryNode: node, - entryKind: "bridge-member", - entry: `${key.value}.${name}`, - channel: null, - steps: [step(node, "exposure", `${key.value}.${name}`)], - active: new Set(), - }, - node, - ); - } + for (const { name, path, fn } of bridgeMemberFunctions(object)) { + const memberContext = metadataContext(keyContext, fn); + if (!memberContext) continue; + const entry = `${key.value}.${name}`; + functionBody( + fn, + fn.node.params.map(() => symbolicUnknown("public API argument")), + { + ...memberContext, + entryNode: node, + entryKind: "bridge-member", + entry, + entryPath: [key.value, ...path], + channel: null, + steps: [step(node, "exposure", entry)], + active: new Set(), + }, + node, + ); } }; const call = ( diff --git a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts index 9cab84239..50f6ceb09 100644 --- a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts +++ b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts @@ -11,7 +11,10 @@ import { } from "./javascriptApplicationGraph.fixture.js"; import { findApplicationFeatureSeeds } from "./javascriptFeatureSeed.js"; import { traceApplicationFeature } from "./javascriptFeatureTrace.js"; -import type { ApplicationFeatureSeed } from "./javascriptFeatureTraceSchemas.js"; +import { + applicationFeatureSeedSchema, + type ApplicationFeatureSeed, +} from "./javascriptFeatureTraceSchemas.js"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; import type { JsonValue } from "../jsonValue.js"; @@ -268,6 +271,32 @@ describe("feature seed selection controls", () => { }); describe("public feature trace from contextBridge source members", () => { + it.each(["openProject", "missingProject"])( + "normalizes optional seed defaults before deriving trace identity for %s", + (value) => { + const input = { + sourceEvidenceId, + graph: contextBridgeGraph(), + nativeEvidence: [], + direction: "both" as const, + }; + const omitted = traceApplicationFeature({ + ...input, + seed: { kind: "api", value }, + }); + const explicit = traceApplicationFeature({ + ...input, + seed: { + kind: "api", + value, + match: null, + case_sensitive: false, + }, + }); + expect(omitted).toEqual(explicit); + }, + ); + it("traces one exact member seed through the public feature workflow", () => { const mode = { value: "openProject", @@ -381,12 +410,13 @@ const sourceEvidenceId = `ev_${"b".repeat(64)}`; const seed = ( kind: ApplicationFeatureSeed["kind"], value = "openProject", -): ApplicationFeatureSeed => ({ - kind, - value, - match: "exact", - case_sensitive: true, -}); +): ApplicationFeatureSeed => + applicationFeatureSeedSchema.parse({ + kind, + value, + match: "exact", + case_sensitive: true, + }); const memberNode = ( kind: ApplicationNode["kind"], diff --git a/src/domain/javascript/javascriptFeatureSeed.ts b/src/domain/javascript/javascriptFeatureSeed.ts index 929733f00..d5f06b817 100644 --- a/src/domain/javascript/javascriptFeatureSeed.ts +++ b/src/domain/javascript/javascriptFeatureSeed.ts @@ -52,10 +52,28 @@ const seedMatch = ( ] : []; if (!kindMatches(node, seed.kind)) return []; + const requestedEntryPath = + seed.kind === "api" && "entry_path" in seed ? seed.entry_path : undefined; + if ( + seed.kind === "api" && + requestedEntryPath !== undefined && + !node.observations.some(({ properties }) => { + const entryPath = properties.entry_path; + return ( + Array.isArray(entryPath) && + entryPath.length === requestedEntryPath.length && + entryPath.every( + (segment, index) => segment === requestedEntryPath[index], + ) + ); + }) + ) + return []; const mode = featureSeedMatchMode(seed); - const query = normalized(seed.value, seed.case_sensitive); + const caseSensitive = seed.case_sensitive; + const query = normalized(seed.value, caseSensitive); const match = candidateFields(node, seed.kind).find((candidate) => { - const value = normalized(candidate.value, seed.case_sensitive); + const value = normalized(candidate.value, caseSensitive); return mode === "exact" ? value === query : value.includes(query); }); return match === undefined diff --git a/src/domain/javascript/javascriptFeatureTrace.ts b/src/domain/javascript/javascriptFeatureTrace.ts index 47333bab6..e4109b85d 100644 --- a/src/domain/javascript/javascriptFeatureTrace.ts +++ b/src/domain/javascript/javascriptFeatureTrace.ts @@ -12,6 +12,8 @@ import type { } from "./javascriptApplicationGraphSchemas.js"; import { applicationFeatureTraceResultSchema, + applicationFeatureSeedSchema, + type ApplicationFeatureSeedInput, type ApplicationFeatureTraceResult, type TraceApplicationFeatureInput, } from "./javascriptFeatureTraceSchemas.js"; @@ -30,14 +32,18 @@ export interface ApplicationFeatureTraceProjectionInput { readonly sourceEvidenceId: string; readonly graph: JavaScriptApplicationGraph; readonly nativeEvidence: readonly Evidence[]; - readonly seed: TraceApplicationFeatureInput["seed"]; + readonly seed: ApplicationFeatureSeedInput; readonly direction: TraceApplicationFeatureInput["direction"]; } /** Trace a literal feature through its complete reachable, authority-preserving JAG. */ export const traceApplicationFeature = ( - input: ApplicationFeatureTraceProjectionInput, + source: ApplicationFeatureTraceProjectionInput, ): ApplicationFeatureTraceResult => { + const input = { + ...source, + seed: applicationFeatureSeedSchema.parse(source.seed), + }; const allSeedMatches = findApplicationFeatureSeeds( input.graph.nodes, input.seed, diff --git a/src/domain/javascript/javascriptFeatureTraceSchemas.ts b/src/domain/javascript/javascriptFeatureTraceSchemas.ts index ee34bad09..b341ce142 100644 --- a/src/domain/javascript/javascriptFeatureTraceSchemas.ts +++ b/src/domain/javascript/javascriptFeatureTraceSchemas.ts @@ -17,33 +17,65 @@ export const featureSeedMatchMode = (seed: { }): "exact" | "contains" => seed.match ?? (seed.kind === "string" ? "contains" : "exact"); -const literalSeedKinds = [ - "route", - "string", - "api", - "channel", - "module", - "native-export", -] as const; +const apiSeedSchema = z.union([ + z.strictObject({ + kind: z.literal("api"), + value: boundedTextSchema, + match: z.enum(["exact", "contains"]).nullable().default(null), + case_sensitive: z.boolean().default(false), + }), + z.strictObject({ + kind: z.literal("api"), + value: z.literal(""), + match: z.literal("exact"), + case_sensitive: z.boolean().default(false), + }), +]); + +const apiPathSeedSchema = z.union([ + z.strictObject({ + kind: z.literal("api"), + value: boundedTextSchema, + match: z.enum(["exact", "contains"]).nullable().default(null), + case_sensitive: z.boolean().default(false), + entry_path: z.array(z.string()).min(1), + }), + z.strictObject({ + kind: z.literal("api"), + value: z.literal(""), + match: z.literal("exact"), + case_sensitive: z.boolean().default(false), + entry_path: z.array(z.string()).min(1), + }), +]); /** * Literal starting point for one application feature trace. An empty value, * such as the channel in `ipcMain.handle("")`, must be an explicit exact * literal seed: empty containment would select every node. */ -const applicationFeatureSeedSchema = z.union([ +export const applicationFeatureSeedSchema = z.union([ z.strictObject({ - kind: z.enum(["node-id", ...literalSeedKinds]), + kind: z.enum([ + "node-id", + "route", + "string", + "channel", + "module", + "native-export", + ]), value: boundedTextSchema, match: z.enum(["exact", "contains"]).nullable().default(null), case_sensitive: z.boolean().default(false), }), z.strictObject({ - kind: z.enum(literalSeedKinds), + kind: z.enum(["route", "string", "channel", "module", "native-export"]), value: z.literal(""), match: z.literal("exact"), case_sensitive: z.boolean().default(false), }), + apiSeedSchema, + apiPathSeedSchema, ]); /** Check unique native identities before projecting an application trace. */ @@ -156,7 +188,10 @@ export const applicationFeatureTraceResultSchema = z.strictObject({ export type TraceApplicationFeatureInput = z.infer< typeof traceApplicationFeatureInputSchema >; -export type ApplicationFeatureSeed = z.infer< +export type ApplicationFeatureSeed = z.output< + typeof applicationFeatureSeedSchema +>; +export type ApplicationFeatureSeedInput = z.input< typeof applicationFeatureSeedSchema >; export type ApplicationFeatureTraceResult = z.infer< diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index b1f37269f..62c70f953 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -47,6 +47,14 @@ interface SymbolicReturnFlow { readonly fallsThrough: boolean; } const emptyEnvironment: SymbolicEnvironment = { values: new Map() }; +const isObjectPrototypeSetter = ( + property: t.ObjectMethod | t.ObjectProperty, + name: string, +): boolean => + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__"; /** Demand-driven substitutions for local bindings, closures and literal module tables. */ export class JavaScriptSymbolicBindings { @@ -243,15 +251,30 @@ export class JavaScriptSymbolicBindings { index = null; break; } + if (isObjectPrototypeSetter(property, name)) continue; properties.set(name, property); } this.#literalPropertyIndex.set(value.node, index); } if (index !== null) { const property = index.get(key); - return property === undefined - ? symbolicUnknown("missing property") - : this.#objectPropertyValue(property, value.environment); + if (property !== undefined) + return this.#objectPropertyValue(property, value.environment); + if (key === "__proto__") { + const prototypeSetter = [...value.node.properties] + .reverse() + .filter( + (candidate): candidate is t.ObjectMethod | t.ObjectProperty => + !t.isSpreadElement(candidate), + ) + .find((candidate) => isObjectPrototypeSetter(candidate, key)); + if (prototypeSetter !== undefined) + return this.#objectPropertyValue( + prototypeSetter, + value.environment, + ); + } + return symbolicUnknown("missing property"); } for (const p of [...value.node.properties].reverse()) { if (t.isSpreadElement(p)) { @@ -314,7 +337,8 @@ export class JavaScriptSymbolicBindings { t.isSpreadElement(p) ? this.members(this.evaluate(p.argument, value.environment), active) : [semanticStaticPropertyKey(p.key, p.computed)].filter( - (k): k is string => k !== null, + (k): k is string => + k !== null && !isObjectPrototypeSetter(p, k), ), ), ), diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 84a63d79b..40073f1ae 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -157,6 +157,7 @@ const electron = z z.strictObject({ entry_kind: z.enum(["bridge-member", "ipc-handler"]), entry: z.string(), + entry_path: strings.optional(), channel: z.string().nullable(), terminal: z.string(), resolution: z.literal("candidate"), @@ -181,6 +182,13 @@ const electron = z ), }), ) + .transform((paths) => + paths.map(({ entry_kind, entry, entry_path, ...rest }) => + entry_path === undefined + ? { entry_kind, entry, ...rest } + : { entry_kind, entry, entry_path, ...rest }, + ), + ) .optional(), browser_windows: z.array( z.union([ diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index af61f0885..4dd0d02a8 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -23,7 +23,7 @@ const analyze = async (cli: TestCli, extra = "") => { const output = await cli.run({ arguments: ["analyze-javascript-application", root, "--json"], }); - expect(output.exitCode).toBe(0); + expect(output.exitCode, output.stderr).toBe(0); const { normalized_result: result } = z .object({ normalized_result: javascriptApplicationAnalysisResultSchema, @@ -122,6 +122,190 @@ cliTest( }, ); +cliTest( + "traces a nested contextBridge member through a unique IPC channel", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-nested-bridge-"); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + contextBridge.exposeInMainWorld("api", { + nested: { write: (value) => ipcRenderer.invoke("nested:write", value) } + });`, + "main.js": `const { ipcMain, clipboard } = require("electron"); + ipcMain.handle("nested:write", (event, value) => clipboard.writeText(value));`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const { graph } = application; + const paths = graph.nodes + .flatMap((node) => node.observations) + .filter( + (observation) => + observation.properties.entry === "api.nested.write" && + observation.properties.entry_kind === "bridge-member", + ); + expect( + paths.some( + (observation) => observation.properties.step_kind === "exposure", + ), + ).toBe(true); + expect( + paths.some( + (observation) => observation.properties.step_kind === "terminal", + ), + ).toBe(true); + const joins = graph.edges.filter( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ); + expect(joins.map((edge) => edge.properties.channel)).toEqual([ + "nested:write", + ]); + + const traceInput = join(root, "trace-nested-input.json"); + await writeFile( + traceInput, + JSON.stringify({ + application: output.json, + seed: { kind: "api", value: "api.nested.write", match: "exact" }, + direction: "outgoing", + }), + ); + const traced = await cli.run({ + arguments: ["trace-application-feature", traceInput, "--json"], + }); + expect(traced.exitCode).toBe(0); + const { normalized_result: trace } = z + .object({ normalized_result: applicationFeatureTraceResultSchema }) + .parse(traced.json); + expect(trace.seed_matches).toHaveLength(1); + expect(trace.paths.some((path) => path.contains_inference)).toBe(true); + }, +); + +cliTest( + "preserves exact bridge member segments through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-bridge-segments-"); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + const write = () => ipcRenderer.send("write"); + contextBridge.exposeInMainWorld("desktop", { + "nested.write": write, + nested: { write }, + __proto__: { inherited: write }, + ["__proto__"]: { own: write }, + });`, + "main.js": `const { ipcMain } = require("electron"); + ipcMain.on("write", (event) => event.sender.copy());`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const observations = application.graph.nodes.flatMap((node) => + node.observations.filter( + (observation) => + observation.properties.entry_kind === "bridge-member" && + observation.properties.step_kind === "exposure", + ), + ); + const bridge = application.graph.nodes + .filter((node) => node.kind === "context-bridge-api") + .flatMap((node) => node.observations) + .find((observation) => observation.properties.api_key === "desktop"); + expect(bridge?.properties.members).not.toContain("__proto__.inherited"); + expect(bridge?.properties.members).toContain("__proto__.own"); + const paths = observations.map( + (observation) => observation.properties.entry_path, + ); + expect(paths).toEqual( + expect.arrayContaining([ + ["desktop", "nested.write"], + ["desktop", "nested", "write"], + ["desktop", "__proto__", "own"], + ]), + ); + expect(paths).not.toContainEqual(["desktop", "__proto__", "inherited"]); + const handlerPaths = application.graph.nodes + .flatMap((node) => node.observations) + .filter( + (observation) => observation.properties.entry_kind === "ipc-handler", + ); + expect(handlerPaths.length).toBeGreaterThan(0); + expect( + handlerPaths.every( + (observation) => !("entry_path" in observation.properties), + ), + ).toBe(true); + }, +); + +cliTest( + "retains a nested bridge path when its IPC handler has no external terminal", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-nested-return-"); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + contextBridge.exposeInMainWorld("api", { + nested: { write: (value) => ipcRenderer.invoke("nested:write", value) } + });`, + "main.js": `const { ipcMain } = require("electron"); + ipcMain.handle("nested:write", (event, value) => value);`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const { graph } = application; + expect( + graph.nodes.some((node) => + node.observations.some( + (observation) => + observation.properties.entry === "api.nested.write" && + observation.properties.step_kind === "exposure", + ), + ), + ).toBe(true); + expect( + graph.edges.some( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ), + ).toBe(false); + + const traceInput = join(root, "trace-nested-input.json"); + await writeFile( + traceInput, + JSON.stringify({ + application: output.json, + seed: { kind: "api", value: "api.nested.write", match: "exact" }, + direction: "outgoing", + }), + ); + const traced = await cli.run({ + arguments: ["trace-application-feature", traceInput, "--json"], + }); + expect(traced.exitCode).toBe(0); + const { normalized_result: trace } = z + .object({ normalized_result: applicationFeatureTraceResultSchema }) + .parse(traced.json); + expect(trace.seed_matches).toHaveLength(1); + expect(trace.graph?.nodes.length).toBeGreaterThan(1); + }, +); + for (const surface of ["CLI", "stdio MCP"] as const) { cliTest( `preserves handler ambiguity and short-circuit factory paths through ${surface}`, @@ -225,3 +409,89 @@ cliTest( expect(joins.map((e) => e.properties.channel)).toEqual(["test:cut"]); }, ); + +cliTest( + "keeps shallow bridge members when a sibling exceeds the nesting limit", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-deep-bridge-"); + let deepMember = '() => e.ipcRenderer.send("deep:run")'; + for (let index = 0; index < 129; index += 1) + deepMember = `{ level${index}: ${deepMember} }`; + await writeFixtureFiles(root, { + "preload.js": `const e=require("electron"); + e.contextBridge.exposeInMainWorld("desktop", { + deep: ${deepMember}, + shallow: () => e.ipcRenderer.send("shallow:run") + });`, + "main.js": `const e=require("electron"); + e.ipcMain.on("shallow:run", event => event.sender.copy()); + e.ipcMain.on("deep:run", event => event.sender.copy());`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const { graph } = application; + const joins = graph.edges.filter( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ); + expect(joins.map((edge) => edge.properties.channel)).toEqual([ + "shallow:run", + ]); + expect( + graph.nodes + .flatMap((node) => node.observations) + .some( + (observation) => + observation.properties.entry === "desktop.shallow" && + observation.properties.step_kind === "exposure", + ), + ).toBe(true); + }, +); + +cliTest( + "preserves empty bridge member segments in nested paths", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-electron-empty-bridge-key-", + ); + await writeFixtureFiles(root, { + "preload.js": `const { contextBridge, ipcRenderer } = require("electron"); + contextBridge.exposeInMainWorld("desktop", { + "": { nested: { call: () => ipcRenderer.send("empty:key") } } + });`, + "main.js": `const { ipcMain } = require("electron"); + ipcMain.on("empty:key", event => event.sender.copy());`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: application } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const observations = application.graph.nodes.flatMap( + (node) => node.observations, + ); + expect( + observations.some( + (observation) => + observation.properties.entry === "desktop..nested.call" && + observation.properties.step_kind === "exposure", + ), + ).toBe(true); + expect( + application.graph.edges + .filter( + (edge) => + edge.evidence.extractor.operation === "join-electron-wrapper-paths", + ) + .map((edge) => edge.properties.channel), + ).toEqual(["empty:key"]); + }, +); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts index 0306f250f..0490ae211 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts @@ -9,7 +9,10 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { matchJavaScriptApplicationVersions } from "../../../../src/domain/javascript/javascriptApplicationVersionKeys.js"; import { findApplicationFeatureSeeds } from "../../../../src/domain/javascript/javascriptFeatureSeed.js"; -import { traceApplicationFeatureInputSchema } from "../../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; +import { + applicationFeatureSeedSchema, + traceApplicationFeatureInputSchema, +} from "../../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; const analyzeSource = async ( @@ -76,6 +79,59 @@ it("retains empty literal evidence across the application boundary surfaces", as ); }); +it("keeps bridge entry paths stable through worker validation and API seed matching", async () => { + const output = await analyzeSource(` + const { contextBridge, ipcRenderer } = require("electron"); + const literal = () => ipcRenderer.send("literal"); + const nested = () => ipcRenderer.send("nested"); + contextBridge.exposeInMainWorld("desktop", { + "nested.write": literal, + nested: { write: nested }, + }); + `); + const semanticPathIds = new Set( + (output.semantic_graph?.nodes ?? []) + .map(({ properties }) => properties.path_id) + .filter((pathId): pathId is string => typeof pathId === "string"), + ); + const applicationPathIds = new Set( + output.graph.nodes.flatMap(({ observations }) => + observations + .map(({ properties }) => properties.path_id) + .filter((pathId): pathId is string => typeof pathId === "string"), + ), + ); + expect(semanticPathIds).toEqual(applicationPathIds); + + const matches = findApplicationFeatureSeeds( + output.graph.nodes, + applicationFeatureSeedSchema.parse({ + kind: "api", + value: "desktop.nested.write", + entry_path: ["desktop", "nested.write"], + }), + ); + expect(matches).toHaveLength(1); + expect( + output.graph.nodes + .find(({ node_id: nodeId }) => nodeId === matches[0]?.node_id) + ?.observations.some( + ({ properties }) => + Array.isArray(properties.entry_path) && + properties.entry_path.join("\0") === "desktop\0nested.write", + ), + ).toBe(true); + expect( + findApplicationFeatureSeeds( + output.graph.nodes, + applicationFeatureSeedSchema.parse({ + kind: "api", + value: "desktop.nested.write", + }), + ), + ).toHaveLength(2); +}); + it("analyzes a plain long-line loader without package metadata", async () => { const output = await analyzeSource( `var Module = {}; fetch(""); new Worker(""); var payload = "${"x".repeat(65_536)}";`, @@ -240,8 +296,18 @@ it.each([ [{ kind: "channel", value: "" }, false], [{ kind: "channel", value: "", match: "contains" }, false], [{ kind: "node-id", value: "", match: "exact" }, false], + [{ kind: "api", value: "desktop.nested.write" }, true], + [ + { + kind: "api", + value: "desktop.nested.write", + entry_path: ["desktop", "nested.write"], + }, + true, + ], + [{ kind: "channel", value: "channel", entry_path: ["channel"] }, false], ] as const)( - "validates empty seed %j at runtime and in the advertised schema", + "validates seed %j at runtime and in the advertised schema", (seed, accepted) => { expect(seedSchema.safeParse(seed).success).toBe(accepted); expect(validateAdvertisedSeed(seed)).toBe(accepted); From 1016c96e6ba920c1c7da6898db16929145c749be Mon Sep 17 00:00:00 2001 From: Yann Roberto <165009858+shoyann@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:17:09 +0800 Subject: [PATCH 83/92] fix(source-maps): accept omitted names during browser collection (#2831) --- src/javascript/sourceMaps/SourceMapFormat.ts | 5 - .../browser/webSourceMapFetcher.test.ts | 98 +++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index fc3d3e8cd..f8975c9b6 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -267,11 +267,6 @@ const inspectLeaf = ( consumeRecords(budget, Array.isArray(rawNames) ? rawNames.length : 0); consumeRecords(budget, Array.isArray(rawContents) ? rawContents.length : 0); options.check?.(); - if (options.profile === "browser-collection" && !Array.isArray(rawNames)) - throw new SourceMapFormatFailure( - "format", - "Source-map leaf requires a names array.", - ); if ( options.profile === "browser-collection" && !hasValidSourceMapContents( diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index b1227fa26..6dcabb75b 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -29,6 +29,104 @@ const expectInvalidIncludedSourceMaps = (result: WebSourceMaps): void => { expectInvalidSourceMaps({ ...result, status: "unavailable" }); }; +it.each(["regular", "indexed"])( + "collects an HTTP %s source map with omitted names without accepting invalid names", + async (format) => { + const leaf = { + version: 3, + sources: ["src/main.ts"], + sourcesContent: ["import './dependency.ts';"], + mappings: "AAAA", + }; + const texts = [ + leaf, + { ...leaf, names: [] }, + { ...leaf, names: null }, + { ...leaf, names: 42 }, + { ...leaf, names: [42] }, + { ...leaf, mappings: "AAAAA" }, + ].map((map) => + JSON.stringify( + format === "indexed" + ? { + version: 3, + sections: [{ offset: { line: 2, column: 5 }, map }], + } + : map, + ), + ); + const server = createServer((incoming, response) => { + response.setHeader("content-type", "application/source-map+json"); + response.end(texts[Number(incoming.url?.slice(1))]); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new TypeError("Expected a TCP listener address"); + const localOrigin = `http://127.0.0.1:${String(address.port)}`; + const result = await fetchWebSourceMaps( + texts.map((_, index) => ({ + ...request, + fetchUrl: `${localOrigin}/${index}`, + declaredUrl: `${localOrigin}/${index}`, + })), + input({ allowed_origins: [localOrigin] }), + ); + expect(result.items.map(({ status }) => status)).toEqual([ + "included", + "included", + "invalid", + "invalid", + "invalid", + "invalid", + ]); + for (const [index, text] of texts.slice(0, 2).entries()) + expect(result.items[index]).toMatchObject({ + artifact: { + text, + bytes: Buffer.byteLength(text), + sha256: createHash("sha256").update(text).digest("hex"), + }, + original_sources: [{ source: `${localOrigin}/src/main.ts` }], + mappings: [ + { + generated_line: format === "indexed" ? 3 : 1, + generated_column: format === "indexed" ? 5 : 0, + source: `${localOrigin}/src/main.ts`, + original_line: 1, + original_column: 0, + name: null, + }, + ], + original_module_edges: [ + { + from_source: `${localOrigin}/src/main.ts`, + resolved_source: `${localOrigin}/src/dependency.ts`, + }, + ], + }); + for (const item of result.items.slice(2)) + expect(item).toMatchObject({ + artifact: null, + mappings: [], + original_sources: [], + }); + } finally { + server.closeAllConnections(); + if (server.listening) + await new Promise((resolve, reject) => { + server.close((error) => + error === undefined ? resolve() : reject(error), + ); + }); + } + }, +); + it("parses ECMA-426 HTTP source-map prefixes while retaining exact response evidence", async () => { const map = JSON.stringify({ version: 3, From 865c35e25a0123c3c3a24d63f98d7c585e609091 Mon Sep 17 00:00:00 2001 From: Hifza Zafar <152292315+hifzabuildsai@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:17:27 +0500 Subject: [PATCH 84/92] fix(ghidra): disclose missing PDB symbol coverage (#2275) * fix(ghidra): disclose missing PDB symbol coverage * style: format reviewed source changes * fix(ghidra): bound PDB handshake and retain coverage uncertainty --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: morluto <17176810+morluto@users.noreply.github.com> --- bridge/ghidra/ReaGhidraBridge.java | 40 +++++++ docs/ghidra-provider.md | 20 ++++ src/ghidra/GhidraAnalysisProfile.ts | 3 + src/ghidra/GhidraProvider.behavior.test.ts | 107 ++++++++++++++++++ src/ghidra/GhidraProviderClient.ts | 26 ++++- src/ghidra/GhidraSessionValues.test.ts | 43 +++++++ src/ghidra/GhidraSessionValues.ts | 71 ++++++++++++ tests/boundary/mcp/ghidraInventoryMcp.test.ts | 76 +++++++++++++ tests/boundary/mcp/ghidraMcpHarness.ts | 12 +- 9 files changed, 391 insertions(+), 7 deletions(-) diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index c180c5866..7c90d2e4a 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -56,6 +56,8 @@ import ghidra.app.decompiler.DecompiledFunction; import ghidra.app.decompiler.ClangCaseToken; import ghidra.app.decompiler.ClangNode; import ghidra.framework.Application; +import ghidra.app.util.bin.format.pdb.PdbParserConstants; +import ghidra.framework.options.Options; import ghidra.program.model.address.Address; import ghidra.program.model.address.AddressIterator; import ghidra.program.model.address.AddressSet; @@ -110,6 +112,8 @@ import ghidra.program.util.SymbolicPropogator; import ghidra.program.util.VarnodeContext; public final class ReaGhidraBridge extends HeadlessScript { + // At most 24 KiB after worst-case JSON escaping (six bytes per UTF-16 unit). + private static final int PDB_ATTRIBUTE_UTF16_BUDGET = 4096; private static final int MAX_HIGH_PCODE_OPS = 3000; private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64; private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000; @@ -454,6 +458,7 @@ public final class ReaGhidraBridge extends HeadlessScript { .toArray(String[]::new) )); result.add("target", target); + result.add("pdb", pdbObservation()); JsonArray extensions = analysisExtensions.reports(); if (extensions.size() > 0) result.add("analysis_extensions", extensions); // Written by ReaGhidraApplySeeds before auto-analysis; REA checks it @@ -469,6 +474,41 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + /** Read loader/analyzer-owned facts without searching for or applying companions. */ + private JsonObject pdbObservation() { + Options info = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO); + JsonObject pdb = new JsonObject(); + String[] names = {"file", "guid", "age", "signature", "version"}; + String[] attributes = { + PdbParserConstants.PDB_FILE, PdbParserConstants.PDB_GUID, + PdbParserConstants.PDB_AGE, PdbParserConstants.PDB_SIGNATURE, + PdbParserConstants.PDB_VERSION + }; + int remaining = PDB_ATTRIBUTE_UTF16_BUDGET; + JsonArray omitted = new JsonArray(); + for (int index = 0; index < names.length; index++) { + String value = info.getString(attributes[index], null); + if (value != null && value.length() > remaining) { + omitted.add(names[index]); + pdb.add(names[index], JsonNull.INSTANCE); + } else { + pdb.addProperty(names[index], value); + if (value != null) remaining -= value.length(); + } + } + if (omitted.size() > 0) { + JsonObject limit = new JsonObject(); + limit.addProperty("utf16_budget", PDB_ATTRIBUTE_UTF16_BUDGET); + limit.add("omitted_attributes", omitted); + pdb.add("resource_limit", limit); + } + if (info.contains(PdbParserConstants.PDB_LOADED)) + pdb.addProperty("loaded", info.getBoolean(PdbParserConstants.PDB_LOADED, false)); + else + pdb.add("loaded", JsonNull.INSTANCE); + return pdb; + } + private static String bytesHex(byte[] bytes) { return java.util.HexFormat.of().formatHex(bytes); } diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 5b83ee152..0876b8ad5 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -41,6 +41,26 @@ does not prove text is absent. Both list and search Evidence carry this limitation. When an address is known, use `read_bytes` and `xrefs` to inspect bytes and references. +## PE debug-symbol coverage + +The post-analysis handshake reports Ghidra's PDB Program Information attributes: +the referenced file, GUID, hexadecimal age, legacy signature, version, and +`PDB Loaded` state. Absent attributes remain null. PE Evidence carries a +limitation when a PDB reference is present but Ghidra reports it as not loaded, +or its load state is unknown. A bridge without this report leaves PDB coverage +unknown. A reported loaded state is a Ghidra observation, not an independent +PDB identity or complete symbol-coverage attestation, and retains an inline +coverage caveat. Handshake PDB strings share a 4096 UTF-16-unit serialization +budget (at most 24 KiB after JSON escaping); whole attributes that do not fit +are omitted with an explicit resource limitation, rather than truncated. + +Original names and types may be missing even when auto-analysis completes. +An empty name search does not establish that a function is absent; use +`list_procedures` and address-based analysis to inspect recovered functions. +REA imports a private executable copy and does not admit a caller-selected +PDB companion. This reporting does not search symbol servers, copy a sibling +PDB, or change analyzer settings. + ## Loaded addresses and file offsets `rea analyze --provider ghidra --json` and MCP `binary_overview` report diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 66be760bf..d4bd5a433 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -206,6 +206,9 @@ export const resolveGhidraAnalysisProfile = async ( ...(target.availableArchitectures ?? []), ].sort(), import_mode: "ephemeral-source-immutable", + ...(target.format === "pe" + ? { pdb_observations: "program-info-v1" } + : {}), annotation_policy: "atomic-function-entry-metadata-v1", load_image_observations: "source-mappings-entry-context-v2", function_body_evidence: "complete-inclusive-ranges-v1", diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index c9084905b..ccdfd96f7 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1541,6 +1541,113 @@ const sessionInfo = () => ({ }, }); +describe("Ghidra PDB coverage", () => { + it.each([ + { name: "missing bridge report", pdb: undefined, warns: true }, + { + name: "report without a PDB reference", + pdb: { + file: null, + guid: null, + age: null, + signature: null, + version: null, + loaded: null, + }, + warns: false, + }, + ])("distinguishes $name in direct PE searches", async ({ pdb, warns }) => { + const ghidra = provider(installationHost(), () => ({ + start: () => + Promise.resolve( + ok(pdb === undefined ? sessionInfo() : { ...sessionInfo(), pdb }), + ), + callTool: () => Promise.resolve(ok([])), + close: () => Promise.resolve(ok(null)), + })); + const target = executableTarget("pe", "x86_64"); + const resolved = await ghidra.resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("Expected a PE profile"); + const client = ghidra.createClient(target, resolved.value.profile); + try { + const searched = await client.execute("search_procedures", { + pattern: "add_numbers", + }); + if (!searched.ok) throw searched.error; + expect(searched.value.result).toEqual([]); + const warnings = searched.value.limitations.filter((limitation) => + limitation.includes("PDB"), + ); + expect(warnings).toEqual( + warns + ? [ + "Ghidra did not report PDB attributes; debug-symbol coverage is unknown.", + ] + : [], + ); + } finally { + await client.close(); + } + }); + const pdb = { + file: "C:\\build\\fixture.pdb", + guid: "01234567-89AB-CDEF-0123-456789ABCDEF", + age: "a", + signature: null, + version: "RSDS", + loaded: false as boolean | null, + }; + it.each([false, null, true])( + "carries PDB load state %s into direct search and health Evidence", + async (loaded) => { + let starts = 0; + const ghidra = provider(installationHost(), () => ({ + start: () => { + starts += 1; + return Promise.resolve( + ok({ ...sessionInfo(), pdb: { ...pdb, loaded } }), + ); + }, + callTool: () => Promise.resolve(ok([])), + close: () => Promise.resolve(ok(null)), + })); + const target = executableTarget("pe", "x86_64"); + const resolved = await ghidra.resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("Expected a PE profile"); + const client = ghidra.createClient(target, resolved.value.profile); + // No preceding health call: a name search must establish symbol coverage itself. + const searched = await client.execute("search_procedures", { + pattern: "add_numbers", + }); + expect(starts).toBe(1); + if (!searched.ok) throw searched.error; + expect(searched.value.result).toEqual([]); + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + expect(health.value.result).toMatchObject({ pdb: { ...pdb, loaded } }); + for (const observation of [searched.value, health.value]) { + const limitations = observation.limitations.join("\n"); + if (loaded === true) { + expect(limitations).toContain("loaded PDB"); + expect(limitations).toContain("not an independent PDB identity"); + expect(limitations).toContain("empty name search does not prove"); + } else { + expect(limitations).toContain(pdb.file); + expect(limitations).toContain(pdb.guid); + expect(limitations).toContain("age (hex) a"); + expect(limitations).toContain("list_procedures"); + expect(limitations).toContain( + loaded === null ? "unknown" : "not loaded", + ); + } + } + await client.close(); + }, + ); +}); + const executableTarget = ( format: "mach-o" | "elf" | "pe", architecture: Extract["architecture"], diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index cff45f78b..25838453e 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -88,7 +88,10 @@ import { relationshipCoverageFor, } from "./GhidraProviderCapabilities.js"; import type { GhidraSessionError } from "./GhidraSessionError.js"; -import type { GhidraSessionInfo } from "./GhidraSessionValues.js"; +import { + ghidraPdbLimitations, + type GhidraSessionInfo, +} from "./GhidraSessionValues.js"; import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js"; import { ghidraExtensionSchema, @@ -526,6 +529,7 @@ export const createGhidraProviderClient = (input: { let extensionFailure: AnalysisError | undefined; // Replaced by the reported outcome once the session handshake is checked. const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)]; + const pdbLimitations: string[] = []; // Replaced by the reported storage outcome once the handshake is checked. const softFloatLimitations: string[] = [ ...ghidraMipsSoftFloatLimitations(softFloat), @@ -646,10 +650,16 @@ export const createGhidraProviderClient = (input: { ...(context === undefined ? {} : { runId: context.runId }), logger: logger.child({ layer: "ghidra-bridge" }), }); - const checkExtensions = async ( + const checkSessionObservations = async ( operation: AnalysisOperation, info: GhidraSessionInfo, ): Promise => { + if (target.format === "pe") + pdbLimitations.splice( + 0, + pdbLimitations.length, + ...ghidraPdbLimitations(info.pdb), + ); const softFloatReason = ghidraMipsSoftFloatFailure( softFloat, info.mips_soft_float_storage, @@ -701,6 +711,7 @@ export const createGhidraProviderClient = (input: { ...softFloatLimitations, ...ghidraExtensionLimitations(extensions), ...seedLimitations, + ...pdbLimitations, ...(releaseLimitation === undefined ? [] : [releaseLimitation]), ]; const nativeAotOwner = @@ -772,7 +783,7 @@ export const createGhidraProviderClient = (input: { config.ghidraStartupTimeoutMs, ), ); - const failed = await checkExtensions(operation, started.value); + const failed = await checkSessionObservations(operation, started.value); if (failed !== undefined) return err(failed); return ok( createAnalysisExecution(started.value, committedProfile.provider, { @@ -785,7 +796,12 @@ export const createGhidraProviderClient = (input: { ? parseGhidraFunctionInput(operation, parameters) : parseGhidraInventoryInput(operation, parameters); if (!input.ok) return input; - if (extensions.length > 0 || seeds !== undefined || softFloat) { + if ( + target.format === "pe" || + extensions.length > 0 || + seeds !== undefined || + softFloat + ) { const started = await client.start(options?.signal, options?.progress); if (!started.ok) return err( @@ -795,7 +811,7 @@ export const createGhidraProviderClient = (input: { config.ghidraStartupTimeoutMs, ), ); - const failed = await checkExtensions(operation, started.value); + const failed = await checkSessionObservations(operation, started.value); if (failed !== undefined) return err(failed); } const called = await client.callTool(operation, input.value, { diff --git a/src/ghidra/GhidraSessionValues.test.ts b/src/ghidra/GhidraSessionValues.test.ts index a0430f35e..a68ed1615 100644 --- a/src/ghidra/GhidraSessionValues.test.ts +++ b/src/ghidra/GhidraSessionValues.test.ts @@ -52,6 +52,49 @@ describe("Ghidra DOS import commitment", () => { }); }); +describe("Ghidra PDB observations", () => { + const pdb = { + file: "C:\\build\\fixture.pdb", + guid: "01234567-89AB-CDEF-0123-456789ABCDEF", + age: "a", + signature: null, + version: "RSDS", + loaded: false, + }; + it("retains the referenced identity and observed load state", () => { + const parsed = parseGhidraSessionInfo({ ...session(), pdb }, expected); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.pdb).toEqual(pdb); + }); + it("preserves missing attributes as unknown", () => { + const unknown = Object.fromEntries( + Object.keys(pdb).map((key) => [key, null]), + ); + const parsed = parseGhidraSessionInfo( + { ...session(), pdb: unknown }, + expected, + ); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.pdb).toEqual(unknown); + }); + it.each([ + { ...pdb, loaded: "false" }, + { ...pdb, age: 1 }, + { + guid: pdb.guid, + age: pdb.age, + signature: null, + version: "RSDS", + loaded: false, + }, + { ...pdb, matched: true }, + ])("rejects malformed PDB reports", (report) => { + expect( + parseGhidraSessionInfo({ ...session(), pdb: report }, expected).ok, + ).toBe(false); + }); +}); + describe("Ghidra MIPS soft-float storage report", () => { const valid = { policy: "o32-soft-float-prototype-v1", diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index 0ee2a6bf1..e370892f3 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -25,6 +25,13 @@ export const GHIDRA_MUTATING_OPERATIONS: ReadonlySet = new Set([ ...GHIDRA_NAMING_OPERATIONS, ]); +// Shared wire budget: Java bounds the aggregate before serializing the handshake. +const PDB_ATTRIBUTE_UTF16_BUDGET = 4096; +const pdbAttributeSchema = z + .string() + .max(PDB_ATTRIBUTE_UTF16_BUDGET) + .nullable(); + const capabilitySchema = z.enum(GHIDRA_SESSION_CAPABILITIES); const sessionInfoSchema = z @@ -41,6 +48,39 @@ const sessionInfoSchema = z read_only: z.boolean(), analysis_complete: z.boolean(), analysis_timed_out: z.boolean(), + /** Program Information attributes; absent load state remains unknown. */ + pdb: z + .strictObject({ + file: pdbAttributeSchema, + guid: pdbAttributeSchema, + /** Ghidra stores CodeView age as a hexadecimal string. */ + age: pdbAttributeSchema, + signature: pdbAttributeSchema, + version: pdbAttributeSchema, + loaded: z.boolean().nullable(), + resource_limit: z + .strictObject({ + utf16_budget: z.literal(PDB_ATTRIBUTE_UTF16_BUDGET), + omitted_attributes: z + .array(z.enum(["file", "guid", "age", "signature", "version"])) + .min(1) + .max(5), + }) + .optional(), + }) + .refine( + (pdb) => + [ + pdb.file, + pdb.guid, + pdb.age, + pdb.signature, + pdb.version, + ].reduce((sum, value) => sum + (value?.length ?? 0), 0) <= + PDB_ATTRIBUTE_UTF16_BUDGET, + "PDB attributes exceed the aggregate wire budget", + ) + .optional(), analysis_extensions: z.array(ghidraExtensionResultSchema).optional(), analysis_seeds: ghidraSeedReportSchema.optional(), mips_soft_float_storage: z @@ -68,6 +108,37 @@ const sessionInfoSchema = z /** Authenticated metadata produced after Ghidra finishes auto-analysis. */ export type GhidraSessionInfo = z.infer; +/** Describe missing PDB authority without claiming all names are generated. */ +export const ghidraPdbLimitations = ( + pdb: GhidraSessionInfo["pdb"], +): readonly string[] => { + if (pdb === undefined) + return [ + "Ghidra did not report PDB attributes; debug-symbol coverage is unknown.", + ]; + const limitations: string[] = []; + if (pdb.resource_limit !== undefined) + limitations.push( + `PDB attributes ${pdb.resource_limit.omitted_attributes.join(", ")} were omitted because the handshake's ${pdb.resource_limit.utf16_budget}-UTF-16-unit PDB attribute budget was exhausted. Omitted values remain unknown; they were not truncated or reported as absent.`, + ); + if (pdb.loaded === true) + return [ + ...limitations, + "Ghidra reports a loaded PDB; this is not an independent PDB identity or complete symbol/type-coverage attestation. Original names and types may still be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions.", + ]; + if ( + [pdb.file, pdb.guid, pdb.age, pdb.signature, pdb.version].every( + (value) => value === null, + ) + ) + return limitations; + const state = pdb.loaded === false ? "not loaded" : "load state unknown"; + return [ + ...limitations, + `The target references PDB ${pdb.file ?? "(file unknown)"} (GUID ${pdb.guid ?? "unknown"}, age (hex) ${pdb.age ?? "unknown"}, signature ${pdb.signature ?? "unknown"}, version ${pdb.version ?? "unknown"}); Ghidra reports ${state}. Original names and types may be missing; an empty name search does not prove a procedure is absent. Use list_procedures and address-based analysis to inspect recovered functions. REA does not admit a caller-selected PDB companion.`, + ]; +}; + /** Require exact run, provider, and profile identity from the Java bridge. */ export const parseGhidraSessionInfo = ( value: JsonValue, diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index b24dac153..5573c0cbe 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -1,10 +1,86 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { ok } from "../../../src/domain/result.js"; import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js"; import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; +it("retains missing PDB coverage in empty PE name-search Evidence and snapshot replay", async () => { + const root = await createTestTempDirectory("rea-pdb-evidence-"); + const targetPath = join(root, "fixture.exe"); + await writeFile(targetPath, nativeAotPeFixture()); + // Synthetic post-analysis facts at the real provider boundary; no engine is simulated as verified. + const pdb = { + file: "C:\\build\\fixture.pdb", + guid: "01234567-89AB-CDEF-0123-456789ABCDEF", + age: "a", + signature: null, + version: "RSDS", + loaded: false, + }; + const harness = await connectGhidraMcp( + "ghidra-pdb-coverage", + (operation) => + Promise.resolve( + ok( + operation === "procedure_pseudo_code" + ? { + value: "int FUN_140001000(void) { return 0; }", + limitations: [], + } + : [], + ), + ), + { + targetPath, + pdb, + }, + ); + try { + const reply = await harness.mcp.callTool({ + name: "search_procedures", + arguments: { pattern: "add_numbers" }, + }); + expect(reply.isError).not.toBe(true); + const evidence = sessionEvidence(harness.session, reply.structuredContent); + expect(evidence.normalized_result).toEqual([]); + expect(evidence.analysis_profile?.parameters.pdb_observations).toBe( + "program-info-v1", + ); + const limitation = evidence.limitations.find((value) => + value.includes(pdb.file), + ); + expect(limitation).toContain(pdb.guid); + expect(limitation).toContain("age (hex) a"); + expect(limitation).toContain("not loaded"); + const decompiled = await harness.mcp.callTool({ + name: "procedure_pseudo_code", + arguments: { procedure: "0x140001000", document: "fixture" }, + }); + expect(decompiled.isError).not.toBe(true); + const functionEvidence = sessionEvidence( + harness.session, + decompiled.structuredContent, + ); + expect(functionEvidence.limitations).toContain(limitation); + const replay = await harness.session.execute( + "procedure_pseudo_code", + functionEvidence.parameters, + ); + if (!replay.ok) throw replay.error; + expect(replay.value.limitations).toContain(limitation); + expect(replay.value.limitations).toContainEqual( + expect.stringContaining("Loaded from a local REA analysis snapshot"), + ); + } finally { + await harness.close(); + } +}); + it("retains defined-string coverage in empty and matching search Evidence", async () => { let output = jsonValueSchema.parse([]); const harness = await connectGhidraMcp("ghidra-string-coverage", () => diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index a9b2248dc..2ff9d0790 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -12,6 +12,7 @@ import type { GhidraOperation } from "../../../src/ghidra/GhidraClient.js"; import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; import type { GhidraProviderClientFactory } from "../../../src/ghidra/GhidraProviderClient.js"; import { GHIDRA_SESSION_CAPABILITIES } from "../../../src/ghidra/GhidraSessionValues.js"; +import type { GhidraSessionInfo } from "../../../src/ghidra/GhidraSessionValues.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { @@ -26,12 +27,19 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; export const connectGhidraMcp = async ( name: string, execute?: ReturnType["callTool"], + observations: { + readonly targetPath?: string; + readonly pdb?: GhidraSessionInfo["pdb"]; + } = {}, ) => { const calls: GhidraOperation[] = []; const factory: GhidraProviderClientFactory = (options) => ({ start: () => Promise.resolve( - ok(sessionInfo(options.profileDigest, options.targetSha256)), + ok({ + ...sessionInfo(options.profileDigest, options.targetSha256), + ...(observations.pdb === undefined ? {} : { pdb: observations.pdb }), + }), ), callTool: (operation, input, options) => { calls.push(operation); @@ -68,7 +76,7 @@ export const connectGhidraMcp = async ( await mcp.connect(clientTransport); const opened = await mcp.callTool({ name: "open_binary", - arguments: { path: process.execPath }, + arguments: { path: observations.targetPath ?? process.execPath }, }); if (opened.isError === true) throw new Error( From cbe769b5043b32c5bbcf785cace094e29960d374 Mon Sep 17 00:00:00 2001 From: Aimal Khan <94559553+aimalexe@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:25:32 +0500 Subject: [PATCH 85/92] docs: move translated READMEs under docs/readme (#2165) Move all 18 translated READMEs into docs/readme/ and keep the English entry at the repository root. Update navigation, guide/image paths, website discovery and documentation checks together. Preserve the contributor's work and current main content. Cover every translated command and HTML destination, and repair the verified Persian heading fragments. Validation: 203 focused tests on the synchronized head, plus 119 tests and documentation checks on the integration with main fc0dbe7f9; 1,501 local references and 399 authored fragments verified. The complete CI workflow passes. The upstream x64 dylib fixture correction is included; an unchanged-checkout rerun cleared the earlier deadline timing assertion. Closes #2141 Co-authored-by: Aimal Khan <94559553+aimalexe@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 2 +- docs/.vitepress/config.ts | 1 + README_ar.md => docs/readme/README_ar.md | 94 ++++++++-------- README_de.md => docs/readme/README_de.md | 94 ++++++++-------- README_es.md => docs/readme/README_es.md | 94 ++++++++-------- README_fa.md => docs/readme/README_fa.md | 100 ++++++++--------- README_fr.md => docs/readme/README_fr.md | 96 ++++++++--------- README_id.md => docs/readme/README_id.md | 102 +++++++++--------- README_it.md => docs/readme/README_it.md | 98 ++++++++--------- README_ja.md => docs/readme/README_ja.md | 94 ++++++++-------- README_ko.md => docs/readme/README_ko.md | 94 ++++++++-------- README_pl.md => docs/readme/README_pl.md | 94 ++++++++-------- .../readme/README_pt-BR.md | 94 ++++++++-------- README_ru.md => docs/readme/README_ru.md | 94 ++++++++-------- README_th.md => docs/readme/README_th.md | 94 ++++++++-------- README_tr.md => docs/readme/README_tr.md | 94 ++++++++-------- README_uk.md => docs/readme/README_uk.md | 94 ++++++++-------- README_vi.md => docs/readme/README_vi.md | 94 ++++++++-------- .../readme/README_zh-TW.md | 94 ++++++++-------- README_zh.md => docs/readme/README_zh.md | 94 ++++++++-------- scripts/ci/scopes.mjs | 1 + scripts/lib/docs-facts.mjs | 69 ++++++++++-- scripts/test-website.py | 4 +- scripts/website_i18n.py | 21 +++- .../boundary/filesystem/ciChangeScope.test.ts | 20 +++- .../filesystem/productCatalog.test.ts | 91 +++++++++------- tests/conformance/readmeConsistency.test.ts | 45 ++++---- website/locales/README.md | 2 +- website/locales/languages.json | 96 +++++++++++++---- 29 files changed, 1108 insertions(+), 956 deletions(-) rename README_ar.md => docs/readme/README_ar.md (77%) rename README_de.md => docs/readme/README_de.md (76%) rename README_es.md => docs/readme/README_es.md (75%) rename README_fa.md => docs/readme/README_fa.md (74%) rename README_fr.md => docs/readme/README_fr.md (76%) rename README_id.md => docs/readme/README_id.md (75%) rename README_it.md => docs/readme/README_it.md (74%) rename README_ja.md => docs/readme/README_ja.md (76%) rename README_ko.md => docs/readme/README_ko.md (74%) rename README_pl.md => docs/readme/README_pl.md (76%) rename README_pt-BR.md => docs/readme/README_pt-BR.md (75%) rename README_ru.md => docs/readme/README_ru.md (80%) rename README_th.md => docs/readme/README_th.md (81%) rename README_tr.md => docs/readme/README_tr.md (76%) rename README_uk.md => docs/readme/README_uk.md (80%) rename README_vi.md => docs/readme/README_vi.md (75%) rename README_zh-TW.md => docs/readme/README_zh-TW.md (73%) rename README_zh.md => docs/readme/README_zh.md (73%) diff --git a/README.md b/README.md index 05b417959..53a86c5fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-**English** · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +**English** · [简体中文](docs/readme/README_zh.md) · [繁體中文](docs/readme/README_zh-TW.md) · [日本語](docs/readme/README_ja.md) · [한국어](docs/readme/README_ko.md) · [Türkçe](docs/readme/README_tr.md) · [Русский](docs/readme/README_ru.md) · [Tiếng Việt](docs/readme/README_vi.md) · [ไทย](docs/readme/README_th.md) · [Deutsch](docs/readme/README_de.md) · [Español](docs/readme/README_es.md) · [Français](docs/readme/README_fr.md) · [Українська](docs/readme/README_uk.md) · [Polski](docs/readme/README_pl.md) · [Português (Brasil)](docs/readme/README_pt-BR.md) · [العربية](docs/readme/README_ar.md) · [فارسی](docs/readme/README_fa.md) · [Bahasa Indonesia](docs/readme/README_id.md) · [Italiano](docs/readme/README_it.md) # REA: Reverse Engineer Anything diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 001c07fbe..5d7fcc2f3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -6,6 +6,7 @@ export default defineConfig({ "Reverse engineer binaries, applications, and runtime behavior with one CLI and MCP server.", lang: "en-US", base: "/rea/", + srcExclude: ["readme/**"], themeConfig: { nav: [ { text: "Get started", link: "/installation" }, diff --git a/README_ar.md b/docs/readme/README_ar.md similarity index 77% rename from README_ar.md rename to docs/readme/README_ar.md index 1c0bc367e..8df2522bc 100644 --- a/README_ar.md +++ b/docs/readme/README_ar.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · **العربية** · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · **العربية** · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: هندسة عكسية لأي شيء @@ -9,10 +9,10 @@ **وجدت ميزة تعجبك؟ افهم طريقة عملها حتى مستوى الشيفرة الثنائية.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA يشغّل جسر التحليل داخل Hopper لفحص ملف ثنائي للشيفرة الأصلية +REA يشغّل جسر التحليل داخل Hopper لفحص ملف ثنائي للشيفرة الأصلية

@@ -33,7 +33,7 @@ - Discord
+ Discord
انضم إلى مجتمع الهندسة العكسية

Discord · أسئلة وأجوبة · مشاركة النتائج @@ -67,7 +67,7 @@ npx rea-agents setup اختر الوكلاء، وراجع التغييرات المقترحة، ثم وافق عليها. يضيف الإعداد خادم MCP الخاص بـ REA وتعليمات سير العمل المطابقة، مع الاحتفاظ بنسخ احتياطية من الإعدادات الموجودة. أعد تشغيل وكيلك بعد ذلك. -يدعم الإعداد Claude Code وCodex وCursor وGemini CLI وGrok Build و[وكلاء آخرين](docs/installation.md#supported-agents). راجع [التثبيت والإعداد](docs/installation.md) لتهيئة موفّري التحليل وتسجيل MCP يدويًا. +يدعم الإعداد Claude Code وCodex وCursor وGemini CLI وGrok Build و[وكلاء آخرين](../installation.md#supported-agents). راجع [التثبيت والإعداد](../installation.md) لتهيئة موفّري التحليل وتسجيل MCP يدويًا. ### اطلب من وكيلك @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -لتحليل الشيفرة الأصلية، هيّئ موفّرًا أولًا. راجع [دليل CLI وEvidence](docs/cli.md) لأوامر تحليل الشيفرة الأصلية، واختيار الموفّر، واللقطات، والاستخدام في السكربتات. +لتحليل الشيفرة الأصلية، هيّئ موفّرًا أولًا. راجع [دليل CLI وEvidence](../cli.md) لأوامر تحليل الشيفرة الأصلية، واختيار الموفّر، واللقطات، والاستخدام في السكربتات. ### تحديث REA @@ -120,9 +120,9 @@ npx rea-agents@latest setup يستدعي وكيلك REA عبر MCP لفحص الهدف وتتبع الشيفرة ذات الصلة. يعيد REA النتائج مع أدلتها. يستخدمها الوكيل لطرح أسئلة متابعة، أو شرح السلوك، أو كتابة تنفيذ واختباره. تستخدم أوامر CLI سير العمل نفسه. -![مسار الاستقصاء في REA: يسأل وكيلك عن هدف محلي، ويفحصه REA ويتتبعه بأدوات التحليل، ثم يستخدم الوكيل الشيفرة والمراجع والجوانب غير المحسومة التي أعادتها الأدوات لشرح السلوك وتنفيذه واختباره.](website/public/assets/figures/rea-investigation-flow.svg) +![مسار الاستقصاء في REA: يسأل وكيلك عن هدف محلي، ويفحصه REA ويتتبعه بأدوات التحليل، ثم يستخدم الوكيل الشيفرة والمراجع والجوانب غير المحسومة التي أعادتها الأدوات لشرح السلوك وتنفيذه واختباره.](../../website/public/assets/figures/rea-investigation-flow.svg) -[افتح الشكل بحجمه الكامل](website/public/assets/figures/rea-investigation-flow.svg). +[افتح الشكل بحجمه الكامل](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ npx rea-agents@latest setup يتطلب REA إصدار Node.js 22.x (>=22.19) أو 24.x (>=24.11) أو 26+، إضافة إلى npm. تعتمد الأدوات الإضافية والأنظمة المضيفة المدعومة على الهدف: -| الهدف | ما يعيده REA | المتطلبات والدليل | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](https://rea.tools/ar/guides/native/) | -| بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](docs/binary-diagnostics.md) | -| شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](docs/evm-bytecode.md) | -| أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](docs/recorded-crashes.md) | -| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) | -| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) | -| تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](docs/web-network-captures.md) | -| التقاط الحزم | نقاط النهاية وDNS وTLS SNI وتوزيع البروتوكولات وطلبات HTTP | tshark وcapinfos يوفّرهما المُستدعي؛ [دليل pcap](docs/pcap-analysis.md) | -| تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](docs/managed-code-analysis.md) | -| حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS/Windows x64؛ [دليل Android](docs/android-analysis.md) | -| أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](docs/adb-device-analysis.md) | -| مشاريع JEB | وحدات المشروع من المحرك وبصمات المصنّفات والكود الزائف للأنواع/الدوال | عميل JEB يبدأه المُستدعي ويقدّم MCP، ويُختار عبر REA_JEB_MCP_URL؛ [دليل JEB](docs/jeb-analysis.md) | -| موارد Android | البيان المفكوك الترميز وجداول السلاسل واللغات وحقائق الإصدار | مُشغّل Apktool يوفّره المُستدعي؛ [دليل الموارد](docs/apktool-resource-analysis.md) | -| تطبيقات Flutter | بصمة لقطة Dart وهشوش المكتبات لكل ABI وأصل بناء المحرك | لا شيء (تحليل APK ساكن)؛ [دليل Flutter](docs/flutter-analysis.md) | -| البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | Binwalk / Unblob على Linux؛ [دليل البرامج الثابتة](docs/firmware-analysis.md) | -| الحزم المسجلة | حقول مختارة وقيم متكررة ومرشحات وقواعد فك الترميز | Linux: TShark 4.x + prlimit; [دليل التقاط الحزم](docs/packet-captures.md) | -| الإشارات المنطقية المسجلة | تعليقات مفكك الترميز ونطاقات العينات ومعلومات التشخيص | Linux: sigrok-cli 0.7.2 + prlimit; [دليل الإشارات](docs/signal-recordings.md) | -| الحزم والموارد | قوائم الملفات، والبصمات، وملفات plist، وبنية حزم Apple، والموارد المستخرجة | [دليل تحليل الملفات وJavaScript](docs/javascript-artifact-reconstruction.md)، [تطبيقات Apple](docs/apple-application-analysis.md) | -| سلوك العمليات | مخرجات الطرفية، والتفاعلات، وملاحظات الخروج ونظام الملفات، ومقارنات التشغيل | Linux/macOS مع PTY أصلي؛ [تسجيل سلوك العمليات](docs/process-capture.md) | +| الهدف | ما يعيده REA | المتطلبات والدليل | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](https://rea.tools/ar/guides/native/) | +| بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) | +| شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) | +| أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) | +| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) | +| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) | +| تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](../web-network-captures.md) | +| التقاط الحزم | نقاط النهاية وDNS وTLS SNI وتوزيع البروتوكولات وطلبات HTTP | tshark وcapinfos يوفّرهما المُستدعي؛ [دليل pcap](../pcap-analysis.md) | +| تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](../managed-code-analysis.md) | +| حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS/Windows x64؛ [دليل Android](../android-analysis.md) | +| أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](../adb-device-analysis.md) | +| مشاريع JEB | وحدات المشروع من المحرك وبصمات المصنّفات والكود الزائف للأنواع/الدوال | عميل JEB يبدأه المُستدعي ويقدّم MCP، ويُختار عبر REA_JEB_MCP_URL؛ [دليل JEB](../jeb-analysis.md) | +| موارد Android | البيان المفكوك الترميز وجداول السلاسل واللغات وحقائق الإصدار | مُشغّل Apktool يوفّره المُستدعي؛ [دليل الموارد](../apktool-resource-analysis.md) | +| تطبيقات Flutter | بصمة لقطة Dart وهشوش المكتبات لكل ABI وأصل بناء المحرك | لا شيء (تحليل APK ساكن)؛ [دليل Flutter](../flutter-analysis.md) | +| البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | Binwalk / Unblob على Linux؛ [دليل البرامج الثابتة](../firmware-analysis.md) | +| الحزم المسجلة | حقول مختارة وقيم متكررة ومرشحات وقواعد فك الترميز | Linux: TShark 4.x + prlimit; [دليل التقاط الحزم](../packet-captures.md) | +| الإشارات المنطقية المسجلة | تعليقات مفكك الترميز ونطاقات العينات ومعلومات التشخيص | Linux: sigrok-cli 0.7.2 + prlimit; [دليل الإشارات](../signal-recordings.md) | +| الحزم والموارد | قوائم الملفات، والبصمات، وملفات plist، وبنية حزم Apple، والموارد المستخرجة | [دليل تحليل الملفات وJavaScript](../javascript-artifact-reconstruction.md)، [تطبيقات Apple](../apple-application-analysis.md) | +| سلوك العمليات | مخرجات الطرفية، والتفاعلات، وملاحظات الخروج ونظام الملفات، ومقارنات التشغيل | Linux/macOS مع PTY أصلي؛ [تسجيل سلوك العمليات](../process-capture.md) | يقرأ الفحص الثابت لـ JavaScript و.NET الملفات المقدّمة دون تشغيل التطبيق. يشغّل التسجيل أثناء التنفيذ الهدف المحدد أو يتفاعل معه بصلاحيات مستخدمك؛ ويشرح كل دليل للتسجيل أثناء التنفيذ آثاره. -تختلف صيغ ملفات الشيفرة الأصلية والأنظمة المضيفة المدعومة باختلاف الموفّر. راجع [إعداد Hopper وGhidra](docs/installation.md#hopper)، و[دليل IDA](docs/ida-provider.md)، و[دعم Ghidra التجريبي على Windows](docs/windows-ghidra-p0.md). يدعم Ghidra أيضًا [تحليل DOS ذي 16 بت](docs/ghidra-dos.md). لاختيار الموفّر، راجع [دليل CLI](docs/cli.md#choose-a-provider). تحقّق من [توفر الميزات في الإصدارات](docs/installation.md#released-package-and-main) للميزات المضافة بعد أحدث إصدار على npm. +تختلف صيغ ملفات الشيفرة الأصلية والأنظمة المضيفة المدعومة باختلاف الموفّر. راجع [إعداد Hopper وGhidra](../installation.md#hopper)، و[دليل IDA](../ida-provider.md)، و[دعم Ghidra التجريبي على Windows](../windows-ghidra-p0.md). يدعم Ghidra أيضًا [تحليل DOS ذي 16 بت](../ghidra-dos.md). لاختيار الموفّر، راجع [دليل CLI](../cli.md#choose-a-provider). تحقّق من [توفر الميزات في الإصدارات](../installation.md#released-package-and-main) للميزات المضافة بعد أحدث إصدار على npm. ## دراسات الحالة -[![رسوم توضيحية لأمثلة توزيع الصوت في DX-Ball وجسر الحافظة في Notion وحلقة الطلقات في TH04](docs/assets/rea-showcases.png)](https://rea.tools/ar/showcase/) +[![رسوم توضيحية لأمثلة توزيع الصوت في DX-Ball وجسر الحافظة في Notion وحلقة الطلقات في TH04](../assets/rea-showcases.png)](https://rea.tools/ar/showcase/) ### DX-Ball: إعادة بناء حساب توزيع الصوت بين القناتين @@ -189,28 +189,28 @@ npx rea-agents@latest setup
أي وكلاء يمكنهم استخدام REA؟ -أي وكيل يدعم خوادم MCP المحلية. يهيّئ الإعداد [الوكلاء المدعومين](docs/installation.md#supported-agents)؛ ويمكن للعملاء الآخرين استخدام [تسجيل MCP يدويًا](docs/installation.md#mcp-registry). +أي وكيل يدعم خوادم MCP المحلية. يهيّئ الإعداد [الوكلاء المدعومين](../installation.md#supported-agents)؛ ويمكن للعملاء الآخرين استخدام [تسجيل MCP يدويًا](../installation.md#mcp-registry).
هل أحتاج إلى Hopper أو Ghidra أو IDA؟ -يستخدم تحليل الشيفرة الأصلية المتعمق أحدها. يعمل الفحص الثابت لـ JavaScript و.NET دون محرك لتحليل الشيفرة الأصلية. يستطيع الإعداد تثبيت Hopper بعد الموافقة؛ أما Ghidra وIDA فيستخدمان تثبيتاتك الموجودة. راجع [إعداد الموفّرين](docs/installation.md#hopper). +يستخدم تحليل الشيفرة الأصلية المتعمق أحدها. يعمل الفحص الثابت لـ JavaScript و.NET دون محرك لتحليل الشيفرة الأصلية. يستطيع الإعداد تثبيت Hopper بعد الموافقة؛ أما Ghidra وIDA فيستخدمان تثبيتاتك الموجودة. راجع [إعداد الموفّرين](../installation.md#hopper).
هل أحتاج إلى تشغيل Hopper أولًا؟ -يشغّل REA برنامج Hopper عندما تحتاج العملية إليه. على macOS، قد تظهر عند التشغيل الأول نافذة تطلب اختيار وضع العرض التجريبي أو تفعيل الترخيص. راجع [تشغيل Hopper واستكشاف الأخطاء](docs/installation.md#launcher-paths-and-troubleshooting). +يشغّل REA برنامج Hopper عندما تحتاج العملية إليه. على macOS، قد تظهر عند التشغيل الأول نافذة تطلب اختيار وضع العرض التجريبي أو تفعيل الترخيص. راجع [تشغيل Hopper واستكشاف الأخطاء](../installation.md#launcher-paths-and-troubleshooting).
ماذا يفعل تثبيت المهارة من skills.sh؟ -توفّر المهارة تعليمات الاستقصاء لوكيلك. استخدم `npx rea-agents setup` لتسجيل خادم MCP الخاص بـ REA وتثبيت التعليمات المطابقة، ثم أعد تشغيل وكيلك. راجع [تثبيت المهارة وحدها](docs/installation.md#skill-only-installation). +توفّر المهارة تعليمات الاستقصاء لوكيلك. استخدم `npx rea-agents setup` لتسجيل خادم MCP الخاص بـ REA وتثبيت التعليمات المطابقة، ثم أعد تشغيل وكيلك. راجع [تثبيت المهارة وحدها](../installation.md#skill-only-installation).
@@ -253,14 +253,14 @@ npx rea-agents@latest setup ابدأ بـ [الأدلة العملية](https://rea.tools/ar/guides/) على الموقع. للخيارات الدقيقة والمتطلبات وعقود النتائج: -- [التثبيت والإعداد](docs/installation.md): تسجيل الوكلاء، وتهيئة الموفّرين، والتحديثات، وإلغاء التثبيت. -- [الجاهزية واستكشاف الأخطاء](docs/installation.md#check-readiness-for-your-task): تشخيص وكيل أو محرك تحليل محدد. -- [CLI وEvidence](docs/cli.md): الأوامر، واختيار الموفّر، واللقطات، والاستيراد/التصدير، وحالات الخروج. -- [عقود MCP](docs/mcp-contracts.md) و[تعليمات الوكلاء](docs/mcp-prompts.md): نتائج الأدوات، والجلسات، والاستقصاءات الموجّهة. -- [كتالوج الأدوات](docs/mcp-contracts.md#generated-catalog): قائمة الأدوات والموفّرين وأوامر CLI المولّدة أثناء البناء. -- [خطة العمل](docs/roadmap.md): الأعمال المخطط لها ومتابعة القدرات. +- [التثبيت والإعداد](../installation.md): تسجيل الوكلاء، وتهيئة الموفّرين، والتحديثات، وإلغاء التثبيت. +- [الجاهزية واستكشاف الأخطاء](../installation.md#check-readiness-for-your-task): تشخيص وكيل أو محرك تحليل محدد. +- [CLI وEvidence](../cli.md): الأوامر، واختيار الموفّر، واللقطات، والاستيراد/التصدير، وحالات الخروج. +- [عقود MCP](../mcp-contracts.md) و[تعليمات الوكلاء](../mcp-prompts.md): نتائج الأدوات، والجلسات، والاستقصاءات الموجّهة. +- [كتالوج الأدوات](../mcp-contracts.md#generated-catalog): قائمة الأدوات والموفّرين وأوامر CLI المولّدة أثناء البناء. +- [خطة العمل](../roadmap.md): الأعمال المخطط لها ومتابعة القدرات. -أبلغ عن الثغرات وفق [SECURITY.md](SECURITY.md). +أبلغ عن الثغرات وفق [SECURITY.md](../../SECURITY.md). ## سجل النجوم @@ -278,7 +278,7 @@ npx rea-agents@latest setup ## إخلاء المسؤولية -راجع [DISCLAIMER.md](DISCLAIMER.md) للحصول على إرشادات الاستخدام المسؤول. +راجع [DISCLAIMER.md](../../DISCLAIMER.md) للحصول على إرشادات الاستخدام المسؤول. REA مخصص للبحث والتعليم والتشغيل البيني وحفظ البرمجيات بشكل قانوني، ولتحليل البرمجيات التي تملكها أو لديك تفويض لفحصها. @@ -290,14 +290,14 @@ applicable licenses. نرحّب بمساعدتك في تطوير REA! [افتح issue](https://github.com/morluto/rea/issues) للإبلاغ عن خطأ أو اقتراح ميزة، أو [أرسل pull request](https://github.com/morluto/rea/pulls) لتحسين الشيفرة أو التوثيق. -راجع [CONTRIBUTING.md](CONTRIBUTING.md) لإعداد بيئة التطوير والفحوص، و[دليل الاختبار](docs/testing.md) لمسارات التحقق، و[خريطة البنية](docs/architecture.mermaid) لهيكل المشروع. +راجع [CONTRIBUTING.md](../../CONTRIBUTING.md) لإعداد بيئة التطوير والفحوص، و[دليل الاختبار](../testing.md) لمسارات التحقق، و[خريطة البنية](../architecture.mermaid) لهيكل المشروع. ## الترخيص -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_de.md b/docs/readme/README_de.md similarity index 76% rename from README_de.md rename to docs/readme/README_de.md index 2c6d20290..2dbb1d41a 100644 --- a/README_de.md +++ b/docs/readme/README_de.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · **Deutsch** · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · **Deutsch** · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Alles reverse-engineeren @@ -9,10 +9,10 @@ **Eine Funktion gefällt dir? Verstehe, wie sie funktioniert – bis hinunter auf die Binärebene.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA startet seine Analysebrücke in Hopper und untersucht eine native Binärdatei +REA startet seine Analysebrücke in Hopper und untersucht eine native Binärdatei

@@ -33,7 +33,7 @@ - Discord
+ Discord
Der Reverse-Engineering-Community beitreten

Discord · Fragen und Antworten · Ergebnisse teilen @@ -67,7 +67,7 @@ npx rea-agents setup Wähle deine Agenten aus, prüfe die geplanten Änderungen und bestätige sie. Die Einrichtung fügt den MCP-Server von REA und passende Workflow-Anweisungen hinzu und sichert vorhandene Konfigurationen. Starte deinen Agenten anschließend neu. -Die Einrichtung unterstützt Claude Code, Codex, Cursor, Gemini CLI, Grok Build und [weitere Agenten](docs/installation.md#supported-agents). Informationen zur Provider-Konfiguration und manuellen MCP-Registrierung findest du unter [Installation und Einrichtung](docs/installation.md). +Die Einrichtung unterstützt Claude Code, Codex, Cursor, Gemini CLI, Grok Build und [weitere Agenten](../installation.md#supported-agents). Informationen zur Provider-Konfiguration und manuellen MCP-Registrierung findest du unter [Installation und Einrichtung](../installation.md). ### Deinen Agenten fragen @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Konfiguriere für native Analysen zuerst einen Provider. Native Befehle, Provider-Auswahl, Snapshots und Skriptnutzung beschreibt der [CLI- und Evidence-Leitfaden](docs/cli.md). +Konfiguriere für native Analysen zuerst einen Provider. Native Befehle, Provider-Auswahl, Snapshots und Skriptnutzung beschreibt der [CLI- und Evidence-Leitfaden](../cli.md). ### REA aktualisieren @@ -120,9 +120,9 @@ Prüfe die Änderungen und starte deinen Agenten neu. Für einzelne CLI-Aufrufe Dein Agent ruft REA über MCP auf, um das Ziel zu untersuchen und relevanten Code zu verfolgen. REA liefert Ergebnisse mit ihren Belegen zurück. Der Agent nutzt sie für weitere Fragen, Erklärungen oder zum Schreiben und Testen einer Implementierung. CLI-Befehle verwenden dieselben Workflows. -![REA-Untersuchungsablauf: Dein Agent fragt nach einem lokalen Ziel, REA untersucht und verfolgt es mit Analysewerkzeugen, und der Agent nutzt den zurückgegebenen Code, Verweise und offene Fragen zum Erklären, Implementieren und Testen.](website/public/assets/figures/rea-investigation-flow.svg) +![REA-Untersuchungsablauf: Dein Agent fragt nach einem lokalen Ziel, REA untersucht und verfolgt es mit Analysewerkzeugen, und der Agent nutzt den zurückgegebenen Code, Verweise und offene Fragen zum Erklären, Implementieren und Testen.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Abbildung in voller Größe öffnen](website/public/assets/figures/rea-investigation-flow.svg). +[Abbildung in voller Größe öffnen](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ Dein Agent ruft REA über MCP auf, um das Ziel zu untersuchen und relevanten Cod REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätzliche Werkzeuge und unterstützte Hostsysteme hängen vom Ziel ab: -| Ziel | Was REA zurückgibt | Voraussetzungen und Anleitung | -| ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](https://rea.tools/de/guides/native/) | -| Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](docs/binary-diagnostics.md) | -| EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](docs/evm-bytecode.md) | -| Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](docs/recorded-crashes.md) | -| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) | -| Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) | -| Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](docs/web-network-captures.md) | -| Paketerfassungen | Endpunkte, DNS, TLS SNI, Protokollverteilung und HTTP-Anfragen | Vom Aufrufer bereitgestellte tshark- und capinfos-Binaries; [pcap-Leitfaden](docs/pcap-analysis.md) | -| .NET-Assemblies | Metadaten, CIL-Anweisungen, deklarierte native Abhängigkeiten und Build-Vergleiche | Statische Untersuchung; [Leitfaden für verwalteten Code](docs/managed-code-analysis.md) | -| Android-APKs | Manifest-Deklarationen, Klassen, dekompilierte Methoden und Verweise | JADX ohne grafische Oberfläche und ein vollständiges JDK unter Linux/macOS/Windows x64; [Android-Leitfaden](docs/android-analysis.md) | -| Android-Geräte | Geräte, Pakete, Prozesse, Logs, Dumps, Bildschirmaufnahmen sowie APK- und Dateiübertragungen | Vom Aufrufer bereitgestelltes adb (Emulator oder Gerät); [ADB-Leitfaden](docs/adb-device-analysis.md) | -| JEB-Projekte | Engine-gestützte Projekteinheiten, Artefakt-Prüfsummen und Pseudokode von Typen/Methoden | Ein vom Aufrufer gestarteter JEB-Client mit MCP, ausgewählt über REA_JEB_MCP_URL; [JEB-Leitfaden](docs/jeb-analysis.md) | -| Android-Ressourcen | Dekodiertes Manifest, String-Tabellen, Sprachen und Versionsfakten | Vom Aufrufer bereitgestellter Apktool-Starter; [Ressourcen-Leitfaden](docs/apktool-resource-analysis.md) | -| Flutter-Apps | Dart-Snapshot-Hash, Per-ABI-Bibliotheksprüfsummen und Engine-Build-Provenienz | Keine (statische APK-Analyse); [Flutter-Leitfaden](docs/flutter-analysis.md) | -| Firmware | Bereiche, Extraktionsergebnisse und Übergaben an die native Analyse | Binwalk / Unblob unter Linux; [Firmware-Leitfaden](docs/firmware-analysis.md) | -| Aufgezeichnete Pakete | Ausgewählte Felder, wiederholte Werte, Filter und Decode-as-Regeln | Linux: TShark 4.x + prlimit; [Mitschnitt-Anleitung](docs/packet-captures.md) | -| Aufgezeichnete Logiksignale | Decoder-Anmerkungen, Sample-Bereiche und Diagnosen | Linux: sigrok-cli 0.7.2 + prlimit; [Signalaufzeichnungen](docs/signal-recordings.md) | -| Pakete und Ressourcen | Dateiinventare, Hashwerte, plists, Aufbau von Apple-Bundles und extrahierte Ressourcen | [Artefakt- und JavaScript-Leitfaden](docs/javascript-artifact-reconstruction.md), [Apple-Anwendungen](docs/apple-application-analysis.md) | -| Prozessverhalten | Terminalausgabe, Interaktionen, Beobachtungen zu Prozessende und Dateisystem sowie Laufvergleiche | Linux/macOS mit nativer PTY; [Prozessaufzeichnung](docs/process-capture.md) | +| Ziel | Was REA zurückgibt | Voraussetzungen und Anleitung | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](https://rea.tools/de/guides/native/) | +| Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) | +| EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) | +| Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) | +| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) | +| Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) | +| Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](../web-network-captures.md) | +| Paketerfassungen | Endpunkte, DNS, TLS SNI, Protokollverteilung und HTTP-Anfragen | Vom Aufrufer bereitgestellte tshark- und capinfos-Binaries; [pcap-Leitfaden](../pcap-analysis.md) | +| .NET-Assemblies | Metadaten, CIL-Anweisungen, deklarierte native Abhängigkeiten und Build-Vergleiche | Statische Untersuchung; [Leitfaden für verwalteten Code](../managed-code-analysis.md) | +| Android-APKs | Manifest-Deklarationen, Klassen, dekompilierte Methoden und Verweise | JADX ohne grafische Oberfläche und ein vollständiges JDK unter Linux/macOS/Windows x64; [Android-Leitfaden](../android-analysis.md) | +| Android-Geräte | Geräte, Pakete, Prozesse, Logs, Dumps, Bildschirmaufnahmen sowie APK- und Dateiübertragungen | Vom Aufrufer bereitgestelltes adb (Emulator oder Gerät); [ADB-Leitfaden](../adb-device-analysis.md) | +| JEB-Projekte | Engine-gestützte Projekteinheiten, Artefakt-Prüfsummen und Pseudokode von Typen/Methoden | Ein vom Aufrufer gestarteter JEB-Client mit MCP, ausgewählt über REA_JEB_MCP_URL; [JEB-Leitfaden](../jeb-analysis.md) | +| Android-Ressourcen | Dekodiertes Manifest, String-Tabellen, Sprachen und Versionsfakten | Vom Aufrufer bereitgestellter Apktool-Starter; [Ressourcen-Leitfaden](../apktool-resource-analysis.md) | +| Flutter-Apps | Dart-Snapshot-Hash, Per-ABI-Bibliotheksprüfsummen und Engine-Build-Provenienz | Keine (statische APK-Analyse); [Flutter-Leitfaden](../flutter-analysis.md) | +| Firmware | Bereiche, Extraktionsergebnisse und Übergaben an die native Analyse | Binwalk / Unblob unter Linux; [Firmware-Leitfaden](../firmware-analysis.md) | +| Aufgezeichnete Pakete | Ausgewählte Felder, wiederholte Werte, Filter und Decode-as-Regeln | Linux: TShark 4.x + prlimit; [Mitschnitt-Anleitung](../packet-captures.md) | +| Aufgezeichnete Logiksignale | Decoder-Anmerkungen, Sample-Bereiche und Diagnosen | Linux: sigrok-cli 0.7.2 + prlimit; [Signalaufzeichnungen](../signal-recordings.md) | +| Pakete und Ressourcen | Dateiinventare, Hashwerte, plists, Aufbau von Apple-Bundles und extrahierte Ressourcen | [Artefakt- und JavaScript-Leitfaden](../javascript-artifact-reconstruction.md), [Apple-Anwendungen](../apple-application-analysis.md) | +| Prozessverhalten | Terminalausgabe, Interaktionen, Beobachtungen zu Prozessende und Dateisystem sowie Laufvergleiche | Linux/macOS mit nativer PTY; [Prozessaufzeichnung](../process-capture.md) | Statische JavaScript- und .NET-Untersuchungen lesen die bereitgestellten Dateien, ohne die Anwendung auszuführen. Laufzeitaufzeichnungen führen das gewählte Ziel mit deinen Benutzerrechten aus oder interagieren damit; die jeweiligen Anleitungen beschreiben die Auswirkungen. -Native Formate und unterstützte Hostsysteme unterscheiden sich je nach Provider. Siehe [Hopper- und Ghidra-Einrichtung](docs/installation.md#hopper), den [IDA-Leitfaden](docs/ida-provider.md) und die [experimentelle Ghidra-Unterstützung unter Windows](docs/windows-ghidra-p0.md). Ghidra unterstützt auch [16-Bit-DOS-Analyse](docs/ghidra-dos.md). Die Provider-Auswahl beschreibt der [CLI-Leitfaden](docs/cli.md#choose-a-provider). Prüfe die [Verfügbarkeit in Releases](docs/installation.md#released-package-and-main) für Funktionen, die seit der neuesten npm-Version hinzugekommen sind. +Native Formate und unterstützte Hostsysteme unterscheiden sich je nach Provider. Siehe [Hopper- und Ghidra-Einrichtung](../installation.md#hopper), den [IDA-Leitfaden](../ida-provider.md) und die [experimentelle Ghidra-Unterstützung unter Windows](../windows-ghidra-p0.md). Ghidra unterstützt auch [16-Bit-DOS-Analyse](../ghidra-dos.md). Die Provider-Auswahl beschreibt der [CLI-Leitfaden](../cli.md#choose-a-provider). Prüfe die [Verfügbarkeit in Releases](../installation.md#released-package-and-main) für Funktionen, die seit der neuesten npm-Version hinzugekommen sind. ## Fallstudien -[![Illustrationen der Fallstudien zu DX-Balls Klangbalance, Notions Zwischenablage-Brücke und TH04s Geschossring](docs/assets/rea-showcases.png)](https://rea.tools/de/showcase/) +[![Illustrationen der Fallstudien zu DX-Balls Klangbalance, Notions Zwischenablage-Brücke und TH04s Geschossring](../assets/rea-showcases.png)](https://rea.tools/de/showcase/) ### DX-Ball: Eine Stereo-Panning-Berechnung rekonstruieren @@ -187,28 +187,28 @@ Wenn du mit REA etwas Interessantes untersucht hast, würden wir es gern sehen.
Welche Agenten können REA verwenden? -Jeder Agent, der lokale MCP-Server unterstützt. Die Einrichtung konfiguriert die [unterstützten Agenten](docs/installation.md#supported-agents); andere Clients können die [manuelle MCP-Registrierung](docs/installation.md#mcp-registry) nutzen. +Jeder Agent, der lokale MCP-Server unterstützt. Die Einrichtung konfiguriert die [unterstützten Agenten](../installation.md#supported-agents); andere Clients können die [manuelle MCP-Registrierung](../installation.md#mcp-registry) nutzen.
Brauche ich Hopper, Ghidra oder IDA? -Für tiefgehende native Analysen wird eines dieser Programme verwendet. Statische JavaScript- und .NET-Untersuchungen funktionieren ohne native Analyse-Engine. Die Einrichtung kann Hopper nach Zustimmung installieren; Ghidra und IDA nutzen deine vorhandenen Installationen. Siehe [Provider-Einrichtung](docs/installation.md#hopper). +Für tiefgehende native Analysen wird eines dieser Programme verwendet. Statische JavaScript- und .NET-Untersuchungen funktionieren ohne native Analyse-Engine. Die Einrichtung kann Hopper nach Zustimmung installieren; Ghidra und IDA nutzen deine vorhandenen Installationen. Siehe [Provider-Einrichtung](../installation.md#hopper).
Muss ich Hopper vorher starten? -REA startet Hopper, wenn eine Operation es benötigt. Unter macOS kann beim ersten Start ein Dialog erscheinen, in dem du den Demomodus auswählst oder deine Lizenz aktivierst. Siehe [Hopper-Start und Fehlerbehebung](docs/installation.md#launcher-paths-and-troubleshooting). +REA startet Hopper, wenn eine Operation es benötigt. Unter macOS kann beim ersten Start ein Dialog erscheinen, in dem du den Demomodus auswählst oder deine Lizenz aktivierst. Siehe [Hopper-Start und Fehlerbehebung](../installation.md#launcher-paths-and-troubleshooting).
Was bewirkt die Installation des Skills über skills.sh? -Der Skill stellt Untersuchungsanweisungen für deinen Agenten bereit. Verwende `npx rea-agents setup`, um den MCP-Server von REA zu registrieren und passende Anweisungen zu installieren. Starte deinen Agenten anschließend neu. Siehe [Installation nur des Skills](docs/installation.md#skill-only-installation). +Der Skill stellt Untersuchungsanweisungen für deinen Agenten bereit. Verwende `npx rea-agents setup`, um den MCP-Server von REA zu registrieren und passende Anweisungen zu installieren. Starte deinen Agenten anschließend neu. Siehe [Installation nur des Skills](../installation.md#skill-only-installation).
@@ -251,14 +251,14 @@ Wenn du einen Agenten verwendest, schließe die [Aktualisierung der Einrichtung] Beginne mit den [praxisnahen Anleitungen](https://rea.tools/de/guides/) auf der Website. Genaue Optionen, Voraussetzungen und Ergebniskontrakte findest du hier: -- [Installation und Einrichtung](docs/installation.md): Agentenregistrierung, Provider-Konfiguration, Updates und Deinstallation. -- [Bereitschaft und Fehlerbehebung](docs/installation.md#check-readiness-for-your-task): Diagnose eines bestimmten Agenten oder einer Analyse-Engine. -- [CLI und Evidence](docs/cli.md): Befehle, Provider-Auswahl, Snapshots, Import/Export und Exit-Status. -- [MCP-Kontrakte](docs/mcp-contracts.md) und [Agenten-Prompts](docs/mcp-prompts.md): Werkzeugergebnisse, Sitzungen und geführte Untersuchungen. -- [Werkzeugkatalog](docs/mcp-contracts.md#generated-catalog): Beim Build erzeugtes Inventar von Werkzeugen, Providern und CLI-Befehlen. -- [Roadmap](docs/roadmap.md): Geplante Arbeiten und Fortschrittsübersichten zu Fähigkeiten. +- [Installation und Einrichtung](../installation.md): Agentenregistrierung, Provider-Konfiguration, Updates und Deinstallation. +- [Bereitschaft und Fehlerbehebung](../installation.md#check-readiness-for-your-task): Diagnose eines bestimmten Agenten oder einer Analyse-Engine. +- [CLI und Evidence](../cli.md): Befehle, Provider-Auswahl, Snapshots, Import/Export und Exit-Status. +- [MCP-Kontrakte](../mcp-contracts.md) und [Agenten-Prompts](../mcp-prompts.md): Werkzeugergebnisse, Sitzungen und geführte Untersuchungen. +- [Werkzeugkatalog](../mcp-contracts.md#generated-catalog): Beim Build erzeugtes Inventar von Werkzeugen, Providern und CLI-Befehlen. +- [Roadmap](../roadmap.md): Geplante Arbeiten und Fortschrittsübersichten zu Fähigkeiten. -Melde Sicherheitslücken gemäß [SECURITY.md](SECURITY.md). +Melde Sicherheitslücken gemäß [SECURITY.md](../../SECURITY.md). ## Sternverlauf @@ -276,7 +276,7 @@ Danke an alle, die REA nutzen, Fehler melden, Funktionswünsche äußern, Builds ## Haftungsausschluss -Hinweise zur verantwortungsvollen Nutzung findest du in [DISCLAIMER.md](DISCLAIMER.md). +Hinweise zur verantwortungsvollen Nutzung findest du in [DISCLAIMER.md](../../DISCLAIMER.md). REA dient der rechtmäßigen Forschung, Bildung, Interoperabilität, Softwareerhaltung und Analyse von Software, die dir gehört oder die du mit entsprechender Genehmigung untersuchen darfst. @@ -288,14 +288,14 @@ applicable licenses. Wir freuen uns über deine Hilfe bei REA! [Öffne ein Issue](https://github.com/morluto/rea/issues), um einen Fehler zu melden oder eine Funktion vorzuschlagen, oder [sende einen Pull Request](https://github.com/morluto/rea/pulls), um Code oder Dokumentation zu verbessern. -Die Entwicklungsumgebung und Prüfungen beschreibt [CONTRIBUTING.md](CONTRIBUTING.md), die Verifikationspfade der [Testleitfaden](docs/testing.md) und die Projektstruktur die [Architekturübersicht](docs/architecture.mermaid). +Die Entwicklungsumgebung und Prüfungen beschreibt [CONTRIBUTING.md](../../CONTRIBUTING.md), die Verifikationspfade der [Testleitfaden](../testing.md) und die Projektstruktur die [Architekturübersicht](../architecture.mermaid). ## Lizenz -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_es.md b/docs/readme/README_es.md similarity index 75% rename from README_es.md rename to docs/readme/README_es.md index f955b5891..d09be25e2 100644 --- a/README_es.md +++ b/docs/readme/README_es.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · **Español** · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · **Español** · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Ingeniería inversa de cualquier cosa @@ -9,10 +9,10 @@ **¿Ves una función que te gusta? Entiende cómo funciona, hasta el nivel binario.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA inicia su puente de análisis dentro de Hopper mientras inspecciona un binario nativo +REA inicia su puente de análisis dentro de Hopper mientras inspecciona un binario nativo

@@ -33,7 +33,7 @@ - Discord
+ Discord
Únete a la comunidad de ingeniería inversa

Discord · Preguntas y respuestas · Comparte tus resultados @@ -67,7 +67,7 @@ npx rea-agents setup Elige tus agentes, revisa los cambios propuestos y apruébalos. La configuración añade el servidor MCP de REA y las instrucciones de trabajo correspondientes, con copias de seguridad de la configuración existente. Reinicia tu agente al terminar. -La configuración admite Claude Code, Codex, Cursor, Gemini CLI, Grok Build y [otros agentes](docs/installation.md#supported-agents). Consulta [instalación y configuración](docs/installation.md) para configurar proveedores y registrar MCP manualmente. +La configuración admite Claude Code, Codex, Cursor, Gemini CLI, Grok Build y [otros agentes](../installation.md#supported-agents). Consulta [instalación y configuración](../installation.md) para configurar proveedores y registrar MCP manualmente. ### Pregunta a tu agente @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Para el análisis nativo, configura primero un proveedor. Consulta la [guía de CLI y Evidence](docs/cli.md) para los comandos nativos, la selección de proveedores, las instantáneas y el uso en scripts. +Para el análisis nativo, configura primero un proveedor. Consulta la [guía de CLI y Evidence](../cli.md) para los comandos nativos, la selección de proveedores, las instantáneas y el uso en scripts. ### Actualiza REA @@ -120,9 +120,9 @@ Revisa los cambios de configuración y reinicia tu agente. Para ejecutar comando Tu agente llama a REA mediante MCP para inspeccionar el objetivo y seguir el código relevante. REA devuelve los hallazgos junto con sus pruebas. El agente los utiliza para hacer preguntas de seguimiento, explicar el comportamiento o escribir y probar una implementación. Los comandos de CLI usan los mismos flujos de trabajo. -![Flujo de investigación de REA: tu agente pregunta sobre un objetivo local, REA lo inspecciona y rastrea con herramientas de análisis, y el agente utiliza el código, las referencias y los aspectos desconocidos devueltos para explicar, implementar y probar.](website/public/assets/figures/rea-investigation-flow.svg) +![Flujo de investigación de REA: tu agente pregunta sobre un objetivo local, REA lo inspecciona y rastrea con herramientas de análisis, y el agente utiliza el código, las referencias y los aspectos desconocidos devueltos para explicar, implementar y probar.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Abre la figura a tamaño completo](website/public/assets/figures/rea-investigation-flow.svg). +[Abre la figura a tamaño completo](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ Tu agente llama a REA mediante MCP para inspeccionar el objetivo y seguir el có REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las herramientas adicionales y los sistemas anfitriones compatibles dependen del objetivo: -| Objetivo | Qué devuelve REA | Requisitos y guía | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](https://rea.tools/es/guides/native/) | -| Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](docs/binary-diagnostics.md) | -| Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](docs/evm-bytecode.md) | -| Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](docs/recorded-crashes.md) | -| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](https://rea.tools/es/guides/javascript/) | -| Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) | -| Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](docs/web-network-captures.md) | -| Capturas de paquetes | Endpoints, DNS, TLS SNI, distribución de protocolos y solicitudes HTTP | tshark y capinfos suministrados por el autor de la llamada; [guía pcap](docs/pcap-analysis.md) | -| Ensamblados .NET | Metadatos, instrucciones CIL, dependencias nativas declaradas y comparaciones de compilaciones | Inspección estática; [guía de código administrado](docs/managed-code-analysis.md) | -| APK de Android | Declaraciones del manifiesto, clases, métodos descompilados y referencias | JADX sin interfaz gráfica y un JDK completo en Linux/macOS/Windows x64; [guía de Android](docs/android-analysis.md) | -| Dispositivos Android | Dispositivos, paquetes, procesos, registros, volcados, capturas de pantalla y transferencias de APK/archivos | adb suministrado por el autor de la llamada (emulador o dispositivo); [guía de ADB](docs/adb-device-analysis.md) | -| Proyectos JEB | Unidades de proyecto del motor, resúmenes de artefactos y pseudocódigo de tipos/métodos | Un cliente JEB iniciado por el autor de la llamada que sirve MCP, seleccionado con REA_JEB_MCP_URL; [guía de JEB](docs/jeb-analysis.md) | -| Recursos de Android | Manifiesto decodificado, tablas de cadenas, idiomas y datos de versión | Lanzador de Apktool suministrado por el autor de la llamada; [guía de recursos](docs/apktool-resource-analysis.md) | -| Aplicaciones Flutter | Hash de snapshot de Dart, resúmenes de bibliotecas por ABI y procedencia de compilación del motor | Ninguna (análisis APK estático); [guía de Flutter](docs/flutter-analysis.md) | -| Firmware | Regiones, resultados de extracción y derivaciones al análisis nativo | Binwalk / Unblob en Linux; [guía de firmware](docs/firmware-analysis.md) | -| Paquetes grabados | Campos seleccionados, valores repetidos, filtros y reglas de decodificación | Linux: TShark 4.x + prlimit; [guía de capturas](docs/packet-captures.md) | -| Señales lógicas grabadas | Anotaciones del decodificador, rangos de muestras y diagnósticos | Linux: sigrok-cli 0.7.2 + prlimit; [guía de señales](docs/signal-recordings.md) | -| Paquetes y recursos | Inventarios de archivos, resúmenes criptográficos, plists, estructura de paquetes de Apple y recursos extraídos | [Guía de artefactos y JavaScript](docs/javascript-artifact-reconstruction.md), [aplicaciones de Apple](docs/apple-application-analysis.md) | -| Comportamiento de procesos | Salida de la terminal, interacciones, observaciones de salida y del sistema de archivos, y comparaciones entre ejecuciones | Linux/macOS con una PTY nativa; [captura de procesos](docs/process-capture.md) | +| Objetivo | Qué devuelve REA | Requisitos y guía | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](https://rea.tools/es/guides/native/) | +| Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) | +| Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) | +| Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) | +| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](https://rea.tools/es/guides/javascript/) | +| Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) | +| Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](../web-network-captures.md) | +| Capturas de paquetes | Endpoints, DNS, TLS SNI, distribución de protocolos y solicitudes HTTP | tshark y capinfos suministrados por el autor de la llamada; [guía pcap](../pcap-analysis.md) | +| Ensamblados .NET | Metadatos, instrucciones CIL, dependencias nativas declaradas y comparaciones de compilaciones | Inspección estática; [guía de código administrado](../managed-code-analysis.md) | +| APK de Android | Declaraciones del manifiesto, clases, métodos descompilados y referencias | JADX sin interfaz gráfica y un JDK completo en Linux/macOS/Windows x64; [guía de Android](../android-analysis.md) | +| Dispositivos Android | Dispositivos, paquetes, procesos, registros, volcados, capturas de pantalla y transferencias de APK/archivos | adb suministrado por el autor de la llamada (emulador o dispositivo); [guía de ADB](../adb-device-analysis.md) | +| Proyectos JEB | Unidades de proyecto del motor, resúmenes de artefactos y pseudocódigo de tipos/métodos | Un cliente JEB iniciado por el autor de la llamada que sirve MCP, seleccionado con REA_JEB_MCP_URL; [guía de JEB](../jeb-analysis.md) | +| Recursos de Android | Manifiesto decodificado, tablas de cadenas, idiomas y datos de versión | Lanzador de Apktool suministrado por el autor de la llamada; [guía de recursos](../apktool-resource-analysis.md) | +| Aplicaciones Flutter | Hash de snapshot de Dart, resúmenes de bibliotecas por ABI y procedencia de compilación del motor | Ninguna (análisis APK estático); [guía de Flutter](../flutter-analysis.md) | +| Firmware | Regiones, resultados de extracción y derivaciones al análisis nativo | Binwalk / Unblob en Linux; [guía de firmware](../firmware-analysis.md) | +| Paquetes grabados | Campos seleccionados, valores repetidos, filtros y reglas de decodificación | Linux: TShark 4.x + prlimit; [guía de capturas](../packet-captures.md) | +| Señales lógicas grabadas | Anotaciones del decodificador, rangos de muestras y diagnósticos | Linux: sigrok-cli 0.7.2 + prlimit; [guía de señales](../signal-recordings.md) | +| Paquetes y recursos | Inventarios de archivos, resúmenes criptográficos, plists, estructura de paquetes de Apple y recursos extraídos | [Guía de artefactos y JavaScript](../javascript-artifact-reconstruction.md), [aplicaciones de Apple](../apple-application-analysis.md) | +| Comportamiento de procesos | Salida de la terminal, interacciones, observaciones de salida y del sistema de archivos, y comparaciones entre ejecuciones | Linux/macOS con una PTY nativa; [captura de procesos](../process-capture.md) | La inspección estática de JavaScript y .NET lee los archivos suministrados sin ejecutar la aplicación. La captura en ejecución inicia el objetivo seleccionado o interactúa con él con los permisos de tu usuario; cada guía describe sus efectos. -Los formatos nativos y los sistemas anfitriones compatibles varían según el proveedor. Consulta [configuración de Hopper y Ghidra](docs/installation.md#hopper), la [guía de IDA](docs/ida-provider.md) y la [compatibilidad experimental de Ghidra en Windows](docs/windows-ghidra-p0.md). Ghidra también admite [análisis de DOS de 16 bits](docs/ghidra-dos.md). Para elegir un proveedor, consulta la [guía de CLI](docs/cli.md#choose-a-provider). Comprueba la [disponibilidad por versión](docs/installation.md#released-package-and-main) de las funciones añadidas después de la última versión publicada en npm. +Los formatos nativos y los sistemas anfitriones compatibles varían según el proveedor. Consulta [configuración de Hopper y Ghidra](../installation.md#hopper), la [guía de IDA](../ida-provider.md) y la [compatibilidad experimental de Ghidra en Windows](../windows-ghidra-p0.md). Ghidra también admite [análisis de DOS de 16 bits](../ghidra-dos.md). Para elegir un proveedor, consulta la [guía de CLI](../cli.md#choose-a-provider). Comprueba la [disponibilidad por versión](../installation.md#released-package-and-main) de las funciones añadidas después de la última versión publicada en npm. ## Casos prácticos -[![Ilustraciones de los casos de panoramización de sonido de DX-Ball, el puente del portapapeles de Notion y el anillo de proyectiles de TH04](docs/assets/rea-showcases.png)](https://rea.tools/es/showcase/) +[![Ilustraciones de los casos de panoramización de sonido de DX-Ball, el puente del portapapeles de Notion y el anillo de proyectiles de TH04](../assets/rea-showcases.png)](https://rea.tools/es/showcase/) ### DX-Ball: reconstruir un cálculo de panoramización de sonido @@ -187,28 +187,28 @@ Si has usado REA para investigar algo interesante, nos gustaría verlo. Comparte
¿Qué agentes pueden usar REA? -Cualquier agente que admita servidores MCP locales. La configuración prepara los [agentes compatibles](docs/installation.md#supported-agents); otros clientes pueden usar el [registro manual de MCP](docs/installation.md#mcp-registry). +Cualquier agente que admita servidores MCP locales. La configuración prepara los [agentes compatibles](../installation.md#supported-agents); otros clientes pueden usar el [registro manual de MCP](../installation.md#mcp-registry).
¿Necesito Hopper, Ghidra o IDA? -El análisis nativo en profundidad utiliza uno de ellos. La inspección estática de JavaScript y .NET funciona sin un motor de análisis nativo. La configuración puede instalar Hopper después de tu aprobación; Ghidra e IDA usan las instalaciones que ya tienes. Consulta [configuración de proveedores](docs/installation.md#hopper). +El análisis nativo en profundidad utiliza uno de ellos. La inspección estática de JavaScript y .NET funciona sin un motor de análisis nativo. La configuración puede instalar Hopper después de tu aprobación; Ghidra e IDA usan las instalaciones que ya tienes. Consulta [configuración de proveedores](../installation.md#hopper).
¿Tengo que iniciar Hopper primero? -REA inicia Hopper cuando una operación lo necesita. En macOS, puede aparecer un diálogo al iniciarlo por primera vez para elegir el modo de demostración o activar tu licencia. Consulta [inicio de Hopper y resolución de problemas](docs/installation.md#launcher-paths-and-troubleshooting). +REA inicia Hopper cuando una operación lo necesita. En macOS, puede aparecer un diálogo al iniciarlo por primera vez para elegir el modo de demostración o activar tu licencia. Consulta [inicio de Hopper y resolución de problemas](../installation.md#launcher-paths-and-troubleshooting).
¿Qué hace la instalación de la skill desde skills.sh? -La skill proporciona instrucciones de investigación a tu agente. Usa `npx rea-agents setup` para registrar el servidor MCP de REA e instalar las instrucciones correspondientes; después reinicia tu agente. Consulta [instalación solo de la skill](docs/installation.md#skill-only-installation). +La skill proporciona instrucciones de investigación a tu agente. Usa `npx rea-agents setup` para registrar el servidor MCP de REA e instalar las instrucciones correspondientes; después reinicia tu agente. Consulta [instalación solo de la skill](../installation.md#skill-only-installation).
@@ -251,14 +251,14 @@ Si usas un agente, completa la [actualización de la configuración](#actualiza- Empieza con las [guías prácticas](https://rea.tools/es/guides/) del sitio web. Para opciones exactas, requisitos y contratos de resultados: -- [Instalación y configuración](docs/installation.md): registro de agentes, configuración de proveedores, actualizaciones y desinstalación. -- [Preparación y resolución de problemas](docs/installation.md#check-readiness-for-your-task): diagnóstico de un agente o motor de análisis concreto. -- [CLI y Evidence](docs/cli.md): comandos, selección de proveedores, instantáneas, importación/exportación y estados de salida. -- [Contratos de MCP](docs/mcp-contracts.md) y [prompts para agentes](docs/mcp-prompts.md): resultados de herramientas, sesiones e investigaciones guiadas. -- [Catálogo de herramientas](docs/mcp-contracts.md#generated-catalog): inventario de herramientas, proveedores y comandos de CLI generado durante la compilación. -- [Hoja de ruta](docs/roadmap.md): trabajo previsto y seguimiento de capacidades. +- [Instalación y configuración](../installation.md): registro de agentes, configuración de proveedores, actualizaciones y desinstalación. +- [Preparación y resolución de problemas](../installation.md#check-readiness-for-your-task): diagnóstico de un agente o motor de análisis concreto. +- [CLI y Evidence](../cli.md): comandos, selección de proveedores, instantáneas, importación/exportación y estados de salida. +- [Contratos de MCP](../mcp-contracts.md) y [prompts para agentes](../mcp-prompts.md): resultados de herramientas, sesiones e investigaciones guiadas. +- [Catálogo de herramientas](../mcp-contracts.md#generated-catalog): inventario de herramientas, proveedores y comandos de CLI generado durante la compilación. +- [Hoja de ruta](../roadmap.md): trabajo previsto y seguimiento de capacidades. -Informa de vulnerabilidades siguiendo [SECURITY.md](SECURITY.md). +Informa de vulnerabilidades siguiendo [SECURITY.md](../../SECURITY.md). ## Historial de estrellas @@ -276,7 +276,7 @@ Gracias a quienes usan REA, informan de errores, solicitan funciones, prueban co ## Aviso legal -Consulta [DISCLAIMER.md](DISCLAIMER.md) para obtener orientación sobre el uso responsable. +Consulta [DISCLAIMER.md](../../DISCLAIMER.md) para obtener orientación sobre el uso responsable. REA está destinado a la investigación, la educación, la interoperabilidad, la preservación de software y el análisis de software propio o autorizado, dentro de la ley. @@ -288,14 +288,14 @@ applicable licenses. ¡Nos encantaría que ayudaras con REA! [Abre una issue](https://github.com/morluto/rea/issues) para comunicar un error o proponer una función, o [envía una pull request](https://github.com/morluto/rea/pulls) para mejorar el código o la documentación. -Consulta [CONTRIBUTING.md](CONTRIBUTING.md) para preparar el entorno de desarrollo y las comprobaciones, la [guía de pruebas](docs/testing.md) para los procesos de verificación y el [mapa de arquitectura](docs/architecture.mermaid) para la estructura del proyecto. +Consulta [CONTRIBUTING.md](../../CONTRIBUTING.md) para preparar el entorno de desarrollo y las comprobaciones, la [guía de pruebas](../testing.md) para los procesos de verificación y el [mapa de arquitectura](../architecture.mermaid) para la estructura del proyecto. ## Licencia -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_fa.md b/docs/readme/README_fa.md similarity index 74% rename from README_fa.md rename to docs/readme/README_fa.md index 54af87c57..b24e4380c 100644 --- a/README_fa.md +++ b/docs/readme/README_fa.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · **فارسی** · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · **فارسی** · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: مهندسی معکوس هر چیزی @@ -9,27 +9,27 @@ **قابلیتی دیده‌اید که دوستش دارید؟ سازوکارش را تا سطح باینری درک کنید.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift **[وب‌سایت](https://rea.tools/fa/) · [راهنماها](https://rea.tools/fa/guides/) · [نمونه‌های عملی](https://rea.tools/fa/showcase/)** -[شروع سریع](#شروع-سریع) · [REA چگونه کار می‌کند؟](#rea-چگونه-کار-میکند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-میتوان-تحلیل-کرد) · [نمونه‌های عملی](#نمونههای-عملی) · [پرسش‌های متداول](#پرسشهای-متداول) · [مستندات](#مستندات) +[شروع سریع](#شروع-سریع) · [REA چگونه کار می‌کند؟](#rea-چگونه-کار-می‌کند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-می‌توان-تحلیل-کرد) · [نمونه‌های عملی](#نمونه‌های-عملی) · [پرسش‌های متداول](#پرسش‌های-متداول) · [مستندات](#مستندات) npx rea-agents setup
-اجرای پل تحلیل REA در Hopper هنگام بررسی یک فایل باینری بومی +اجرای پل تحلیل REA در Hopper هنگام بررسی یک فایل باینری بومی

-
Discord
به انجمن مهندسی معکوس بپیوندید

دیسکورد · پرسش و پاسخ · نمایش دستاوردها
+
Discord
به انجمن مهندسی معکوس بپیوندید

دیسکورد · پرسش و پاسخ · نمایش دستاوردها

@@ -59,7 +59,7 @@ npx rea-agents setup ایجنت‌های موردنظر را انتخاب کنید، تغییرات پیشنهادی را بررسی و تأیید کنید. فرایند راه‌اندازی، سرور MCP مربوط به REA و دستورالعمل‌های گردش‌کار متناظر را اضافه می‌کند و از پیکربندی موجود نسخهٔ پشتیبان می‌گیرد. پس از آن ایجنت خود را مجدداً راه‌اندازی کنید. -راه‌اندازی از Claude Code، Codex، Cursor، Gemini CLI، Grok Build و [ایجنت‌های دیگر](docs/installation.md#supported-agents) پشتیبانی می‌کند. برای پیکربندی ارائه‌دهنده و ثبت دستی MCP، [نصب و راه‌اندازی](docs/installation.md) را ببینید. +راه‌اندازی از Claude Code، Codex، Cursor، Gemini CLI، Grok Build و [ایجنت‌های دیگر](../installation.md#supported-agents) پشتیبانی می‌کند. برای پیکربندی ارائه‌دهنده و ثبت دستی MCP، [نصب و راه‌اندازی](../installation.md) را ببینید. ### از ایجنت خود بپرسید @@ -86,7 +86,7 @@ npm install --global rea-agents rea --help ``` -برای تحلیل بومی ابتدا یک ارائه‌دهنده پیکربندی کنید. برای دستورات بومی، انتخاب ارائه‌دهنده، snapshotها و اسکریپت‌نویسی، [راهنمای CLI و شواهد](docs/cli.md) را ببینید. +برای تحلیل بومی ابتدا یک ارائه‌دهنده پیکربندی کنید. برای دستورات بومی، انتخاب ارائه‌دهنده، snapshotها و اسکریپت‌نویسی، [راهنمای CLI و شواهد](../cli.md) را ببینید. ### به‌روزرسانی REA @@ -112,9 +112,9 @@ npx rea-agents@latest setup ایجنت شما از طریق MCP، REA را فراخوانی می‌کند تا هدف را بررسی و کد مرتبط را ردیابی کند. REA یافته‌ها را همراه با شواهد برمی‌گرداند. ایجنت از این نتایج برای طرح پرسش‌های تکمیلی، توضیح رفتار یا نوشتن و آزمایش یک پیاده‌سازی استفاده می‌کند. دستورات CLI نیز از همین گردش‌کارها بهره می‌برند. -![جریان بررسی REA: ایجنت دربارهٔ هدف محلی سؤال می‌پرسد، REA آن را با ابزارهای تحلیل بررسی و ردیابی می‌کند و ایجنت از کد، ارجاعات و ناشناخته‌های بازگردانده‌شده برای توضیح، پیاده‌سازی و آزمایش استفاده می‌کند.](website/public/assets/figures/rea-investigation-flow.svg) +![جریان بررسی REA: ایجنت دربارهٔ هدف محلی سؤال می‌پرسد، REA آن را با ابزارهای تحلیل بررسی و ردیابی می‌کند و ایجنت از کد، ارجاعات و ناشناخته‌های بازگردانده‌شده برای توضیح، پیاده‌سازی و آزمایش استفاده می‌کند.](../../website/public/assets/figures/rea-investigation-flow.svg) -[نمایش تصویر در اندازهٔ کامل](website/public/assets/figures/rea-investigation-flow.svg). +[نمایش تصویر در اندازهٔ کامل](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -122,37 +122,37 @@ npx rea-agents@latest setup REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه npm نیاز دارد. ابزارهای اضافی و پشتیبانی سیستم میزبان به نوع هدف بستگی دارند: -| هدف | خروجی REA | پیش‌نیازها و راهنما | -| ---------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) | -| ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](docs/binary-diagnostics.md) | -| بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](docs/evm-bytecode.md) | -| کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](docs/recorded-crashes.md) | -| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) | -| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) | -| داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](docs/web-network-captures.md) | -| اسمبلی‌های .NET | فراداده، دستورالعمل‌های CIL، وابستگی‌های بومی اعلام‌شده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریت‌شده](docs/managed-code-analysis.md) | -| فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](docs/android-analysis.md) | -| دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](docs/adb-device-analysis.md) | -| پروژه‌های JEB | واحدهای پروژه از موتور، هش‌های مصنوع و شبه‌کدِ نوع/متد | یک کلاینت JEB که توسط فراخوان‌کننده اجرا شده و MCP ارائه می‌دهد و با REA_JEB_MCP_URL انتخاب می‌شود؛ [راهنمای JEB](docs/jeb-analysis.md) | -| منابع اندروید | مانیفست رمزگشایی‌شده، جداول رشته‌ها، زبان‌ها و اطلاعات نسخه | راه‌انداز Apktool تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای منابع](docs/apktool-resource-analysis.md) | -| برنامه‌های فلاتر | هش اسنپ‌شات دارت، هش کتابخانه‌ها به تفکیک ABI و اصالت بیلد موتور | هیچ (تحلیل ایستا APK)؛ [راهنمای فلاتر](docs/flutter-analysis.md) | -| Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) | -| بسته‌های ضبط‌شده | فیلدهای منتخب، مقادیر تکراری، فیلترها و قواعد رمزگشایی | Linux: TShark 4.x + prlimit; [راهنمای ضبط بسته](docs/packet-captures.md) | -| ضبط بسته‌ها | نقاط پایانی، DNS، TLS SNI، توزیع پروتکل و درخواست‌های HTTP | tshark و capinfos تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای pcap](docs/pcap-analysis.md) | -| سیگنال‌های منطقی ضبط‌شده | توضیحات رمزگشا، بازه‌های نمونه و اطلاعات عیب‌یابی | Linux: sigrok-cli 0.7.2 + prlimit; [راهنمای سیگنال‌ها](docs/signal-recordings.md) | -| بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامه‌های اپل](docs/apple-application-analysis.md) | -| رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) | +| هدف | خروجی REA | پیش‌نیازها و راهنما | +| ---------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) | +| ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) | +| بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) | +| کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](../recorded-crashes.md) | +| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) | +| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) | +| داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](../web-network-captures.md) | +| اسمبلی‌های .NET | فراداده، دستورالعمل‌های CIL، وابستگی‌های بومی اعلام‌شده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریت‌شده](../managed-code-analysis.md) | +| فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](../android-analysis.md) | +| دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](../adb-device-analysis.md) | +| پروژه‌های JEB | واحدهای پروژه از موتور، هش‌های مصنوع و شبه‌کدِ نوع/متد | یک کلاینت JEB که توسط فراخوان‌کننده اجرا شده و MCP ارائه می‌دهد و با REA_JEB_MCP_URL انتخاب می‌شود؛ [راهنمای JEB](../jeb-analysis.md) | +| منابع اندروید | مانیفست رمزگشایی‌شده، جداول رشته‌ها، زبان‌ها و اطلاعات نسخه | راه‌انداز Apktool تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای منابع](../apktool-resource-analysis.md) | +| برنامه‌های فلاتر | هش اسنپ‌شات دارت، هش کتابخانه‌ها به تفکیک ABI و اصالت بیلد موتور | هیچ (تحلیل ایستا APK)؛ [راهنمای فلاتر](../flutter-analysis.md) | +| Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](../firmware-analysis.md) | +| بسته‌های ضبط‌شده | فیلدهای منتخب، مقادیر تکراری، فیلترها و قواعد رمزگشایی | Linux: TShark 4.x + prlimit; [راهنمای ضبط بسته](../packet-captures.md) | +| ضبط بسته‌ها | نقاط پایانی، DNS، TLS SNI، توزیع پروتکل و درخواست‌های HTTP | tshark و capinfos تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای pcap](../pcap-analysis.md) | +| سیگنال‌های منطقی ضبط‌شده | توضیحات رمزگشا، بازه‌های نمونه و اطلاعات عیب‌یابی | Linux: sigrok-cli 0.7.2 + prlimit; [راهنمای سیگنال‌ها](../signal-recordings.md) | +| بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](../javascript-artifact-reconstruction.md)، [برنامه‌های اپل](../apple-application-analysis.md) | +| رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](../process-capture.md) | بررسی ایستای JavaScript و .NET فایل‌های ارائه‌شده را بدون اجرای برنامه می‌خواند. ضبط زمان اجرا، هدف انتخاب‌شده را با مجوزهای کاربری شما اجرا می‌کند یا با آن تعامل دارد؛ آثار این عملیات در راهنمای مربوط به هر نوع ضبط توضیح داده شده‌اند. -فرمت‌های بومی و پشتیبانی سیستم میزبان بر اساس ارائه‌دهنده متفاوت‌اند. [راه‌اندازی Hopper و Ghidra](docs/installation.md#hopper)، [راهنمای IDA](docs/ida-provider.md) و [پشتیبانی آزمایشی Ghidra در ویندوز](docs/windows-ghidra-p0.md) را ببینید. Ghidra همچنین از [تحلیل DOS شانزده‌بیتی](docs/ghidra-dos.md) پشتیبانی می‌کند. برای انتخاب ارائه‌دهنده، به [راهنمای CLI](docs/cli.md#choose-a-provider) مراجعه کنید. برای قابلیت‌هایی که پس از آخرین انتشار npm اضافه شده‌اند، [وضعیت انتشار](docs/installation.md#released-package-and-main) را بررسی کنید. +فرمت‌های بومی و پشتیبانی سیستم میزبان بر اساس ارائه‌دهنده متفاوت‌اند. [راه‌اندازی Hopper و Ghidra](../installation.md#hopper)، [راهنمای IDA](../ida-provider.md) و [پشتیبانی آزمایشی Ghidra در ویندوز](../windows-ghidra-p0.md) را ببینید. Ghidra همچنین از [تحلیل DOS شانزده‌بیتی](../ghidra-dos.md) پشتیبانی می‌کند. برای انتخاب ارائه‌دهنده، به [راهنمای CLI](../cli.md#choose-a-provider) مراجعه کنید. برای قابلیت‌هایی که پس از آخرین انتشار npm اضافه شده‌اند، [وضعیت انتشار](../installation.md#released-package-and-main) را بررسی کنید. ## نمونه‌های عملی -[![تصویرهایی از نمونه‌های پنینگ صدای DX-Ball، پل کلیپ‌بورد Notion و حلقهٔ گلوله‌های TH04](docs/assets/rea-showcases.png)](https://rea.tools/fa/showcase/) +[![تصویرهایی از نمونه‌های پنینگ صدای DX-Ball، پل کلیپ‌بورد Notion و حلقهٔ گلوله‌های TH04](../assets/rea-showcases.png)](https://rea.tools/fa/showcase/) ### DX-Ball: بازسازی محاسبهٔ موقعیت استریوی صدا @@ -178,31 +178,31 @@ API کلیپ‌بورد در renderer را پیدا کنید، مسیر آن ر
کدام ایجنت‌ها می‌توانند از REA استفاده کنند؟ -هر ایجنتی که از سرورهای MCP محلی پشتیبانی کند. فرایند setup، [ایجنت‌های پشتیبانی‌شده](docs/installation.md#supported-agents) را پیکربندی می‌کند؛ سایر کلاینت‌ها می‌توانند از [ثبت دستی MCP](docs/installation.md#mcp-registry) استفاده کنند. +هر ایجنتی که از سرورهای MCP محلی پشتیبانی کند. فرایند setup، [ایجنت‌های پشتیبانی‌شده](../installation.md#supported-agents) را پیکربندی می‌کند؛ سایر کلاینت‌ها می‌توانند از [ثبت دستی MCP](../installation.md#mcp-registry) استفاده کنند.
آیا به Hopper، Ghidra یا IDA نیاز دارم؟ -تحلیل عمیق بومی به یکی از آن‌ها نیاز دارد. بررسی ایستای JavaScript و .NET بدون موتور تحلیل بومی کار می‌کند. setup می‌تواند با تأیید شما Hopper را نصب کند؛ Ghidra و IDA از نصب‌های موجود شما استفاده می‌کنند. [راه‌اندازی ارائه‌دهنده](docs/installation.md#hopper) را ببینید. +تحلیل عمیق بومی به یکی از آن‌ها نیاز دارد. بررسی ایستای JavaScript و .NET بدون موتور تحلیل بومی کار می‌کند. setup می‌تواند با تأیید شما Hopper را نصب کند؛ Ghidra و IDA از نصب‌های موجود شما استفاده می‌کنند. [راه‌اندازی ارائه‌دهنده](../installation.md#hopper) را ببینید.
آیا باید ابتدا Hopper را اجرا کنم؟ -REA زمانی که عملیاتی به Hopper نیاز داشته باشد آن را اجرا می‌کند. در macOS، ممکن است هنگام نخستین اجرا پنجره‌ای برای انتخاب حالت آزمایشی یا فعال‌سازی مجوز نمایش داده شود. [راه‌اندازی Hopper و رفع اشکال](docs/installation.md#launcher-paths-and-troubleshooting) را ببینید. +REA زمانی که عملیاتی به Hopper نیاز داشته باشد آن را اجرا می‌کند. در macOS، ممکن است هنگام نخستین اجرا پنجره‌ای برای انتخاب حالت آزمایشی یا فعال‌سازی مجوز نمایش داده شود. [راه‌اندازی Hopper و رفع اشکال](../installation.md#launcher-paths-and-troubleshooting) را ببینید.
نصب skill از skills.sh چه کاری انجام می‌دهد؟ -این skill دستورالعمل‌های بررسی را برای ایجنت فراهم می‌کند. برای ثبت سرور MCP مربوط به REA و نصب دستورالعمل‌های متناظر، `npx rea-agents setup` را اجرا کنید و سپس ایجنت را مجدداً راه‌اندازی کنید. [نصب مستقل skill](docs/installation.md#skill-only-installation) را ببینید. +این skill دستورالعمل‌های بررسی را برای ایجنت فراهم می‌کند. برای ثبت سرور MCP مربوط به REA و نصب دستورالعمل‌های متناظر، `npx rea-agents setup` را اجرا کنید و سپس ایجنت را مجدداً راه‌اندازی کنید. [نصب مستقل skill](../installation.md#skill-only-installation) را ببینید.
REA چه نوع کدی برمی‌گرداند؟ -تحلیل بومی، شبه‌کد و اسمبلی برمی‌گرداند. تحلیل JavaScript/Electron ماژول‌ها و روابط میان آن‌ها را بازیابی می‌کند. ایجنت شما از این یافته‌ها برای نوشتن و آزمایش یک پیاده‌سازی استفاده می‌کند؛ [نمونه‌های عملی](#نمونههای-عملی) مثال‌های گام‌به‌گام ارائه می‌دهند. +تحلیل بومی، شبه‌کد و اسمبلی برمی‌گرداند. تحلیل JavaScript/Electron ماژول‌ها و روابط میان آن‌ها را بازیابی می‌کند. ایجنت شما از این یافته‌ها برای نوشتن و آزمایش یک پیاده‌سازی استفاده می‌کند؛ [نمونه‌های عملی](#نمونه‌های-عملی) مثال‌های گام‌به‌گام ارائه می‌دهند.
@@ -228,7 +228,7 @@ rea update npx rea-agents@latest setup ``` -اگر از ایجنت استفاده می‌کنید، [به‌روزرسانی راه‌اندازی](#بهروزرسانی-rea) را تکمیل و ایجنت را مجدداً اجرا کنید. همان کار را دوباره امتحان کنید. اگر مشکل ادامه داشت، یک [issue ثبت کنید](https://github.com/morluto/rea/issues) و نسخهٔ REA، نوع هدف، مراحل بازتولید و خروجی خطا را ذکر کنید. +اگر از ایجنت استفاده می‌کنید، [به‌روزرسانی راه‌اندازی](#به‌روزرسانی-rea) را تکمیل و ایجنت را مجدداً اجرا کنید. همان کار را دوباره امتحان کنید. اگر مشکل ادامه داشت، یک [issue ثبت کنید](https://github.com/morluto/rea/issues) و نسخهٔ REA، نوع هدف، مراحل بازتولید و خروجی خطا را ذکر کنید. @@ -236,14 +236,14 @@ npx rea-agents@latest setup از [راهنماهای عملی وب‌سایت](https://rea.tools/fa/guides/) شروع کنید. برای گزینه‌های دقیق، پیش‌نیازها و قراردادهای خروجی: -- [نصب و راه‌اندازی](docs/installation.md): ثبت ایجنت، پیکربندی ارائه‌دهنده، به‌روزرسانی و حذف نصب. -- [آمادگی و رفع اشکال](docs/installation.md#check-readiness-for-your-task): عیب‌یابی یک ایجنت یا موتور تحلیل. -- [CLI و شواهد](docs/cli.md): دستورات، انتخاب ارائه‌دهنده، snapshotها، ورود/خروج داده و وضعیت‌های خروج. -- [قراردادهای MCP](docs/mcp-contracts.md) و [پرامپت‌های ایجنت](docs/mcp-prompts.md): نتایج ابزارها، نشست‌ها و بررسی‌های هدایت‌شده. -- [فهرست ابزارها](docs/mcp-contracts.md#generated-catalog): فهرست تولیدشده هنگام build از ابزارها، ارائه‌دهندگان و دستورات CLI. -- [نقشهٔ راه](docs/roadmap.md): برنامه‌های آینده و پیگیری قابلیت‌ها. +- [نصب و راه‌اندازی](../installation.md): ثبت ایجنت، پیکربندی ارائه‌دهنده، به‌روزرسانی و حذف نصب. +- [آمادگی و رفع اشکال](../installation.md#check-readiness-for-your-task): عیب‌یابی یک ایجنت یا موتور تحلیل. +- [CLI و شواهد](../cli.md): دستورات، انتخاب ارائه‌دهنده، snapshotها، ورود/خروج داده و وضعیت‌های خروج. +- [قراردادهای MCP](../mcp-contracts.md) و [پرامپت‌های ایجنت](../mcp-prompts.md): نتایج ابزارها، نشست‌ها و بررسی‌های هدایت‌شده. +- [فهرست ابزارها](../mcp-contracts.md#generated-catalog): فهرست تولیدشده هنگام build از ابزارها، ارائه‌دهندگان و دستورات CLI. +- [نقشهٔ راه](../roadmap.md): برنامه‌های آینده و پیگیری قابلیت‌ها. -آسیب‌پذیری‌ها را از طریق [SECURITY.md](SECURITY.md) گزارش کنید. +آسیب‌پذیری‌ها را از طریق [SECURITY.md](../../SECURITY.md) گزارش کنید. ## تاریخچهٔ ستاره‌ها @@ -255,7 +255,7 @@ npx rea-agents@latest setup ## سلب مسئولیت -برای راهنمای استفادهٔ مسئولانه، [DISCLAIMER.md](DISCLAIMER.md) را ببینید. +برای راهنمای استفادهٔ مسئولانه، [DISCLAIMER.md](../../DISCLAIMER.md) را ببینید. REA برای پژوهش، آموزش، تعامل‌پذیری، حفظ نرم‌افزار و تحلیل نرم‌افزارهایی است که مالک آن‌ها هستید یا اجازهٔ بررسی آن‌ها را دارید، در چارچوب قانون. @@ -267,14 +267,14 @@ applicable licenses. از کمک شما به REA استقبال می‌کنیم! برای گزارش خطا یا پیشنهاد قابلیت، یک [issue باز کنید](https://github.com/morluto/rea/issues) یا برای بهبود کد و مستندات [pull request بفرستید](https://github.com/morluto/rea/pulls). -برای راه‌اندازی محیط توسعه و بررسی‌ها، [CONTRIBUTING.md](CONTRIBUTING.md)، برای مراحل اعتبارسنجی [آزمایش‌ها](docs/testing.md) و برای ساختار پروژه [نقشهٔ معماری](docs/architecture.mermaid) را ببینید. +برای راه‌اندازی محیط توسعه و بررسی‌ها، [CONTRIBUTING.md](../../CONTRIBUTING.md)، برای مراحل اعتبارسنجی [آزمایش‌ها](../testing.md) و برای ساختار پروژه [نقشهٔ معماری](../architecture.mermaid) را ببینید. ## مجوز -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_fr.md b/docs/readme/README_fr.md similarity index 76% rename from README_fr.md rename to docs/readme/README_fr.md index 346210525..e6278ac93 100644 --- a/README_fr.md +++ b/docs/readme/README_fr.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · **Français** · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · **Français** · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA : rétro-ingénierie de tout @@ -9,10 +9,10 @@ **Repérez une fonctionnalité qui vous plaît. Comprenez son fonctionnement, jusqu'au niveau du binaire.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA lance son pont d'analyse dans Hopper pendant l'inspection d'un binaire natif +REA lance son pont d'analyse dans Hopper pendant l'inspection d'un binaire natif

@@ -33,7 +33,7 @@ - Discord
+ Discord
Rejoignez la communauté de rétro-ingénierie

Discord · Questions et réponses · Partage de projets @@ -67,7 +67,7 @@ npx rea-agents setup Choisissez vos agents, vérifiez les modifications proposées et approuvez-les. Setup ajoute le serveur MCP de REA et les instructions de workflow correspondantes, et sauvegarde la configuration existante. Redémarrez ensuite votre agent. -Setup prend en charge Claude Code, Codex, Cursor, Gemini CLI, Grok Build et [d'autres agents](docs/installation.md#supported-agents). Consultez [Installation et configuration](docs/installation.md) pour la configuration des fournisseurs et l'enregistrement MCP manuel. +Setup prend en charge Claude Code, Codex, Cursor, Gemini CLI, Grok Build et [d'autres agents](../installation.md#supported-agents). Consultez [Installation et configuration](../installation.md) pour la configuration des fournisseurs et l'enregistrement MCP manuel. ### Interroger votre agent @@ -95,7 +95,7 @@ npm install --global rea-agents rea --help ``` -Pour l'analyse native, configurez d'abord un fournisseur. Consultez le [guide CLI et Evidence](docs/cli.md) pour les commandes natives, le choix du fournisseur, les snapshots et l'utilisation dans des scripts. +Pour l'analyse native, configurez d'abord un fournisseur. Consultez le [guide CLI et Evidence](../cli.md) pour les commandes natives, le choix du fournisseur, les snapshots et l'utilisation dans des scripts. ### Mettre à jour REA @@ -123,9 +123,9 @@ Vérifiez les modifications proposées par Setup et redémarrez votre agent. Pou Votre agent appelle REA via MCP pour inspecter la cible et suivre le code pertinent. REA renvoie ses constatations avec les preuves correspondantes. L'agent s'en sert pour poser des questions complémentaires, expliquer le comportement, ou écrire et tester une implémentation. Les commandes CLI utilisent les mêmes workflows. -![Flux d'investigation de REA : votre agent pose une question sur une cible locale, REA l'inspecte et la trace avec des outils d'analyse, et l'agent utilise le code, les références et les inconnues renvoyés pour expliquer, implémenter et tester.](website/public/assets/figures/rea-investigation-flow.svg) +![Flux d'investigation de REA : votre agent pose une question sur une cible locale, REA l'inspecte et la trace avec des outils d'analyse, et l'agent utilise le code, les références et les inconnues renvoyés pour expliquer, implémenter et tester.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Ouvrir la figure en taille réelle](website/public/assets/figures/rea-investigation-flow.svg). +[Ouvrir la figure en taille réelle](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -133,38 +133,38 @@ Votre agent appelle REA via MCP pour inspecter la cible et suivre le code pertin REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les outils supplémentaires et les hôtes pris en charge dépendent de la cible : -| Cible | Ce que REA renvoie | Prérequis et guide | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Binaires natifs | Pseudo-code, assembleur, chaînes, symboles, appels et références | Hopper, Ghidra ou IDA ; [analyse native](https://rea.tools/fr/guides/native/) | -| Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](docs/binary-diagnostics.md) | -| Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` | -| Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](docs/evm-bytecode.md) | -| Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](docs/recorded-crashes.md) | -| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) | -| Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) | -| Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](docs/web-network-captures.md) | -| Captures de paquets | Points de terminaison, DNS, TLS SNI, distribution des protocoles et requêtes HTTP | tshark et capinfos fournis par l'appelant ; [guide pcap](docs/pcap-analysis.md) | -| Assemblys .NET | Métadonnées, instructions CIL, dépendances natives déclarées et comparaisons de builds | Inspection statique ; [guide du code managé](docs/managed-code-analysis.md) | -| APK Android | Déclarations du manifeste, classes, méthodes décompilées et références | JADX headless et un JDK complet sous Linux/macOS/Windows x64 ; [guide Android](docs/android-analysis.md) | -| Appareils Android | Appareils, paquets, processus, journaux, dumps, captures d'écran et transferts d'APK/fichiers | adb fourni par l'appelant (émulateur ou appareil) ; [guide ADB](docs/adb-device-analysis.md) | -| Projets JEB | Unités de projet fournies par le moteur, condensés d'artefacts et pseudo-code de types/méthodes | Un client JEB démarré par l'appelant servant MCP, sélectionné via REA_JEB_MCP_URL ; [guide JEB](docs/jeb-analysis.md) | -| Ressources Android | Manifeste décodé, tables de chaînes, langues et faits de version | Lanceur Apktool fourni par l'appelant ; [guide des ressources](docs/apktool-resource-analysis.md) | -| Applications Flutter | Empreinte de snapshot Dart, condensés de bibliothèques par ABI et provenance de build du moteur | Aucune (analyse APK statique) ; [guide Flutter](docs/flutter-analysis.md) | -| Firmware | Régions, résultats d'extraction et passage de relais vers l'analyse native | Binwalk / Unblob sous Linux ; [guide firmware](docs/firmware-analysis.md) | -| Paquets enregistrés | Champs sélectionnés, valeurs répétées, filtres et règles de décodage | Linux: TShark 4.x + prlimit; [guide des captures](docs/packet-captures.md) | -| Signaux logiques enregistrés | Annotations du décodeur, plages d’échantillons et diagnostics | Linux: sigrok-cli 0.7.2 + prlimit; [guide des signaux](docs/signal-recordings.md) | -| Paquets et ressources | Inventaires de fichiers, empreintes, plists, anatomie des bundles Apple et ressources extraites | [Guide artefacts et JavaScript](docs/javascript-artifact-reconstruction.md), [applications Apple](docs/apple-application-analysis.md) | -| Comportement des processus | Sortie du terminal, interactions, code de sortie, observations du système de fichiers et comparaisons | Linux/macOS avec un PTY natif ; [capture de processus](docs/process-capture.md) | +| Cible | Ce que REA renvoie | Prérequis et guide | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Binaires natifs | Pseudo-code, assembleur, chaînes, symboles, appels et références | Hopper, Ghidra ou IDA ; [analyse native](https://rea.tools/fr/guides/native/) | +| Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) | +| Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` | +| Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) | +| Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) | +| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) | +| Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) | +| Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](../web-network-captures.md) | +| Captures de paquets | Points de terminaison, DNS, TLS SNI, distribution des protocoles et requêtes HTTP | tshark et capinfos fournis par l'appelant ; [guide pcap](../pcap-analysis.md) | +| Assemblys .NET | Métadonnées, instructions CIL, dépendances natives déclarées et comparaisons de builds | Inspection statique ; [guide du code managé](../managed-code-analysis.md) | +| APK Android | Déclarations du manifeste, classes, méthodes décompilées et références | JADX headless et un JDK complet sous Linux/macOS/Windows x64 ; [guide Android](../android-analysis.md) | +| Appareils Android | Appareils, paquets, processus, journaux, dumps, captures d'écran et transferts d'APK/fichiers | adb fourni par l'appelant (émulateur ou appareil) ; [guide ADB](../adb-device-analysis.md) | +| Projets JEB | Unités de projet fournies par le moteur, condensés d'artefacts et pseudo-code de types/méthodes | Un client JEB démarré par l'appelant servant MCP, sélectionné via REA_JEB_MCP_URL ; [guide JEB](../jeb-analysis.md) | +| Ressources Android | Manifeste décodé, tables de chaînes, langues et faits de version | Lanceur Apktool fourni par l'appelant ; [guide des ressources](../apktool-resource-analysis.md) | +| Applications Flutter | Empreinte de snapshot Dart, condensés de bibliothèques par ABI et provenance de build du moteur | Aucune (analyse APK statique) ; [guide Flutter](../flutter-analysis.md) | +| Firmware | Régions, résultats d'extraction et passage de relais vers l'analyse native | Binwalk / Unblob sous Linux ; [guide firmware](../firmware-analysis.md) | +| Paquets enregistrés | Champs sélectionnés, valeurs répétées, filtres et règles de décodage | Linux: TShark 4.x + prlimit; [guide des captures](../packet-captures.md) | +| Signaux logiques enregistrés | Annotations du décodeur, plages d’échantillons et diagnostics | Linux: sigrok-cli 0.7.2 + prlimit; [guide des signaux](../signal-recordings.md) | +| Paquets et ressources | Inventaires de fichiers, empreintes, plists, anatomie des bundles Apple et ressources extraites | [Guide artefacts et JavaScript](../javascript-artifact-reconstruction.md), [applications Apple](../apple-application-analysis.md) | +| Comportement des processus | Sortie du terminal, interactions, code de sortie, observations du système de fichiers et comparaisons | Linux/macOS avec un PTY natif ; [capture de processus](../process-capture.md) | L'inspection statique JavaScript et .NET lit les fichiers fournis sans exécuter l'application. La capture à l'exécution lance la cible choisie ou interagit avec elle avec vos permissions utilisateur ; chaque guide d'exécution décrit ses effets. -Les formats natifs et les hôtes pris en charge varient selon le fournisseur. Consultez [la configuration de Hopper et Ghidra](docs/installation.md#hopper), le [guide IDA](docs/ida-provider.md) et [la prise en charge expérimentale de Ghidra sous Windows](docs/windows-ghidra-p0.md). Ghidra prend aussi en charge [l'analyse DOS 16 bits](docs/ghidra-dos.md). Pour les gros binaires, augmentez son délai de démarrage avec `REA_GHIDRA_STARTUP_TIMEOUT_MS`. Pour le choix du fournisseur, consultez le [guide CLI](docs/cli.md#choose-a-provider). Vérifiez la [disponibilité dans les versions publiées](docs/installation.md#released-package-and-main) pour les fonctionnalités ajoutées depuis la dernière version npm. +Les formats natifs et les hôtes pris en charge varient selon le fournisseur. Consultez [la configuration de Hopper et Ghidra](../installation.md#hopper), le [guide IDA](../ida-provider.md) et [la prise en charge expérimentale de Ghidra sous Windows](../windows-ghidra-p0.md). Ghidra prend aussi en charge [l'analyse DOS 16 bits](../ghidra-dos.md). Pour les gros binaires, augmentez son délai de démarrage avec `REA_GHIDRA_STARTUP_TIMEOUT_MS`. Pour le choix du fournisseur, consultez le [guide CLI](../cli.md#choose-a-provider). Vérifiez la [disponibilité dans les versions publiées](../installation.md#released-package-and-main) pour les fonctionnalités ajoutées depuis la dernière version npm. ## Études de cas -[![Illustrations des études sur la spatialisation sonore de DX-Ball, le pont du presse-papiers de Notion et le cercle de projectiles de TH04](docs/assets/rea-showcases.png)](https://rea.tools/fr/showcase/) +[![Illustrations des études sur la spatialisation sonore de DX-Ball, le pont du presse-papiers de Notion et le cercle de projectiles de TH04](../assets/rea-showcases.png)](https://rea.tools/fr/showcase/) ### DX-Ball : reconstruire un calcul de panoramique sonore @@ -191,28 +191,28 @@ Si vous avez utilisé REA sur un projet intéressant, nous serions ravis de le d
Quels agents peuvent utiliser REA ? -Tout agent compatible avec les serveurs MCP locaux. Setup configure les [agents pris en charge](docs/installation.md#supported-agents) ; les autres clients peuvent utiliser [l'enregistrement MCP manuel](docs/installation.md#mcp-registry). +Tout agent compatible avec les serveurs MCP locaux. Setup configure les [agents pris en charge](../installation.md#supported-agents) ; les autres clients peuvent utiliser [l'enregistrement MCP manuel](../installation.md#mcp-registry).
Ai-je besoin de Hopper, Ghidra ou IDA ? -L'analyse native approfondie utilise l'un d'eux. L'inspection statique JavaScript et .NET fonctionne sans moteur d'analyse native. Setup peut installer Hopper après votre accord ; Ghidra et IDA utilisent vos installations existantes. Consultez [la configuration des fournisseurs](docs/installation.md#hopper). +L'analyse native approfondie utilise l'un d'eux. L'inspection statique JavaScript et .NET fonctionne sans moteur d'analyse native. Setup peut installer Hopper après votre accord ; Ghidra et IDA utilisent vos installations existantes. Consultez [la configuration des fournisseurs](../installation.md#hopper).
Dois-je lancer Hopper au préalable ? -REA lance Hopper lorsqu'une opération en a besoin. Sous macOS, une boîte de dialogue de premier lancement peut vous demander de choisir le mode démo ou d'activer votre licence. Consultez [Démarrage et dépannage de Hopper](docs/installation.md#launcher-paths-and-troubleshooting). +REA lance Hopper lorsqu'une opération en a besoin. Sous macOS, une boîte de dialogue de premier lancement peut vous demander de choisir le mode démo ou d'activer votre licence. Consultez [Démarrage et dépannage de Hopper](../installation.md#launcher-paths-and-troubleshooting).
Que fait l'installation du skill depuis skills.sh ? -Le skill fournit des instructions d'investigation à votre agent. Utilisez `npx rea-agents setup` pour enregistrer le serveur MCP de REA et installer les instructions correspondantes, puis redémarrez votre agent. Consultez [l'installation du skill seul](docs/installation.md#skill-only-installation). +Le skill fournit des instructions d'investigation à votre agent. Utilisez `npx rea-agents setup` pour enregistrer le serveur MCP de REA et installer les instructions correspondantes, puis redémarrez votre agent. Consultez [l'installation du skill seul](../installation.md#skill-only-installation).
@@ -255,14 +255,14 @@ Si vous utilisez un agent, terminez l'[actualisation de la configuration](#mettr Commencez par les [guides pas à pas](https://rea.tools/fr/guides/) du site web. Pour les options exactes, les prérequis et les contrats de résultats : -- [Installation et configuration](docs/installation.md) : enregistrement des agents, configuration des fournisseurs, mises à jour et désinstallation. -- [Vérification et dépannage](docs/installation.md#check-readiness-for-your-task) : diagnostiquer un agent ou un moteur d'analyse donné. -- [CLI et Evidence](docs/cli.md) : commandes, choix du fournisseur, snapshots, import/export et codes de sortie. -- [Contrats MCP](docs/mcp-contracts.md) et [prompts pour agents](docs/mcp-prompts.md) : résultats des outils, sessions et investigations guidées. -- [Catalogue des outils](docs/mcp-contracts.md#generated-catalog) : inventaire des outils, fournisseurs et commandes CLI, généré lors du build. -- [Feuille de route](docs/roadmap.md) : travaux prévus et suivi des capacités. +- [Installation et configuration](../installation.md) : enregistrement des agents, configuration des fournisseurs, mises à jour et désinstallation. +- [Vérification et dépannage](../installation.md#check-readiness-for-your-task) : diagnostiquer un agent ou un moteur d'analyse donné. +- [CLI et Evidence](../cli.md) : commandes, choix du fournisseur, snapshots, import/export et codes de sortie. +- [Contrats MCP](../mcp-contracts.md) et [prompts pour agents](../mcp-prompts.md) : résultats des outils, sessions et investigations guidées. +- [Catalogue des outils](../mcp-contracts.md#generated-catalog) : inventaire des outils, fournisseurs et commandes CLI, généré lors du build. +- [Feuille de route](../roadmap.md) : travaux prévus et suivi des capacités. -Signalez les vulnérabilités via [SECURITY.md](SECURITY.md). +Signalez les vulnérabilités via [SECURITY.md](../../SECURITY.md). ## Historique des étoiles @@ -280,7 +280,7 @@ Merci à toutes les personnes qui utilisent REA, signalent des bugs, proposent d ## Avertissement -Consultez [DISCLAIMER.md](DISCLAIMER.md) pour les consignes d'utilisation responsable. +Consultez [DISCLAIMER.md](../../DISCLAIMER.md) pour les consignes d'utilisation responsable. REA est destiné à la recherche, à l’éducation, à l’interopérabilité, à la préservation logicielle et à l’analyse de logiciels que vous possédez ou êtes autorisé à examiner, dans le respect de la loi. @@ -292,14 +292,14 @@ applicable licenses. Votre aide sur REA est la bienvenue ! [Ouvrez une issue](https://github.com/morluto/rea/issues) pour signaler un bug ou proposer une fonctionnalité, ou [envoyez une pull request](https://github.com/morluto/rea/pulls) pour améliorer le code ou la documentation. -Consultez [CONTRIBUTING.md](CONTRIBUTING.md) pour l'environnement de développement et les vérifications, la page [tests](docs/testing.md) pour les procédures de vérification, et la [carte de l'architecture](docs/architecture.mermaid) pour la structure du projet. +Consultez [CONTRIBUTING.md](../../CONTRIBUTING.md) pour l'environnement de développement et les vérifications, la page [tests](../testing.md) pour les procédures de vérification, et la [carte de l'architecture](../architecture.mermaid) pour la structure du projet. ## Licence -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_id.md b/docs/readme/README_id.md similarity index 75% rename from README_id.md rename to docs/readme/README_id.md index 7a564c434..fba947fb2 100644 --- a/README_id.md +++ b/docs/readme/README_id.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · **Bahasa Indonesia** · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · **Bahasa Indonesia** · [Italiano](README_it.md) # REA: Rekayasa Balik Apa Pun @@ -9,10 +9,10 @@ **Melihat fitur yang Anda sukai. Pahami cara kerjanya hingga ke tingkat biner.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA menjalankan jembatan analisisnya di dalam Hopper saat memeriksa biner native +REA menjalankan jembatan analisisnya di dalam Hopper saat memeriksa biner native

@@ -33,7 +33,7 @@ - Discord
+ Discord
Bergabunglah dengan Komunitas Rekayasa Balik

Discord · Tanya Jawab · Berbagi Hasil @@ -67,7 +67,7 @@ npx rea-agents setup Pilih agen yang ingin digunakan, tinjau perubahan yang diusulkan, lalu setujui perubahan tersebut. Proses penyiapan menambahkan server MCP REA dan instruksi alur kerja yang sesuai, serta membuat cadangan konfigurasi yang sudah ada. Setelah itu, mulai ulang agen Anda. -Penyiapan mendukung Claude Code, Codex, Cursor, Gemini CLI, Grok Build, dan [agen lainnya](docs/installation.md#supported-agents). Lihat [instalasi dan penyiapan](docs/installation.md) untuk konfigurasi penyedia dan pendaftaran MCP secara manual. +Penyiapan mendukung Claude Code, Codex, Cursor, Gemini CLI, Grok Build, dan [agen lainnya](../installation.md#supported-agents). Lihat [instalasi dan penyiapan](../installation.md) untuk konfigurasi penyedia dan pendaftaran MCP secara manual. ### Minta agen Anda @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Untuk analisis native, konfigurasikan penyedia terlebih dahulu. Lihat [Panduan CLI dan Bukti](docs/cli.md) untuk perintah native, pemilihan penyedia, snapshot, dan pembuatan skrip. +Untuk analisis native, konfigurasikan penyedia terlebih dahulu. Lihat [Panduan CLI dan Bukti](../cli.md) untuk perintah native, pemilihan penyedia, snapshot, dan pembuatan skrip. ### Perbarui REA @@ -124,9 +124,9 @@ Agen Anda memanggil REA melalui MCP untuk memeriksa target dan menelusuri kode y Perintah CLI menggunakan alur kerja yang sama. -![Alur investigasi REA: agen Anda menanyakan sesuatu tentang target lokal, REA memeriksa dan menelusurinya menggunakan alat analisis, lalu agen menggunakan kode, referensi, dan hal-hal yang belum diketahui dari hasil tersebut untuk menjelaskan, mengimplementasikan, dan menguji.](website/public/assets/figures/rea-investigation-flow.svg) +![Alur investigasi REA: agen Anda menanyakan sesuatu tentang target lokal, REA memeriksa dan menelusurinya menggunakan alat analisis, lalu agen menggunakan kode, referensi, dan hal-hal yang belum diketahui dari hasil tersebut untuk menjelaskan, mengimplementasikan, dan menguji.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Buka ilustrasi dalam ukuran penuh](website/public/assets/figures/rea-investigation-flow.svg). +[Buka ilustrasi dalam ukuran penuh](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -136,46 +136,46 @@ REA memerlukan Node.js 22.x (>=22.19), 24.x (>=24.11), atau 26+, beserta npm. Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: -| Target | Hasil yang diberikan REA | Persyaratan dan panduan | -| -------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](https://rea.tools/id/guides/native/) | -| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](docs/binary-diagnostics.md) | -| Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` | -| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](docs/evm-bytecode.md) | -| Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](docs/recorded-crashes.md) | -| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) | -| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) | -| Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](docs/web-network-captures.md) | -| Capturan paket | Endpoint, DNS, TLS SNI, distribusi protokol, dan permintaan HTTP | tshark dan capinfos buatan pemanggil; [panduan pcap](docs/pcap-analysis.md) | -| Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](docs/managed-code-analysis.md) | -| APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](docs/android-analysis.md) | -| Perangkat Android | Perangkat, paket, proses, log, dump, tangkapan layar, serta transfer APK/berkas | adb buatan pemanggil (emulator atau perangkat); [panduan ADB](docs/adb-device-analysis.md) | -| Proyek JEB | Unit proyek bertenaga mesin, hash artefak, dan pseudokode tipe/metode | Klien JEB yang dijalankan pemanggil dan menyajikan MCP, dipilih dengan REA_JEB_MCP_URL; [panduan JEB](docs/jeb-analysis.md) | -| Resource Android | Manifest terdekode, tabel string, lokal, dan fakta versi | Peluncur Apktool buatan pemanggil; [panduan resource](docs/apktool-resource-analysis.md) | -| Aplikasi Flutter | Hash snapshot Dart, hash pustaka per ABI, dan asal build engine | Tidak ada (parsing APK statis); [panduan Flutter](docs/flutter-analysis.md) | -| Firmware | Wilayah, hasil ekstraksi, dan pengalihan ke analisis native | Binwalk / Unblob di Linux; [panduan firmware](docs/firmware-analysis.md) | -| Paket rekaman | Kolom terpilih, nilai berulang, filter, dan aturan decode-as | Linux: TShark 4.x + prlimit; [panduan tangkapan](docs/packet-captures.md) | -| Sinyal logika terekam | Anotasi dekoder, rentang sampel, dan diagnostik | Linux: sigrok-cli 0.7.2 + prlimit; [panduan sinyal](docs/signal-recordings.md) | -| Paket dan sumber daya | Inventaris berkas, digest, plist, struktur paket Apple, dan sumber daya yang diekstrak | [Panduan artefak dan JavaScript](docs/javascript-artifact-reconstruction.md), [aplikasi Apple](docs/apple-application-analysis.md) | -| Perilaku proses | Keluaran terminal, interaksi, pengamatan proses keluar dan sistem berkas, serta perbandingan eksekusi | Linux/macOS dengan PTY native; [perekaman proses](docs/process-capture.md) | +| Target | Hasil yang diberikan REA | Persyaratan dan panduan | +| -------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](https://rea.tools/id/guides/native/) | +| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) | +| Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` | +| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) | +| Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) | +| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) | +| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) | +| Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](../web-network-captures.md) | +| Capturan paket | Endpoint, DNS, TLS SNI, distribusi protokol, dan permintaan HTTP | tshark dan capinfos buatan pemanggil; [panduan pcap](../pcap-analysis.md) | +| Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](../managed-code-analysis.md) | +| APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](../android-analysis.md) | +| Perangkat Android | Perangkat, paket, proses, log, dump, tangkapan layar, serta transfer APK/berkas | adb buatan pemanggil (emulator atau perangkat); [panduan ADB](../adb-device-analysis.md) | +| Proyek JEB | Unit proyek bertenaga mesin, hash artefak, dan pseudokode tipe/metode | Klien JEB yang dijalankan pemanggil dan menyajikan MCP, dipilih dengan REA_JEB_MCP_URL; [panduan JEB](../jeb-analysis.md) | +| Resource Android | Manifest terdekode, tabel string, lokal, dan fakta versi | Peluncur Apktool buatan pemanggil; [panduan resource](../apktool-resource-analysis.md) | +| Aplikasi Flutter | Hash snapshot Dart, hash pustaka per ABI, dan asal build engine | Tidak ada (parsing APK statis); [panduan Flutter](../flutter-analysis.md) | +| Firmware | Wilayah, hasil ekstraksi, dan pengalihan ke analisis native | Binwalk / Unblob di Linux; [panduan firmware](../firmware-analysis.md) | +| Paket rekaman | Kolom terpilih, nilai berulang, filter, dan aturan decode-as | Linux: TShark 4.x + prlimit; [panduan tangkapan](../packet-captures.md) | +| Sinyal logika terekam | Anotasi dekoder, rentang sampel, dan diagnostik | Linux: sigrok-cli 0.7.2 + prlimit; [panduan sinyal](../signal-recordings.md) | +| Paket dan sumber daya | Inventaris berkas, digest, plist, struktur paket Apple, dan sumber daya yang diekstrak | [Panduan artefak dan JavaScript](../javascript-artifact-reconstruction.md), [aplikasi Apple](../apple-application-analysis.md) | +| Perilaku proses | Keluaran terminal, interaksi, pengamatan proses keluar dan sistem berkas, serta perbandingan eksekusi | Linux/macOS dengan PTY native; [perekaman proses](../process-capture.md) | Pemeriksaan JavaScript dan .NET statis membaca berkas yang disediakan tanpa menjalankan aplikasinya. Perekaman runtime menjalankan atau berinteraksi dengan target yang dipilih menggunakan izin pengguna Anda. Setiap panduan runtime menjelaskan dampaknya. -Format native dan dukungan sistem host berbeda-beda menurut penyedia. Lihat [penyiapan Hopper dan Ghidra](docs/installation.md#hopper), [panduan IDA](docs/ida-provider.md), dan [dukungan eksperimental Ghidra di Windows](docs/windows-ghidra-p0.md). +Format native dan dukungan sistem host berbeda-beda menurut penyedia. Lihat [penyiapan Hopper dan Ghidra](../installation.md#hopper), [panduan IDA](../ida-provider.md), dan [dukungan eksperimental Ghidra di Windows](../windows-ghidra-p0.md). -Ghidra juga mendukung [analisis DOS 16-bit](docs/ghidra-dos.md). +Ghidra juga mendukung [analisis DOS 16-bit](../ghidra-dos.md). Untuk biner berukuran besar, tingkatkan batas waktu startup menggunakan `REA_GHIDRA_STARTUP_TIMEOUT_MS`. -Untuk memilih penyedia, lihat [panduan CLI](docs/cli.md#choose-a-provider). +Untuk memilih penyedia, lihat [panduan CLI](../cli.md#choose-a-provider). -Periksa [ketersediaan rilis](docs/installation.md#released-package-and-main) untuk mengetahui fitur yang ditambahkan setelah rilis npm terbaru. +Periksa [ketersediaan rilis](../installation.md#released-package-and-main) untuk mengetahui fitur yang ditambahkan setelah rilis npm terbaru. ## Contoh Penggunaan -[![Ilustrasi contoh penggunaan DX-Ball sound-pan, Notion clipboard-bridge, dan TH04 bullet-ring](docs/assets/rea-showcases.png)](https://rea.tools/id/showcase/) +[![Ilustrasi contoh penggunaan DX-Ball sound-pan, Notion clipboard-bridge, dan TH04 bullet-ring](../assets/rea-showcases.png)](https://rea.tools/id/showcase/) ### DX-Ball: merekonstruksi perhitungan sound-pan @@ -202,7 +202,7 @@ Jika Anda telah menggunakan REA untuk menganalisis sesuatu yang menarik, kami in
Agen apa saja yang dapat menggunakan REA? -Agen apa pun yang mendukung server MCP lokal dapat menggunakan REA. Proses penyiapan mengonfigurasi [agen yang didukung](docs/installation.md#supported-agents). Klien lain dapat menggunakan [pendaftaran MCP manual](docs/installation.md#mcp-registry). +Agen apa pun yang mendukung server MCP lokal dapat menggunakan REA. Proses penyiapan mengonfigurasi [agen yang didukung](../installation.md#supported-agents). Klien lain dapat menggunakan [pendaftaran MCP manual](../installation.md#mcp-registry).
@@ -211,7 +211,7 @@ Agen apa pun yang mendukung server MCP lokal dapat menggunakan REA. Proses penyi Analisis native mendalam menggunakan salah satu dari ketiganya. Pemeriksaan JavaScript dan .NET statis dapat dilakukan tanpa mesin analisis native. -Proses penyiapan dapat memasang Hopper setelah memperoleh persetujuan. Ghidra dan IDA menggunakan instalasi yang sudah tersedia. Lihat [penyiapan penyedia](docs/installation.md#hopper). +Proses penyiapan dapat memasang Hopper setelah memperoleh persetujuan. Ghidra dan IDA menggunakan instalasi yang sudah tersedia. Lihat [penyiapan penyedia](../installation.md#hopper). @@ -220,7 +220,7 @@ Proses penyiapan dapat memasang Hopper setelah memperoleh persetujuan. Ghidra da REA akan menjalankan Hopper saat suatu operasi membutuhkannya. Di macOS, dialog saat pertama kali dijalankan mungkin meminta Anda memilih mode demo atau mengaktifkan lisensi. -Lihat [startup dan pemecahan masalah Hopper](docs/installation.md#launcher-paths-and-troubleshooting). +Lihat [startup dan pemecahan masalah Hopper](../installation.md#launcher-paths-and-troubleshooting). @@ -229,7 +229,7 @@ Lihat [startup dan pemecahan masalah Hopper](docs/installation.md#launcher-paths Skill tersebut menyediakan instruksi investigasi untuk agen Anda. Gunakan `npx rea-agents setup` untuk mendaftarkan server MCP REA dan memasang instruksi yang sesuai, lalu mulai ulang agen Anda. -Lihat [instalasi khusus skill](docs/installation.md#skill-only-installation). +Lihat [instalasi khusus skill](../installation.md#skill-only-installation). @@ -276,14 +276,14 @@ Mulailah dengan [panduan praktik di situs web](https://rea.tools/id/guides/). Untuk mengetahui opsi, prasyarat, dan kontrak hasil secara tepat: -- [Instalasi dan penyiapan](docs/installation.md): pendaftaran agen, konfigurasi penyedia, pembaruan, dan penghapusan instalasi. -- [Kesiapan dan pemecahan masalah](docs/installation.md#check-readiness-for-your-task): mendiagnosis satu agen atau mesin analisis. -- [CLI dan Bukti](docs/cli.md): perintah, pemilihan penyedia, snapshot, impor/ekspor, dan status keluar. -- [Kontrak MCP](docs/mcp-contracts.md) dan [prompt agen](docs/mcp-prompts.md): hasil alat, sesi, dan investigasi terpandu. -- [Katalog alat](docs/mcp-contracts.md#generated-catalog): inventaris alat, penyedia, dan perintah CLI yang dibuat secara otomatis. -- [Peta jalan](docs/roadmap.md): pekerjaan yang direncanakan dan pelacak kemampuan. +- [Instalasi dan penyiapan](../installation.md): pendaftaran agen, konfigurasi penyedia, pembaruan, dan penghapusan instalasi. +- [Kesiapan dan pemecahan masalah](../installation.md#check-readiness-for-your-task): mendiagnosis satu agen atau mesin analisis. +- [CLI dan Bukti](../cli.md): perintah, pemilihan penyedia, snapshot, impor/ekspor, dan status keluar. +- [Kontrak MCP](../mcp-contracts.md) dan [prompt agen](../mcp-prompts.md): hasil alat, sesi, dan investigasi terpandu. +- [Katalog alat](../mcp-contracts.md#generated-catalog): inventaris alat, penyedia, dan perintah CLI yang dibuat secara otomatis. +- [Peta jalan](../roadmap.md): pekerjaan yang direncanakan dan pelacak kemampuan. -Laporkan kerentanan melalui [SECURITY.md](SECURITY.md). +Laporkan kerentanan melalui [SECURITY.md](../../SECURITY.md). ## Riwayat Bintang @@ -301,7 +301,7 @@ Terima kasih kepada semua orang yang menggunakan REA, melaporkan bug, meminta fi ## Penafian -Lihat [DISCLAIMER.md](DISCLAIMER.md) untuk panduan penggunaan yang bertanggung jawab. +Lihat [DISCLAIMER.md](../../DISCLAIMER.md) untuk panduan penggunaan yang bertanggung jawab. REA ditujukan untuk penelitian, pendidikan, interoperabilitas, pelestarian perangkat lunak, dan analisis perangkat lunak yang Anda miliki atau diizinkan untuk Anda periksa, sesuai hukum. @@ -313,14 +313,14 @@ applicable licenses. Kami sangat menghargai bantuan Anda untuk REA! [Buat issue](https://github.com/morluto/rea/issues) untuk melaporkan bug atau menyarankan fitur, atau [kirim pull request](https://github.com/morluto/rea/pulls) untuk meningkatkan kode atau dokumentasi. -Lihat [CONTRIBUTING.md](CONTRIBUTING.md) untuk penyiapan pengembangan dan pemeriksaan, [pengujian](docs/testing.md) untuk tahapan verifikasi, serta [peta arsitektur](docs/architecture.mermaid) untuk struktur proyek. +Lihat [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk penyiapan pengembangan dan pemeriksaan, [pengujian](../testing.md) untuk tahapan verifikasi, serta [peta arsitektur](../architecture.mermaid) untuk struktur proyek. ## Lisensi -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_it.md b/docs/readme/README_it.md similarity index 74% rename from README_it.md rename to docs/readme/README_it.md index 8b1c55afe..b7e7c6100 100644 --- a/README_it.md +++ b/docs/readme/README_it.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · **Italiano** +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · **Italiano** # REA: Reverse Engineer Anything @@ -9,10 +9,10 @@ **Vedi una funzionalità che ti piace. Capisci come funziona, fino al livello binario.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#cosa-puoi-analizzare) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA che avvia il suo bridge di analisi dentro Hopper mentre ispeziona un binario nativo +REA che avvia il suo bridge di analisi dentro Hopper mentre ispeziona un binario nativo

@@ -33,7 +33,7 @@ - Discord
+ Discord
Unisciti alla community di ingegneria inversa

Discord · Domande e risposte · Show and Tell @@ -67,7 +67,7 @@ npx rea-agents setup Scegli i tuoi agenti, rivedi le modifiche proposte e approvale. La configurazione aggiunge il server MCP di REA e le istruzioni di workflow corrispondenti, con backup della configurazione esistente. Riavvia quindi il tuo agente. -La configurazione supporta Claude Code, Codex, Cursor, Gemini CLI, Grok Build e [altri agenti](docs/installation.md#supported-agents). Vedi [installazione e configurazione](docs/installation.md) per la configurazione dei provider e la registrazione manuale MCP. +La configurazione supporta Claude Code, Codex, Cursor, Gemini CLI, Grok Build e [altri agenti](../installation.md#supported-agents). Vedi [installazione e configurazione](../installation.md) per la configurazione dei provider e la registrazione manuale MCP. ### Chiedi al tuo agente @@ -83,7 +83,7 @@ Sostituisci Note con la tua app di destinazione e la funzionalità che vuoi capi Ispeziona una directory di app JavaScript/Electron estratta o un ASAR: ```bash -npx -y rea-agents@latest analyze-javascript-application /percorso/assoluto/verso/app --json +npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json ``` Il risultato include moduli, import, confini Electron e le relative prove. Sostituisci il percorso con il tuo bersaglio, ad esempio `"D:/apps/example"` su Windows. @@ -95,7 +95,7 @@ npm install --global rea-agents rea --help ``` -Per l'analisi nativa, configura prima un provider. Vedi la [guida CLI ed Evidence](docs/cli.md) per i comandi nativi, la selezione del provider, gli snapshot e lo scripting. +Per l'analisi nativa, configura prima un provider. Vedi la [guida CLI ed Evidence](../cli.md) per i comandi nativi, la selezione del provider, gli snapshot e lo scripting. ### Aggiorna REA @@ -123,9 +123,9 @@ Rivedi le modifiche di configurazione e riavvia il tuo agente. Per comandi CLI s Il tuo agente chiama REA tramite MCP per ispezionare il bersaglio e tracciare il codice rilevante. REA restituisce i risultati con le relative prove. L'agente li usa per fare domande di approfondimento, spiegare il comportamento o scrivere e testare un'implementazione. Anche i comandi CLI usano gli stessi workflow. -![Flusso di indagine REA: il tuo agente pone domande su un bersaglio locale, REA lo ispeziona e ne traccia il codice usando strumenti di analisi, e l'agente usa il codice, i riferimenti e gli elementi sconosciuti restituiti per spiegare, implementare e testare.](website/public/assets/figures/rea-investigation-flow.svg) +![Flusso di indagine REA: il tuo agente pone domande su un bersaglio locale, REA lo ispeziona e ne traccia il codice usando strumenti di analisi, e l'agente usa il codice, i riferimenti e gli elementi sconosciuti restituiti per spiegare, implementare e testare.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Apri la figura a dimensione intera](website/public/assets/figures/rea-investigation-flow.svg). +[Apri la figura a dimensione intera](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -133,38 +133,38 @@ Il tuo agente chiama REA tramite MCP per ispezionare il bersaglio e tracciare il REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strumenti aggiuntivi e supporto host dipendono dal bersaglio: -| Obiettivo | Cosa restituisce REA | Requisiti e guida | -| ---------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Binari nativi | Pseudocodice, assembly, stringhe, simboli, chiamate e riferimenti | Hopper, Ghidra o IDA; [analisi nativa](https://rea.tools/it/guides/native/) | -| Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](docs/binary-diagnostics.md) | -| Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` | -| Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](docs/evm-bytecode.md) | -| Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](docs/recorded-crashes.md) | -| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) | -| Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) | -| Acquisizioni di rete salvate | Richieste, risposte, payload esposti e posizioni sorgente | HAR; mitmdump su Linux per acquisizioni mitmproxy native; [guida alle acquisizioni](docs/web-network-captures.md) | -| Captured di pacchetti | Endpoint, DNS, TLS SNI, distribuzione dei protocolli e richieste HTTP | tshark e capinfos forniti dal chiamante; [guida pcap](docs/pcap-analysis.md) | -| Assembly .NET | Metadati, istruzioni CIL, dipendenze native dichiarate e confronti di build | Nessuno (solo statico); [guida managed-code](docs/managed-code-analysis.md) | -| APK Android | Dichiarazioni manifest, classi, metodi decompilati e riferimenti | JADX headless e un JDK completo su Linux/macOS/Windows x64; [guida Android](docs/android-analysis.md) | -| Dispositivi Android | Dispositivi, pacchetti, processi, log, dump, screenshot e trasferimenti APK/file | adb fornito dal chiamante (emulatore o hardware); [guida ADB](docs/adb-device-analysis.md) | -| Progetti JEB | Unità di progetto dal motore, digest degli artifact e pseudocodice di tipi/metodi | Client JEB avviato dal chiamante via MCP, selezionato con REA_JEB_MCP_URL; [guida JEB](docs/jeb-analysis.md) | -| Risorse Android | Manifest decodificato, tabelle stringhe, lingue e dati di versione | Launcher Apktool fornito dal chiamante; [guida alle risorse](docs/apktool-resource-analysis.md) | -| App Flutter | Hash dello snapshot Dart, digest delle librerie per ABI e provenienza della build del motore | Nessuno (analisi APK statica); [guida Flutter](docs/flutter-analysis.md) | -| Firmware | Regioni, risultati di estrazione e passaggi all'analisi nativa | Binwalk / Unblob su Linux; [guida firmware](docs/firmware-analysis.md) | -| Pacchetti registrati | Campi selezionati, valori ripetuti, filtri e regole di decodifica | Linux: TShark 4.x + prlimit; [guida alle catture](docs/packet-captures.md) | -| Segnali logici registrati | Annotazioni del decoder, intervalli di campioni e diagnostica | Linux: sigrok-cli 0.7.2 + prlimit; [guida ai segnali](docs/signal-recordings.md) | -| Pacchetti e risorse | Inventari file, digest, plist, anatomia dei bundle Apple e risorse estratte | [Guida artifact e JavaScript](docs/javascript-artifact-reconstruction.md), [Applicazioni Apple](docs/apple-application-analysis.md) | -| Comportamento dei processi | Output del terminale, interazioni, osservazioni su uscita e filesystem ed confronti di esecuzione | Linux/macOS con un PTY nativo; [cattura dei processi](docs/process-capture.md) | +| Obiettivo | Cosa restituisce REA | Requisiti e guida | +| ---------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Binari nativi | Pseudocodice, assembly, stringhe, simboli, chiamate e riferimenti | Hopper, Ghidra o IDA; [analisi nativa](https://rea.tools/it/guides/native/) | +| Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) | +| Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` | +| Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) | +| Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) | +| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) | +| Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) | +| Acquisizioni di rete salvate | Richieste, risposte, payload esposti e posizioni sorgente | HAR; mitmdump su Linux per acquisizioni mitmproxy native; [guida alle acquisizioni](../web-network-captures.md) | +| Captured di pacchetti | Endpoint, DNS, TLS SNI, distribuzione dei protocolli e richieste HTTP | tshark e capinfos forniti dal chiamante; [guida pcap](../pcap-analysis.md) | +| Assembly .NET | Metadati, istruzioni CIL, dipendenze native dichiarate e confronti di build | Nessuno (solo statico); [guida managed-code](../managed-code-analysis.md) | +| APK Android | Dichiarazioni manifest, classi, metodi decompilati e riferimenti | JADX headless e un JDK completo su Linux/macOS/Windows x64; [guida Android](../android-analysis.md) | +| Dispositivi Android | Dispositivi, pacchetti, processi, log, dump, screenshot e trasferimenti APK/file | adb fornito dal chiamante (emulatore o hardware); [guida ADB](../adb-device-analysis.md) | +| Progetti JEB | Unità di progetto dal motore, digest degli artifact e pseudocodice di tipi/metodi | Client JEB avviato dal chiamante via MCP, selezionato con REA_JEB_MCP_URL; [guida JEB](../jeb-analysis.md) | +| Risorse Android | Manifest decodificato, tabelle stringhe, lingue e dati di versione | Launcher Apktool fornito dal chiamante; [guida alle risorse](../apktool-resource-analysis.md) | +| App Flutter | Hash dello snapshot Dart, digest delle librerie per ABI e provenienza della build del motore | Nessuno (analisi APK statica); [guida Flutter](../flutter-analysis.md) | +| Firmware | Regioni, risultati di estrazione e passaggi all'analisi nativa | Binwalk / Unblob su Linux; [guida firmware](../firmware-analysis.md) | +| Pacchetti registrati | Campi selezionati, valori ripetuti, filtri e regole di decodifica | Linux: TShark 4.x + prlimit; [guida alle catture](../packet-captures.md) | +| Segnali logici registrati | Annotazioni del decoder, intervalli di campioni e diagnostica | Linux: sigrok-cli 0.7.2 + prlimit; [guida ai segnali](../signal-recordings.md) | +| Pacchetti e risorse | Inventari file, digest, plist, anatomia dei bundle Apple e risorse estratte | [Guida artifact e JavaScript](../javascript-artifact-reconstruction.md), [Applicazioni Apple](../apple-application-analysis.md) | +| Comportamento dei processi | Output del terminale, interazioni, osservazioni su uscita e filesystem ed confronti di esecuzione | Linux/macOS con un PTY nativo; [cattura dei processi](../process-capture.md) | L'ispezione statica di JavaScript e .NET legge i file forniti senza eseguire l'applicazione. La cattura a runtime esegue o interagisce con il bersaglio selezionato usando i tuoi permessi utente; ogni guida runtime descrive i suoi effetti. -I formati nativi e il supporto host variano in base al provider. Vedi la [configurazione di Hopper e Ghidra](docs/installation.md#hopper), la [guida IDA](docs/ida-provider.md) e il [supporto sperimentale Windows Ghidra](docs/windows-ghidra-p0.md). Ghidra supporta anche l'[analisi DOS a 16 bit](docs/ghidra-dos.md). Per binari di grandi dimensioni, aumenta il timeout di avvio con `REA_GHIDRA_STARTUP_TIMEOUT_MS`. Per la selezione del provider, vedi la [guida CLI](docs/cli.md#choose-a-provider). Controlla la [disponibilità delle release](docs/installation.md#released-package-and-main) per le funzionalità aggiunte dall'ultima release npm. +I formati nativi e il supporto host variano in base al provider. Vedi la [configurazione di Hopper e Ghidra](../installation.md#hopper), la [guida IDA](../ida-provider.md) e il [supporto sperimentale Windows Ghidra](../windows-ghidra-p0.md). Ghidra supporta anche l'[analisi DOS a 16 bit](../ghidra-dos.md). Per binari di grandi dimensioni, aumenta il timeout di avvio con `REA_GHIDRA_STARTUP_TIMEOUT_MS`. Per la selezione del provider, vedi la [guida CLI](../cli.md#choose-a-provider). Controlla la [disponibilità delle release](../installation.md#released-package-and-main) per le funzionalità aggiunte dall'ultima release npm. ## Showcase -[![Illustrazioni degli showcase del sound-pan di DX-Ball, del clipboard-bridge di Notion e dell'anello di proiettili di TH04](docs/assets/rea-showcases.png)](https://rea.tools/it/showcase/) +[![Illustrazioni degli showcase del sound-pan di DX-Ball, del clipboard-bridge di Notion e dell'anello di proiettili di TH04](../assets/rea-showcases.png)](https://rea.tools/it/showcase/) ### DX-Ball: ricostruisci un calcolo sound-pan @@ -191,28 +191,28 @@ Se hai usato REA su qualcosa di interessante, ci piacerebbe vederlo. Condividi i
Quali agenti possono usare REA? -Qualsiasi agente che supporti server MCP locali. La configurazione configura gli [agenti supportati](docs/installation.md#supported-agents); altri client possono usare la [registrazione MCP manuale](docs/installation.md#mcp-registry). +Qualsiasi agente che supporti server MCP locali. La configurazione configura gli [agenti supportati](../installation.md#supported-agents); altri client possono usare la [registrazione MCP manuale](../installation.md#mcp-registry).
Ho bisogno di Hopper, Ghidra o IDA? -L'analisi nativa profonda ne usa uno. L'ispezione statica di JavaScript e .NET funziona senza un motore di analisi nativo. La configurazione può installare Hopper previa approvazione; Ghidra e IDA usano le tue installazioni esistenti. Vedi [configurazione provider](docs/installation.md#hopper). +L'analisi nativa profonda ne usa uno. L'ispezione statica di JavaScript e .NET funziona senza un motore di analisi nativo. La configurazione può installare Hopper previa approvazione; Ghidra e IDA usano le tue installazioni esistenti. Vedi [configurazione provider](../installation.md#hopper).
Devo avviare Hopper per primo? -REA avvia Hopper quando un'operazione ne ha bisogno. Su macOS, una finestra di dialogo al primo avvio potrebbe chiederti di scegliere la modalità demo o di attivare la licenza. Vedi [avvio di Hopper e risoluzione dei problemi](docs/installation.md#launcher-paths-and-troubleshooting). +REA avvia Hopper quando un'operazione ne ha bisogno. Su macOS, una finestra di dialogo al primo avvio potrebbe chiederti di scegliere la modalità demo o di attivare la licenza. Vedi [avvio di Hopper e risoluzione dei problemi](../installation.md#launcher-paths-and-troubleshooting).
Cosa fa l'installazione dello skill da skills.sh? -Lo skill fornisce istruzioni di indagine per il tuo agente. Usa `npx rea-agents setup` per registrare il server MCP di REA e installare le istruzioni corrispondenti, quindi riavvia il tuo agente. Vedi [installazione solo skill](docs/installation.md#skill-only-installation). +Lo skill fornisce istruzioni di indagine per il tuo agente. Usa `npx rea-agents setup` per registrare il server MCP di REA e installare le istruzioni corrispondenti, quindi riavvia il tuo agente. Vedi [installazione solo skill](../installation.md#skill-only-installation).
@@ -255,14 +255,14 @@ Se stai usando un agente, completa l'[aggiornamento della configurazione](#aggio Inizia dalle [guide pratiche](https://rea.tools/it/guides/) del sito web. Per opzioni esatte, prerequisiti e contratti dei risultati: -- [Installazione e configurazione](docs/installation.md): registrazione agente, configurazione provider, aggiornamenti e disinstallazione. -- [Pronto all'uso e risoluzione dei problemi](docs/installation.md#check-readiness-for-your-task): diagnostica un agente o un motore di analisi. -- [CLI ed Evidence](docs/cli.md): comandi, selezione provider, snapshot, import/export e stati di uscita. -- [Contratti MCP](docs/mcp-contracts.md) e [prompt agente](docs/mcp-prompts.md): risultati degli strumenti, sessioni e indagini guidate. -- [Catalogo strumenti](docs/mcp-contracts.md#generated-catalog): inventario generato di strumenti, provider e comandi CLI. -- [Roadmap](docs/roadmap.md): lavoro pianificato e tracker delle capacità. +- [Installazione e configurazione](../installation.md): registrazione agente, configurazione provider, aggiornamenti e disinstallazione. +- [Pronto all'uso e risoluzione dei problemi](../installation.md#check-readiness-for-your-task): diagnostica un agente o un motore di analisi. +- [CLI ed Evidence](../cli.md): comandi, selezione provider, snapshot, import/export e stati di uscita. +- [Contratti MCP](../mcp-contracts.md) e [prompt agente](../mcp-prompts.md): risultati degli strumenti, sessioni e indagini guidate. +- [Catalogo strumenti](../mcp-contracts.md#generated-catalog): inventario generato di strumenti, provider e comandi CLI. +- [Roadmap](../roadmap.md): lavoro pianificato e tracker delle capacità. -Segnala vulnerabilità tramite [SECURITY.md](SECURITY.md). +Segnala vulnerabilità tramite [SECURITY.md](../../SECURITY.md). ## Cronologia delle stelle @@ -280,7 +280,7 @@ Grazie a tutti coloro che usano REA, segnalano bug, propongono funzionalità, te ## Disclaimer -Consulta [DISCLAIMER.md](DISCLAIMER.md) per le indicazioni sull'uso responsabile. +Consulta [DISCLAIMER.md](../../DISCLAIMER.md) per le indicazioni sull'uso responsabile. REA è destinato alla ricerca, alla formazione, all’interoperabilità, alla conservazione del software e all’analisi di software di tua proprietà o che sei autorizzato a esaminare, nel rispetto della legge. @@ -292,14 +292,14 @@ applicable licenses. Apprezzeremmo il tuo aiuto con REA! [Apri un issue](https://github.com/morluto/rea/issues) per segnalare un bug o suggerire una funzionalità, oppure [invia una pull request](https://github.com/morluto/rea/pulls) per migliorare il codice o la documentazione. -Vedi [CONTRIBUTING.md](CONTRIBUTING.md) per la configurazione di sviluppo e i controlli, [testing](docs/testing.md) per le lane di verifica e la [mappa dell'architettura](docs/architecture.mermaid) per la struttura del progetto. +Vedi [CONTRIBUTING.md](../../CONTRIBUTING.md) per la configurazione di sviluppo e i controlli, [testing](../testing.md) per le lane di verifica e la [mappa dell'architettura](../architecture.mermaid) per la struttura del progetto. ## License -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_ja.md b/docs/readme/README_ja.md similarity index 76% rename from README_ja.md rename to docs/readme/README_ja.md index 5fb42d096..c9d7981ba 100644 --- a/README_ja.md +++ b/docs/readme/README_ja.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · **日本語** · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · **日本語** · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA:あらゆるものをリバースエンジニアリング @@ -9,10 +9,10 @@ **気に入った機能を見つけたら、その仕組みをバイナリのレベルまで理解する。** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA が Hopper 内で分析ブリッジを起動し、ネイティブバイナリを調べる様子 +REA が Hopper 内で分析ブリッジを起動し、ネイティブバイナリを調べる様子

@@ -33,7 +33,7 @@ - Discord
+ Discord
リバースエンジニアリングのコミュニティに参加

Discord · 質問と回答 · 成果の共有 @@ -67,7 +67,7 @@ npx rea-agents setup 使うエージェントを選び、予定されている変更を確認して承認してください。セットアップは REA の MCP サーバーと対応するワークフローの手順を追加し、既存の設定をバックアップします。完了後にエージェントを再起動してください。 -セットアップは Claude Code、Codex、Cursor、Gemini CLI、Grok Build、および[その他のエージェント](docs/installation.md#supported-agents)に対応しています。プロバイダーの設定と MCP の手動登録については、[インストールとセットアップ](docs/installation.md)を参照してください。 +セットアップは Claude Code、Codex、Cursor、Gemini CLI、Grok Build、および[その他のエージェント](../installation.md#supported-agents)に対応しています。プロバイダーの設定と MCP の手動登録については、[インストールとセットアップ](../installation.md)を参照してください。 ### エージェントに依頼する @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -ネイティブ分析には、先にプロバイダーの設定が必要です。ネイティブ分析のコマンド、プロバイダーの選択、スナップショット、スクリプトでの利用は、[CLI と Evidence のガイド](docs/cli.md)を参照してください。 +ネイティブ分析には、先にプロバイダーの設定が必要です。ネイティブ分析のコマンド、プロバイダーの選択、スナップショット、スクリプトでの利用は、[CLI と Evidence のガイド](../cli.md)を参照してください。 ### REA を更新する @@ -120,9 +120,9 @@ npx rea-agents@latest setup エージェントは MCP 経由で REA を呼び出し、対象を調べて関連するコードを追跡します。REA は調査結果と根拠を返します。エージェントはそれを使って追加の質問をしたり、動作を説明したり、実装を書いてテストしたりします。CLI コマンドも同じワークフローを使います。 -![REA の調査フロー:エージェントがローカルの対象について質問し、REA が分析ツールで調査・追跡します。エージェントは返されたコード、参照、未解明の点を使い、説明、実装、テストを行います。](website/public/assets/figures/rea-investigation-flow.svg) +![REA の調査フロー:エージェントがローカルの対象について質問し、REA が分析ツールで調査・追跡します。エージェントは返されたコード、参照、未解明の点を使い、説明、実装、テストを行います。](../../website/public/assets/figures/rea-investigation-flow.svg) -[原寸大の図を開く](website/public/assets/figures/rea-investigation-flow.svg)。 +[原寸大の図を開く](../../website/public/assets/figures/rea-investigation-flow.svg)。 @@ -130,37 +130,37 @@ npx rea-agents@latest setup REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm が必要です。追加のツールや対応ホストは、対象によって異なります。 -| 対象 | REA が返すもの | 要件とガイド | -| -------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/ja/guides/native/) | -| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](docs/binary-diagnostics.md) | -| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](docs/evm-bytecode.md) | -| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](docs/recorded-crashes.md) | -| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) | -| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) | -| 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](docs/web-network-captures.md) | -| パケットキャプチャ | エンドポイント、DNS、TLS SNI、プロトコル分布、HTTPリクエスト | 呼び出し側が用意した tshark と capinfos;[pcapガイド](docs/pcap-analysis.md) | -| .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](docs/managed-code-analysis.md) | -| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](docs/android-analysis.md) | -| Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](docs/adb-device-analysis.md) | -| JEBプロジェクト | エンジンによるプロジェクトユニット、アーティファクトのダイジェスト、型/メソッドの擬似コード | 呼び出し側が起動し MCP を提供する JEB クライアント(REA_JEB_MCP_URL で選択);[JEB ガイド](docs/jeb-analysis.md) | -| Android リソース | デコードされたマニフェスト、文字列テーブル、ロケール、バージョン情報 | 呼び出し側が用意した Apktool ランチャー;[リソースガイド](docs/apktool-resource-analysis.md) | -| Flutter アプリ | Dart スナップショットハッシュ、ABI ごとのライブラリダイジェスト、エンジンビルドの来歴 | 不要(静的 APK 解析);[Flutter ガイド](docs/flutter-analysis.md) | -| ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | Linux 上の Binwalk / Unblob;[ファームウェアガイド](docs/firmware-analysis.md) | -| 記録済みパケット | 選択フィールド、繰り返し値、フィルター、デコード指定 | Linux: TShark 4.x + prlimit; [キャプチャガイド](docs/packet-captures.md) | -| 記録済みロジック信号 | デコーダ注釈、サンプル範囲、診断情報 | Linux: sigrok-cli 0.7.2 + prlimit; [信号記録ガイド](docs/signal-recordings.md) | -| パッケージとリソース | ファイル一覧、ダイジェスト、plist、Apple バンドルの構造、抽出されたリソース | [アーティファクトと JavaScript のガイド](docs/javascript-artifact-reconstruction.md)、[Apple アプリケーション](docs/apple-application-analysis.md) | -| プロセスの動作 | ターミナル出力、対話、終了とファイルシステムの観測結果、実行の比較 | ネイティブ PTY を備えた Linux/macOS;[プロセスキャプチャ](docs/process-capture.md) | +| 対象 | REA が返すもの | 要件とガイド | +| -------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/ja/guides/native/) | +| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) | +| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) | +| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) | +| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) | +| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) | +| 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](../web-network-captures.md) | +| パケットキャプチャ | エンドポイント、DNS、TLS SNI、プロトコル分布、HTTPリクエスト | 呼び出し側が用意した tshark と capinfos;[pcapガイド](../pcap-analysis.md) | +| .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](../managed-code-analysis.md) | +| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](../android-analysis.md) | +| Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](../adb-device-analysis.md) | +| JEBプロジェクト | エンジンによるプロジェクトユニット、アーティファクトのダイジェスト、型/メソッドの擬似コード | 呼び出し側が起動し MCP を提供する JEB クライアント(REA_JEB_MCP_URL で選択);[JEB ガイド](../jeb-analysis.md) | +| Android リソース | デコードされたマニフェスト、文字列テーブル、ロケール、バージョン情報 | 呼び出し側が用意した Apktool ランチャー;[リソースガイド](../apktool-resource-analysis.md) | +| Flutter アプリ | Dart スナップショットハッシュ、ABI ごとのライブラリダイジェスト、エンジンビルドの来歴 | 不要(静的 APK 解析);[Flutter ガイド](../flutter-analysis.md) | +| ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | Linux 上の Binwalk / Unblob;[ファームウェアガイド](../firmware-analysis.md) | +| 記録済みパケット | 選択フィールド、繰り返し値、フィルター、デコード指定 | Linux: TShark 4.x + prlimit; [キャプチャガイド](../packet-captures.md) | +| 記録済みロジック信号 | デコーダ注釈、サンプル範囲、診断情報 | Linux: sigrok-cli 0.7.2 + prlimit; [信号記録ガイド](../signal-recordings.md) | +| パッケージとリソース | ファイル一覧、ダイジェスト、plist、Apple バンドルの構造、抽出されたリソース | [アーティファクトと JavaScript のガイド](../javascript-artifact-reconstruction.md)、[Apple アプリケーション](../apple-application-analysis.md) | +| プロセスの動作 | ターミナル出力、対話、終了とファイルシステムの観測結果、実行の比較 | ネイティブ PTY を備えた Linux/macOS;[プロセスキャプチャ](../process-capture.md) | 静的な JavaScript と .NET の検査は、指定されたファイルを読み取り、アプリケーションを実行しません。実行時キャプチャは、あなたのユーザー権限で対象を実行したり、対象とやり取りしたりします。それぞれの実行時ガイドに、どのような操作が行われるかを記載しています。 -対応するネイティブ形式とホストは、プロバイダーによって異なります。[Hopper と Ghidra のセットアップ](docs/installation.md#hopper)、[IDA ガイド](docs/ida-provider.md)、[Windows 向け Ghidra の実験的対応](docs/windows-ghidra-p0.md)を参照してください。Ghidra は [16 ビット DOS 分析](docs/ghidra-dos.md)にも対応しています。プロバイダーの選択は [CLI ガイド](docs/cli.md#choose-a-provider)を参照してください。最新の npm リリース以降に追加された機能については、[リリースでの利用可否](docs/installation.md#released-package-and-main)を確認してください。 +対応するネイティブ形式とホストは、プロバイダーによって異なります。[Hopper と Ghidra のセットアップ](../installation.md#hopper)、[IDA ガイド](../ida-provider.md)、[Windows 向け Ghidra の実験的対応](../windows-ghidra-p0.md)を参照してください。Ghidra は [16 ビット DOS 分析](../ghidra-dos.md)にも対応しています。プロバイダーの選択は [CLI ガイド](../cli.md#choose-a-provider)を参照してください。最新の npm リリース以降に追加された機能については、[リリースでの利用可否](../installation.md#released-package-and-main)を確認してください。 ## 事例 -[![DX-Ball の音声パン、Notion のクリップボードブリッジ、TH04 の弾幕リングの事例を表すイラスト](docs/assets/rea-showcases.png)](https://rea.tools/ja/showcase/) +[![DX-Ball の音声パン、Notion のクリップボードブリッジ、TH04 の弾幕リングの事例を表すイラスト](../assets/rea-showcases.png)](https://rea.tools/ja/showcase/) ### DX-Ball:音声のパン計算を再構築する @@ -189,28 +189,28 @@ REA で興味深い対象を調べたら、ぜひ共有してください。[iss
どのエージェントで REA を使えますか? -ローカル MCP サーバーに対応するエージェントなら使えます。セットアップは[対応エージェント](docs/installation.md#supported-agents)を設定します。その他のクライアントでは、[MCP の手動登録](docs/installation.md#mcp-registry)を利用できます。 +ローカル MCP サーバーに対応するエージェントなら使えます。セットアップは[対応エージェント](../installation.md#supported-agents)を設定します。その他のクライアントでは、[MCP の手動登録](../installation.md#mcp-registry)を利用できます。
Hopper、Ghidra、IDA は必要ですか? -深いネイティブ分析には、そのいずれかを使います。静的な JavaScript と .NET の検査には、ネイティブ分析エンジンは必要ありません。セットアップは承認後に Hopper をインストールできます。Ghidra と IDA は既存のインストールを使います。[プロバイダーのセットアップ](docs/installation.md#hopper)を参照してください。 +深いネイティブ分析には、そのいずれかを使います。静的な JavaScript と .NET の検査には、ネイティブ分析エンジンは必要ありません。セットアップは承認後に Hopper をインストールできます。Ghidra と IDA は既存のインストールを使います。[プロバイダーのセットアップ](../installation.md#hopper)を参照してください。
先に Hopper を起動する必要はありますか? -REA は必要な操作の際に Hopper を起動します。macOS では、初回起動時にデモモードを選ぶか、ライセンスを有効化するよう求められることがあります。[Hopper の起動とトラブルシューティング](docs/installation.md#launcher-paths-and-troubleshooting)を参照してください。 +REA は必要な操作の際に Hopper を起動します。macOS では、初回起動時にデモモードを選ぶか、ライセンスを有効化するよう求められることがあります。[Hopper の起動とトラブルシューティング](../installation.md#launcher-paths-and-troubleshooting)を参照してください。
skills.sh からスキルをインストールすると何ができますか? -スキルはエージェントに調査の手順を提供します。`npx rea-agents setup` で REA の MCP サーバーを登録し、対応する手順をインストールしてから、エージェントを再起動してください。[スキルのみのインストール](docs/installation.md#skill-only-installation)を参照してください。 +スキルはエージェントに調査の手順を提供します。`npx rea-agents setup` で REA の MCP サーバーを登録し、対応する手順をインストールしてから、エージェントを再起動してください。[スキルのみのインストール](../installation.md#skill-only-installation)を参照してください。
@@ -253,14 +253,14 @@ npx rea-agents@latest setup まずはウェブサイトの[実践ガイド](https://rea.tools/ja/guides/)を参照してください。正確なオプション、前提条件、結果の契約については、以下を参照してください。 -- [インストールとセットアップ](docs/installation.md):エージェントの登録、プロバイダーの設定、更新、アンインストール。 -- [準備状況の確認とトラブルシューティング](docs/installation.md#check-readiness-for-your-task):特定のエージェントや分析エンジンを診断。 -- [CLI と Evidence](docs/cli.md):コマンド、プロバイダーの選択、スナップショット、インポート/エクスポート、終了ステータス。 -- [MCP 契約](docs/mcp-contracts.md)と[エージェント向けプロンプト](docs/mcp-prompts.md):ツールの結果、セッション、ガイド付き調査。 -- [ツールカタログ](docs/mcp-contracts.md#generated-catalog):ビルド時に生成されるツール、プロバイダー、CLI コマンドの一覧。 -- [ロードマップ](docs/roadmap.md):計画中の作業と機能の進捗。 +- [インストールとセットアップ](../installation.md):エージェントの登録、プロバイダーの設定、更新、アンインストール。 +- [準備状況の確認とトラブルシューティング](../installation.md#check-readiness-for-your-task):特定のエージェントや分析エンジンを診断。 +- [CLI と Evidence](../cli.md):コマンド、プロバイダーの選択、スナップショット、インポート/エクスポート、終了ステータス。 +- [MCP 契約](../mcp-contracts.md)と[エージェント向けプロンプト](../mcp-prompts.md):ツールの結果、セッション、ガイド付き調査。 +- [ツールカタログ](../mcp-contracts.md#generated-catalog):ビルド時に生成されるツール、プロバイダー、CLI コマンドの一覧。 +- [ロードマップ](../roadmap.md):計画中の作業と機能の進捗。 -脆弱性の報告は [SECURITY.md](SECURITY.md) に従ってください。 +脆弱性の報告は [SECURITY.md](../../SECURITY.md) に従ってください。 ## スター履歴 @@ -278,7 +278,7 @@ REA の利用、バグ報告、機能の要望、ビルドのテスト、修正 ## 免責事項 -責任ある利用については [DISCLAIMER.md](DISCLAIMER.md) を参照してください。 +責任ある利用については [DISCLAIMER.md](../../DISCLAIMER.md) を参照してください。 REA は、法令に従った研究、教育、相互運用性の確保、ソフトウェアの保存、および所有するソフトウェアや調査を許可されたソフトウェアの解析を目的としています。 @@ -290,14 +290,14 @@ applicable licenses. REA への貢献を歓迎します![issue を作成](https://github.com/morluto/rea/issues)してバグや機能の提案を報告したり、[pull request を送信](https://github.com/morluto/rea/pulls)してコードやドキュメントを改善したりできます。 -開発環境とチェックは [CONTRIBUTING.md](CONTRIBUTING.md)、検証レーンは[テストガイド](docs/testing.md)、プロジェクト構造は[アーキテクチャ図](docs/architecture.mermaid)を参照してください。 +開発環境とチェックは [CONTRIBUTING.md](../../CONTRIBUTING.md)、検証レーンは[テストガイド](../testing.md)、プロジェクト構造は[アーキテクチャ図](../architecture.mermaid)を参照してください。 ## ライセンス -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_ko.md b/docs/readme/README_ko.md similarity index 74% rename from README_ko.md rename to docs/readme/README_ko.md index 5a9e7cd5d..26db0d824 100644 --- a/README_ko.md +++ b/docs/readme/README_ko.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · **한국어** · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · **한국어** · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: 무엇이든 리버스 엔지니어링 @@ -9,10 +9,10 @@ **마음에 드는 기능을 발견했다면, 바이너리 수준까지 작동 원리를 이해하세요.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA가 Hopper 안에서 분석 브리지를 실행해 네이티브 바이너리를 검사하는 모습 +REA가 Hopper 안에서 분석 브리지를 실행해 네이티브 바이너리를 검사하는 모습

@@ -33,7 +33,7 @@ - Discord
+ Discord
리버스 엔지니어링 커뮤니티에 참여하세요

Discord · 질문과 답변 · 결과 공유 @@ -67,7 +67,7 @@ npx rea-agents setup 에이전트를 선택하고, 예정된 변경 사항을 검토한 뒤 승인하세요. 설정 과정은 기존 설정을 백업하고 REA의 MCP 서버와 버전에 맞는 워크플로 지침을 추가합니다. 완료 후 에이전트를 다시 시작하세요. -설정은 Claude Code, Codex, Cursor, Gemini CLI, Grok Build 및 [다른 에이전트](docs/installation.md#supported-agents)를 지원합니다. 제공자 설정과 수동 MCP 등록은 [설치 및 설정](docs/installation.md)을 참고하세요. +설정은 Claude Code, Codex, Cursor, Gemini CLI, Grok Build 및 [다른 에이전트](../installation.md#supported-agents)를 지원합니다. 제공자 설정과 수동 MCP 등록은 [설치 및 설정](../installation.md)을 참고하세요. ### 에이전트에게 요청 @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -네이티브 분석에는 먼저 제공자를 설정해야 합니다. 네이티브 명령, 제공자 선택, 스냅샷, 스크립트 사용은 [CLI 및 Evidence 가이드](docs/cli.md)를 참고하세요. +네이티브 분석에는 먼저 제공자를 설정해야 합니다. 네이티브 명령, 제공자 선택, 스냅샷, 스크립트 사용은 [CLI 및 Evidence 가이드](../cli.md)를 참고하세요. ### REA 업데이트 @@ -120,9 +120,9 @@ npx rea-agents@latest setup 에이전트는 MCP를 통해 REA를 호출하여 대상을 검사하고 관련 코드를 추적합니다. REA는 조사 결과와 근거를 반환합니다. 에이전트는 이를 바탕으로 추가 질문을 하고, 동작을 설명하거나, 구현을 작성하고 테스트합니다. CLI 명령도 같은 워크플로를 사용합니다. -![REA 조사 흐름: 에이전트가 로컬 대상에 대해 질문하면 REA가 분석 도구로 대상을 검사하고 추적합니다. 에이전트는 반환된 코드, 참조, 확인되지 않은 사항을 이용해 설명하고 구현하고 테스트합니다.](website/public/assets/figures/rea-investigation-flow.svg) +![REA 조사 흐름: 에이전트가 로컬 대상에 대해 질문하면 REA가 분석 도구로 대상을 검사하고 추적합니다. 에이전트는 반환된 코드, 참조, 확인되지 않은 사항을 이용해 설명하고 구현하고 테스트합니다.](../../website/public/assets/figures/rea-investigation-flow.svg) -[원본 크기의 그림 열기](website/public/assets/figures/rea-investigation-flow.svg). +[원본 크기의 그림 열기](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ npx rea-agents@latest setup REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니다. 추가 도구와 호스트 지원은 대상에 따라 다릅니다. -| 대상 | REA가 반환하는 내용 | 요구 사항 및 가이드 | -| --------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) | -| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](docs/binary-diagnostics.md) | -| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](docs/evm-bytecode.md) | -| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](docs/recorded-crashes.md) | -| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) | -| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) | -| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](docs/web-network-captures.md) | -| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](docs/pcap-analysis.md) | -| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](docs/managed-code-analysis.md) | -| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](docs/android-analysis.md) | -| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](docs/adb-device-analysis.md) | -| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](docs/jeb-analysis.md) | -| Android 리소스 | 디코딩된 매니페스트, 문자열 테이블, 로캘, 버전 정보 | 호출자 제공 Apktool 런처; [리소스 가이드](docs/apktool-resource-analysis.md) | -| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](docs/flutter-analysis.md) | -| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](docs/firmware-analysis.md) | -| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](docs/packet-captures.md) | -| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](docs/signal-recordings.md) | -| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](docs/javascript-artifact-reconstruction.md), [Apple 애플리케이션](docs/apple-application-analysis.md) | -| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](docs/process-capture.md) | +| 대상 | REA가 반환하는 내용 | 요구 사항 및 가이드 | +| --------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) | +| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) | +| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) | +| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) | +| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) | +| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) | +| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) | +| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](../pcap-analysis.md) | +| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](../managed-code-analysis.md) | +| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) | +| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) | +| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](../jeb-analysis.md) | +| Android 리소스 | 디코딩된 매니페스트, 문자열 테이블, 로캘, 버전 정보 | 호출자 제공 Apktool 런처; [리소스 가이드](../apktool-resource-analysis.md) | +| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](../flutter-analysis.md) | +| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) | +| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](../packet-captures.md) | +| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) | +| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) | +| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) | 정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다. -지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](docs/installation.md#hopper), [IDA 가이드](docs/ida-provider.md), [실험적 Windows Ghidra 지원](docs/windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](docs/ghidra-dos.md)도 지원합니다. 제공자 선택은 [CLI 가이드](docs/cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](docs/installation.md#released-package-and-main)를 확인하세요. +지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요. ## 사례 -[![DX-Ball 사운드 패닝, Notion 클립보드 브리지, TH04 탄환 고리 사례를 보여 주는 그림](docs/assets/rea-showcases.png)](https://rea.tools/ko/showcase/) +[![DX-Ball 사운드 패닝, Notion 클립보드 브리지, TH04 탄환 고리 사례를 보여 주는 그림](../assets/rea-showcases.png)](https://rea.tools/ko/showcase/) ### DX-Ball: 사운드 패닝 계산 재구현 @@ -189,28 +189,28 @@ REA로 흥미로운 대상을 분석했다면 알려 주세요. [issue](https://
어떤 에이전트에서 REA를 사용할 수 있나요? -로컬 MCP 서버를 지원하는 에이전트라면 사용할 수 있습니다. 설정 과정은 [지원하는 에이전트](docs/installation.md#supported-agents)를 구성합니다. 다른 클라이언트는 [수동 MCP 등록](docs/installation.md#mcp-registry)을 이용할 수 있습니다. +로컬 MCP 서버를 지원하는 에이전트라면 사용할 수 있습니다. 설정 과정은 [지원하는 에이전트](../installation.md#supported-agents)를 구성합니다. 다른 클라이언트는 [수동 MCP 등록](../installation.md#mcp-registry)을 이용할 수 있습니다.
Hopper, Ghidra 또는 IDA가 필요한가요? -심층 네이티브 분석에는 이 중 하나를 사용합니다. 정적 JavaScript 및 .NET 검사에는 네이티브 분석 엔진이 필요하지 않습니다. 설정 과정에서 승인 후 Hopper를 설치할 수 있으며, Ghidra와 IDA는 기존 설치를 사용합니다. [제공자 설정](docs/installation.md#hopper)을 참고하세요. +심층 네이티브 분석에는 이 중 하나를 사용합니다. 정적 JavaScript 및 .NET 검사에는 네이티브 분석 엔진이 필요하지 않습니다. 설정 과정에서 승인 후 Hopper를 설치할 수 있으며, Ghidra와 IDA는 기존 설치를 사용합니다. [제공자 설정](../installation.md#hopper)을 참고하세요.
Hopper를 먼저 실행해야 하나요? -REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. [Hopper 실행 및 문제 해결](docs/installation.md#launcher-paths-and-troubleshooting)을 참고하세요. +REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
skills.sh에서 설치하는 스킬은 어떤 역할을 하나요? -스킬은 에이전트에게 조사 지침을 제공합니다. `npx rea-agents setup`으로 REA의 MCP 서버를 등록하고 버전에 맞는 지침을 설치한 뒤 에이전트를 다시 시작하세요. [스킬만 설치하기](docs/installation.md#skill-only-installation)를 참고하세요. +스킬은 에이전트에게 조사 지침을 제공합니다. `npx rea-agents setup`으로 REA의 MCP 서버를 등록하고 버전에 맞는 지침을 설치한 뒤 에이전트를 다시 시작하세요. [스킬만 설치하기](../installation.md#skill-only-installation)를 참고하세요.
@@ -253,14 +253,14 @@ npx rea-agents@latest setup 웹사이트의 [실습 가이드](https://rea.tools/ko/guides/)부터 읽어 보세요. 정확한 옵션, 사전 요구 사항, 결과 계약은 다음 문서를 참고하세요. -- [설치 및 설정](docs/installation.md): 에이전트 등록, 제공자 설정, 업데이트, 제거. -- [준비 상태 확인 및 문제 해결](docs/installation.md#check-readiness-for-your-task): 특정 에이전트 또는 분석 엔진 진단. -- [CLI 및 Evidence](docs/cli.md): 명령, 제공자 선택, 스냅샷, 가져오기/내보내기, 종료 상태. -- [MCP 계약](docs/mcp-contracts.md) 및 [에이전트 프롬프트](docs/mcp-prompts.md): 도구 결과, 세션, 안내에 따른 조사. -- [도구 카탈로그](docs/mcp-contracts.md#generated-catalog): 빌드 시 생성되는 도구, 제공자, CLI 명령 목록. -- [로드맵](docs/roadmap.md): 계획된 작업과 기능 진행 상황. +- [설치 및 설정](../installation.md): 에이전트 등록, 제공자 설정, 업데이트, 제거. +- [준비 상태 확인 및 문제 해결](../installation.md#check-readiness-for-your-task): 특정 에이전트 또는 분석 엔진 진단. +- [CLI 및 Evidence](../cli.md): 명령, 제공자 선택, 스냅샷, 가져오기/내보내기, 종료 상태. +- [MCP 계약](../mcp-contracts.md) 및 [에이전트 프롬프트](../mcp-prompts.md): 도구 결과, 세션, 안내에 따른 조사. +- [도구 카탈로그](../mcp-contracts.md#generated-catalog): 빌드 시 생성되는 도구, 제공자, CLI 명령 목록. +- [로드맵](../roadmap.md): 계획된 작업과 기능 진행 상황. -취약점은 [SECURITY.md](SECURITY.md)에 따라 보고하세요. +취약점은 [SECURITY.md](../../SECURITY.md)에 따라 보고하세요. ## 스타 기록 @@ -278,7 +278,7 @@ REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를 ## 면책 조항 -책임 있는 사용에 관한 안내는 [DISCLAIMER.md](DISCLAIMER.md)를 참고하세요. +책임 있는 사용에 관한 안내는 [DISCLAIMER.md](../../DISCLAIMER.md)를 참고하세요. REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다. @@ -290,14 +290,14 @@ applicable licenses. REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/rea/issues)해 버그나 기능을 제안하거나, [pull request를 보내](https://github.com/morluto/rea/pulls) 코드와 문서를 개선할 수 있습니다. -개발 환경과 검사 항목은 [CONTRIBUTING.md](CONTRIBUTING.md), 검증 절차는 [테스트 가이드](docs/testing.md), 프로젝트 구조는 [아키텍처 지도](docs/architecture.mermaid)를 참고하세요. +개발 환경과 검사 항목은 [CONTRIBUTING.md](../../CONTRIBUTING.md), 검증 절차는 [테스트 가이드](../testing.md), 프로젝트 구조는 [아키텍처 지도](../architecture.mermaid)를 참고하세요. ## 라이선스 -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_pl.md b/docs/readme/README_pl.md similarity index 76% rename from README_pl.md rename to docs/readme/README_pl.md index c54af50fe..48200087d 100644 --- a/README_pl.md +++ b/docs/readme/README_pl.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · **Polski** · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · **Polski** · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Reverse Engineer Anything @@ -9,10 +9,10 @@ **Widzisz funkcję, która Ci się podoba? Dowiedz się, jak działa, aż do poziomu kodu binarnego.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA uruchamia most analityczny w Hopperze podczas analizy natywnego pliku binarnego +REA uruchamia most analityczny w Hopperze podczas analizy natywnego pliku binarnego

@@ -33,7 +33,7 @@ - Discord
+ Discord
Dołącz do społeczności inżynierii wstecznej

Discord · Pytania i odpowiedzi · Pokaż swój projekt @@ -67,7 +67,7 @@ npx rea-agents setup Wybierz swoich agentów, sprawdź proponowane zmiany i zatwierdź je. Konfigurator dodaje serwer MCP REA i pasujące instrukcje pracy, tworząc kopie zapasowe istniejącej konfiguracji. Następnie uruchom agenta ponownie. -Konfigurator obsługuje Claude Code, Codex, Cursor, Gemini CLI, Grok Build i [innych agentów](docs/installation.md#supported-agents). Konfigurację dostawców analizy i ręczną rejestrację MCP opisuje przewodnik [instalacji i konfiguracji](docs/installation.md). +Konfigurator obsługuje Claude Code, Codex, Cursor, Gemini CLI, Grok Build i [innych agentów](../installation.md#supported-agents). Konfigurację dostawców analizy i ręczną rejestrację MCP opisuje przewodnik [instalacji i konfiguracji](../installation.md). ### Zapytaj agenta @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Przed analizą kodu natywnego skonfiguruj dostawcę analizy. Polecenia analizy natywnej, wybór dostawcy, migawki i użycie w skryptach opisuje [przewodnik CLI i Evidence](docs/cli.md). +Przed analizą kodu natywnego skonfiguruj dostawcę analizy. Polecenia analizy natywnej, wybór dostawcy, migawki i użycie w skryptach opisuje [przewodnik CLI i Evidence](../cli.md). ### Zaktualizuj REA @@ -120,9 +120,9 @@ Sprawdź zmiany konfiguracji i uruchom agenta ponownie. Do jednorazowych polece Twój agent wywołuje REA przez MCP, aby analizować cel i śledzić istotny kod. REA zwraca ustalenia wraz z dowodami. Agent wykorzystuje je do zadawania dalszych pytań, wyjaśniania zachowania lub pisania i testowania implementacji. Polecenia CLI korzystają z tych samych procedur. -![Przebieg badania z REA: agent pyta o lokalny cel, REA analizuje go i śledzi za pomocą narzędzi analitycznych, a agent korzysta ze zwróconego kodu, odwołań i niewiadomych, aby wyjaśnić działanie, stworzyć implementację i ją przetestować.](website/public/assets/figures/rea-investigation-flow.svg) +![Przebieg badania z REA: agent pyta o lokalny cel, REA analizuje go i śledzi za pomocą narzędzi analitycznych, a agent korzysta ze zwróconego kodu, odwołań i niewiadomych, aby wyjaśnić działanie, stworzyć implementację i ją przetestować.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Otwórz ilustrację w pełnym rozmiarze](website/public/assets/figures/rea-investigation-flow.svg). +[Otwórz ilustrację w pełnym rozmiarze](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ Twój agent wywołuje REA przez MCP, aby analizować cel i śledzić istotny kod REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe narzędzia i obsługiwane platformy zależą od celu analizy: -| Cel | Co zwraca REA | Wymagania i przewodnik | -| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](https://rea.tools/pl/guides/native/) | -| Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](docs/binary-diagnostics.md) | -| Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](docs/evm-bytecode.md) | -| Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](docs/recorded-crashes.md) | -| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) | -| Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) | -| Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](docs/web-network-captures.md) | -| Przechwytywanie pakietów | Punkty końcowe, DNS, TLS SNI, dystrybucja protokołów i żądania HTTP | Dostarczone przez wywołującego tshark i capinfos; [przewodnik pcap](docs/pcap-analysis.md) | -| Zestawy .NET | Metadane, instrukcje CIL, zadeklarowane zależności natywne i porównania kompilacji | Analiza statyczna; [przewodnik kodu zarządzanego](docs/managed-code-analysis.md) | -| Pakiety APK dla Androida | Deklaracje manifestu, klasy, zdekompilowane metody i odwołania | JADX bez interfejsu graficznego i pełny JDK w systemach Linux/macOS/Windows x64; [przewodnik Androida](docs/android-analysis.md) | -| Urządzenia z Androidem | Urządzenia, pakiety, procesy, logi, zrzuty, zrzuty ekranu oraz transfery APK/plików | adb dostarczony przez wywołującego (emulator lub urządzenie); [przewodnik ADB](docs/adb-device-analysis.md) | -| Projekty JEB | Jednostki projektu obsługiwane przez silnik, skróty artefaktów i pseudokod typów/metod | Uruchomiony przez wywołującego klient JEB udostępniający MCP, wybrany przez REA_JEB_MCP_URL; [przewodnik JEB](docs/jeb-analysis.md) | -| Zasoby Androida | Zdekodowany manifest, tablice ciągów, języki i informacje o wersji | Należący do wywołującego launcher Apktool; [przewodnik zasobów](docs/apktool-resource-analysis.md) | -| Aplikacje Flutter | Hash migawki Dart, sumy bibliotek per ABI i pochodzenie kompilacji silnika | Brak (statyczna analiza APK); [przewodnik Flutter](docs/flutter-analysis.md) | -| Oprogramowanie układowe | Regiony, wyniki wyodrębniania i przekazanie do analizy kodu natywnego | Binwalk / Unblob w systemie Linux; [przewodnik oprogramowania układowego](docs/firmware-analysis.md) | -| Zapisane pakiety | Wybrane pola, powtarzane wartości, filtry i reguły dekodowania | Linux: TShark 4.x + prlimit; [przewodnik po przechwyconych pakietach](docs/packet-captures.md) | -| Zapisane sygnały logiczne | Adnotacje dekodera, zakresy próbek i diagnostyka | Linux: sigrok-cli 0.7.2 + prlimit; [przewodnik po sygnałach](docs/signal-recordings.md) | -| Pakiety i zasoby | Spisy plików, skróty, pliki plist, struktura pakietów Apple i wyodrębnione zasoby | [Przewodnik artefaktów i JavaScript](docs/javascript-artifact-reconstruction.md), [aplikacje Apple](docs/apple-application-analysis.md) | -| Zachowanie procesów | Wyjście terminala, interakcje, obserwacje zakończenia i systemu plików oraz porównania uruchomień | Linux/macOS z natywnym PTY; [przechwytywanie procesów](docs/process-capture.md) | +| Cel | Co zwraca REA | Wymagania i przewodnik | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](https://rea.tools/pl/guides/native/) | +| Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) | +| Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) | +| Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) | +| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) | +| Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) | +| Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](../web-network-captures.md) | +| Przechwytywanie pakietów | Punkty końcowe, DNS, TLS SNI, dystrybucja protokołów i żądania HTTP | Dostarczone przez wywołującego tshark i capinfos; [przewodnik pcap](../pcap-analysis.md) | +| Zestawy .NET | Metadane, instrukcje CIL, zadeklarowane zależności natywne i porównania kompilacji | Analiza statyczna; [przewodnik kodu zarządzanego](../managed-code-analysis.md) | +| Pakiety APK dla Androida | Deklaracje manifestu, klasy, zdekompilowane metody i odwołania | JADX bez interfejsu graficznego i pełny JDK w systemach Linux/macOS/Windows x64; [przewodnik Androida](../android-analysis.md) | +| Urządzenia z Androidem | Urządzenia, pakiety, procesy, logi, zrzuty, zrzuty ekranu oraz transfery APK/plików | adb dostarczony przez wywołującego (emulator lub urządzenie); [przewodnik ADB](../adb-device-analysis.md) | +| Projekty JEB | Jednostki projektu obsługiwane przez silnik, skróty artefaktów i pseudokod typów/metod | Uruchomiony przez wywołującego klient JEB udostępniający MCP, wybrany przez REA_JEB_MCP_URL; [przewodnik JEB](../jeb-analysis.md) | +| Zasoby Androida | Zdekodowany manifest, tablice ciągów, języki i informacje o wersji | Należący do wywołującego launcher Apktool; [przewodnik zasobów](../apktool-resource-analysis.md) | +| Aplikacje Flutter | Hash migawki Dart, sumy bibliotek per ABI i pochodzenie kompilacji silnika | Brak (statyczna analiza APK); [przewodnik Flutter](../flutter-analysis.md) | +| Oprogramowanie układowe | Regiony, wyniki wyodrębniania i przekazanie do analizy kodu natywnego | Binwalk / Unblob w systemie Linux; [przewodnik oprogramowania układowego](../firmware-analysis.md) | +| Zapisane pakiety | Wybrane pola, powtarzane wartości, filtry i reguły dekodowania | Linux: TShark 4.x + prlimit; [przewodnik po przechwyconych pakietach](../packet-captures.md) | +| Zapisane sygnały logiczne | Adnotacje dekodera, zakresy próbek i diagnostyka | Linux: sigrok-cli 0.7.2 + prlimit; [przewodnik po sygnałach](../signal-recordings.md) | +| Pakiety i zasoby | Spisy plików, skróty, pliki plist, struktura pakietów Apple i wyodrębnione zasoby | [Przewodnik artefaktów i JavaScript](../javascript-artifact-reconstruction.md), [aplikacje Apple](../apple-application-analysis.md) | +| Zachowanie procesów | Wyjście terminala, interakcje, obserwacje zakończenia i systemu plików oraz porównania uruchomień | Linux/macOS z natywnym PTY; [przechwytywanie procesów](../process-capture.md) | Statyczna analiza JavaScript i .NET odczytuje dostarczone pliki bez uruchamiania aplikacji. Przechwytywanie w czasie wykonywania uruchamia wybrany cel lub wchodzi z nim w interakcje z uprawnieniami Twojego użytkownika; skutki opisano w odpowiednich przewodnikach. -Obsługiwane formaty natywne i platformy zależą od dostawcy analizy. Zobacz [konfigurację Hoppera i Ghidry](docs/installation.md#hopper), [przewodnik IDA](docs/ida-provider.md) oraz [eksperymentalną obsługę Ghidry w systemie Windows](docs/windows-ghidra-p0.md). Ghidra obsługuje też [analizę 16-bitowego DOS-a](docs/ghidra-dos.md). Wybór dostawcy opisuje [przewodnik CLI](docs/cli.md#choose-a-provider). Sprawdź [dostępność w wydaniach](docs/installation.md#released-package-and-main) dla funkcji dodanych po najnowszym wydaniu npm. +Obsługiwane formaty natywne i platformy zależą od dostawcy analizy. Zobacz [konfigurację Hoppera i Ghidry](../installation.md#hopper), [przewodnik IDA](../ida-provider.md) oraz [eksperymentalną obsługę Ghidry w systemie Windows](../windows-ghidra-p0.md). Ghidra obsługuje też [analizę 16-bitowego DOS-a](../ghidra-dos.md). Wybór dostawcy opisuje [przewodnik CLI](../cli.md#choose-a-provider). Sprawdź [dostępność w wydaniach](../installation.md#released-package-and-main) dla funkcji dodanych po najnowszym wydaniu npm. ## Przykłady zastosowań -[![Ilustracje studiów przypadku panoramowania dźwięku w DX-Ball, mostka schowka w Notion i pierścienia pocisków w TH04](docs/assets/rea-showcases.png)](https://rea.tools/pl/showcase/) +[![Ilustracje studiów przypadku panoramowania dźwięku w DX-Ball, mostka schowka w Notion i pierścienia pocisków w TH04](../assets/rea-showcases.png)](https://rea.tools/pl/showcase/) ### DX-Ball: odtworzenie obliczania panoramy dźwięku @@ -187,28 +187,28 @@ Jeśli użyłeś REA do czegoś ciekawego, chętnie to zobaczymy. Opisz swój pr
Którzy agenci mogą korzystać z REA? -Każdy agent obsługujący lokalne serwery MCP. Konfigurator konfiguruje [obsługiwanych agentów](docs/installation.md#supported-agents); inne klienty mogą użyć [ręcznej rejestracji MCP](docs/installation.md#mcp-registry). +Każdy agent obsługujący lokalne serwery MCP. Konfigurator konfiguruje [obsługiwanych agentów](../installation.md#supported-agents); inne klienty mogą użyć [ręcznej rejestracji MCP](../installation.md#mcp-registry).
Czy potrzebuję Hoppera, Ghidry lub IDA? -Dogłębna analiza kodu natywnego używa jednego z nich. Statyczna analiza JavaScript i .NET działa bez silnika analizy natywnej. Konfigurator może zainstalować Hoppera po uzyskaniu zgody; Ghidra i IDA korzystają z istniejących instalacji. Zobacz [konfigurację dostawców analizy](docs/installation.md#hopper). +Dogłębna analiza kodu natywnego używa jednego z nich. Statyczna analiza JavaScript i .NET działa bez silnika analizy natywnej. Konfigurator może zainstalować Hoppera po uzyskaniu zgody; Ghidra i IDA korzystają z istniejących instalacji. Zobacz [konfigurację dostawców analizy](../installation.md#hopper).
Czy muszę najpierw uruchomić Hoppera? -REA uruchamia Hoppera, gdy wymaga tego operacja. W systemie macOS przy pierwszym uruchomieniu okno dialogowe może poprosić o wybór trybu demonstracyjnego lub aktywację licencji. Zobacz [uruchamianie Hoppera i rozwiązywanie problemów](docs/installation.md#launcher-paths-and-troubleshooting). +REA uruchamia Hoppera, gdy wymaga tego operacja. W systemie macOS przy pierwszym uruchomieniu okno dialogowe może poprosić o wybór trybu demonstracyjnego lub aktywację licencji. Zobacz [uruchamianie Hoppera i rozwiązywanie problemów](../installation.md#launcher-paths-and-troubleshooting).
Co daje zainstalowanie umiejętności z skills.sh? -Umiejętność dostarcza agentowi instrukcje prowadzenia analizy. Użyj `npx rea-agents setup`, aby zarejestrować serwer MCP REA i zainstalować pasujące instrukcje, a następnie uruchom agenta ponownie. Zobacz [instalację samej umiejętności](docs/installation.md#skill-only-installation). +Umiejętność dostarcza agentowi instrukcje prowadzenia analizy. Użyj `npx rea-agents setup`, aby zarejestrować serwer MCP REA i zainstalować pasujące instrukcje, a następnie uruchom agenta ponownie. Zobacz [instalację samej umiejętności](../installation.md#skill-only-installation).
@@ -251,14 +251,14 @@ Jeśli używasz agenta, wykonaj [odświeżenie konfiguracji](#zaktualizuj-rea) i Zacznij od [przewodników z przykładami](https://rea.tools/pl/guides/) na stronie internetowej. Dokładne opcje, wymagania wstępne i kontrakty wyników opisano tutaj: -- [Instalacja i konfiguracja](docs/installation.md): rejestracja agentów, konfiguracja dostawców analizy, aktualizacje i odinstalowanie. -- [Gotowość i rozwiązywanie problemów](docs/installation.md#check-readiness-for-your-task): diagnostyka jednego agenta lub silnika analizy. -- [CLI i Evidence](docs/cli.md): polecenia, wybór dostawcy, migawki, import/eksport i kody zakończenia. -- [Kontrakty MCP](docs/mcp-contracts.md) i [prompty dla agentów](docs/mcp-prompts.md): wyniki narzędzi, sesje i prowadzone analizy. -- [Katalog narzędzi](docs/mcp-contracts.md#generated-catalog): generowany podczas kompilacji wykaz narzędzi, dostawców i poleceń CLI. -- [Plan rozwoju](docs/roadmap.md): planowane prace i śledzenie funkcjonalności. +- [Instalacja i konfiguracja](../installation.md): rejestracja agentów, konfiguracja dostawców analizy, aktualizacje i odinstalowanie. +- [Gotowość i rozwiązywanie problemów](../installation.md#check-readiness-for-your-task): diagnostyka jednego agenta lub silnika analizy. +- [CLI i Evidence](../cli.md): polecenia, wybór dostawcy, migawki, import/eksport i kody zakończenia. +- [Kontrakty MCP](../mcp-contracts.md) i [prompty dla agentów](../mcp-prompts.md): wyniki narzędzi, sesje i prowadzone analizy. +- [Katalog narzędzi](../mcp-contracts.md#generated-catalog): generowany podczas kompilacji wykaz narzędzi, dostawców i poleceń CLI. +- [Plan rozwoju](../roadmap.md): planowane prace i śledzenie funkcjonalności. -Zgłaszaj podatności zgodnie z [SECURITY.md](SECURITY.md). +Zgłaszaj podatności zgodnie z [SECURITY.md](../../SECURITY.md). ## Historia gwiazdek @@ -276,7 +276,7 @@ Dziękujemy wszystkim, którzy używają REA, zgłaszają błędy, proponują no ## Zastrzeżenie -Wskazówki dotyczące odpowiedzialnego korzystania znajdziesz w [DISCLAIMER.md](DISCLAIMER.md). +Wskazówki dotyczące odpowiedzialnego korzystania znajdziesz w [DISCLAIMER.md](../../DISCLAIMER.md). REA służy zgodnym z prawem badaniom, edukacji, zapewnianiu interoperacyjności, zachowaniu oprogramowania oraz analizie oprogramowania, które posiadasz lub masz prawo badać. @@ -288,14 +288,14 @@ applicable licenses. Chętnie przyjmiemy Twoją pomoc w rozwoju REA! [Otwórz zgłoszenie](https://github.com/morluto/rea/issues), aby poinformować o błędzie lub zaproponować funkcję, albo [wyślij pull request](https://github.com/morluto/rea/pulls) z ulepszeniami kodu lub dokumentacji. -Zobacz [CONTRIBUTING.md](CONTRIBUTING.md), aby poznać konfigurację środowiska programistycznego i kontrole, [testowanie](docs/testing.md), aby poznać ścieżki weryfikacji, oraz [mapę architektury](docs/architecture.mermaid), aby poznać strukturę projektu. +Zobacz [CONTRIBUTING.md](../../CONTRIBUTING.md), aby poznać konfigurację środowiska programistycznego i kontrole, [testowanie](../testing.md), aby poznać ścieżki weryfikacji, oraz [mapę architektury](../architecture.mermaid), aby poznać strukturę projektu. ## Licencja -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_pt-BR.md b/docs/readme/README_pt-BR.md similarity index 75% rename from README_pt-BR.md rename to docs/readme/README_pt-BR.md index dd138f7ff..6c3f34e35 100644 --- a/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · **Português (Brasil)** · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · **Português (Brasil)** · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Faça engenharia reversa de qualquer coisa @@ -9,10 +9,10 @@ **Viu uma funcionalidade de que gostou? Entenda como ela funciona, até o nível binário.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA inicia sua ponte de análise dentro do Hopper enquanto inspeciona um binário nativo +REA inicia sua ponte de análise dentro do Hopper enquanto inspeciona um binário nativo

@@ -33,7 +33,7 @@ - Discord
+ Discord
Participe da comunidade de engenharia reversa

Discord · Perguntas e respostas · Compartilhe seus resultados @@ -67,7 +67,7 @@ npx rea-agents setup Escolha seus agentes, revise as alterações propostas e aprove-as. A configuração adiciona o servidor MCP do REA e as instruções de fluxo de trabalho correspondentes, com backups das configurações existentes. Reinicie seu agente depois. -A configuração oferece suporte ao Claude Code, Codex, Cursor, Gemini CLI, Grok Build e [outros agentes](docs/installation.md#supported-agents). Consulte [instalação e configuração](docs/installation.md) para configurar provedores e registrar o MCP manualmente. +A configuração oferece suporte ao Claude Code, Codex, Cursor, Gemini CLI, Grok Build e [outros agentes](../installation.md#supported-agents). Consulte [instalação e configuração](../installation.md) para configurar provedores e registrar o MCP manualmente. ### Peça ao seu agente @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Para análise nativa, configure primeiro um provedor. Consulte o [guia de CLI e Evidence](docs/cli.md) para comandos nativos, seleção de provedores, snapshots e uso em scripts. +Para análise nativa, configure primeiro um provedor. Consulte o [guia de CLI e Evidence](../cli.md) para comandos nativos, seleção de provedores, snapshots e uso em scripts. ### Atualize o REA @@ -120,9 +120,9 @@ Revise as alterações de configuração e reinicie seu agente. Para comandos av Seu agente chama o REA por MCP para inspecionar o alvo e rastrear o código relevante. O REA retorna os resultados com suas evidências. O agente usa esses resultados para fazer novas perguntas, explicar o comportamento ou escrever e testar uma implementação. Os comandos da CLI usam os mesmos fluxos de trabalho. -![Fluxo de investigação do REA: seu agente pergunta sobre um alvo local, o REA o inspeciona e rastreia com ferramentas de análise, e o agente usa o código, as referências e os pontos desconhecidos retornados para explicar, implementar e testar.](website/public/assets/figures/rea-investigation-flow.svg) +![Fluxo de investigação do REA: seu agente pergunta sobre um alvo local, o REA o inspeciona e rastreia com ferramentas de análise, e o agente usa o código, as referências e os pontos desconhecidos retornados para explicar, implementar e testar.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Abra a figura em tamanho completo](website/public/assets/figures/rea-investigation-flow.svg). +[Abra a figura em tamanho completo](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ Seu agente chama o REA por MCP para inspecionar o alvo e rastrear o código rele O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As ferramentas adicionais e os sistemas anfitriões compatíveis dependem do alvo: -| Alvo | O que o REA retorna | Requisitos e guia | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](https://rea.tools/pt-BR/guides/native/) | -| Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](docs/binary-diagnostics.md) | -| Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](docs/evm-bytecode.md) | -| Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](docs/recorded-crashes.md) | -| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) | -| Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) | -| Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](docs/web-network-captures.md) | -| Capturas de pacotes | Endpoints, DNS, TLS SNI, distribuição de protocolos e requisições HTTP | tshark e capinfos fornecidos pelo chamador; [guia pcap](docs/pcap-analysis.md) | -| Assemblies .NET | Metadados, instruções CIL, dependências nativas declaradas e comparações de builds | Inspeção estática; [guia de código gerenciado](docs/managed-code-analysis.md) | -| APKs Android | Declarações do manifesto, classes, métodos descompilados e referências | JADX sem interface gráfica e um JDK completo no Linux/macOS/Windows x64; [guia de Android](docs/android-analysis.md) | -| Dispositivos Android | Dispositivos, pacotes, processos, logs, despejos, capturas de tela e transferências de APK/arquivos | adb fornecido pelo chamador (emulador ou aparelho); [guia de ADB](docs/adb-device-analysis.md) | -| Projetos JEB | Unidades de projeto do motor, hashes de artefatos e pseudocódigo de tipos/métodos | Um cliente JEB iniciado pelo chamador servindo MCP, selecionado com REA_JEB_MCP_URL; [guia de JEB](docs/jeb-analysis.md) | -| Recursos do Android | Manifesto decodificado, tabelas de strings, idiomas e fatos de versão | Lançador do Apktool fornecido pelo chamador; [guia de recursos](docs/apktool-resource-analysis.md) | -| Aplicativos Flutter | Hash de snapshot Dart, hashes de bibliotecas por ABI e procedência de build do motor | Nenhuma (análise estática de APK); [guia Flutter](docs/flutter-analysis.md) | -| Firmware | Regiões, resultados de extração e encaminhamentos à análise nativa | Binwalk / Unblob no Linux; [guia de firmware](docs/firmware-analysis.md) | -| Pacotes gravados | Campos selecionados, valores repetidos, filtros e regras de decodificação | Linux: TShark 4.x + prlimit; [guia de capturas](docs/packet-captures.md) | -| Sinais lógicos gravados | Anotações do decodificador, intervalos de amostras e diagnósticos | Linux: sigrok-cli 0.7.2 + prlimit; [guia de sinais](docs/signal-recordings.md) | -| Pacotes e recursos | Inventários de arquivos, digests, plists, estrutura de bundles Apple e recursos extraídos | [Guia de artefatos e JavaScript](docs/javascript-artifact-reconstruction.md), [aplicativos Apple](docs/apple-application-analysis.md) | -| Comportamento de processos | Saída do terminal, interações, observações de encerramento e do sistema de arquivos, e comparações entre execuções | Linux/macOS com PTY nativo; [captura de processos](docs/process-capture.md) | +| Alvo | O que o REA retorna | Requisitos e guia | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](https://rea.tools/pt-BR/guides/native/) | +| Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) | +| Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) | +| Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) | +| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) | +| Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) | +| Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](../web-network-captures.md) | +| Capturas de pacotes | Endpoints, DNS, TLS SNI, distribuição de protocolos e requisições HTTP | tshark e capinfos fornecidos pelo chamador; [guia pcap](../pcap-analysis.md) | +| Assemblies .NET | Metadados, instruções CIL, dependências nativas declaradas e comparações de builds | Inspeção estática; [guia de código gerenciado](../managed-code-analysis.md) | +| APKs Android | Declarações do manifesto, classes, métodos descompilados e referências | JADX sem interface gráfica e um JDK completo no Linux/macOS/Windows x64; [guia de Android](../android-analysis.md) | +| Dispositivos Android | Dispositivos, pacotes, processos, logs, despejos, capturas de tela e transferências de APK/arquivos | adb fornecido pelo chamador (emulador ou aparelho); [guia de ADB](../adb-device-analysis.md) | +| Projetos JEB | Unidades de projeto do motor, hashes de artefatos e pseudocódigo de tipos/métodos | Um cliente JEB iniciado pelo chamador servindo MCP, selecionado com REA_JEB_MCP_URL; [guia de JEB](../jeb-analysis.md) | +| Recursos do Android | Manifesto decodificado, tabelas de strings, idiomas e fatos de versão | Lançador do Apktool fornecido pelo chamador; [guia de recursos](../apktool-resource-analysis.md) | +| Aplicativos Flutter | Hash de snapshot Dart, hashes de bibliotecas por ABI e procedência de build do motor | Nenhuma (análise estática de APK); [guia Flutter](../flutter-analysis.md) | +| Firmware | Regiões, resultados de extração e encaminhamentos à análise nativa | Binwalk / Unblob no Linux; [guia de firmware](../firmware-analysis.md) | +| Pacotes gravados | Campos selecionados, valores repetidos, filtros e regras de decodificação | Linux: TShark 4.x + prlimit; [guia de capturas](../packet-captures.md) | +| Sinais lógicos gravados | Anotações do decodificador, intervalos de amostras e diagnósticos | Linux: sigrok-cli 0.7.2 + prlimit; [guia de sinais](../signal-recordings.md) | +| Pacotes e recursos | Inventários de arquivos, digests, plists, estrutura de bundles Apple e recursos extraídos | [Guia de artefatos e JavaScript](../javascript-artifact-reconstruction.md), [aplicativos Apple](../apple-application-analysis.md) | +| Comportamento de processos | Saída do terminal, interações, observações de encerramento e do sistema de arquivos, e comparações entre execuções | Linux/macOS com PTY nativo; [captura de processos](../process-capture.md) | A inspeção estática de JavaScript e .NET lê os arquivos fornecidos sem executar o aplicativo. A captura em execução inicia o alvo selecionado ou interage com ele usando as permissões do seu usuário; cada guia descreve seus efeitos. -Os formatos nativos e os sistemas anfitriões compatíveis variam por provedor. Consulte [configuração do Hopper e Ghidra](docs/installation.md#hopper), o [guia do IDA](docs/ida-provider.md) e o [suporte experimental ao Ghidra no Windows](docs/windows-ghidra-p0.md). O Ghidra também oferece [análise de DOS de 16 bits](docs/ghidra-dos.md). Para selecionar um provedor, consulte o [guia da CLI](docs/cli.md#choose-a-provider). Verifique a [disponibilidade nas versões](docs/installation.md#released-package-and-main) das funcionalidades adicionadas após a versão mais recente no npm. +Os formatos nativos e os sistemas anfitriões compatíveis variam por provedor. Consulte [configuração do Hopper e Ghidra](../installation.md#hopper), o [guia do IDA](../ida-provider.md) e o [suporte experimental ao Ghidra no Windows](../windows-ghidra-p0.md). O Ghidra também oferece [análise de DOS de 16 bits](../ghidra-dos.md). Para selecionar um provedor, consulte o [guia da CLI](../cli.md#choose-a-provider). Verifique a [disponibilidade nas versões](../installation.md#released-package-and-main) das funcionalidades adicionadas após a versão mais recente no npm. ## Casos de uso -[![Ilustrações dos estudos de panoramização de áudio do DX-Ball, da ponte da área de transferência do Notion e do anel de projéteis do TH04](docs/assets/rea-showcases.png)](https://rea.tools/pt-BR/showcase/) +[![Ilustrações dos estudos de panoramização de áudio do DX-Ball, da ponte da área de transferência do Notion e do anel de projéteis do TH04](../assets/rea-showcases.png)](https://rea.tools/pt-BR/showcase/) ### DX-Ball: reconstruir um cálculo de panorâmica de áudio @@ -187,28 +187,28 @@ Se você usou o REA em algo interessante, gostaríamos de conhecer. Compartilhe
Quais agentes podem usar o REA? -Qualquer agente que ofereça suporte a servidores MCP locais. A configuração prepara os [agentes compatíveis](docs/installation.md#supported-agents); outros clientes podem usar o [registro manual do MCP](docs/installation.md#mcp-registry). +Qualquer agente que ofereça suporte a servidores MCP locais. A configuração prepara os [agentes compatíveis](../installation.md#supported-agents); outros clientes podem usar o [registro manual do MCP](../installation.md#mcp-registry).
Preciso do Hopper, Ghidra ou IDA? -A análise nativa aprofundada usa um desses motores. A inspeção estática de JavaScript e .NET funciona sem um motor de análise nativa. A configuração pode instalar o Hopper após aprovação; Ghidra e IDA usam as instalações existentes. Consulte [configuração dos provedores](docs/installation.md#hopper). +A análise nativa aprofundada usa um desses motores. A inspeção estática de JavaScript e .NET funciona sem um motor de análise nativa. A configuração pode instalar o Hopper após aprovação; Ghidra e IDA usam as instalações existentes. Consulte [configuração dos provedores](../installation.md#hopper).
Preciso iniciar o Hopper antes? -O REA inicia o Hopper quando uma operação precisa dele. No macOS, um diálogo na primeira execução pode pedir que você escolha o modo de demonstração ou ative a licença. Consulte [inicialização do Hopper e solução de problemas](docs/installation.md#launcher-paths-and-troubleshooting). +O REA inicia o Hopper quando uma operação precisa dele. No macOS, um diálogo na primeira execução pode pedir que você escolha o modo de demonstração ou ative a licença. Consulte [inicialização do Hopper e solução de problemas](../installation.md#launcher-paths-and-troubleshooting).
O que faz a instalação da skill pelo skills.sh? -A skill fornece instruções de investigação ao seu agente. Use `npx rea-agents setup` para registrar o servidor MCP do REA e instalar as instruções correspondentes, depois reinicie seu agente. Consulte [instalação apenas da skill](docs/installation.md#skill-only-installation). +A skill fornece instruções de investigação ao seu agente. Use `npx rea-agents setup` para registrar o servidor MCP do REA e instalar as instruções correspondentes, depois reinicie seu agente. Consulte [instalação apenas da skill](../installation.md#skill-only-installation).
@@ -251,14 +251,14 @@ Se você usa um agente, conclua a [atualização da configuração](#atualize-o- Comece pelos [guias práticos](https://rea.tools/pt-BR/guides/) do site. Para opções exatas, pré-requisitos e contratos dos resultados: -- [Instalação e configuração](docs/installation.md): registro de agentes, configuração de provedores, atualizações e desinstalação. -- [Prontidão e solução de problemas](docs/installation.md#check-readiness-for-your-task): diagnóstico de um agente ou motor de análise específico. -- [CLI e Evidence](docs/cli.md): comandos, seleção de provedores, snapshots, importação/exportação e status de saída. -- [Contratos MCP](docs/mcp-contracts.md) e [prompts para agentes](docs/mcp-prompts.md): resultados das ferramentas, sessões e investigações guiadas. -- [Catálogo de ferramentas](docs/mcp-contracts.md#generated-catalog): inventário de ferramentas, provedores e comandos da CLI gerado durante o build. -- [Roadmap](docs/roadmap.md): trabalho planejado e acompanhamento das capacidades. +- [Instalação e configuração](../installation.md): registro de agentes, configuração de provedores, atualizações e desinstalação. +- [Prontidão e solução de problemas](../installation.md#check-readiness-for-your-task): diagnóstico de um agente ou motor de análise específico. +- [CLI e Evidence](../cli.md): comandos, seleção de provedores, snapshots, importação/exportação e status de saída. +- [Contratos MCP](../mcp-contracts.md) e [prompts para agentes](../mcp-prompts.md): resultados das ferramentas, sessões e investigações guiadas. +- [Catálogo de ferramentas](../mcp-contracts.md#generated-catalog): inventário de ferramentas, provedores e comandos da CLI gerado durante o build. +- [Roadmap](../roadmap.md): trabalho planejado e acompanhamento das capacidades. -Relate vulnerabilidades conforme [SECURITY.md](SECURITY.md). +Relate vulnerabilidades conforme [SECURITY.md](../../SECURITY.md). ## Histórico de estrelas @@ -276,7 +276,7 @@ Obrigado a todos que usam o REA, relatam bugs, sugerem funcionalidades, testam b ## Aviso legal -Consulte [DISCLAIMER.md](DISCLAIMER.md) para orientações sobre o uso responsável. +Consulte [DISCLAIMER.md](../../DISCLAIMER.md) para orientações sobre o uso responsável. O REA destina-se à pesquisa, à educação, à interoperabilidade, à preservação de software e à análise de software que você possui ou está autorizado a examinar, dentro da lei. @@ -288,14 +288,14 @@ applicable licenses. Sua ajuda com o REA é bem-vinda! [Abra uma issue](https://github.com/morluto/rea/issues) para relatar um bug ou sugerir uma funcionalidade, ou [envie um pull request](https://github.com/morluto/rea/pulls) para melhorar o código ou a documentação. -Consulte [CONTRIBUTING.md](CONTRIBUTING.md) para configurar o ambiente de desenvolvimento e as verificações, o [guia de testes](docs/testing.md) para os fluxos de verificação e o [mapa da arquitetura](docs/architecture.mermaid) para a estrutura do projeto. +Consulte [CONTRIBUTING.md](../../CONTRIBUTING.md) para configurar o ambiente de desenvolvimento e as verificações, o [guia de testes](../testing.md) para os fluxos de verificação e o [mapa da arquitetura](../architecture.mermaid) para a estrutura do projeto. ## Licença -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_ru.md b/docs/readme/README_ru.md similarity index 80% rename from README_ru.md rename to docs/readme/README_ru.md index 34e7d3af2..744e26f6c 100644 --- a/README_ru.md +++ b/docs/readme/README_ru.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · **Русский** · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · **Русский** · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Реверс-инжиниринг чего угодно @@ -9,10 +9,10 @@ **Нашли интересную функцию? Разберитесь, как она работает, вплоть до двоичного кода.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA запускает мост анализа внутри Hopper при исследовании нативного бинарного файла +REA запускает мост анализа внутри Hopper при исследовании нативного бинарного файла

@@ -33,7 +33,7 @@ - Discord
+ Discord
Присоединяйтесь к сообществу реверс-инжиниринга

Discord · Вопросы и ответы · Обмен результатами @@ -67,7 +67,7 @@ npx rea-agents setup Выберите агентов, проверьте предлагаемые изменения и подтвердите их. Настройка добавляет MCP-сервер REA и соответствующие рабочие инструкции, сохраняя резервные копии существующих настроек. После этого перезапустите агента. -Настройка поддерживает Claude Code, Codex, Cursor, Gemini CLI, Grok Build и [других агентов](docs/installation.md#supported-agents). Настройка провайдеров и ручная регистрация MCP описаны в разделе [установка и настройка](docs/installation.md). +Настройка поддерживает Claude Code, Codex, Cursor, Gemini CLI, Grok Build и [других агентов](../installation.md#supported-agents). Настройка провайдеров и ручная регистрация MCP описаны в разделе [установка и настройка](../installation.md). ### Задайте вопрос агенту @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Для нативного анализа сначала настройте провайдер. Нативные команды, выбор провайдера, снимки и работу со скриптами описывает [руководство по CLI и Evidence](docs/cli.md). +Для нативного анализа сначала настройте провайдер. Нативные команды, выбор провайдера, снимки и работу со скриптами описывает [руководство по CLI и Evidence](../cli.md). ### Обновите REA @@ -120,9 +120,9 @@ npx rea-agents@latest setup Агент вызывает REA через MCP, чтобы исследовать цель и проследить связанный код. REA возвращает результаты вместе с подтверждающими данными. Агент использует их для уточняющих вопросов, объяснения поведения или написания и тестирования реализации. Команды CLI используют те же рабочие процессы. -![Процесс исследования с REA: агент задаёт вопрос о локальной цели, REA исследует её инструментами анализа, а агент использует возвращённый код, ссылки и неизвестные сведения для объяснения, реализации и тестирования.](website/public/assets/figures/rea-investigation-flow.svg) +![Процесс исследования с REA: агент задаёт вопрос о локальной цели, REA исследует её инструментами анализа, а агент использует возвращённый код, ссылки и неизвестные сведения для объяснения, реализации и тестирования.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Открыть рисунок в полном размере](website/public/assets/figures/rea-investigation-flow.svg). +[Открыть рисунок в полном размере](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ npx rea-agents@latest setup REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а также npm. Дополнительные инструменты и поддерживаемые системы зависят от цели: -| Цель | Что возвращает REA | Требования и руководство | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) | -| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](docs/binary-diagnostics.md) | -| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](docs/evm-bytecode.md) | -| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](docs/recorded-crashes.md) | -| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) | -| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) | -| Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](docs/web-network-captures.md) | -| Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](docs/managed-code-analysis.md) | -| Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](docs/android-analysis.md) | -| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](docs/adb-device-analysis.md) | -| Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](docs/jeb-analysis.md) | -| Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](docs/apktool-resource-analysis.md) | -| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](docs/flutter-analysis.md) | -| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](docs/firmware-analysis.md) | -| Записанные пакеты | Выбранные поля, повторяющиеся значения, фильтры и правила декодирования | Linux: TShark 4.x + prlimit; [руководство по захватам](docs/packet-captures.md) | -| Захват пакетов | Конечные точки, DNS, TLS SNI, распределение протоколов и HTTP-запросы | Предоставленные вызывающей стороной tshark и capinfos; [руководство по pcap](docs/pcap-analysis.md) | -| Записанные логические сигналы | Аннотации декодера, диапазоны отсчётов и диагностика | Linux: sigrok-cli 0.7.2 + prlimit; [руководство по сигналам](docs/signal-recordings.md) | -| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](docs/javascript-artifact-reconstruction.md), [приложения Apple](docs/apple-application-analysis.md) | -| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](docs/process-capture.md) | +| Цель | Что возвращает REA | Требования и руководство | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) | +| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) | +| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) | +| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | +| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) | +| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) | +| Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](../web-network-captures.md) | +| Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](../managed-code-analysis.md) | +| Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](../android-analysis.md) | +| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](../adb-device-analysis.md) | +| Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](../jeb-analysis.md) | +| Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](../apktool-resource-analysis.md) | +| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](../flutter-analysis.md) | +| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](../firmware-analysis.md) | +| Записанные пакеты | Выбранные поля, повторяющиеся значения, фильтры и правила декодирования | Linux: TShark 4.x + prlimit; [руководство по захватам](../packet-captures.md) | +| Захват пакетов | Конечные точки, DNS, TLS SNI, распределение протоколов и HTTP-запросы | Предоставленные вызывающей стороной tshark и capinfos; [руководство по pcap](../pcap-analysis.md) | +| Записанные логические сигналы | Аннотации декодера, диапазоны отсчётов и диагностика | Linux: sigrok-cli 0.7.2 + prlimit; [руководство по сигналам](../signal-recordings.md) | +| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](../javascript-artifact-reconstruction.md), [приложения Apple](../apple-application-analysis.md) | +| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](../process-capture.md) | Статическое исследование JavaScript и .NET читает предоставленные файлы, не запуская приложение. Захват во время выполнения запускает выбранную цель или взаимодействует с ней с правами вашего пользователя; соответствующие руководства описывают последствия. -Нативные форматы и поддерживаемые системы различаются у разных провайдеров. См. [настройку Hopper и Ghidra](docs/installation.md#hopper), [руководство по IDA](docs/ida-provider.md) и [экспериментальную поддержку Ghidra в Windows](docs/windows-ghidra-p0.md). Ghidra также поддерживает [анализ 16-битного DOS](docs/ghidra-dos.md). Выбор провайдера описан в [руководстве по CLI](docs/cli.md#choose-a-provider). Для функций, добавленных после последней версии в npm, проверьте [доступность в релизах](docs/installation.md#released-package-and-main). +Нативные форматы и поддерживаемые системы различаются у разных провайдеров. См. [настройку Hopper и Ghidra](../installation.md#hopper), [руководство по IDA](../ida-provider.md) и [экспериментальную поддержку Ghidra в Windows](../windows-ghidra-p0.md). Ghidra также поддерживает [анализ 16-битного DOS](../ghidra-dos.md). Выбор провайдера описан в [руководстве по CLI](../cli.md#choose-a-provider). Для функций, добавленных после последней версии в npm, проверьте [доступность в релизах](../installation.md#released-package-and-main). ## Примеры -[![Иллюстрации примеров панорамирования звука в DX-Ball, моста буфера обмена Notion и кольца пуль в TH04](docs/assets/rea-showcases.png)](https://rea.tools/ru/showcase/) +[![Иллюстрации примеров панорамирования звука в DX-Ball, моста буфера обмена Notion и кольца пуль в TH04](../assets/rea-showcases.png)](https://rea.tools/ru/showcase/) ### DX-Ball: восстановление расчёта звуковой панорамы @@ -187,28 +187,28 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
Какие агенты могут использовать REA? -Любой агент с поддержкой локальных MCP-серверов. Настройка конфигурирует [поддерживаемых агентов](docs/installation.md#supported-agents); другие клиенты могут использовать [ручную регистрацию MCP](docs/installation.md#mcp-registry). +Любой агент с поддержкой локальных MCP-серверов. Настройка конфигурирует [поддерживаемых агентов](../installation.md#supported-agents); другие клиенты могут использовать [ручную регистрацию MCP](../installation.md#mcp-registry).
Нужны ли мне Hopper, Ghidra или IDA? -Углублённый нативный анализ использует один из этих инструментов. Статическое исследование JavaScript и .NET работает без движка нативного анализа. Настройка может установить Hopper после подтверждения; Ghidra и IDA используют существующие установки. См. [настройку провайдеров](docs/installation.md#hopper). +Углублённый нативный анализ использует один из этих инструментов. Статическое исследование JavaScript и .NET работает без движка нативного анализа. Настройка может установить Hopper после подтверждения; Ghidra и IDA используют существующие установки. См. [настройку провайдеров](../installation.md#hopper).
Нужно ли сначала запускать Hopper? -REA запускает Hopper, когда он нужен для операции. В macOS при первом запуске может появиться диалог выбора деморежима или активации лицензии. См. [запуск Hopper и устранение неполадок](docs/installation.md#launcher-paths-and-troubleshooting). +REA запускает Hopper, когда он нужен для операции. В macOS при первом запуске может появиться диалог выбора деморежима или активации лицензии. См. [запуск Hopper и устранение неполадок](../installation.md#launcher-paths-and-troubleshooting).
Что даёт установка skill с skills.sh? -Skill предоставляет агенту инструкции по исследованию. Используйте `npx rea-agents setup`, чтобы зарегистрировать MCP-сервер REA и установить соответствующие инструкции, затем перезапустите агента. См. [установку только skill](docs/installation.md#skill-only-installation). +Skill предоставляет агенту инструкции по исследованию. Используйте `npx rea-agents setup`, чтобы зарегистрировать MCP-сервер REA и установить соответствующие инструкции, затем перезапустите агента. См. [установку только skill](../installation.md#skill-only-installation).
@@ -251,14 +251,14 @@ npx rea-agents@latest setup Начните с [практических руководств](https://rea.tools/ru/guides/) на сайте. Точные параметры, требования и контракты результатов описаны здесь: -- [Установка и настройка](docs/installation.md): регистрация агентов, настройка провайдеров, обновление и удаление. -- [Готовность и устранение неполадок](docs/installation.md#check-readiness-for-your-task): диагностика конкретного агента или движка анализа. -- [CLI и Evidence](docs/cli.md): команды, выбор провайдера, снимки, импорт/экспорт и коды завершения. -- [Контракты MCP](docs/mcp-contracts.md) и [промпты агентов](docs/mcp-prompts.md): результаты инструментов, сеансы и исследования с подсказками. -- [Каталог инструментов](docs/mcp-contracts.md#generated-catalog): создаваемый при сборке перечень инструментов, провайдеров и команд CLI. -- [План развития](docs/roadmap.md): запланированные работы и отслеживание возможностей. +- [Установка и настройка](../installation.md): регистрация агентов, настройка провайдеров, обновление и удаление. +- [Готовность и устранение неполадок](../installation.md#check-readiness-for-your-task): диагностика конкретного агента или движка анализа. +- [CLI и Evidence](../cli.md): команды, выбор провайдера, снимки, импорт/экспорт и коды завершения. +- [Контракты MCP](../mcp-contracts.md) и [промпты агентов](../mcp-prompts.md): результаты инструментов, сеансы и исследования с подсказками. +- [Каталог инструментов](../mcp-contracts.md#generated-catalog): создаваемый при сборке перечень инструментов, провайдеров и команд CLI. +- [План развития](../roadmap.md): запланированные работы и отслеживание возможностей. -Сообщайте об уязвимостях согласно [SECURITY.md](SECURITY.md). +Сообщайте об уязвимостях согласно [SECURITY.md](../../SECURITY.md). ## История звёзд @@ -276,7 +276,7 @@ npx rea-agents@latest setup ## Отказ от ответственности -Рекомендации по ответственному использованию приведены в [DISCLAIMER.md](DISCLAIMER.md). +Рекомендации по ответственному использованию приведены в [DISCLAIMER.md](../../DISCLAIMER.md). REA предназначен для законных исследований, обучения, обеспечения совместимости, сохранения программного обеспечения и анализа программ, которыми вы владеете или которые вам разрешено изучать. @@ -288,14 +288,14 @@ applicable licenses. Будем рады вашей помощи с REA! [Откройте issue](https://github.com/morluto/rea/issues), чтобы сообщить об ошибке или предложить функцию, либо [отправьте pull request](https://github.com/morluto/rea/pulls) с улучшениями кода или документации. -Настройка среды разработки и проверки описаны в [CONTRIBUTING.md](CONTRIBUTING.md), направления проверки — в [руководстве по тестированию](docs/testing.md), а структура проекта — на [схеме архитектуры](docs/architecture.mermaid). +Настройка среды разработки и проверки описаны в [CONTRIBUTING.md](../../CONTRIBUTING.md), направления проверки — в [руководстве по тестированию](../testing.md), а структура проекта — на [схеме архитектуры](../architecture.mermaid). ## Лицензия -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_th.md b/docs/readme/README_th.md similarity index 81% rename from README_th.md rename to docs/readme/README_th.md index d07bbb6a3..734b28c86 100644 --- a/README_th.md +++ b/docs/readme/README_th.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · **ไทย** · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · **ไทย** · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Reverse Engineer Anything @@ -9,10 +9,10 @@ **เห็นฟีเจอร์ที่ชอบแล้วอยากรู้ว่าทำงานอย่างไร? เข้าใจได้ลึกถึงระดับไบนารี** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA เริ่มบริดจ์การวิเคราะห์ภายใน Hopper ขณะตรวจสอบไบนารีเนทีฟ +REA เริ่มบริดจ์การวิเคราะห์ภายใน Hopper ขณะตรวจสอบไบนารีเนทีฟ

@@ -33,7 +33,7 @@ - Discord
+ Discord
เข้าร่วมชุมชนวิศวกรรมย้อนกลับ

Discord · ถามตอบ · แบ่งปันผลงาน @@ -67,7 +67,7 @@ npx rea-agents setup เลือกเอเจนต์ ตรวจสอบการเปลี่ยนแปลงที่เสนอ แล้วอนุมัติ ขั้นตอนตั้งค่าจะเพิ่มเซิร์ฟเวอร์ MCP ของ REA และคำแนะนำการทำงานที่ตรงกัน พร้อมสำรองการตั้งค่าที่มีอยู่ จากนั้นเริ่มเอเจนต์ใหม่ -ขั้นตอนตั้งค่ารองรับ Claude Code, Codex, Cursor, Gemini CLI, Grok Build และ[เอเจนต์อื่น ๆ](docs/installation.md#supported-agents) ดู[การติดตั้งและตั้งค่า](docs/installation.md) สำหรับการตั้งค่าผู้ให้บริการวิเคราะห์และการลงทะเบียน MCP ด้วยตนเอง +ขั้นตอนตั้งค่ารองรับ Claude Code, Codex, Cursor, Gemini CLI, Grok Build และ[เอเจนต์อื่น ๆ](../installation.md#supported-agents) ดู[การติดตั้งและตั้งค่า](../installation.md) สำหรับการตั้งค่าผู้ให้บริการวิเคราะห์และการลงทะเบียน MCP ด้วยตนเอง ### ถามเอเจนต์ @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -สำหรับการวิเคราะห์เนทีฟ ให้ตั้งค่าผู้ให้บริการวิเคราะห์ก่อน ดู[คู่มือ CLI และ Evidence](docs/cli.md) สำหรับคำสั่งเนทีฟ การเลือกผู้ให้บริการ สแนปช็อต และการใช้งานในสคริปต์ +สำหรับการวิเคราะห์เนทีฟ ให้ตั้งค่าผู้ให้บริการวิเคราะห์ก่อน ดู[คู่มือ CLI และ Evidence](../cli.md) สำหรับคำสั่งเนทีฟ การเลือกผู้ให้บริการ สแนปช็อต และการใช้งานในสคริปต์ ### อัปเดต REA @@ -120,9 +120,9 @@ npx rea-agents@latest setup เอเจนต์เรียก REA ผ่าน MCP เพื่อตรวจสอบเป้าหมายและติดตามโค้ดที่เกี่ยวข้อง REA ส่งข้อค้นพบกลับมาพร้อมหลักฐาน เอเจนต์ใช้ข้อมูลนี้เพื่อถามคำถามต่อ อธิบายพฤติกรรม หรือเขียนและทดสอบการนำไปใช้งาน คำสั่ง CLI ใช้กระบวนการเดียวกัน -![กระบวนการตรวจสอบด้วย REA: เอเจนต์ถามเกี่ยวกับเป้าหมายบนเครื่อง REA ตรวจสอบและติดตามด้วยเครื่องมือวิเคราะห์ แล้วเอเจนต์ใช้โค้ด การอ้างอิง และสิ่งที่ยังไม่ทราบที่ได้รับกลับมาเพื่ออธิบาย สร้าง และทดสอบ](website/public/assets/figures/rea-investigation-flow.svg) +![กระบวนการตรวจสอบด้วย REA: เอเจนต์ถามเกี่ยวกับเป้าหมายบนเครื่อง REA ตรวจสอบและติดตามด้วยเครื่องมือวิเคราะห์ แล้วเอเจนต์ใช้โค้ด การอ้างอิง และสิ่งที่ยังไม่ทราบที่ได้รับกลับมาเพื่ออธิบาย สร้าง และทดสอบ](../../website/public/assets/figures/rea-investigation-flow.svg) -[เปิดภาพขนาดเต็ม](website/public/assets/figures/rea-investigation-flow.svg) +[เปิดภาพขนาดเต็ม](../../website/public/assets/figures/rea-investigation-flow.svg) @@ -130,37 +130,37 @@ npx rea-agents@latest setup REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26+ และ npm เครื่องมือเพิ่มเติมและแพลตฟอร์มที่รองรับขึ้นอยู่กับเป้าหมาย: -| เป้าหมาย | สิ่งที่ REA ส่งกลับ | ข้อกำหนดและคู่มือ | -| ---------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](https://rea.tools/th/guides/native/) | -| โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](docs/binary-diagnostics.md) | -| ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](docs/evm-bytecode.md) | -| ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](docs/recorded-crashes.md) | -| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) | -| เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) | -| ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](docs/web-network-captures.md) | -| การจับแพ็กเก็ต | จุดเชื่อมต่อ, DNS, TLS SNI, การกระจายโปรโตคอล และคำขอ HTTP | tshark และ capinfos ที่ผู้เรียกจัดเตรียม; [คู่มือ pcap](docs/pcap-analysis.md) | -| แอสเซมบลี .NET | เมทาดาทา คำสั่ง CIL การพึ่งพาเนทีฟที่ประกาศไว้ และการเปรียบเทียบผลการบิลด์ | การตรวจสอบแบบสถิต; [คู่มือโค้ดแบบจัดการ](docs/managed-code-analysis.md) | -| APK ของ Android | การประกาศใน manifest คลาส เมธอดที่ดีคอมไพล์ และการอ้างอิง | JADX แบบไม่มี GUI และ JDK แบบเต็มบน Linux/macOS/Windows x64; [คู่มือ Android](docs/android-analysis.md) | -| อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](docs/adb-device-analysis.md) | -| โปรเจกต์ JEB | หน่วยโปรเจกต์จากเอนจิน, แฮชของอาร์ติแฟกต์ และซูโดโค้ดของไทป์/เมท็อด | ไคลเอนต์ JEB ที่ผู้เรียกเริ่มและให้บริการ MCP เลือกด้วย REA_JEB_MCP_URL; [คู่มือ JEB](docs/jeb-analysis.md) | -| ทรัพยากร Android | ไฟล์ manifest ที่ถอดรหัสแล้ว ตารางสตริง ภาษา และข้อมูลเวอร์ชัน | ตัวเรียก Apktool ที่ผู้เรียกจัดเตรียม; [คู่มือทรัพยากร](docs/apktool-resource-analysis.md) | -| แอป Flutter | แฮชสแนปชอต Dart, แฮชไลบรารีราย ABI และที่มาของบิลด์เอนจิน | ไม่ต้อง (วิเคราะห์ APK แบบ static); [คู่มือ Flutter](docs/flutter-analysis.md) | -| เฟิร์มแวร์ | พื้นที่ข้อมูล ผลการแตกไฟล์ และการส่งต่อไปวิเคราะห์เนทีฟ | Binwalk / Unblob บน Linux; [คู่มือเฟิร์มแวร์](docs/firmware-analysis.md) | -| แพ็กเก็ตที่บันทึกไว้ | ฟิลด์ที่เลือก ค่าซ้ำ ตัวกรอง และกฎการถอดรหัส | Linux: TShark 4.x + prlimit; [คู่มือไฟล์แคปเจอร์](docs/packet-captures.md) | -| สัญญาณลอจิกที่บันทึกไว้ | คำอธิบายจากตัวถอดรหัส ช่วงตัวอย่าง และข้อมูลวินิจฉัย | Linux: sigrok-cli 0.7.2 + prlimit; [คู่มือสัญญาณ](docs/signal-recordings.md) | -| แพ็กเกจและทรัพยากร | รายการไฟล์ ค่าแฮช plist โครงสร้างบันเดิลของ Apple และทรัพยากรที่แตกออกมา | [คู่มืออาร์ติแฟกต์และ JavaScript](docs/javascript-artifact-reconstruction.md), [แอปพลิเคชัน Apple](docs/apple-application-analysis.md) | -| พฤติกรรมของโปรเซส | เอาต์พุตเทอร์มินัล การโต้ตอบ ข้อมูลการสิ้นสุดและระบบไฟล์ที่สังเกตได้ และการเปรียบเทียบการรัน | Linux/macOS ที่มี PTY เนทีฟ; [การบันทึกโปรเซส](docs/process-capture.md) | +| เป้าหมาย | สิ่งที่ REA ส่งกลับ | ข้อกำหนดและคู่มือ | +| ---------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](https://rea.tools/th/guides/native/) | +| โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) | +| ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) | +| ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) | +| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) | +| เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) | +| ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](../web-network-captures.md) | +| การจับแพ็กเก็ต | จุดเชื่อมต่อ, DNS, TLS SNI, การกระจายโปรโตคอล และคำขอ HTTP | tshark และ capinfos ที่ผู้เรียกจัดเตรียม; [คู่มือ pcap](../pcap-analysis.md) | +| แอสเซมบลี .NET | เมทาดาทา คำสั่ง CIL การพึ่งพาเนทีฟที่ประกาศไว้ และการเปรียบเทียบผลการบิลด์ | การตรวจสอบแบบสถิต; [คู่มือโค้ดแบบจัดการ](../managed-code-analysis.md) | +| APK ของ Android | การประกาศใน manifest คลาส เมธอดที่ดีคอมไพล์ และการอ้างอิง | JADX แบบไม่มี GUI และ JDK แบบเต็มบน Linux/macOS/Windows x64; [คู่มือ Android](../android-analysis.md) | +| อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](../adb-device-analysis.md) | +| โปรเจกต์ JEB | หน่วยโปรเจกต์จากเอนจิน, แฮชของอาร์ติแฟกต์ และซูโดโค้ดของไทป์/เมท็อด | ไคลเอนต์ JEB ที่ผู้เรียกเริ่มและให้บริการ MCP เลือกด้วย REA_JEB_MCP_URL; [คู่มือ JEB](../jeb-analysis.md) | +| ทรัพยากร Android | ไฟล์ manifest ที่ถอดรหัสแล้ว ตารางสตริง ภาษา และข้อมูลเวอร์ชัน | ตัวเรียก Apktool ที่ผู้เรียกจัดเตรียม; [คู่มือทรัพยากร](../apktool-resource-analysis.md) | +| แอป Flutter | แฮชสแนปชอต Dart, แฮชไลบรารีราย ABI และที่มาของบิลด์เอนจิน | ไม่ต้อง (วิเคราะห์ APK แบบ static); [คู่มือ Flutter](../flutter-analysis.md) | +| เฟิร์มแวร์ | พื้นที่ข้อมูล ผลการแตกไฟล์ และการส่งต่อไปวิเคราะห์เนทีฟ | Binwalk / Unblob บน Linux; [คู่มือเฟิร์มแวร์](../firmware-analysis.md) | +| แพ็กเก็ตที่บันทึกไว้ | ฟิลด์ที่เลือก ค่าซ้ำ ตัวกรอง และกฎการถอดรหัส | Linux: TShark 4.x + prlimit; [คู่มือไฟล์แคปเจอร์](../packet-captures.md) | +| สัญญาณลอจิกที่บันทึกไว้ | คำอธิบายจากตัวถอดรหัส ช่วงตัวอย่าง และข้อมูลวินิจฉัย | Linux: sigrok-cli 0.7.2 + prlimit; [คู่มือสัญญาณ](../signal-recordings.md) | +| แพ็กเกจและทรัพยากร | รายการไฟล์ ค่าแฮช plist โครงสร้างบันเดิลของ Apple และทรัพยากรที่แตกออกมา | [คู่มืออาร์ติแฟกต์และ JavaScript](../javascript-artifact-reconstruction.md), [แอปพลิเคชัน Apple](../apple-application-analysis.md) | +| พฤติกรรมของโปรเซส | เอาต์พุตเทอร์มินัล การโต้ตอบ ข้อมูลการสิ้นสุดและระบบไฟล์ที่สังเกตได้ และการเปรียบเทียบการรัน | Linux/macOS ที่มี PTY เนทีฟ; [การบันทึกโปรเซส](../process-capture.md) | การตรวจสอบ JavaScript และ .NET แบบสถิตอ่านไฟล์ที่ให้มาโดยไม่รันแอปพลิเคชัน การบันทึกขณะทำงานจะรันหรือโต้ตอบกับเป้าหมายที่เลือกโดยใช้สิทธิ์ของผู้ใช้คุณ คู่มือการทำงานแต่ละฉบับอธิบายผลที่เกิดขึ้นไว้ -รูปแบบไบนารีเนทีฟและแพลตฟอร์มที่รองรับแตกต่างกันตามผู้ให้บริการวิเคราะห์ ดู[การตั้งค่า Hopper และ Ghidra](docs/installation.md#hopper), [คู่มือ IDA](docs/ida-provider.md) และ[การรองรับ Ghidra บน Windows แบบทดลอง](docs/windows-ghidra-p0.md) Ghidra ยังรองรับ[การวิเคราะห์ DOS แบบ 16 บิต](docs/ghidra-dos.md) สำหรับการเลือกผู้ให้บริการ ดู[คู่มือ CLI](docs/cli.md#choose-a-provider) ตรวจสอบ[ความพร้อมใช้งานในแต่ละรุ่น](docs/installation.md#released-package-and-main) สำหรับฟีเจอร์ที่เพิ่มหลังรุ่นล่าสุดบน npm +รูปแบบไบนารีเนทีฟและแพลตฟอร์มที่รองรับแตกต่างกันตามผู้ให้บริการวิเคราะห์ ดู[การตั้งค่า Hopper และ Ghidra](../installation.md#hopper), [คู่มือ IDA](../ida-provider.md) และ[การรองรับ Ghidra บน Windows แบบทดลอง](../windows-ghidra-p0.md) Ghidra ยังรองรับ[การวิเคราะห์ DOS แบบ 16 บิต](../ghidra-dos.md) สำหรับการเลือกผู้ให้บริการ ดู[คู่มือ CLI](../cli.md#choose-a-provider) ตรวจสอบ[ความพร้อมใช้งานในแต่ละรุ่น](../installation.md#released-package-and-main) สำหรับฟีเจอร์ที่เพิ่มหลังรุ่นล่าสุดบน npm ## ตัวอย่างการใช้งาน -[![ภาพประกอบกรณีศึกษาการแพนเสียงใน DX-Ball บริดจ์คลิปบอร์ดของ Notion และวงแหวนกระสุนใน TH04](docs/assets/rea-showcases.png)](https://rea.tools/th/showcase/) +[![ภาพประกอบกรณีศึกษาการแพนเสียงใน DX-Ball บริดจ์คลิปบอร์ดของ Notion และวงแหวนกระสุนใน TH04](../assets/rea-showcases.png)](https://rea.tools/th/showcase/) ### DX-Ball: สร้างการคำนวณแพนเสียงขึ้นใหม่ @@ -187,28 +187,28 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
เอเจนต์ใดใช้ REA ได้บ้าง? -เอเจนต์ใดก็ตามที่รองรับเซิร์ฟเวอร์ MCP บนเครื่อง ขั้นตอนตั้งค่าจะกำหนดค่าให้กับ[เอเจนต์ที่รองรับ](docs/installation.md#supported-agents) ส่วนไคลเอนต์อื่นใช้[การลงทะเบียน MCP ด้วยตนเอง](docs/installation.md#mcp-registry) ได้ +เอเจนต์ใดก็ตามที่รองรับเซิร์ฟเวอร์ MCP บนเครื่อง ขั้นตอนตั้งค่าจะกำหนดค่าให้กับ[เอเจนต์ที่รองรับ](../installation.md#supported-agents) ส่วนไคลเอนต์อื่นใช้[การลงทะเบียน MCP ด้วยตนเอง](../installation.md#mcp-registry) ได้
จำเป็นต้องมี Hopper, Ghidra หรือ IDA หรือไม่? -การวิเคราะห์เนทีฟเชิงลึกต้องใช้หนึ่งในเครื่องมือเหล่านี้ การตรวจสอบ JavaScript และ .NET แบบสถิตทำงานได้โดยไม่มีเอนจินวิเคราะห์เนทีฟ ขั้นตอนตั้งค่าสามารถติดตั้ง Hopper หลังได้รับอนุมัติ ส่วน Ghidra และ IDA ใช้การติดตั้งที่คุณมีอยู่แล้ว ดู[การตั้งค่าผู้ให้บริการวิเคราะห์](docs/installation.md#hopper) +การวิเคราะห์เนทีฟเชิงลึกต้องใช้หนึ่งในเครื่องมือเหล่านี้ การตรวจสอบ JavaScript และ .NET แบบสถิตทำงานได้โดยไม่มีเอนจินวิเคราะห์เนทีฟ ขั้นตอนตั้งค่าสามารถติดตั้ง Hopper หลังได้รับอนุมัติ ส่วน Ghidra และ IDA ใช้การติดตั้งที่คุณมีอยู่แล้ว ดู[การตั้งค่าผู้ให้บริการวิเคราะห์](../installation.md#hopper)
ต้องเริ่ม Hopper ก่อนหรือไม่? -REA เริ่ม Hopper เมื่อการดำเนินการต้องใช้ บน macOS อาจมีหน้าต่างเมื่อรันครั้งแรกให้เลือกโหมดทดลองหรือเปิดใช้ใบอนุญาต ดู[การเริ่ม Hopper และการแก้ไขปัญหา](docs/installation.md#launcher-paths-and-troubleshooting) +REA เริ่ม Hopper เมื่อการดำเนินการต้องใช้ บน macOS อาจมีหน้าต่างเมื่อรันครั้งแรกให้เลือกโหมดทดลองหรือเปิดใช้ใบอนุญาต ดู[การเริ่ม Hopper และการแก้ไขปัญหา](../installation.md#launcher-paths-and-troubleshooting)
การติดตั้งสกิลจาก skills.sh ทำอะไร? -สกิลให้คำแนะนำการตรวจสอบแก่เอเจนต์ ใช้ `npx rea-agents setup` เพื่อลงทะเบียนเซิร์ฟเวอร์ MCP ของ REA และติดตั้งคำแนะนำที่ตรงกัน แล้วเริ่มเอเจนต์ใหม่ ดู[การติดตั้งเฉพาะสกิล](docs/installation.md#skill-only-installation) +สกิลให้คำแนะนำการตรวจสอบแก่เอเจนต์ ใช้ `npx rea-agents setup` เพื่อลงทะเบียนเซิร์ฟเวอร์ MCP ของ REA และติดตั้งคำแนะนำที่ตรงกัน แล้วเริ่มเอเจนต์ใหม่ ดู[การติดตั้งเฉพาะสกิล](../installation.md#skill-only-installation)
@@ -251,14 +251,14 @@ npx rea-agents@latest setup เริ่มจาก[คู่มือพร้อมตัวอย่าง](https://rea.tools/th/guides/) บนเว็บไซต์ สำหรับตัวเลือก ข้อกำหนดเบื้องต้น และสัญญาผลลัพธ์ที่แน่นอน: -- [การติดตั้งและตั้งค่า](docs/installation.md): การลงทะเบียนเอเจนต์ การตั้งค่าผู้ให้บริการ การอัปเดต และการถอนการติดตั้ง -- [ความพร้อมใช้งานและการแก้ไขปัญหา](docs/installation.md#check-readiness-for-your-task): ตรวจวินิจฉัยเอเจนต์หรือเอนจินวิเคราะห์หนึ่งตัว -- [CLI และ Evidence](docs/cli.md): คำสั่ง การเลือกผู้ให้บริการ สแนปช็อต การนำเข้า/ส่งออก และรหัสการสิ้นสุด -- [สัญญา MCP](docs/mcp-contracts.md) และ[พรอมป์ต์สำหรับเอเจนต์](docs/mcp-prompts.md): ผลลัพธ์เครื่องมือ เซสชัน และการตรวจสอบตามคำแนะนำ -- [แค็ตตาล็อกเครื่องมือ](docs/mcp-contracts.md#generated-catalog): รายการเครื่องมือ ผู้ให้บริการ และคำสั่ง CLI ที่สร้างระหว่างการบิลด์ -- [แผนพัฒนา](docs/roadmap.md): งานที่วางแผนไว้และรายการติดตามความสามารถ +- [การติดตั้งและตั้งค่า](../installation.md): การลงทะเบียนเอเจนต์ การตั้งค่าผู้ให้บริการ การอัปเดต และการถอนการติดตั้ง +- [ความพร้อมใช้งานและการแก้ไขปัญหา](../installation.md#check-readiness-for-your-task): ตรวจวินิจฉัยเอเจนต์หรือเอนจินวิเคราะห์หนึ่งตัว +- [CLI และ Evidence](../cli.md): คำสั่ง การเลือกผู้ให้บริการ สแนปช็อต การนำเข้า/ส่งออก และรหัสการสิ้นสุด +- [สัญญา MCP](../mcp-contracts.md) และ[พรอมป์ต์สำหรับเอเจนต์](../mcp-prompts.md): ผลลัพธ์เครื่องมือ เซสชัน และการตรวจสอบตามคำแนะนำ +- [แค็ตตาล็อกเครื่องมือ](../mcp-contracts.md#generated-catalog): รายการเครื่องมือ ผู้ให้บริการ และคำสั่ง CLI ที่สร้างระหว่างการบิลด์ +- [แผนพัฒนา](../roadmap.md): งานที่วางแผนไว้และรายการติดตามความสามารถ -รายงานช่องโหว่ผ่าน [SECURITY.md](SECURITY.md) +รายงานช่องโหว่ผ่าน [SECURITY.md](../../SECURITY.md) ## ประวัติดาว @@ -276,7 +276,7 @@ npx rea-agents@latest setup ## ข้อสงวนสิทธิ์ -ดูแนวทางการใช้งานอย่างรับผิดชอบใน [DISCLAIMER.md](DISCLAIMER.md) +ดูแนวทางการใช้งานอย่างรับผิดชอบใน [DISCLAIMER.md](../../DISCLAIMER.md) REA มีไว้สำหรับการวิจัย การศึกษา การทำงานร่วมกัน การอนุรักษ์ซอฟต์แวร์ และการวิเคราะห์ซอฟต์แวร์ที่คุณเป็นเจ้าของหรือได้รับอนุญาตให้ตรวจสอบ โดยปฏิบัติตามกฎหมาย @@ -288,14 +288,14 @@ applicable licenses. เรายินดีรับความช่วยเหลือในการพัฒนา REA! [เปิด issue](https://github.com/morluto/rea/issues) เพื่อรายงานบั๊กหรือเสนอฟีเจอร์ หรือ[ส่ง pull request](https://github.com/morluto/rea/pulls) เพื่อปรับปรุงโค้ดหรือเอกสาร -ดู [CONTRIBUTING.md](CONTRIBUTING.md) สำหรับการตั้งค่าสภาพแวดล้อมพัฒนาและการตรวจสอบ ดู[การทดสอบ](docs/testing.md) สำหรับแนวทางการตรวจยืนยัน และ[แผนผังสถาปัตยกรรม](docs/architecture.mermaid) สำหรับโครงสร้างโปรเจกต์ +ดู [CONTRIBUTING.md](../../CONTRIBUTING.md) สำหรับการตั้งค่าสภาพแวดล้อมพัฒนาและการตรวจสอบ ดู[การทดสอบ](../testing.md) สำหรับแนวทางการตรวจยืนยัน และ[แผนผังสถาปัตยกรรม](../architecture.mermaid) สำหรับโครงสร้างโปรเจกต์ ## สัญญาอนุญาต -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_tr.md b/docs/readme/README_tr.md similarity index 76% rename from README_tr.md rename to docs/readme/README_tr.md index 1daaaf9d0..dc33a4a38 100644 --- a/README_tr.md +++ b/docs/readme/README_tr.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **Türkçe** · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **Türkçe** · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Her şeyi tersine mühendislikle inceleyin @@ -9,10 +9,10 @@ **Beğendiğiniz bir özellik mi gördünüz? İkili kod düzeyine kadar nasıl çalıştığını anlayın.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA, yerel bir ikili dosyayı incelerken Hopper içinde analiz köprüsünü başlatıyor +REA, yerel bir ikili dosyayı incelerken Hopper içinde analiz köprüsünü başlatıyor

@@ -33,7 +33,7 @@ - Discord
+ Discord
Tersine mühendislik topluluğuna katılın

Discord · Soru-cevap · Sonuç paylaşımı @@ -67,7 +67,7 @@ npx rea-agents setup Ajanlarınızı seçin, önerilen değişiklikleri inceleyin ve onaylayın. Kurulum, mevcut yapılandırmaları yedekleyerek REA'nın MCP sunucusunu ve uyumlu iş akışı talimatlarını ekler. Ardından ajanınızı yeniden başlatın. -Kurulum Claude Code, Codex, Cursor, Gemini CLI, Grok Build ve [diğer ajanları](docs/installation.md#supported-agents) destekler. Sağlayıcı yapılandırması ve elle MCP kaydı için [yükleme ve kurulum](docs/installation.md) belgesine bakın. +Kurulum Claude Code, Codex, Cursor, Gemini CLI, Grok Build ve [diğer ajanları](../installation.md#supported-agents) destekler. Sağlayıcı yapılandırması ve elle MCP kaydı için [yükleme ve kurulum](../installation.md) belgesine bakın. ### Ajanınıza sorun @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Yerel makine kodu analizi için önce bir sağlayıcı yapılandırın. Yerel analiz komutları, sağlayıcı seçimi, anlık görüntüler ve betik kullanımı için [CLI ve Evidence rehberine](docs/cli.md) bakın. +Yerel makine kodu analizi için önce bir sağlayıcı yapılandırın. Yerel analiz komutları, sağlayıcı seçimi, anlık görüntüler ve betik kullanımı için [CLI ve Evidence rehberine](../cli.md) bakın. ### REA'yı güncelleyin @@ -120,9 +120,9 @@ Kurulum değişikliklerini inceleyin ve ajanınızı yeniden başlatın. Tek sef Ajanınız, hedefi incelemek ve ilgili kodu izlemek için MCP üzerinden REA'yı çağırır. REA bulguları kanıtlarıyla birlikte döndürür. Ajan bunları takip soruları sormak, davranışı açıklamak veya bir uygulama yazıp test etmek için kullanır. CLI komutları aynı iş akışlarını kullanır. -![REA araştırma akışı: ajanınız yerel bir hedef hakkında soru sorar, REA analiz araçlarıyla hedefi inceler ve izler; ajan döndürülen kodu, referansları ve bilinmeyenleri açıklama, geliştirme ve test için kullanır.](website/public/assets/figures/rea-investigation-flow.svg) +![REA araştırma akışı: ajanınız yerel bir hedef hakkında soru sorar, REA analiz araçlarıyla hedefi inceler ve izler; ajan döndürülen kodu, referansları ve bilinmeyenleri açıklama, geliştirme ve test için kullanır.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Şekli tam boyutuyla açın](website/public/assets/figures/rea-investigation-flow.svg). +[Şekli tam boyutuyla açın](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ Ajanınız, hedefi incelemek ve ilgili kodu izlemek için MCP üzerinden REA'yı REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araçlar ve desteklenen ana bilgisayar sistemleri hedefe bağlıdır: -| Hedef | REA'nın döndürdükleri | Gereksinimler ve rehber | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Yerel makine kodu içeren ikili dosyalar | Sözde kod, assembly kodu, dizeler, semboller, çağrılar ve referanslar | Hopper, Ghidra veya IDA; [yerel kod analizi](https://rea.tools/tr/guides/native/) | -| Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](docs/binary-diagnostics.md) | -| EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](docs/evm-bytecode.md) | -| Kaydedilmiş Linux çökmeleri | Ham note kayıtları, kaydedilen her iş parçacığının yazmaçları/sinyalleri ve isteğe bağlı eşleme adayları | Linux x64 üzerinde çağıranın sağladığı pwntools; isteğe bağlı GDB/pwndbg; [kaydedilmiş çökmeler](docs/recorded-crashes.md) | -| JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](https://rea.tools/tr/guides/javascript/) | -| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](https://rea.tools/tr/guides/browser/) | -| Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR; mitmproxy'nin kendi biçimindeki yakalamalar için Linux üzerinde mitmdump; [ağ yakalama rehberi](docs/web-network-captures.md) | -| Paket yakalamaları | Uç noktalar, DNS, TLS SNI, protokol dağılımı ve HTTP istekleri | Çağıranın sağladığı tshark ve capinfos; [pcap rehberi](docs/pcap-analysis.md) | -| .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](docs/managed-code-analysis.md) | -| Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](docs/android-analysis.md) | -| Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](docs/adb-device-analysis.md) | -| JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](docs/jeb-analysis.md) | -| Android kaynakları | Çözülmüş manifest, dize tabloları, diller ve sürüm bilgileri | Çağıranın sağladığı Apktool başlatıcısı; [kaynak rehberi](docs/apktool-resource-analysis.md) | -| Flutter uygulamaları | Dart anlık görüntü karması, ABI başına kitaplık özetleri ve motor derleme kökeni | Yok (statik APK çözümleme); [Flutter rehberi](docs/flutter-analysis.md) | -| Donanım yazılımı | Bölgeler, çıkarma sonuçları ve yerel kod analizine devirler | Linux üzerinde Binwalk / Unblob; [donanım yazılımı rehberi](docs/firmware-analysis.md) | -| Kaydedilmiş paketler | Seçilen alanlar, yinelenen değerler, filtreler ve çözümleme kuralları | Linux: TShark 4.x + prlimit; [yakalama kılavuzu](docs/packet-captures.md) | -| Kaydedilmiş mantık sinyalleri | Kod çözücü açıklamaları, örnek aralıkları ve tanılama | Linux: sigrok-cli 0.7.2 + prlimit; [sinyal kılavuzu](docs/signal-recordings.md) | -| Paketler ve kaynaklar | Dosya envanterleri, özet değerleri, plist'ler, Apple paket yapısı ve çıkarılmış kaynaklar | [Artefakt ve JavaScript rehberi](docs/javascript-artifact-reconstruction.md), [Apple uygulamaları](docs/apple-application-analysis.md) | -| Süreç davranışı | Terminal çıktısı, etkileşimler, sonlanma ve dosya sistemi gözlemleri, çalıştırma karşılaştırmaları | Yerel PTY ile Linux/macOS; [süreç yakalama](docs/process-capture.md) | +| Hedef | REA'nın döndürdükleri | Gereksinimler ve rehber | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Yerel makine kodu içeren ikili dosyalar | Sözde kod, assembly kodu, dizeler, semboller, çağrılar ve referanslar | Hopper, Ghidra veya IDA; [yerel kod analizi](https://rea.tools/tr/guides/native/) | +| Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) | +| EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) | +| Kaydedilmiş Linux çökmeleri | Ham note kayıtları, kaydedilen her iş parçacığının yazmaçları/sinyalleri ve isteğe bağlı eşleme adayları | Linux x64 üzerinde çağıranın sağladığı pwntools; isteğe bağlı GDB/pwndbg; [kaydedilmiş çökmeler](../recorded-crashes.md) | +| JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](https://rea.tools/tr/guides/javascript/) | +| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](https://rea.tools/tr/guides/browser/) | +| Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR; mitmproxy'nin kendi biçimindeki yakalamalar için Linux üzerinde mitmdump; [ağ yakalama rehberi](../web-network-captures.md) | +| Paket yakalamaları | Uç noktalar, DNS, TLS SNI, protokol dağılımı ve HTTP istekleri | Çağıranın sağladığı tshark ve capinfos; [pcap rehberi](../pcap-analysis.md) | +| .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](../managed-code-analysis.md) | +| Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](../android-analysis.md) | +| Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](../adb-device-analysis.md) | +| JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](../jeb-analysis.md) | +| Android kaynakları | Çözülmüş manifest, dize tabloları, diller ve sürüm bilgileri | Çağıranın sağladığı Apktool başlatıcısı; [kaynak rehberi](../apktool-resource-analysis.md) | +| Flutter uygulamaları | Dart anlık görüntü karması, ABI başına kitaplık özetleri ve motor derleme kökeni | Yok (statik APK çözümleme); [Flutter rehberi](../flutter-analysis.md) | +| Donanım yazılımı | Bölgeler, çıkarma sonuçları ve yerel kod analizine devirler | Linux üzerinde Binwalk / Unblob; [donanım yazılımı rehberi](../firmware-analysis.md) | +| Kaydedilmiş paketler | Seçilen alanlar, yinelenen değerler, filtreler ve çözümleme kuralları | Linux: TShark 4.x + prlimit; [yakalama kılavuzu](../packet-captures.md) | +| Kaydedilmiş mantık sinyalleri | Kod çözücü açıklamaları, örnek aralıkları ve tanılama | Linux: sigrok-cli 0.7.2 + prlimit; [sinyal kılavuzu](../signal-recordings.md) | +| Paketler ve kaynaklar | Dosya envanterleri, özet değerleri, plist'ler, Apple paket yapısı ve çıkarılmış kaynaklar | [Artefakt ve JavaScript rehberi](../javascript-artifact-reconstruction.md), [Apple uygulamaları](../apple-application-analysis.md) | +| Süreç davranışı | Terminal çıktısı, etkileşimler, sonlanma ve dosya sistemi gözlemleri, çalıştırma karşılaştırmaları | Yerel PTY ile Linux/macOS; [süreç yakalama](../process-capture.md) | Statik JavaScript ve .NET incelemesi, uygulamayı çalıştırmadan sağlanan dosyaları okur. Çalışma zamanı yakalaması, seçilen hedefi kullanıcı izinlerinizle çalıştırır veya onunla etkileşime girer; her çalışma zamanı rehberi etkilerini açıklar. -Yerel makine kodu içeren ikili dosya biçimleri ve ana bilgisayar desteği sağlayıcıya göre değişir. [Hopper ve Ghidra kurulumu](docs/installation.md#hopper), [IDA rehberi](docs/ida-provider.md) ve [Windows için deneysel Ghidra desteği](docs/windows-ghidra-p0.md) belgelerine bakın. Ghidra ayrıca [16 bit DOS analizini](docs/ghidra-dos.md) destekler. Sağlayıcı seçimi için [CLI rehberine](docs/cli.md#choose-a-provider) bakın. En son npm sürümünden sonra eklenen özellikler için [sürümlerde kullanılabilirliği](docs/installation.md#released-package-and-main) kontrol edin. +Yerel makine kodu içeren ikili dosya biçimleri ve ana bilgisayar desteği sağlayıcıya göre değişir. [Hopper ve Ghidra kurulumu](../installation.md#hopper), [IDA rehberi](../ida-provider.md) ve [Windows için deneysel Ghidra desteği](../windows-ghidra-p0.md) belgelerine bakın. Ghidra ayrıca [16 bit DOS analizini](../ghidra-dos.md) destekler. Sağlayıcı seçimi için [CLI rehberine](../cli.md#choose-a-provider) bakın. En son npm sürümünden sonra eklenen özellikler için [sürümlerde kullanılabilirliği](../installation.md#released-package-and-main) kontrol edin. ## Örnek çalışmalar -[![DX-Ball ses kaydırma, Notion pano köprüsü ve TH04 mermi halkası örneklerinin çizimleri](docs/assets/rea-showcases.png)](https://rea.tools/tr/showcase/) +[![DX-Ball ses kaydırma, Notion pano köprüsü ve TH04 mermi halkası örneklerinin çizimleri](../assets/rea-showcases.png)](https://rea.tools/tr/showcase/) ### DX-Ball: Sesin stereo konumlandırma hesabını yeniden oluşturma @@ -187,28 +187,28 @@ REA'yı ilginç bir konuda kullandıysanız görmek isteriz. Hedefi, sorunuzu, R
Hangi ajanlar REA'yı kullanabilir? -Yerel MCP sunucularını destekleyen her ajan REA'yı kullanabilir. Kurulum, [desteklenen ajanları](docs/installation.md#supported-agents) yapılandırır; diğer istemciler [elle MCP kaydını](docs/installation.md#mcp-registry) kullanabilir. +Yerel MCP sunucularını destekleyen her ajan REA'yı kullanabilir. Kurulum, [desteklenen ajanları](../installation.md#supported-agents) yapılandırır; diğer istemciler [elle MCP kaydını](../installation.md#mcp-registry) kullanabilir.
Hopper, Ghidra veya IDA gerekiyor mu? -Derin yerel kod analizi bunlardan birini kullanır. Statik JavaScript ve .NET incelemesi yerel analiz motoru olmadan çalışır. Kurulum onaydan sonra Hopper yükleyebilir; Ghidra ve IDA mevcut kurulumlarınızı kullanır. [Sağlayıcı kurulumuna](docs/installation.md#hopper) bakın. +Derin yerel kod analizi bunlardan birini kullanır. Statik JavaScript ve .NET incelemesi yerel analiz motoru olmadan çalışır. Kurulum onaydan sonra Hopper yükleyebilir; Ghidra ve IDA mevcut kurulumlarınızı kullanır. [Sağlayıcı kurulumuna](../installation.md#hopper) bakın.
Önce Hopper'ı başlatmam gerekiyor mu? -REA, bir işlem gerektirdiğinde Hopper'ı başlatır. macOS'ta ilk çalıştırmada demo modunu seçmenizi veya lisansınızı etkinleştirmenizi isteyen bir iletişim kutusu açılabilir. [Hopper başlatma ve sorun giderme](docs/installation.md#launcher-paths-and-troubleshooting) belgesine bakın. +REA, bir işlem gerektirdiğinde Hopper'ı başlatır. macOS'ta ilk çalıştırmada demo modunu seçmenizi veya lisansınızı etkinleştirmenizi isteyen bir iletişim kutusu açılabilir. [Hopper başlatma ve sorun giderme](../installation.md#launcher-paths-and-troubleshooting) belgesine bakın.
skills.sh üzerinden skill yüklemek ne yapar? -Skill, ajanınıza araştırma talimatları sağlar. REA'nın MCP sunucusunu kaydetmek ve uyumlu talimatları yüklemek için `npx rea-agents setup` kullanın, ardından ajanınızı yeniden başlatın. [Yalnızca skill yükleme](docs/installation.md#skill-only-installation) belgesine bakın. +Skill, ajanınıza araştırma talimatları sağlar. REA'nın MCP sunucusunu kaydetmek ve uyumlu talimatları yüklemek için `npx rea-agents setup` kullanın, ardından ajanınızı yeniden başlatın. [Yalnızca skill yükleme](../installation.md#skill-only-installation) belgesine bakın.
@@ -251,14 +251,14 @@ Ajan kullanıyorsanız [kurulum yenilemesini](#reayı-güncelleyin) tamamlayın Web sitesindeki [uygulamalı rehberlerden](https://rea.tools/tr/guides/) başlayın. Kesin seçenekler, ön koşullar ve sonuç sözleşmeleri için: -- [Yükleme ve kurulum](docs/installation.md): ajan kaydı, sağlayıcı yapılandırması, güncellemeler ve kaldırma. -- [Hazırlık ve sorun giderme](docs/installation.md#check-readiness-for-your-task): belirli bir ajanı veya analiz motorunu tanılama. -- [CLI ve Evidence](docs/cli.md): komutlar, sağlayıcı seçimi, anlık görüntüler, içe/dışa aktarma ve çıkış durumları. -- [MCP sözleşmeleri](docs/mcp-contracts.md) ve [ajan istemleri](docs/mcp-prompts.md): araç sonuçları, oturumlar ve yönlendirmeli araştırmalar. -- [Araç kataloğu](docs/mcp-contracts.md#generated-catalog): derleme sırasında oluşturulan araç, sağlayıcı ve CLI komutu envanteri. -- [Yol haritası](docs/roadmap.md): planlanan çalışmalar ve yetenek takibi. +- [Yükleme ve kurulum](../installation.md): ajan kaydı, sağlayıcı yapılandırması, güncellemeler ve kaldırma. +- [Hazırlık ve sorun giderme](../installation.md#check-readiness-for-your-task): belirli bir ajanı veya analiz motorunu tanılama. +- [CLI ve Evidence](../cli.md): komutlar, sağlayıcı seçimi, anlık görüntüler, içe/dışa aktarma ve çıkış durumları. +- [MCP sözleşmeleri](../mcp-contracts.md) ve [ajan istemleri](../mcp-prompts.md): araç sonuçları, oturumlar ve yönlendirmeli araştırmalar. +- [Araç kataloğu](../mcp-contracts.md#generated-catalog): derleme sırasında oluşturulan araç, sağlayıcı ve CLI komutu envanteri. +- [Yol haritası](../roadmap.md): planlanan çalışmalar ve yetenek takibi. -Güvenlik açıklarını [SECURITY.md](SECURITY.md) üzerinden bildirin. +Güvenlik açıklarını [SECURITY.md](../../SECURITY.md) üzerinden bildirin. ## Yıldız geçmişi @@ -276,7 +276,7 @@ REA'yı kullanan, hata bildiren, özellik talep eden, derlemeleri test eden ve d ## Sorumluluk reddi -Sorumlu kullanım yönergeleri için [DISCLAIMER.md](DISCLAIMER.md) dosyasına bakın. +Sorumlu kullanım yönergeleri için [DISCLAIMER.md](../../DISCLAIMER.md) dosyasına bakın. REA; yasal araştırma, eğitim, birlikte çalışabilirlik, yazılımın korunması ve sahibi olduğunuz ya da inceleme yetkiniz bulunan yazılımların analizi için tasarlanmıştır. @@ -288,14 +288,14 @@ applicable licenses. REA'ya katkınızı bekliyoruz! Hata bildirmek veya özellik önermek için bir [issue açın](https://github.com/morluto/rea/issues); kodu veya belgeleri geliştirmek için bir [pull request gönderin](https://github.com/morluto/rea/pulls). -Geliştirme ortamı ve kontroller için [CONTRIBUTING.md](CONTRIBUTING.md), doğrulama süreçleri için [test rehberi](docs/testing.md) ve proje yapısı için [mimari harita](docs/architecture.mermaid) belgelerine bakın. +Geliştirme ortamı ve kontroller için [CONTRIBUTING.md](../../CONTRIBUTING.md), doğrulama süreçleri için [test rehberi](../testing.md) ve proje yapısı için [mimari harita](../architecture.mermaid) belgelerine bakın. ## Lisans -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_uk.md b/docs/readme/README_uk.md similarity index 80% rename from README_uk.md rename to docs/readme/README_uk.md index 14c3d4984..5c77d80b0 100644 --- a/README_uk.md +++ b/docs/readme/README_uk.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · **Українська** · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · **Українська** · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Реверс-інжиніринг будь-чого @@ -9,10 +9,10 @@ **Побачили цікаву функцію? Зрозумійте, як вона працює, аж до рівня двійкового коду.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA запускає міст аналізу всередині Hopper під час дослідження нативного бінарного файла +REA запускає міст аналізу всередині Hopper під час дослідження нативного бінарного файла

@@ -33,7 +33,7 @@ - Discord
+ Discord
Приєднуйтеся до спільноти реверс-інжинірингу

Discord · Запитання й відповіді · Обмін результатами @@ -67,7 +67,7 @@ npx rea-agents setup Виберіть агентів, перевірте запропоновані зміни та схваліть їх. Налаштування додає MCP-сервер REA та відповідні інструкції робочих процесів, створюючи резервні копії наявної конфігурації. Після цього перезапустіть агента. -Налаштування підтримує Claude Code, Codex, Cursor, Gemini CLI, Grok Build та [інших агентів](docs/installation.md#supported-agents). Конфігурацію провайдерів і ручну реєстрацію MCP описано в розділі [встановлення й налаштування](docs/installation.md). +Налаштування підтримує Claude Code, Codex, Cursor, Gemini CLI, Grok Build та [інших агентів](../installation.md#supported-agents). Конфігурацію провайдерів і ручну реєстрацію MCP описано в розділі [встановлення й налаштування](../installation.md). ### Запитайте агента @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Для нативного аналізу спочатку налаштуйте провайдера. Нативні команди, вибір провайдера, знімки та використання у скриптах описує [посібник CLI та Evidence](docs/cli.md). +Для нативного аналізу спочатку налаштуйте провайдера. Нативні команди, вибір провайдера, знімки та використання у скриптах описує [посібник CLI та Evidence](../cli.md). ### Оновіть REA @@ -120,9 +120,9 @@ npx rea-agents@latest setup Агент викликає REA через MCP, щоб перевірити ціль і простежити пов'язаний код. REA повертає знахідки разом із підтвердженнями. Агент використовує їх для уточнювальних запитань, пояснення поведінки або написання й тестування реалізації. Команди CLI використовують ті самі робочі процеси. -![Процес дослідження з REA: агент запитує про локальну ціль, REA перевіряє й простежує її інструментами аналізу, а агент використовує повернений код, посилання та невідомі дані для пояснення, реалізації й тестування.](website/public/assets/figures/rea-investigation-flow.svg) +![Процес дослідження з REA: агент запитує про локальну ціль, REA перевіряє й простежує її інструментами аналізу, а агент використовує повернений код, посилання та невідомі дані для пояснення, реалізації й тестування.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Відкрити зображення в повному розмірі](website/public/assets/figures/rea-investigation-flow.svg). +[Відкрити зображення в повному розмірі](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ npx rea-agents@latest setup REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а також npm. Додаткові інструменти й підтримувані системи залежать від цілі: -| Ціль | Що повертає REA | Вимоги й посібник | -| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](https://rea.tools/uk/guides/native/) | -| Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](docs/binary-diagnostics.md) | -| Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](docs/evm-bytecode.md) | -| Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](docs/recorded-crashes.md) | -| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) | -| Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) | -| Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](docs/web-network-captures.md) | -| Захоплення пакетів | Кінцеві точки, DNS, TLS SNI, розподіл протоколів та HTTP-запити | Надані викликаючою стороною tshark та capinfos; [підручник pcap](docs/pcap-analysis.md) | -| Збірки .NET | Метадані, інструкції CIL, оголошені нативні залежності й порівняння збірок | Статична перевірка; [посібник керованого коду](docs/managed-code-analysis.md) | -| Android APK | Оголошення маніфесту, класи, декомпільовані методи й посилання | JADX без графічного інтерфейсу й повний JDK у Linux/macOS/Windows x64; [посібник Android](docs/android-analysis.md) | -| Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](docs/adb-device-analysis.md) | -| Проєкти JEB | Єдиниці проєкту від рушія, дайджести артефактів і псевдокод типів/методів | Запущений викликаною стороною клієнт JEB з MCP, обирається через REA_JEB_MCP_URL; [посібник JEB](docs/jeb-analysis.md) | -| Ресурси Android | Декодований маніфест, таблиці рядків, мови та відомості про версію | Засіб запуску Apktool, наданий викликаючою стороною; [підручник ресурсів](docs/apktool-resource-analysis.md) | -| Додатки Flutter | Хеш знімка Dart, дайджести бібліотек за ABI та походження збірки рушія | Не потрібні (статичний розбір APK); [підручник Flutter](docs/flutter-analysis.md) | -| Прошивки | Області, результати видобування й передавання до нативного аналізу | Binwalk / Unblob у Linux; [посібник прошивок](docs/firmware-analysis.md) | -| Записані пакети | Вибрані поля, повторювані значення, фільтри та правила декодування | Linux: TShark 4.x + prlimit; [посібник із записів пакетів](docs/packet-captures.md) | -| Записані логічні сигнали | Анотації декодера, діапазони відліків і діагностика | Linux: sigrok-cli 0.7.2 + prlimit; [посібник із сигналів](docs/signal-recordings.md) | -| Пакети й ресурси | Переліки файлів, хеші, plist, структура пакетів Apple та видобуті ресурси | [Посібник артефактів і JavaScript](docs/javascript-artifact-reconstruction.md), [застосунки Apple](docs/apple-application-analysis.md) | -| Поведінка процесів | Вивід термінала, взаємодії, спостереження за завершенням і файловою системою та порівняння запусків | Linux/macOS із нативним PTY; [захоплення процесів](docs/process-capture.md) | +| Ціль | Що повертає REA | Вимоги й посібник | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](https://rea.tools/uk/guides/native/) | +| Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) | +| Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) | +| Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) | +| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) | +| Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) | +| Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](../web-network-captures.md) | +| Захоплення пакетів | Кінцеві точки, DNS, TLS SNI, розподіл протоколів та HTTP-запити | Надані викликаючою стороною tshark та capinfos; [підручник pcap](../pcap-analysis.md) | +| Збірки .NET | Метадані, інструкції CIL, оголошені нативні залежності й порівняння збірок | Статична перевірка; [посібник керованого коду](../managed-code-analysis.md) | +| Android APK | Оголошення маніфесту, класи, декомпільовані методи й посилання | JADX без графічного інтерфейсу й повний JDK у Linux/macOS/Windows x64; [посібник Android](../android-analysis.md) | +| Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](../adb-device-analysis.md) | +| Проєкти JEB | Єдиниці проєкту від рушія, дайджести артефактів і псевдокод типів/методів | Запущений викликаною стороною клієнт JEB з MCP, обирається через REA_JEB_MCP_URL; [посібник JEB](../jeb-analysis.md) | +| Ресурси Android | Декодований маніфест, таблиці рядків, мови та відомості про версію | Засіб запуску Apktool, наданий викликаючою стороною; [підручник ресурсів](../apktool-resource-analysis.md) | +| Додатки Flutter | Хеш знімка Dart, дайджести бібліотек за ABI та походження збірки рушія | Не потрібні (статичний розбір APK); [підручник Flutter](../flutter-analysis.md) | +| Прошивки | Області, результати видобування й передавання до нативного аналізу | Binwalk / Unblob у Linux; [посібник прошивок](../firmware-analysis.md) | +| Записані пакети | Вибрані поля, повторювані значення, фільтри та правила декодування | Linux: TShark 4.x + prlimit; [посібник із записів пакетів](../packet-captures.md) | +| Записані логічні сигнали | Анотації декодера, діапазони відліків і діагностика | Linux: sigrok-cli 0.7.2 + prlimit; [посібник із сигналів](../signal-recordings.md) | +| Пакети й ресурси | Переліки файлів, хеші, plist, структура пакетів Apple та видобуті ресурси | [Посібник артефактів і JavaScript](../javascript-artifact-reconstruction.md), [застосунки Apple](../apple-application-analysis.md) | +| Поведінка процесів | Вивід термінала, взаємодії, спостереження за завершенням і файловою системою та порівняння запусків | Linux/macOS із нативним PTY; [захоплення процесів](../process-capture.md) | Статична перевірка JavaScript і .NET читає надані файли, не запускаючи застосунок. Захоплення під час виконання запускає вибрану ціль або взаємодіє з нею з правами вашого користувача; відповідні посібники описують його вплив. -Нативні формати й підтримка систем відрізняються залежно від провайдера. Дивіться [налаштування Hopper і Ghidra](docs/installation.md#hopper), [посібник IDA](docs/ida-provider.md) та [експериментальну підтримку Ghidra у Windows](docs/windows-ghidra-p0.md). Ghidra також підтримує [аналіз 16-бітного DOS](docs/ghidra-dos.md). Для вибору провайдера дивіться [посібник CLI](docs/cli.md#choose-a-provider). Для функцій, доданих після найновішої версії в npm, перевірте [доступність у випусках](docs/installation.md#released-package-and-main). +Нативні формати й підтримка систем відрізняються залежно від провайдера. Дивіться [налаштування Hopper і Ghidra](../installation.md#hopper), [посібник IDA](../ida-provider.md) та [експериментальну підтримку Ghidra у Windows](../windows-ghidra-p0.md). Ghidra також підтримує [аналіз 16-бітного DOS](../ghidra-dos.md). Для вибору провайдера дивіться [посібник CLI](../cli.md#choose-a-provider). Для функцій, доданих після найновішої версії в npm, перевірте [доступність у випусках](../installation.md#released-package-and-main). ## Приклади -[![Ілюстрації прикладів панорамування звуку в DX-Ball, мосту буфера обміну Notion та кільця куль у TH04](docs/assets/rea-showcases.png)](https://rea.tools/uk/showcase/) +[![Ілюстрації прикладів панорамування звуку в DX-Ball, мосту буфера обміну Notion та кільця куль у TH04](../assets/rea-showcases.png)](https://rea.tools/uk/showcase/) ### DX-Ball: відновлення розрахунку звукової панорами @@ -187,28 +187,28 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
Які агенти можуть використовувати REA? -Будь-який агент, що підтримує локальні MCP-сервери. Налаштування конфігурує [підтримуваних агентів](docs/installation.md#supported-agents); інші клієнти можуть використати [ручну реєстрацію MCP](docs/installation.md#mcp-registry). +Будь-який агент, що підтримує локальні MCP-сервери. Налаштування конфігурує [підтримуваних агентів](../installation.md#supported-agents); інші клієнти можуть використати [ручну реєстрацію MCP](../installation.md#mcp-registry).
Чи потрібні мені Hopper, Ghidra або IDA? -Поглиблений нативний аналіз використовує один із цих інструментів. Статична перевірка JavaScript і .NET працює без рушія нативного аналізу. Налаштування може встановити Hopper після схвалення; Ghidra й IDA використовують наявні встановлення. Дивіться [налаштування провайдерів](docs/installation.md#hopper). +Поглиблений нативний аналіз використовує один із цих інструментів. Статична перевірка JavaScript і .NET працює без рушія нативного аналізу. Налаштування може встановити Hopper після схвалення; Ghidra й IDA використовують наявні встановлення. Дивіться [налаштування провайдерів](../installation.md#hopper).
Чи потрібно спочатку запускати Hopper? -REA запускає Hopper, коли він потрібен для операції. У macOS під час першого запуску може з'явитися діалог вибору деморежиму або активації ліцензії. Дивіться [запуск Hopper та усунення неполадок](docs/installation.md#launcher-paths-and-troubleshooting). +REA запускає Hopper, коли він потрібен для операції. У macOS під час першого запуску може з'явитися діалог вибору деморежиму або активації ліцензії. Дивіться [запуск Hopper та усунення неполадок](../installation.md#launcher-paths-and-troubleshooting).
Що дає встановлення skill із skills.sh? -Skill надає агенту інструкції дослідження. Використовуйте `npx rea-agents setup`, щоб зареєструвати MCP-сервер REA та встановити відповідні інструкції, а потім перезапустіть агента. Дивіться [встановлення лише skill](docs/installation.md#skill-only-installation). +Skill надає агенту інструкції дослідження. Використовуйте `npx rea-agents setup`, щоб зареєструвати MCP-сервер REA та встановити відповідні інструкції, а потім перезапустіть агента. Дивіться [встановлення лише skill](../installation.md#skill-only-installation).
@@ -251,14 +251,14 @@ npx rea-agents@latest setup Почніть із [практичних посібників](https://rea.tools/uk/guides/) на сайті. Точні параметри, передумови й контракти результатів описано тут: -- [Встановлення й налаштування](docs/installation.md): реєстрація агентів, конфігурація провайдерів, оновлення та видалення. -- [Готовність та усунення неполадок](docs/installation.md#check-readiness-for-your-task): діагностика конкретного агента або рушія аналізу. -- [CLI та Evidence](docs/cli.md): команди, вибір провайдера, знімки, імпорт/експорт і статуси завершення. -- [Контракти MCP](docs/mcp-contracts.md) і [промпти агентів](docs/mcp-prompts.md): результати інструментів, сеанси й керовані дослідження. -- [Каталог інструментів](docs/mcp-contracts.md#generated-catalog): створюваний під час збірки перелік інструментів, провайдерів і команд CLI. -- [План розвитку](docs/roadmap.md): заплановані роботи й відстеження можливостей. +- [Встановлення й налаштування](../installation.md): реєстрація агентів, конфігурація провайдерів, оновлення та видалення. +- [Готовність та усунення неполадок](../installation.md#check-readiness-for-your-task): діагностика конкретного агента або рушія аналізу. +- [CLI та Evidence](../cli.md): команди, вибір провайдера, знімки, імпорт/експорт і статуси завершення. +- [Контракти MCP](../mcp-contracts.md) і [промпти агентів](../mcp-prompts.md): результати інструментів, сеанси й керовані дослідження. +- [Каталог інструментів](../mcp-contracts.md#generated-catalog): створюваний під час збірки перелік інструментів, провайдерів і команд CLI. +- [План розвитку](../roadmap.md): заплановані роботи й відстеження можливостей. -Повідомляйте про вразливості згідно з [SECURITY.md](SECURITY.md). +Повідомляйте про вразливості згідно з [SECURITY.md](../../SECURITY.md). ## Історія зірок @@ -276,7 +276,7 @@ npx rea-agents@latest setup ## Відмова від відповідальності -Рекомендації щодо відповідального використання наведено в [DISCLAIMER.md](DISCLAIMER.md). +Рекомендації щодо відповідального використання наведено в [DISCLAIMER.md](../../DISCLAIMER.md). REA призначений для законних досліджень, навчання, забезпечення сумісності, збереження програмного забезпечення й аналізу програм, якими ви володієте або які маєте дозвіл досліджувати. @@ -288,14 +288,14 @@ applicable licenses. Будемо раді вашій допомозі з REA! [Відкрийте issue](https://github.com/morluto/rea/issues), щоб повідомити про помилку чи запропонувати функцію, або [надішліть pull request](https://github.com/morluto/rea/pulls) з поліпшеннями коду чи документації. -Налаштування середовища розробки й перевірки описано в [CONTRIBUTING.md](CONTRIBUTING.md), шляхи верифікації — у [посібнику тестування](docs/testing.md), а структуру проєкту — на [схемі архітектури](docs/architecture.mermaid). +Налаштування середовища розробки й перевірки описано в [CONTRIBUTING.md](../../CONTRIBUTING.md), шляхи верифікації — у [посібнику тестування](../testing.md), а структуру проєкту — на [схемі архітектури](../architecture.mermaid). ## Ліцензія -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_vi.md b/docs/readme/README_vi.md similarity index 75% rename from README_vi.md rename to docs/readme/README_vi.md index 86544a535..3d2062da6 100644 --- a/README_vi.md +++ b/docs/readme/README_vi.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · **Tiếng Việt** · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · **Tiếng Việt** · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA: Kỹ thuật dịch ngược cho mọi thứ @@ -9,10 +9,10 @@ **Thấy một tính năng bạn thích? Hiểu cách nó hoạt động, đến tận mức mã nhị phân.** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA khởi chạy cầu nối phân tích bên trong Hopper khi kiểm tra tệp nhị phân mã máy +REA khởi chạy cầu nối phân tích bên trong Hopper khi kiểm tra tệp nhị phân mã máy

@@ -33,7 +33,7 @@ - Discord
+ Discord
Tham gia cộng đồng kỹ thuật dịch ngược

Discord · Hỏi đáp · Chia sẻ kết quả @@ -67,7 +67,7 @@ npx rea-agents setup Chọn các tác nhân, xem lại những thay đổi được đề xuất và chấp thuận. Quá trình thiết lập thêm máy chủ MCP của REA cùng hướng dẫn quy trình tương ứng, đồng thời sao lưu cấu hình hiện có. Sau đó khởi động lại tác nhân. -Quá trình thiết lập hỗ trợ Claude Code, Codex, Cursor, Gemini CLI, Grok Build và [các tác nhân khác](docs/installation.md#supported-agents). Xem [cài đặt và thiết lập](docs/installation.md) để cấu hình trình phân tích và đăng ký MCP thủ công. +Quá trình thiết lập hỗ trợ Claude Code, Codex, Cursor, Gemini CLI, Grok Build và [các tác nhân khác](../installation.md#supported-agents). Xem [cài đặt và thiết lập](../installation.md) để cấu hình trình phân tích và đăng ký MCP thủ công. ### Hỏi tác nhân @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -Để phân tích mã máy, trước tiên hãy cấu hình trình phân tích. Xem [hướng dẫn CLI và Evidence](docs/cli.md) về các lệnh phân tích mã máy, lựa chọn trình phân tích, ảnh chụp trạng thái và sử dụng trong script. +Để phân tích mã máy, trước tiên hãy cấu hình trình phân tích. Xem [hướng dẫn CLI và Evidence](../cli.md) về các lệnh phân tích mã máy, lựa chọn trình phân tích, ảnh chụp trạng thái và sử dụng trong script. ### Cập nhật REA @@ -120,9 +120,9 @@ Xem lại thay đổi cấu hình và khởi động lại tác nhân. Với l Tác nhân gọi REA qua MCP để kiểm tra mục tiêu và lần theo mã liên quan. REA trả về các phát hiện kèm bằng chứng. Tác nhân dùng chúng để đặt câu hỏi tiếp theo, giải thích hành vi hoặc viết và kiểm thử một cách triển khai. Các lệnh CLI sử dụng cùng quy trình. -![Quy trình điều tra của REA: tác nhân hỏi về mục tiêu cục bộ, REA kiểm tra và lần theo mục tiêu bằng công cụ phân tích, rồi tác nhân dùng mã, tham chiếu và những điểm chưa biết được trả về để giải thích, triển khai và kiểm thử.](website/public/assets/figures/rea-investigation-flow.svg) +![Quy trình điều tra của REA: tác nhân hỏi về mục tiêu cục bộ, REA kiểm tra và lần theo mục tiêu bằng công cụ phân tích, rồi tác nhân dùng mã, tham chiếu và những điểm chưa biết được trả về để giải thích, triển khai và kiểm thử.](../../website/public/assets/figures/rea-investigation-flow.svg) -[Mở hình ở kích thước đầy đủ](website/public/assets/figures/rea-investigation-flow.svg). +[Mở hình ở kích thước đầy đủ](../../website/public/assets/figures/rea-investigation-flow.svg). @@ -130,37 +130,37 @@ Tác nhân gọi REA qua MCP để kiểm tra mục tiêu và lần theo mã li REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với npm. Công cụ bổ sung và nền tảng chạy REA được hỗ trợ tùy thuộc vào mục tiêu: -| Mục tiêu | REA trả về gì | Yêu cầu và hướng dẫn | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](https://rea.tools/vi/guides/native/) | -| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](docs/binary-diagnostics.md) | -| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](docs/evm-bytecode.md) | -| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](docs/recorded-crashes.md) | -| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) | -| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) | -| Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](docs/web-network-captures.md) | -| Bắt gói tin | Điểm cuối, DNS, TLS SNI, phân bố giao thức và yêu cầu HTTP | tshark và capinfos do nơi gọi cung cấp; [hướng dẫn pcap](docs/pcap-analysis.md) | -| Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](docs/managed-code-analysis.md) | -| APK Android | Khai báo manifest, lớp, phương thức được dịch ngược và tham chiếu | JADX không giao diện đồ họa và JDK đầy đủ trên Linux/macOS/Windows x64; [hướng dẫn Android](docs/android-analysis.md) | -| Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](docs/adb-device-analysis.md) | -| Dự án JEB | Đơn vị dự án từ engine, hash của artifact và mã giả của kiểu/phương thức | Một ứng dụng JEB do nơi gọi khởi động, phục vụ MCP, chọn bằng REA_JEB_MCP_URL; [hướng dẫn JEB](docs/jeb-analysis.md) | -| Tài nguyên Android | Manifest đã giải mã, bảng chuỗi, ngôn ngữ và thông tin phiên bản | Trình khởi chạy Apktool do nơi gọi cung cấp; [hướng dẫn tài nguyên](docs/apktool-resource-analysis.md) | -| Ứng dụng Flutter | Băm snapshot Dart, hash thư viện theo ABI và nguồn gốc build engine | Không cần (phân tích APK tĩnh); [hướng dẫn Flutter](docs/flutter-analysis.md) | -| Phần sụn | Các vùng, kết quả trích xuất và dữ liệu chuyển sang phân tích mã máy | Binwalk / Unblob trên Linux; [hướng dẫn phần sụn](docs/firmware-analysis.md) | -| Gói tin đã ghi | Trường được chọn, giá trị lặp, bộ lọc và quy tắc giải mã | Linux: TShark 4.x + prlimit; [hướng dẫn bản ghi gói tin](docs/packet-captures.md) | -| Tín hiệu logic đã ghi | Chú thích bộ giải mã, phạm vi mẫu và chẩn đoán | Linux: sigrok-cli 0.7.2 + prlimit; [hướng dẫn tín hiệu](docs/signal-recordings.md) | -| Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](docs/javascript-artifact-reconstruction.md), [ứng dụng Apple](docs/apple-application-analysis.md) | -| Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](docs/process-capture.md) | +| Mục tiêu | REA trả về gì | Yêu cầu và hướng dẫn | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](https://rea.tools/vi/guides/native/) | +| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) | +| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) | +| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) | +| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) | +| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) | +| Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](../web-network-captures.md) | +| Bắt gói tin | Điểm cuối, DNS, TLS SNI, phân bố giao thức và yêu cầu HTTP | tshark và capinfos do nơi gọi cung cấp; [hướng dẫn pcap](../pcap-analysis.md) | +| Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](../managed-code-analysis.md) | +| APK Android | Khai báo manifest, lớp, phương thức được dịch ngược và tham chiếu | JADX không giao diện đồ họa và JDK đầy đủ trên Linux/macOS/Windows x64; [hướng dẫn Android](../android-analysis.md) | +| Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](../adb-device-analysis.md) | +| Dự án JEB | Đơn vị dự án từ engine, hash của artifact và mã giả của kiểu/phương thức | Một ứng dụng JEB do nơi gọi khởi động, phục vụ MCP, chọn bằng REA_JEB_MCP_URL; [hướng dẫn JEB](../jeb-analysis.md) | +| Tài nguyên Android | Manifest đã giải mã, bảng chuỗi, ngôn ngữ và thông tin phiên bản | Trình khởi chạy Apktool do nơi gọi cung cấp; [hướng dẫn tài nguyên](../apktool-resource-analysis.md) | +| Ứng dụng Flutter | Băm snapshot Dart, hash thư viện theo ABI và nguồn gốc build engine | Không cần (phân tích APK tĩnh); [hướng dẫn Flutter](../flutter-analysis.md) | +| Phần sụn | Các vùng, kết quả trích xuất và dữ liệu chuyển sang phân tích mã máy | Binwalk / Unblob trên Linux; [hướng dẫn phần sụn](../firmware-analysis.md) | +| Gói tin đã ghi | Trường được chọn, giá trị lặp, bộ lọc và quy tắc giải mã | Linux: TShark 4.x + prlimit; [hướng dẫn bản ghi gói tin](../packet-captures.md) | +| Tín hiệu logic đã ghi | Chú thích bộ giải mã, phạm vi mẫu và chẩn đoán | Linux: sigrok-cli 0.7.2 + prlimit; [hướng dẫn tín hiệu](../signal-recordings.md) | +| Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](../javascript-artifact-reconstruction.md), [ứng dụng Apple](../apple-application-analysis.md) | +| Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](../process-capture.md) | Kiểm tra tĩnh JavaScript và .NET đọc các tệp được cung cấp mà không chạy ứng dụng. Ghi lại khi chạy sẽ khởi chạy hoặc tương tác với mục tiêu đã chọn bằng quyền người dùng của bạn; từng hướng dẫn mô tả tác động của nó. -Định dạng mã máy và nền tảng chạy REA được hỗ trợ khác nhau tùy trình phân tích. Xem [thiết lập Hopper và Ghidra](docs/installation.md#hopper), [hướng dẫn IDA](docs/ida-provider.md) và [hỗ trợ Ghidra thử nghiệm trên Windows](docs/windows-ghidra-p0.md). Ghidra cũng hỗ trợ [phân tích DOS 16 bit](docs/ghidra-dos.md). Để chọn trình phân tích, xem [hướng dẫn CLI](docs/cli.md#choose-a-provider). Kiểm tra [khả năng sử dụng theo bản phát hành](docs/installation.md#released-package-and-main) đối với tính năng được bổ sung sau phiên bản npm mới nhất. +Định dạng mã máy và nền tảng chạy REA được hỗ trợ khác nhau tùy trình phân tích. Xem [thiết lập Hopper và Ghidra](../installation.md#hopper), [hướng dẫn IDA](../ida-provider.md) và [hỗ trợ Ghidra thử nghiệm trên Windows](../windows-ghidra-p0.md). Ghidra cũng hỗ trợ [phân tích DOS 16 bit](../ghidra-dos.md). Để chọn trình phân tích, xem [hướng dẫn CLI](../cli.md#choose-a-provider). Kiểm tra [khả năng sử dụng theo bản phát hành](../installation.md#released-package-and-main) đối với tính năng được bổ sung sau phiên bản npm mới nhất. ## Các nghiên cứu điển hình -[![Minh họa các ví dụ về cân bằng âm thanh DX-Ball, cầu nối bảng nhớ tạm Notion và vòng đạn TH04](docs/assets/rea-showcases.png)](https://rea.tools/vi/showcase/) +[![Minh họa các ví dụ về cân bằng âm thanh DX-Ball, cầu nối bảng nhớ tạm Notion và vòng đạn TH04](../assets/rea-showcases.png)](https://rea.tools/vi/showcase/) ### DX-Ball: tái tạo phép tính phân bố âm thanh trái phải @@ -187,28 +187,28 @@ Nếu bạn dùng REA cho một mục tiêu thú vị, chúng tôi muốn biết
Tác nhân nào có thể dùng REA? -Bất kỳ tác nhân nào hỗ trợ máy chủ MCP cục bộ. Quá trình thiết lập cấu hình các [tác nhân được hỗ trợ](docs/installation.md#supported-agents); ứng dụng khách khác có thể dùng [đăng ký MCP thủ công](docs/installation.md#mcp-registry). +Bất kỳ tác nhân nào hỗ trợ máy chủ MCP cục bộ. Quá trình thiết lập cấu hình các [tác nhân được hỗ trợ](../installation.md#supported-agents); ứng dụng khách khác có thể dùng [đăng ký MCP thủ công](../installation.md#mcp-registry).
Tôi có cần Hopper, Ghidra hoặc IDA không? -Phân tích mã máy chuyên sâu sử dụng một trong những công cụ đó. Kiểm tra tĩnh JavaScript và .NET hoạt động mà không cần bộ máy phân tích mã máy. Quá trình thiết lập có thể cài Hopper sau khi được chấp thuận; Ghidra và IDA dùng bản cài đặt sẵn có. Xem [thiết lập trình phân tích](docs/installation.md#hopper). +Phân tích mã máy chuyên sâu sử dụng một trong những công cụ đó. Kiểm tra tĩnh JavaScript và .NET hoạt động mà không cần bộ máy phân tích mã máy. Quá trình thiết lập có thể cài Hopper sau khi được chấp thuận; Ghidra và IDA dùng bản cài đặt sẵn có. Xem [thiết lập trình phân tích](../installation.md#hopper).
Tôi có cần khởi chạy Hopper trước không? -REA khởi chạy Hopper khi một thao tác cần đến nó. Trên macOS, hộp thoại lần chạy đầu có thể yêu cầu chọn chế độ dùng thử hoặc kích hoạt giấy phép. Xem [khởi động Hopper và khắc phục sự cố](docs/installation.md#launcher-paths-and-troubleshooting). +REA khởi chạy Hopper khi một thao tác cần đến nó. Trên macOS, hộp thoại lần chạy đầu có thể yêu cầu chọn chế độ dùng thử hoặc kích hoạt giấy phép. Xem [khởi động Hopper và khắc phục sự cố](../installation.md#launcher-paths-and-troubleshooting).
Cài skill từ skills.sh có tác dụng gì? -Skill cung cấp hướng dẫn điều tra cho tác nhân. Dùng `npx rea-agents setup` để đăng ký máy chủ MCP của REA và cài hướng dẫn tương ứng, sau đó khởi động lại tác nhân. Xem [chỉ cài skill](docs/installation.md#skill-only-installation). +Skill cung cấp hướng dẫn điều tra cho tác nhân. Dùng `npx rea-agents setup` để đăng ký máy chủ MCP của REA và cài hướng dẫn tương ứng, sau đó khởi động lại tác nhân. Xem [chỉ cài skill](../installation.md#skill-only-installation).
@@ -251,14 +251,14 @@ Nếu dùng tác nhân, hãy hoàn tất [làm mới thiết lập](#cập-nhậ Bắt đầu với [hướng dẫn thực hành](https://rea.tools/vi/guides/) trên trang web. Để biết tùy chọn cụ thể, điều kiện tiên quyết và hợp đồng kết quả: -- [Cài đặt và thiết lập](docs/installation.md): đăng ký tác nhân, cấu hình trình phân tích, cập nhật và gỡ cài đặt. -- [Kiểm tra mức sẵn sàng và khắc phục sự cố](docs/installation.md#check-readiness-for-your-task): chẩn đoán một tác nhân hoặc bộ máy phân tích cụ thể. -- [CLI và Evidence](docs/cli.md): lệnh, lựa chọn trình phân tích, ảnh chụp trạng thái, nhập/xuất và trạng thái thoát. -- [Hợp đồng MCP](docs/mcp-contracts.md) và [prompt cho tác nhân](docs/mcp-prompts.md): kết quả công cụ, phiên làm việc và điều tra có hướng dẫn. -- [Danh mục công cụ](docs/mcp-contracts.md#generated-catalog): danh sách công cụ, trình phân tích và lệnh CLI được tạo khi dựng dự án. -- [Lộ trình](docs/roadmap.md): công việc dự kiến và theo dõi các khả năng. +- [Cài đặt và thiết lập](../installation.md): đăng ký tác nhân, cấu hình trình phân tích, cập nhật và gỡ cài đặt. +- [Kiểm tra mức sẵn sàng và khắc phục sự cố](../installation.md#check-readiness-for-your-task): chẩn đoán một tác nhân hoặc bộ máy phân tích cụ thể. +- [CLI và Evidence](../cli.md): lệnh, lựa chọn trình phân tích, ảnh chụp trạng thái, nhập/xuất và trạng thái thoát. +- [Hợp đồng MCP](../mcp-contracts.md) và [prompt cho tác nhân](../mcp-prompts.md): kết quả công cụ, phiên làm việc và điều tra có hướng dẫn. +- [Danh mục công cụ](../mcp-contracts.md#generated-catalog): danh sách công cụ, trình phân tích và lệnh CLI được tạo khi dựng dự án. +- [Lộ trình](../roadmap.md): công việc dự kiến và theo dõi các khả năng. -Báo cáo lỗ hổng theo [SECURITY.md](SECURITY.md). +Báo cáo lỗ hổng theo [SECURITY.md](../../SECURITY.md). ## Lịch sử sao @@ -276,7 +276,7 @@ Cảm ơn mọi người đã sử dụng REA, báo lỗi, đề xuất tính n ## Miễn trừ trách nhiệm -Xem [DISCLAIMER.md](DISCLAIMER.md) để biết hướng dẫn sử dụng có trách nhiệm. +Xem [DISCLAIMER.md](../../DISCLAIMER.md) để biết hướng dẫn sử dụng có trách nhiệm. REA dành cho nghiên cứu, giáo dục, khả năng tương tác, bảo tồn phần mềm và phân tích phần mềm bạn sở hữu hoặc được phép kiểm tra, trong khuôn khổ pháp luật. @@ -288,14 +288,14 @@ applicable licenses. Chúng tôi hoan nghênh bạn giúp phát triển REA! [Mở issue](https://github.com/morluto/rea/issues) để báo lỗi hoặc đề xuất tính năng, hoặc [gửi pull request](https://github.com/morluto/rea/pulls) để cải thiện mã hay tài liệu. -Xem [CONTRIBUTING.md](CONTRIBUTING.md) về thiết lập môi trường phát triển và các bước kiểm tra, [hướng dẫn kiểm thử](docs/testing.md) về các luồng xác minh và [sơ đồ kiến trúc](docs/architecture.mermaid) về cấu trúc dự án. +Xem [CONTRIBUTING.md](../../CONTRIBUTING.md) về thiết lập môi trường phát triển và các bước kiểm tra, [hướng dẫn kiểm thử](../testing.md) về các luồng xác minh và [sơ đồ kiến trúc](../architecture.mermaid) về cấu trúc dự án. ## Giấy phép -[MIT](LICENSE) +[MIT](../../LICENSE) REA-authored code is licensed under MIT. Third-party material retains its own -licenses and copyright notices; see [Third-party notices](THIRD_PARTY_NOTICES.md) +licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md) for the dependency inventory, preserved license texts, and external-tool terms. **You must legally obtain and be authorized to use commercial tools**, including diff --git a/README_zh-TW.md b/docs/readme/README_zh-TW.md similarity index 73% rename from README_zh-TW.md rename to docs/readme/README_zh-TW.md index 62c9ab2f3..44956b8eb 100644 --- a/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -1,6 +1,6 @@
-[English](README.md) · [简体中文](README_zh.md) · **繁體中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · [简体中文](README_zh.md) · **繁體中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA:逆向一切 @@ -9,10 +9,10 @@ **看到喜歡的功能,弄清它的工作原理,深入到二進位層面。** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA 在 Hopper 中啟動分析橋,檢查原生二進位檔案 +REA 在 Hopper 中啟動分析橋,檢查原生二進位檔案

@@ -33,7 +33,7 @@ - Discord
+ Discord
加入逆向工程社群

Discord · 問答 · 成果分享 @@ -67,7 +67,7 @@ npx rea-agents setup 選擇 AI 代理,檢查計劃中的變更並核准。設定流程會添加 REA 的 MCP 服務和對應的工作流程指引,並備份已有組態。完成後重新啟動 AI 代理。 -設定流程支援 Claude Code、Codex、Cursor、Gemini CLI、Grok Build 和[其他 AI 代理](docs/installation.md#supported-agents)。提供者組態和手動註冊 MCP 的方法見[安裝與設定](docs/installation.md)。 +設定流程支援 Claude Code、Codex、Cursor、Gemini CLI、Grok Build 和[其他 AI 代理](../installation.md#supported-agents)。提供者組態和手動註冊 MCP 的方法見[安裝與設定](../installation.md)。 ### 詢問 AI 代理 @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -原生分析需要先設定分析提供者。原生命令、提供者選擇、快照和腳本用法見 [CLI 與 Evidence 指南](docs/cli.md)。 +原生分析需要先設定分析提供者。原生命令、提供者選擇、快照和腳本用法見 [CLI 與 Evidence 指南](../cli.md)。 ### 更新 REA @@ -120,9 +120,9 @@ npx rea-agents@latest setup AI 代理通過 MCP 呼叫 REA,檢查目標並追蹤相關程式碼。REA 返回發現及對應證據。AI 代理據此提出後續問題、解釋行為,或編寫並測試實現。CLI 命令使用同樣的工作流程。 -![REA 調查流程:AI 代理詢問本地目標的行為,REA 使用分析工具檢查和追蹤目標,AI 代理再利用返回的程式碼、引用和未知項進行解釋、實現與測試。](website/public/assets/figures/rea-investigation-flow.svg) +![REA 調查流程:AI 代理詢問本地目標的行為,REA 使用分析工具檢查和追蹤目標,AI 代理再利用返回的程式碼、引用和未知項進行解釋、實現與測試。](../../website/public/assets/figures/rea-investigation-flow.svg) -[打開完整尺寸的圖示](website/public/assets/figures/rea-investigation-flow.svg)。 +[打開完整尺寸的圖示](../../website/public/assets/figures/rea-investigation-flow.svg)。 @@ -130,37 +130,37 @@ AI 代理通過 MCP 呼叫 REA,檢查目標並追蹤相關程式碼。REA 返 REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。額外工具和主機支援取決於分析目標: -| 目標 | REA 返回的內容 | 要求與指南 | -| --------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/zh-TW/guides/native/) | -| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](docs/binary-diagnostics.md) | -| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](docs/evm-bytecode.md) | -| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](docs/recorded-crashes.md) | -| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) | -| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) | -| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](docs/web-network-captures.md) | -| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](docs/pcap-analysis.md) | -| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](docs/managed-code-analysis.md) | -| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) | -| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](docs/adb-device-analysis.md) | -| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](docs/jeb-analysis.md) | -| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](docs/apktool-resource-analysis.md) | -| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](docs/flutter-analysis.md) | -| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](docs/firmware-analysis.md) | -| 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](docs/packet-captures.md) | -| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](docs/signal-recordings.md) | -| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 應用](docs/apple-application-analysis.md) | -| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](docs/process-capture.md) | +| 目標 | REA 返回的內容 | 要求與指南 | +| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/zh-TW/guides/native/) | +| 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) | +| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) | +| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | +| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) | +| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) | +| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) | +| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) | +| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) | +| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) | +| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](../adb-device-analysis.md) | +| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](../jeb-analysis.md) | +| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](../apktool-resource-analysis.md) | +| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](../flutter-analysis.md) | +| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](../firmware-analysis.md) | +| 封包記錄 | 指定欄位、重複值、篩選條件與解碼規則 | Linux: TShark 4.x + prlimit; [封包分析指南](../packet-captures.md) | +| 錄製的邏輯訊號 | 解碼註解、樣本範圍與診斷資訊 | Linux: sigrok-cli 0.7.2 + prlimit; [訊號記錄指南](../signal-recordings.md) | +| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 應用](../apple-application-analysis.md) | +| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](../process-capture.md) | 靜態 JavaScript 和 .NET 檢查讀取提供的檔案,不執行應用。執行階段擷取會以你的使用者權限執行目標或與之互動;各執行階段指南說明瞭具體影響。 -不同提供者支援的原生格式和主機系統有所不同。請參閱 [Hopper 與 Ghidra 設定](docs/installation.md#hopper)、[IDA 指南](docs/ida-provider.md)和[實驗性 Windows Ghidra 支援](docs/windows-ghidra-p0.md)。Ghidra 也支援 [16 位 DOS 分析](docs/ghidra-dos.md)。提供者選擇見 [CLI 指南](docs/cli.md#choose-a-provider)。對於最新 npm 版本發佈後新增的功能,請查看[發佈可用性](docs/installation.md#released-package-and-main)。 +不同提供者支援的原生格式和主機系統有所不同。請參閱 [Hopper 與 Ghidra 設定](../installation.md#hopper)、[IDA 指南](../ida-provider.md)和[實驗性 Windows Ghidra 支援](../windows-ghidra-p0.md)。Ghidra 也支援 [16 位 DOS 分析](../ghidra-dos.md)。提供者選擇見 [CLI 指南](../cli.md#choose-a-provider)。對於最新 npm 版本發佈後新增的功能,請查看[發佈可用性](../installation.md#released-package-and-main)。 ## 案例 -[![DX-Ball 聲音平移、Notion 剪貼簿橋接與 TH04 彈幕環案例的插圖](docs/assets/rea-showcases.png)](https://rea.tools/zh-TW/showcase/) +[![DX-Ball 聲音平移、Notion 剪貼簿橋接與 TH04 彈幕環案例的插圖](../assets/rea-showcases.png)](https://rea.tools/zh-TW/showcase/) ### DX-Ball:重建聲像計算 @@ -189,28 +189,28 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
哪些 AI 代理可以使用 REA? -任何支援本地 MCP 服務的 AI 代理都可以使用。設定流程會設定[受支援的 AI 代理](docs/installation.md#supported-agents);其他客戶端可通過[手動註冊 MCP](docs/installation.md#mcp-registry) 使用。 +任何支援本地 MCP 服務的 AI 代理都可以使用。設定流程會設定[受支援的 AI 代理](../installation.md#supported-agents);其他客戶端可通過[手動註冊 MCP](../installation.md#mcp-registry) 使用。
需要 Hopper、Ghidra 或 IDA 嗎? -深入的原生分析需要其中一個引擎。靜態 JavaScript 和 .NET 檢查不需要原生分析引擎。設定流程可以在你核准後安裝 Hopper;Ghidra 和 IDA 則使用已有安裝。見[提供者設定](docs/installation.md#hopper)。 +深入的原生分析需要其中一個引擎。靜態 JavaScript 和 .NET 檢查不需要原生分析引擎。設定流程可以在你核准後安裝 Hopper;Ghidra 和 IDA 則使用已有安裝。見[提供者設定](../installation.md#hopper)。
需要先啟動 Hopper 嗎? -REA 會在操作需要時啟動 Hopper。在 macOS 上,首次執行時可能會出現對話框,讓你選擇演示模式或啟用授權。見 [Hopper 啟動與故障排查](docs/installation.md#launcher-paths-and-troubleshooting)。 +REA 會在操作需要時啟動 Hopper。在 macOS 上,首次執行時可能會出現對話框,讓你選擇演示模式或啟用授權。見 [Hopper 啟動與故障排查](../installation.md#launcher-paths-and-troubleshooting)。
從 skills.sh 安裝 skill 有什麼作用? -skill 為 AI 代理提供調查指引。使用 `npx rea-agents setup` 註冊 REA 的 MCP 服務並安裝對應的指引,然後重新啟動 AI 代理。見[僅安裝 skill](docs/installation.md#skill-only-installation)。 +skill 為 AI 代理提供調查指引。使用 `npx rea-agents setup` 註冊 REA 的 MCP 服務並安裝對應的指引,然後重新啟動 AI 代理。見[僅安裝 skill](../installation.md#skill-only-installation)。
@@ -253,14 +253,14 @@ npx rea-agents@latest setup 可以先閱讀網站上的[實操指南](https://rea.tools/zh-TW/guides/)。具體選項、前提條件和結果契約見: -- [安裝與設定](docs/installation.md):AI 代理註冊、提供者組態、更新和卸載。 -- [就緒檢查與故障排查](docs/installation.md#check-readiness-for-your-task):診斷特定 AI 代理或分析引擎。 -- [CLI 與 Evidence](docs/cli.md):命令、提供者選擇、快照、匯入/匯出和退出狀態。 -- [MCP 契約](docs/mcp-contracts.md)和[AI 代理提示詞](docs/mcp-prompts.md):工具結果、會話和引導式調查。 -- [工具目錄](docs/mcp-contracts.md#generated-catalog):建置時生成的工具、提供者和 CLI 命令清單。 -- [路線圖](docs/roadmap.md):計劃中的工作和能力跟蹤。 +- [安裝與設定](../installation.md):AI 代理註冊、提供者組態、更新和卸載。 +- [就緒檢查與故障排查](../installation.md#check-readiness-for-your-task):診斷特定 AI 代理或分析引擎。 +- [CLI 與 Evidence](../cli.md):命令、提供者選擇、快照、匯入/匯出和退出狀態。 +- [MCP 契約](../mcp-contracts.md)和[AI 代理提示詞](../mcp-prompts.md):工具結果、會話和引導式調查。 +- [工具目錄](../mcp-contracts.md#generated-catalog):建置時生成的工具、提供者和 CLI 命令清單。 +- [路線圖](../roadmap.md):計劃中的工作和能力跟蹤。 -請按照 [SECURITY.md](SECURITY.md) 報告漏洞。 +請按照 [SECURITY.md](../../SECURITY.md) 報告漏洞。 ## Star 歷史 @@ -278,7 +278,7 @@ npx rea-agents@latest setup ## 免責聲明 -負責任使用的相關指引請見 [DISCLAIMER.md](DISCLAIMER.md)。 +負責任使用的相關指引請見 [DISCLAIMER.md](../../DISCLAIMER.md)。 REA 用於合法的研究、教育、互通性、軟體保存,以及分析你擁有或獲准檢查的軟體。 @@ -289,14 +289,14 @@ REA 用於合法的研究、教育、互通性、軟體保存,以及分析你 歡迎幫助改進 REA!你可以[提交 issue](https://github.com/morluto/rea/issues) 報告錯誤或建議功能,也可以[提交 pull request](https://github.com/morluto/rea/pulls) 改進程式碼或文件。 -開發設定和檢查見 [CONTRIBUTING.md](CONTRIBUTING.md),驗證流程見[測試指南](docs/testing.md),專案結構見[架構圖](docs/architecture.mermaid)。 +開發設定和檢查見 [CONTRIBUTING.md](../../CONTRIBUTING.md),驗證流程見[測試指南](../testing.md),專案結構見[架構圖](../architecture.mermaid)。 ## 授權條款 -[MIT](LICENSE) +[MIT](../../LICENSE) REA 自行編寫的程式碼採用 MIT 授權;第三方內容保留各自的授權和著作權聲明。 -依賴清單、授權原文及外部工具條款見[第三方聲明](THIRD_PARTY_NOTICES.md)。 +依賴清單、授權原文及外部工具條款見[第三方聲明](../../THIRD_PARTY_NOTICES.md)。 **使用者須自行合法取得商用工具及所需使用權**,包括 IDA Pro / Hex-Rays 反編譯器、 Hopper 和 JEB,並遵守各自授權。REA 不提供工具授權、金鑰或啟用權利;試用版和 diff --git a/README_zh.md b/docs/readme/README_zh.md similarity index 73% rename from README_zh.md rename to docs/readme/README_zh.md index c8d66ce08..85e51651f 100644 --- a/README_zh.md +++ b/docs/readme/README_zh.md @@ -1,6 +1,6 @@
-[English](README.md) · **简体中文** · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) +[English](../../README.md) · **简体中文** · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md) · [Italiano](README_it.md) # REA:逆向一切 @@ -9,10 +9,10 @@ **看到喜欢的功能,弄清它的工作原理,深入到二进制层面。** [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](../mcp-contracts.md#generated-catalog) [![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) -[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) +[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](../../LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) morluto%2Frea | Trendshift @@ -25,7 +25,7 @@
-REA 在 Hopper 中启动分析桥,检查原生二进制文件 +REA 在 Hopper 中启动分析桥,检查原生二进制文件

@@ -33,7 +33,7 @@ - Discord
+ Discord
加入逆向工程社区

Discord · 问答 · 成果分享 @@ -67,7 +67,7 @@ npx rea-agents setup 选择智能体,检查计划中的变更并批准。设置流程会添加 REA 的 MCP 服务和匹配的工作流指引,并备份已有配置。完成后重启智能体。 -设置流程支持 Claude Code、Codex、Cursor、Gemini CLI、Grok Build 和[其他智能体](docs/installation.md#supported-agents)。提供方配置和手动注册 MCP 的方法见[安装与设置](docs/installation.md)。 +设置流程支持 Claude Code、Codex、Cursor、Gemini CLI、Grok Build 和[其他智能体](../installation.md#supported-agents)。提供方配置和手动注册 MCP 的方法见[安装与设置](../installation.md)。 ### 询问智能体 @@ -94,7 +94,7 @@ npm install --global rea-agents rea --help ``` -原生分析需要先配置分析提供方。原生命令、提供方选择、快照和脚本用法见 [CLI 与 Evidence 指南](docs/cli.md)。 +原生分析需要先配置分析提供方。原生命令、提供方选择、快照和脚本用法见 [CLI 与 Evidence 指南](../cli.md)。 ### 更新 REA @@ -120,9 +120,9 @@ npx rea-agents@latest setup 智能体通过 MCP 调用 REA,检查目标并追踪相关代码。REA 返回发现及对应证据。智能体据此提出后续问题、解释行为,或编写并测试实现。CLI 命令使用同样的工作流。 -![REA 调查流程:智能体询问本地目标的行为,REA 使用分析工具检查和追踪目标,智能体再利用返回的代码、引用和未知项进行解释、实现与测试。](website/public/assets/figures/rea-investigation-flow.svg) +![REA 调查流程:智能体询问本地目标的行为,REA 使用分析工具检查和追踪目标,智能体再利用返回的代码、引用和未知项进行解释、实现与测试。](../../website/public/assets/figures/rea-investigation-flow.svg) -[打开完整尺寸的图示](website/public/assets/figures/rea-investigation-flow.svg)。 +[打开完整尺寸的图示](../../website/public/assets/figures/rea-investigation-flow.svg)。 @@ -130,37 +130,37 @@ npx rea-agents@latest setup REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。额外工具和主机支持取决于分析目标: -| 目标 | REA 返回的内容 | 要求与指南 | -| --------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/zh-CN/guides/native/) | -| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](docs/binary-diagnostics.md) | -| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](docs/evm-bytecode.md) | -| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](docs/recorded-crashes.md) | -| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) | -| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) | -| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](docs/web-network-captures.md) | -| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](docs/pcap-analysis.md) | -| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](docs/managed-code-analysis.md) | -| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) | -| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](docs/adb-device-analysis.md) | -| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](docs/jeb-analysis.md) | -| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](docs/apktool-resource-analysis.md) | -| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](docs/flutter-analysis.md) | -| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](docs/firmware-analysis.md) | -| 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](docs/packet-captures.md) | -| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](docs/signal-recordings.md) | -| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 应用](docs/apple-application-analysis.md) | -| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](docs/process-capture.md) | +| 目标 | REA 返回的内容 | 要求与指南 | +| --------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/zh-CN/guides/native/) | +| 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) | +| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) | +| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | +| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) | +| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) | +| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) | +| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) | +| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) | +| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](../android-analysis.md) | +| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](../adb-device-analysis.md) | +| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](../jeb-analysis.md) | +| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](../apktool-resource-analysis.md) | +| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](../flutter-analysis.md) | +| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](../firmware-analysis.md) | +| 抓包文件 | 指定字段、重复值、过滤条件与解码规则 | Linux: TShark 4.x + prlimit; [抓包分析指南](../packet-captures.md) | +| 录制的逻辑信号 | 解码注释、样本范围和诊断信息 | Linux: sigrok-cli 0.7.2 + prlimit; [信号记录指南](../signal-recordings.md) | +| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](../javascript-artifact-reconstruction.md)、[Apple 应用](../apple-application-analysis.md) | +| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](../process-capture.md) | 静态 JavaScript 和 .NET 检查读取提供的文件,不运行应用。运行时捕获会以你的用户权限运行目标或与之交互;各运行时指南说明了具体影响。 -不同提供方支持的原生格式和主机系统有所不同。请参阅 [Hopper 与 Ghidra 设置](docs/installation.md#hopper)、[IDA 指南](docs/ida-provider.md)和[实验性 Windows Ghidra 支持](docs/windows-ghidra-p0.md)。Ghidra 也支持 [16 位 DOS 分析](docs/ghidra-dos.md)。提供方选择见 [CLI 指南](docs/cli.md#choose-a-provider)。对于最新 npm 版本发布后新增的功能,请查看[发布可用性](docs/installation.md#released-package-and-main)。 +不同提供方支持的原生格式和主机系统有所不同。请参阅 [Hopper 与 Ghidra 设置](../installation.md#hopper)、[IDA 指南](../ida-provider.md)和[实验性 Windows Ghidra 支持](../windows-ghidra-p0.md)。Ghidra 也支持 [16 位 DOS 分析](../ghidra-dos.md)。提供方选择见 [CLI 指南](../cli.md#choose-a-provider)。对于最新 npm 版本发布后新增的功能,请查看[发布可用性](../installation.md#released-package-and-main)。 ## 案例 -[![DX-Ball 声音平移、Notion 剪贴板桥接与 TH04 弹幕环案例的插图](docs/assets/rea-showcases.png)](https://rea.tools/zh-CN/showcase/) +[![DX-Ball 声音平移、Notion 剪贴板桥接与 TH04 弹幕环案例的插图](../assets/rea-showcases.png)](https://rea.tools/zh-CN/showcase/) ### DX-Ball:重建声像计算 @@ -189,28 +189,28 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
哪些智能体可以使用 REA? -任何支持本地 MCP 服务的智能体都可以使用。设置流程会配置[受支持的智能体](docs/installation.md#supported-agents);其他客户端可通过[手动注册 MCP](docs/installation.md#mcp-registry) 使用。 +任何支持本地 MCP 服务的智能体都可以使用。设置流程会配置[受支持的智能体](../installation.md#supported-agents);其他客户端可通过[手动注册 MCP](../installation.md#mcp-registry) 使用。
需要 Hopper、Ghidra 或 IDA 吗? -深入的原生分析需要其中一个引擎。静态 JavaScript 和 .NET 检查不需要原生分析引擎。设置流程可以在你批准后安装 Hopper;Ghidra 和 IDA 则使用已有安装。见[提供方设置](docs/installation.md#hopper)。 +深入的原生分析需要其中一个引擎。静态 JavaScript 和 .NET 检查不需要原生分析引擎。设置流程可以在你批准后安装 Hopper;Ghidra 和 IDA 则使用已有安装。见[提供方设置](../installation.md#hopper)。
需要先启动 Hopper 吗? -REA 会在操作需要时启动 Hopper。在 macOS 上,首次运行时可能会出现对话框,让你选择演示模式或激活许可证。见 [Hopper 启动与故障排查](docs/installation.md#launcher-paths-and-troubleshooting)。 +REA 会在操作需要时启动 Hopper。在 macOS 上,首次运行时可能会出现对话框,让你选择演示模式或激活许可证。见 [Hopper 启动与故障排查](../installation.md#launcher-paths-and-troubleshooting)。
从 skills.sh 安装 skill 有什么作用? -skill 为智能体提供调查指引。使用 `npx rea-agents setup` 注册 REA 的 MCP 服务并安装匹配的指引,然后重启智能体。见[仅安装 skill](docs/installation.md#skill-only-installation)。 +skill 为智能体提供调查指引。使用 `npx rea-agents setup` 注册 REA 的 MCP 服务并安装匹配的指引,然后重启智能体。见[仅安装 skill](../installation.md#skill-only-installation)。
@@ -253,14 +253,14 @@ npx rea-agents@latest setup 可以先阅读网站上的[实操指南](https://rea.tools/zh-CN/guides/)。具体选项、前提条件和结果契约见: -- [安装与设置](docs/installation.md):智能体注册、提供方配置、更新和卸载。 -- [就绪检查与故障排查](docs/installation.md#check-readiness-for-your-task):诊断特定智能体或分析引擎。 -- [CLI 与 Evidence](docs/cli.md):命令、提供方选择、快照、导入/导出和退出状态。 -- [MCP 契约](docs/mcp-contracts.md)和[智能体提示词](docs/mcp-prompts.md):工具结果、会话和引导式调查。 -- [工具目录](docs/mcp-contracts.md#generated-catalog):构建时生成的工具、提供方和 CLI 命令清单。 -- [路线图](docs/roadmap.md):计划中的工作和能力跟踪。 +- [安装与设置](../installation.md):智能体注册、提供方配置、更新和卸载。 +- [就绪检查与故障排查](../installation.md#check-readiness-for-your-task):诊断特定智能体或分析引擎。 +- [CLI 与 Evidence](../cli.md):命令、提供方选择、快照、导入/导出和退出状态。 +- [MCP 契约](../mcp-contracts.md)和[智能体提示词](../mcp-prompts.md):工具结果、会话和引导式调查。 +- [工具目录](../mcp-contracts.md#generated-catalog):构建时生成的工具、提供方和 CLI 命令清单。 +- [路线图](../roadmap.md):计划中的工作和能力跟踪。 -请按照 [SECURITY.md](SECURITY.md) 报告漏洞。 +请按照 [SECURITY.md](../../SECURITY.md) 报告漏洞。 ## Star 历史 @@ -278,7 +278,7 @@ npx rea-agents@latest setup ## 免责声明 -负责任使用的相关指引请见 [DISCLAIMER.md](DISCLAIMER.md)。 +负责任使用的相关指引请见 [DISCLAIMER.md](../../DISCLAIMER.md)。 REA 用于合法的研究、教育、互操作性、软件保存,以及分析你拥有或获准检查的软件。 @@ -289,14 +289,14 @@ REA 用于合法的研究、教育、互操作性、软件保存,以及分析 欢迎帮助改进 REA!你可以[提交 issue](https://github.com/morluto/rea/issues) 报告错误或建议功能,也可以[提交 pull request](https://github.com/morluto/rea/pulls) 改进代码或文档。 -开发设置和检查见 [CONTRIBUTING.md](CONTRIBUTING.md),验证流程见[测试指南](docs/testing.md),项目结构见[架构图](docs/architecture.mermaid)。 +开发设置和检查见 [CONTRIBUTING.md](../../CONTRIBUTING.md),验证流程见[测试指南](../testing.md),项目结构见[架构图](../architecture.mermaid)。 ## 许可证 -[MIT](LICENSE) +[MIT](../../LICENSE) REA 自行编写的代码采用 MIT 许可;第三方内容保留各自的许可和著作权声明。 -依赖清单、许可原文及外部工具条款见[第三方声明](THIRD_PARTY_NOTICES.md)。 +依赖清单、许可原文及外部工具条款见[第三方声明](../../THIRD_PARTY_NOTICES.md)。 **用户须自行合法取得商用工具及所需使用权**,包括 IDA Pro / Hex-Rays 反编译器、 Hopper 和 JEB,并遵守各自许可。REA 不提供工具许可、密钥或激活权利;试用版和 diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index eae2430e5..a4f0f0fac 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -363,6 +363,7 @@ export const SCOPES = { "website/**", "scripts/*website*.py", "README*.md", + "docs/readme/README*.md", ".github/workflows/website-check.yml", ".github/workflows/website-pages.yml", ], diff --git a/scripts/lib/docs-facts.mjs b/scripts/lib/docs-facts.mjs index 26d9a4ac3..dda249aaa 100644 --- a/scripts/lib/docs-facts.mjs +++ b/scripts/lib/docs-facts.mjs @@ -1,13 +1,41 @@ import { readFile, readdir, stat } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -const readmeLinkTargets = (content, language = "en") => - [...content.matchAll(/\]\(([^)\s]+)\)/gu)] - .map((match) => match[1] ?? "") +const repoRelative = (root, from, target) => { + const index = target.search(/[#?]/u); + const pathPart = index === -1 ? target : target.slice(0, index); + const suffix = index === -1 ? "" : target.slice(index); + if (pathPart.length === 0 || /^[a-z][a-z0-9+.-]*:/iu.test(pathPart)) + return target; + const absolute = resolve(root, dirname(from), pathPart); + const withinRepo = relative(root, absolute); + if ( + withinRepo === ".." || + withinRepo.startsWith(`..${sep}`) || + isAbsolute(withinRepo) + ) + return target; + return `${withinRepo.split(sep).join("/")}${suffix}`; +}; + +const isLanguageNavigation = (target) => { + const path = target.split(/[#?]/u)[0]; + return ( + path === "README.md" || + /^docs\/readme\/README_[A-Za-z0-9-]+\.md$/u.test(path) + ); +}; + +const readmeDestinations = (content) => [ + ...content.matchAll(/\]\(([^)\s]+)\)/gu), + ...content.matchAll(/\b(?:src|href)="([^"]+)"/gu), +]; + +const readmeLinkTargets = (root, from, content, language = "en") => + readmeDestinations(content) + .map((match) => repoRelative(root, from, match[1] ?? "")) .filter( - (target) => - !target.startsWith("#") && - !/^README(?:_[A-Za-z0-9-]+)?\.md$/u.test(target), + (target) => !target.startsWith("#") && !isLanguageNavigation(target), ) .map((target) => { if (language === "en" || !URL.canParse(target)) return target; @@ -70,9 +98,19 @@ export const documentationFactIssues = async (root, catalog) => { const issues = await skillReferenceIssues( join(root, "skills/reverse-engineer-anything"), ); - const readmePaths = (await readdir(root)) - .filter((path) => /^README(?:_[A-Za-z0-9-]+)?\.md$/u.test(path)) - .sort(); + const translatedFolder = join(root, "docs/readme"); + let translatedNames = []; + try { + translatedNames = await readdir(translatedFolder); + } catch (cause) { + if (cause?.code !== "ENOENT") throw cause; + } + const readmePaths = [ + "README.md", + ...translatedNames + .filter((name) => /^README_[A-Za-z0-9-]+\.md$/u.test(name)) + .map((name) => `docs/readme/${name}`), + ].sort(); const readmes = new Map(); for (const path of readmePaths) { const content = await readFile(join(root, path), "utf8"); @@ -94,7 +132,11 @@ export const documentationFactIssues = async (root, catalog) => { ); } } - const canonicalLinks = readmeLinkTargets(readmes.get("README.md") ?? ""); + const canonicalLinks = readmeLinkTargets( + root, + "README.md", + readmes.get("README.md") ?? "", + ); const languageInventory = JSON.parse( await readFile(join(root, "website/locales/languages.json"), "utf8"), ); @@ -104,7 +146,12 @@ export const documentationFactIssues = async (root, catalog) => { for (const path of readmePaths.filter((path) => path !== "README.md")) if ( JSON.stringify( - readmeLinkTargets(readmes.get(path) ?? "", readmeLanguages.get(path)), + readmeLinkTargets( + root, + path, + readmes.get(path) ?? "", + readmeLanguages.get(path), + ), ) !== JSON.stringify(canonicalLinks) ) issues.push(`${path}: documentation links differ from README.md`); diff --git a/scripts/test-website.py b/scripts/test-website.py index 5c1aef876..ba87c9f15 100644 --- a/scripts/test-website.py +++ b/scripts/test-website.py @@ -261,7 +261,9 @@ class WebsiteTranslationChecks(unittest.TestCase): inventory = [item for item in languages(ROOT) if item["code"] in {"en", "zh-CN", "ar"}] (root / "website/locales/languages.json").write_text(json.dumps(inventory)) for item in inventory: - (root / item["readme"]).write_text("# REA\n") + destination = root / item["readme"] + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("# REA\n") WEBSITE.sync_pages(root) extract_catalogs(root) yield root diff --git a/scripts/website_i18n.py b/scripts/website_i18n.py index 928594df7..0f93546f0 100644 --- a/scripts/website_i18n.py +++ b/scripts/website_i18n.py @@ -25,11 +25,27 @@ LITERALS = {"REA", "Reverse Engineer Anything", "REA · Reverse Engineer Anythin FALLBACK_NOTICE = "Translation is being updated. Read this page in English for now." +def readme_edition_paths(root): + """Return repo-relative README editions: root English plus docs/readme translations.""" + editions = set() + if (root / "README.md").is_file(): + editions.add("README.md") + folder = root / "docs" / "readme" + if folder.is_dir(): + editions.update( + f"docs/readme/{path.name}" + for path in folder.glob("README_*.md") + if path.is_file() + ) + return editions + + def languages(root): """Load the website language inventory and check it against README editions.""" path = root / "website/locales/languages.json" + editions = readme_edition_paths(root) if not path.exists(): - if any(root.glob("README_*.md")): + if any(edition != "README.md" for edition in editions): raise ValueError("Missing languages.json for the translated README editions") return [{"code": "en", "name": "English", "readme": "README.md", "dir": "ltr"}] data = json.loads(path.read_text(encoding="utf-8")) @@ -49,8 +65,7 @@ def languages(root): raise ValueError(f"Duplicate README edition: {item['readme']}") codes.add(item["code"]) filenames.add(item["readme"]) - readmes = {path.name for path in root.glob("README*.md")} - if readmes and ({item["readme"] for item in data} != readmes): + if editions and ({item["readme"] for item in data} != editions): raise ValueError("Website languages must match the README language editions") return data diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 0ba043721..fd7abf65b 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -118,14 +118,21 @@ it("reuses only scheduled qualification and preserves every explicit full reques ).toBe("fast"); }); -it("keeps every translated root README and authored guide on the documentation lane", async () => { +it("keeps every translated README and authored guide on the documentation lane", async () => { const { directory, base } = await fixture(); const readmes = (await readdir(new URL("../../../", import.meta.url))).filter( (name) => /^README(?:_.*)?\.md$/u.test(name), ); + const translated = ( + await readdir(new URL("../../../docs/readme/", import.meta.url)) + ) + .filter((name) => /^README_.*\.md$/u.test(name)) + .map((name) => `docs/readme/${name}`); expect(readmes).toContain("README.md"); + expect(translated.length).toBeGreaterThan(0); const head = await change(directory, [ ...readmes, + ...translated, "docs/guide.md", "AGENTS.md", "CONTRIBUTING.md", @@ -198,7 +205,7 @@ it("emits an observational owner proposal without changing ordinary or full port it("does not charge a documentation PR for unrelated implementation updates on main", async () => { const { directory, base } = await fixture(); await git(directory, ["switch", "-c", "docs/change"]); - const head = await change(directory, ["README_zh-TW.md"]); + const head = await change(directory, ["docs/readme/README_zh-TW.md"]); await git(directory, ["switch", "main"]); const advancedBase = await change(directory, ["src/anotherProvider.ts"]); expect(advancedBase).not.toBe(base); @@ -240,7 +247,7 @@ it("retains complete checks when implementation is renamed into documentation", it("keeps main pushes scoped when a successful ancestor baseline is available", async () => { const { directory, base } = await fixture(); - const head = await change(directory, ["README_ja.md"]); + const head = await change(directory, ["docs/readme/README_ja.md"]); expect(await classify(directory, base, head, "push")).toMatchObject({ tier: "fast", code: false, @@ -474,7 +481,10 @@ it.each([ { code: false, website: true, package: false }, ], ["scripts/website_i18n.py", { code: false, website: true, package: false }], - ["README_ja.md", { code: false, docs: true, website: true, package: false }], + [ + "docs/readme/README_ja.md", + { code: false, docs: true, website: true, package: false }, + ], [ ".github/workflows/website-pages.yml", { code: false, website: true, package: false }, @@ -928,7 +938,7 @@ it.each(["workflow_dispatch", "schedule"])( it("honors ci:full for successive PR revisions", async () => { const { directory, base } = await fixture(); const first = await change(directory, ["README.md"]); - const second = await change(directory, ["README_ja.md"]); + const second = await change(directory, ["docs/readme/README_ja.md"]); for (const head of [first, second]) { expect( await classify(directory, base, head, "pull_request", true), diff --git a/tests/boundary/filesystem/productCatalog.test.ts b/tests/boundary/filesystem/productCatalog.test.ts index 1c5f3ebcd..2d79506f6 100644 --- a/tests/boundary/filesystem/productCatalog.test.ts +++ b/tests/boundary/filesystem/productCatalog.test.ts @@ -37,6 +37,53 @@ import { const root = fileURLToPath(new URL("../../..", import.meta.url)); const temporaryRoots: string[] = []; +const README_FACT_FIXTURE_PATHS = [ + "README.md", + "docs/readme/README_zh.md", + "docs/readme/README_ja.md", + "docs/readme/README_ko.md", + "docs/readme/README_ar.md", + "website/locales/languages.json", + "docs/installation.md", + "AGENTS.md", + ".github/pull_request_template.md", +] as const; + +const README_LINK_DRIFT_MUTATIONS = [ + [ + "(../installation.md#supported-agents)", + "(../installation.md#missing-agents)", + ], + [ + "https://rea.tools/zh-CN/guides/native/", + "https://rea.tools/zh-CN/missing/", + ], + [ + "https://rea.tools/zh-CN/guides/native/", + "https://rea.tools/zh-TW/guides/native/", + ], + [ + "https://rea.tools/zh-CN/guides/native/", + "https://rea.tools/zh-CN/guides/native/?other=1", + ], + [ + 'src="../assets/rea-hopper-analysis.png"', + 'src="../assets/missing-hopper.png"', + ], + ['href="https://discord.gg/GkcryMnJDM"', 'href="https://discord.gg/missing"'], +] as const; + +const seedReadmeFactFixture = async (directory: string) => { + for (const path of README_FACT_FIXTURE_PATHS) { + const destination = join(directory, path); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(join(root, path), destination); + } + await mkdir(join(directory, "skills/reverse-engineer-anything"), { + recursive: true, + }); +}; + afterEach(async () => { await Promise.all( temporaryRoots @@ -176,49 +223,14 @@ describe("canonical product catalog drift", () => { it("keeps translated readmes linked to canonical setup and tool details", async () => { const directory = await createTestTempDirectory("rea-readme-facts-"); temporaryRoots.push(directory); - const paths = [ - "README.md", - "README_zh.md", - "README_ja.md", - "README_ko.md", - "README_ar.md", - "website/locales/languages.json", - "docs/installation.md", - "AGENTS.md", - ".github/pull_request_template.md", - ]; - for (const path of paths) { - const destination = join(directory, path); - await mkdir(dirname(destination), { recursive: true }); - await copyFile(join(root, path), destination); - } - await mkdir(join(directory, "skills/reverse-engineer-anything"), { - recursive: true, - }); - const translatedPath = join(directory, "README_zh.md"); + await seedReadmeFactFixture(directory); + const translatedPath = join(directory, "docs/readme/README_zh.md"); const translated = await readFile(translatedPath, "utf8"); const catalog = await createProductCatalog(root); await expect(documentationFactIssues(directory, catalog)).resolves.toEqual( [], ); - for (const [original, changed] of [ - [ - "(docs/installation.md#supported-agents)", - "(docs/installation.md#missing-agents)", - ], - [ - "https://rea.tools/zh-CN/guides/native/", - "https://rea.tools/zh-CN/missing/", - ], - [ - "https://rea.tools/zh-CN/guides/native/", - "https://rea.tools/zh-TW/guides/native/", - ], - [ - "https://rea.tools/zh-CN/guides/native/", - "https://rea.tools/zh-CN/guides/native/?other=1", - ], - ] as const) { + for (const [original, changed] of README_LINK_DRIFT_MUTATIONS) { expect(translated).toContain(original); await writeFile( translatedPath, @@ -228,11 +240,10 @@ describe("canonical product catalog drift", () => { await expect( documentationFactIssues(directory, catalog), ).resolves.toEqual([ - "README_zh.md: documentation links differ from README.md", + "docs/readme/README_zh.md: documentation links differ from README.md", ]); } }); - it("fails check mode without rewriting a stale generated artifact", async () => { const directory = await createTestTempDirectory("rea-generated-check-"); temporaryRoots.push(directory); diff --git a/tests/conformance/readmeConsistency.test.ts b/tests/conformance/readmeConsistency.test.ts index 90f8e5886..18d5eeae5 100644 --- a/tests/conformance/readmeConsistency.test.ts +++ b/tests/conformance/readmeConsistency.test.ts @@ -4,26 +4,6 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { PRODUCT_IDENTITY } from "../../src/identity.js"; -const translatedReadmes = [ - "README_zh.md", - "README_zh-TW.md", - "README_ja.md", - "README_ko.md", - "README_tr.md", - "README_ru.md", - "README_vi.md", - "README_th.md", - "README_de.md", - "README_es.md", - "README_fr.md", - "README_uk.md", - "README_pl.md", - "README_pt-BR.md", - "README_ar.md", - "README_fa.md", - "README_id.md", -] as const; - const jsonExamples = (content: string): unknown[] => [...content.matchAll(/```json\s*([\s\S]*?)```/gu)].map((match): unknown => JSON.parse(match[1] ?? ""), @@ -64,6 +44,14 @@ describe("onboarding documentation product facts", () => { }); it("keeps translated onboarding commands aligned with the English examples", async () => { + const inventory = JSON.parse( + await readFile(resolve("website/locales/languages.json"), "utf8"), + ) as { code: string; readme: string }[]; + const translatedReadmes = inventory + .filter((language) => language.code !== "en") + .map((language) => language.readme); + expect(translatedReadmes).toContain("docs/readme/README_it.md"); + expect(translatedReadmes).toHaveLength(18); const english = shellExamples(await readFile(resolve("README.md"), "utf8")); for (const path of translatedReadmes) expect( @@ -71,4 +59,21 @@ describe("onboarding documentation product facts", () => { path, ).toEqual(english); }); + + it("preserves Persian joiners in links to GitHub-rendered headings", async () => { + const persian = await readFile(resolve("docs/readme/README_fa.md"), "utf8"); + // Confirmed against GitHub's rendered README, which retains U+200C. + for (const heading of [ + "REA چگونه کار می‌کند", + "چه چیزهایی را می‌توان تحلیل کرد", + "نمونه‌های عملی", + "پرسش‌های متداول", + "به‌روزرسانی REA", + ]) { + expect(persian).toContain(`## ${heading}`); + expect(persian).toContain( + `](#${heading.toLowerCase().replaceAll(" ", "-")})`, + ); + } + }); }); diff --git a/website/locales/README.md b/website/locales/README.md index de337f6cc..49f3c0d6e 100644 --- a/website/locales/README.md +++ b/website/locales/README.md @@ -113,7 +113,7 @@ translated fragments. ## Language coverage and initial translations -`languages.json` lists the same editions as the root README, with native names, +`languages.json` lists the same editions as the English root README and the translations in `docs/readme/`, with native names, standard language tags and text direction. English keeps its existing routes; other editions use a language prefix such as `/ja/` or `/pt-BR/`. Arabic and Persian use right-to-left page layouts; code and numeric demos remain left to diff --git a/website/locales/languages.json b/website/locales/languages.json index 49e004304..50c110e8b 100644 --- a/website/locales/languages.json +++ b/website/locales/languages.json @@ -3,49 +3,109 @@ { "code": "zh-CN", "name": "简体中文", - "readme": "README_zh.md", + "readme": "docs/readme/README_zh.md", "dir": "ltr" }, { "code": "zh-TW", "name": "繁體中文", - "readme": "README_zh-TW.md", + "readme": "docs/readme/README_zh-TW.md", + "dir": "ltr" + }, + { + "code": "ja", + "name": "日本語", + "readme": "docs/readme/README_ja.md", + "dir": "ltr" + }, + { + "code": "ko", + "name": "한국어", + "readme": "docs/readme/README_ko.md", + "dir": "ltr" + }, + { + "code": "tr", + "name": "Türkçe", + "readme": "docs/readme/README_tr.md", + "dir": "ltr" + }, + { + "code": "ru", + "name": "Русский", + "readme": "docs/readme/README_ru.md", "dir": "ltr" }, - { "code": "ja", "name": "日本語", "readme": "README_ja.md", "dir": "ltr" }, - { "code": "ko", "name": "한국어", "readme": "README_ko.md", "dir": "ltr" }, - { "code": "tr", "name": "Türkçe", "readme": "README_tr.md", "dir": "ltr" }, - { "code": "ru", "name": "Русский", "readme": "README_ru.md", "dir": "ltr" }, { "code": "vi", "name": "Tiếng Việt", - "readme": "README_vi.md", + "readme": "docs/readme/README_vi.md", + "dir": "ltr" + }, + { + "code": "th", + "name": "ไทย", + "readme": "docs/readme/README_th.md", + "dir": "ltr" + }, + { + "code": "de", + "name": "Deutsch", + "readme": "docs/readme/README_de.md", + "dir": "ltr" + }, + { + "code": "es", + "name": "Español", + "readme": "docs/readme/README_es.md", + "dir": "ltr" + }, + { + "code": "fr", + "name": "Français", + "readme": "docs/readme/README_fr.md", "dir": "ltr" }, - { "code": "th", "name": "ไทย", "readme": "README_th.md", "dir": "ltr" }, - { "code": "de", "name": "Deutsch", "readme": "README_de.md", "dir": "ltr" }, - { "code": "es", "name": "Español", "readme": "README_es.md", "dir": "ltr" }, - { "code": "fr", "name": "Français", "readme": "README_fr.md", "dir": "ltr" }, { "code": "uk", "name": "Українська", - "readme": "README_uk.md", + "readme": "docs/readme/README_uk.md", + "dir": "ltr" + }, + { + "code": "pl", + "name": "Polski", + "readme": "docs/readme/README_pl.md", "dir": "ltr" }, - { "code": "pl", "name": "Polski", "readme": "README_pl.md", "dir": "ltr" }, { "code": "pt-BR", "name": "Português (Brasil)", - "readme": "README_pt-BR.md", + "readme": "docs/readme/README_pt-BR.md", "dir": "ltr" }, - { "code": "ar", "name": "العربية", "readme": "README_ar.md", "dir": "rtl" }, - { "code": "fa", "name": "فارسی", "readme": "README_fa.md", "dir": "rtl" }, + { + "code": "ar", + "name": "العربية", + "readme": "docs/readme/README_ar.md", + "dir": "rtl" + }, + { + "code": "fa", + "name": "فارسی", + "readme": "docs/readme/README_fa.md", + "dir": "rtl" + }, { "code": "id", "name": "Bahasa Indonesia", - "readme": "README_id.md", + "readme": "docs/readme/README_id.md", "dir": "ltr" }, - { "code": "it", "name": "Italiano", "readme": "README_it.md", "dir": "ltr" } + { + "code": "it", + "name": "Italiano", + "readme": "docs/readme/README_it.md", + "dir": "ltr" + } ] From c85abacdb27e275880722fcb7b4138c54e43a87b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:26:23 +0800 Subject: [PATCH 86/92] fix(ida): mock legacy list_functions in attached transport tests The attached fixture negotiates the legacy protocol, so list_procedures calls list_functions, never list_funcs. The list_funcs mocks added in #2449 never fired and both tests failed on arrival. No product change. --- src/ida/IdaSessionClient.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index c15b34bba..4de487689 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -1413,7 +1413,7 @@ it("binds retained transport diagnostics to the requested operation and recovery const { producer, client } = await fixture("attached"); await client.execute("health", {}); producer.beforeCall = async (name) => { - if (name === "list_funcs") + if (name === "list_functions") throw new ProviderAdapterError("ida", "transport", { diagnostics: { reason: "upstream closed" }, capturedOutput: { @@ -1443,7 +1443,7 @@ it("retains transport diagnostics on a cancellation regardless of stderr", async await client.execute("health", {}); const controller = new AbortController(); producer.beforeCall = async (name) => { - if (name === "list_funcs") { + if (name === "list_functions") { controller.abort(); throw new ProviderAdapterError("ida", "transport", { diagnostics: { reason: "upstream closed after cancellation" }, From 7369b5ee63c69af8af18601bb1de92dd41c6a250 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:27:26 +0800 Subject: [PATCH 87/92] fix(ida): retain cleanup facts and let one-shot commands exit (#2814) * fix(ida): let one-shot commands exit after retaining cleanup When a headless IDA request outlives timeoutMs (for example idb_open on a large binary still running auto-analysis), REA cannot prove the worker stopped and correctly retains the upstream connection and private workspace. The retained stdio child and its pipes stayed referenced, so a one-shot CLI printed its cleanup_incomplete result and then never exited, keeping the supervisor, worker and database alive. Detach the retained connection's child process and pipes from the event loop when cleanup fails. A long-lived MCP server can still retry through the retained connection; a one-shot CLI exits with status 1, and the supervisor observes end of input. Co-Authored-By: Claude Opus 5.5 * fix(ida): report an unverified upstream worker after failed cleanup Upstream ida-pro-mcp spawns headless workers in their own session and deliberately leaves them running when its supervisor exits, so neither a retained connection nor REA exiting proves that a timed-out worker stopped. When cleanup fails before the owned worker's release is confirmed, the cleanup diagnostics now name its private session and state that it may still be running until it finishes and reaches its idle timeout. Failures after a confirmed release, or before any open was attempted, do not report a worker. Co-Authored-By: Claude Opus 5.5 * fix(ida): name the inventory sessions that block worker release When idb_close confirmed the requested worker gone but the follow-up inventory still found a different peer on the private input, the upstream_worker diagnostic named the released session and could misdirect manual cleanup. Record the session ids of the inventory entries that actually blocked release and report those; fall back to the requested private session only when no inventory evidence exists (an incomplete open or an unanswered request). The field becomes session_ids, since more than one entry can block release. Co-Authored-By: Claude Opus 5.5 * fix(ida): keep blocker evidence and backend in cleanup diagnostics The release diagnostic reset its observed blockers on every close, so a retry whose inventory was unavailable fell back to the already-released session; and it kept only session ids, describing a blocking GUI session with detached-worker guidance. Blocker evidence is now replaced only by a successful inventory, and the diagnostic becomes upstream_sessions with each session's id and backend, with guidance that distinguishes workers from GUI sessions. Co-Authored-By: Claude Opus 5.5 * fix(ida): stop naming a worker after its close was confirmed When idb_close confirmed the requested worker's release but the following inventory failed, no blocker evidence existed yet and the diagnostic fell back to the confirmed-absent worker. After a confirmed close the diagnostic now reports an empty session list, meaning any remaining same-input sessions are unknown. Co-Authored-By: Claude Opus 5.5 * test(ida): group cleanup diagnostic cases in their own suite The added cases pushed the reaped-worker describe block past the 150-line limit enforced by lint:built. Co-Authored-By: Claude Opus 5.5 * fix(ida): keep ownership and pre-close peers in cleanup diagnostics Blocking sessions dropped the parsed ownership fact, so an external unowned worker looked the same as REA's own; and recovery inventory recorded same-input peers only when the requested worker was missing, so a peer seen beside it was lost if the following close failed. Each reported session now carries owned (null when no inventory has reported it), and recovery records every same-input session until a later successful inventory replaces that evidence. Co-Authored-By: Claude Opus 5.5 * fix(ida): drop a confirmed worker from retained cleanup blockers Recovery inventory now records the requested worker beside any peers, so once idb_close confirmed its release a failing follow-up inventory still reported it as unreleased. Remove the confirmed session from the retained blockers at close confirmation and keep the remaining peers. Co-Authored-By: Claude Opus 5.5 * test(ida): retry cleanup through a detached stdio connection The retry coverage used a recording connection whose detach() only counts calls, so it could not show that detaching keeps a retained connection usable. Run a real stdio supervisor in a long-lived host, fail the first post-close inventory, and require the retry through the same detached connection to confirm release and remove the workspace. The test fails if detach() destroys the pipes instead of unref-ing them. Co-Authored-By: Claude Opus 5.5 * fix(ida): close the stdio supervisor in failed retry tests Merge main and give its new IdaNativeProtocol connection double the required detach() method. The real-stdio retry test now closes its connection in an unconditional teardown, so a failing assertion cannot leave the supervisor waiting on stdin. Co-Authored-By: Claude Opus 5.5 * test(ida): share the cleanup diagnostic expectation in session tests Repeated upstream_sessions assertions and the unrelated live peer literal pushed repository duplication over the jscpd threshold. Build the expected diagnostic with one helper and reuse one peer constant. Co-Authored-By: Claude Opus 5.5 * test(ida): distinguish observed ownership from unreported input bindings * fix(ida): keep requested-ID conflicts in recovery cleanup Recovery inventory kept only same-input entries, unlike the startup and post-close filters. After an explicitly rejected open, an entry under the requested session ID on another input was dropped, so cleanup could close the connection, remove the private workspace and report success while that session stayed registered. Match the requested ID as well so the entry is retained as a blocker and cleanup stays incomplete. Also split the cleanup diagnostics suite under the lint:built function length limit, and make two startup-inventory fixtures pass a defined session id so the test file typechecks. Co-Authored-By: Claude Opus 5.5 * fix(ida): preserve retained session inventory facts --------- Co-authored-by: Xingchuan Ma Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto --- src/ida/IdaSessionClient.test.ts | 57 +++++++++++++++++++ src/ida/IdaSessionClient.ts | 32 +++++++---- .../cli/idaRetainedCleanupCli.test.ts | 1 + 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 4de487689..d126cfbfd 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -1077,6 +1077,7 @@ describe("headless IDA cleanup diagnostics", () => { backend: "worker", owned: false, input_path: producer.inputPath, + is_active: false, }, ), ); @@ -1368,6 +1369,62 @@ describe("headless IDA cleanup verification", () => { }); }); +it("retains a conflicting requested session ID during pre-close recovery", async () => { + const { producer, client, root } = await fixture("headless"); + producer.overrideCall = (name) => + name === "idb_list" + ? { + sessions: [ + { + session_id: String(producer.database), + input_path: "/conflicting/input", + owned: true, + backend: "worker", + is_active: false, + }, + ], + } + : undefined; + expect((await client.execute("health", {})).ok).toBe(false); + const requested = String(producer.database); + const result = await client.close(); + expect(result).toMatchObject( + reportsSessions({ + session_id: requested, + input_path: "/conflicting/input", + owned: true, + backend: "worker", + is_active: false, + }), + ); + expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false); + expect( + (await readdir(root)).some((name) => name.startsWith("rea-ida-")), + ).toBe(true); +}); + +it.each(["unavailable", "malformed"] as const)( + "does not invent a worker after explicit rejection and %s inventory", + async (kind) => { + const { producer, client } = await fixture("headless"); + producer.overrideCall = (name) => + name === "idb_open" + ? { error: "rejected before worker creation" } + : name === "idb_list" && kind === "malformed" + ? {} + : undefined; + expect((await client.execute("health", {})).ok).toBe(false); + producer.beforeCall = async (name) => { + if (name === "idb_list" && kind === "unavailable") + throw new Error("inventory unavailable"); + }; + const result = await client.close(); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected retained cleanup"); + expect(result.error).not.toHaveProperty("diagnostics.upstream_sessions"); + }, +); + it.each(["idb_close", "idb_list", "connection_close"])( "preserves transport stderr through cleanup at %s", async (stage) => { diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index cff968132..d157b5209 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -98,6 +98,8 @@ interface UpstreamSession { readonly owned: boolean | null; /** Last inventoried input binding; null until upstream reports it. */ readonly input_path: string | null; + /** Last inventoried activity; null before upstream reports it. */ + readonly is_active: boolean | null; } /** A failed cleanup retains its private workspace and reports the remaining resource. */ @@ -130,15 +132,16 @@ class IdaCleanupError extends ProviderAdapterError { upstream_sessions: { state: "unverified", sessions: sessions.map( - ({ session_id, backend, owned, input_path }) => ({ + ({ session_id, backend, owned, input_path, is_active }) => ({ session_id, backend, owned, input_path, + is_active, }), ), reason: - "Release of upstream sessions matching the requested session ID or private input was not confirmed; input_path preserves the last observed binding and is null when no inventory has reported it. A worker outlives its supervisor and may keep running until it finishes and reaches its idle timeout; exiting REA does not stop it. A gui session belongs to an IDA instance REA does not control. An empty session_id means upstream reported none; owned is null when no inventory has reported ownership; an empty list means the requested worker's close was confirmed but no inventory has identified any remaining session.", + "Release of upstream sessions matching the requested session ID or private input was not confirmed; input_path preserves the last observed binding and is null when no inventory has reported it. is_active preserves registry activity and is null when unknown; it does not establish process liveness. A worker outlives its supervisor and may keep running until it finishes and reaches its idle timeout; exiting REA does not stop it. A gui session belongs to an IDA instance REA does not control. An empty session_id means upstream reported none; owned is null when no inventory has reported ownership; an empty list means the requested worker's close was confirmed but no inventory has identified any remaining session.", }, }), }, @@ -691,7 +694,12 @@ export class IdaSessionClient implements AnalysisClient { */ #unreleasedSessions(): readonly UpstreamSession[] | undefined { if (this.#blockingSessions !== undefined) return this.#blockingSessions; - if (!this.#openAttempted || this.#workspace === undefined) return undefined; + if ( + !this.#openAttempted || + this.#workspace === undefined || + this.#openRejected + ) + return undefined; return this.#databaseCloseCompleted ? [] : [ @@ -700,6 +708,7 @@ export class IdaSessionClient implements AnalysisClient { backend: "worker", owned: null, input_path: null, + is_active: null, }, ]; } @@ -719,12 +728,15 @@ export class IdaSessionClient implements AnalysisClient { ): boolean { this.#blockingSessions = entries.length > 0 - ? entries.map(({ session_id, backend, owned, input_path }) => ({ - session_id, - backend, - owned, - input_path, - })) + ? entries.map( + ({ session_id, backend, owned, input_path, is_active }) => ({ + session_id, + backend, + owned, + input_path, + is_active, + }), + ) : undefined; return entries.length > 0; } @@ -781,7 +793,7 @@ export class IdaSessionClient implements AnalysisClient { ); if (this.#database === undefined && blocked) throw new AnalysisProtocolError( - "An unowned worker still uses the private workspace; it was retained.", + "A registered session still matches the requested ID or private input without a verified private worker binding; the workspace was retained.", ); if ( this.#database === undefined && diff --git a/tests/boundary/cli/idaRetainedCleanupCli.test.ts b/tests/boundary/cli/idaRetainedCleanupCli.test.ts index b31903489..826bb7f1d 100644 --- a/tests/boundary/cli/idaRetainedCleanupCli.test.ts +++ b/tests/boundary/cli/idaRetainedCleanupCli.test.ts @@ -79,6 +79,7 @@ describe("headless IDA timeout through the one-shot CLI", () => { backend: "worker", owned: null, input_path: null, + is_active: null, }, ], }, From 6e25cc3c761da358f799e306c2be3545830b6a3b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:32:01 +0800 Subject: [PATCH 88/92] fix(tests): clear lint and typecheck errors on main Split two over-long test functions into module-level helpers (max-lines-per-function): IdaSessionClient transport cases, IDA provider composition, and function comparison identity cases keep their names and assertions. Also fix three typecheck errors: default omitted direct_invokes_exhaustive to null so the result schema stays JsonValue compatible, parse the ghidra dossier before spreading it, and delete the optional beforeCall hook instead of assigning undefined under exactOptionalPropertyTypes. --- src/domain/analysisView/analysisView.test.ts | 2 +- src/domain/android/androidAnalysis.ts | 2 +- src/domain/functionComparison.test.ts | 92 +++++++------ src/ida/IdaSessionClient.test.ts | 2 +- tests/composition/idaProvider.test.ts | 137 ++++++++++--------- 5 files changed, 125 insertions(+), 110 deletions(-) diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 31711ecd4..4bf16e4b4 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -795,7 +795,7 @@ it("rejects unsupported parent operations and incompatible collections", () => { it("preserves selected native facet unavailability instead of an exhausted empty page", () => { const parent = nativeParent({ - ...ghidraFunctionDossier(), + ...functionDossierSchema.parse(ghidraFunctionDossier()), facet_availability: { comments: { available: false, reason: "Typed comments unavailable" }, references: { available: false, reason: "Typed references unavailable" }, diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index 507550537..c1ec76ba8 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -188,7 +188,7 @@ export const androidResultSchemas = { source_line: z.null(), dex_offset: z.null(), /** false means transport-bounded instruction evidence; null/omitted is unknown. */ - direct_invokes_exhaustive: z.boolean().nullable().optional(), + direct_invokes_exhaustive: z.boolean().nullable().default(null), /** Raw direct invoke instructions inside this using node, when available. */ direct_invokes: z .array( diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index b71dcac1d..7170f098b 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -128,6 +128,49 @@ const observe = ( }, ); +const rejectsMalformedFacetAvailability = (): void => { + expect( + functionDossierSchema.safeParse({ + ...dossier("return 0;", "0x1000"), + facet_availability: { cfg: { available: false, reason: "" } }, + }).success, + ).toBe(false); + expect( + functionDossierSchema.safeParse({ + ...dossier("return 0;", "0x1000"), + facet_availability: { cfg: undefined }, + }).success, + ).toBe(false); +}; + +const keepsGeneratedNamesAmbiguous = (): void => { + const named = (base: "0x1000" | "0x2000", name: string) => + functionDossierSchema.parse({ + ...dossier("return 0;", base), + procedure: { + ...procedure(base, name), + signature: "int helper(void)", + locals: [], + }, + }); + const generated = compareFunctions( + observe("1", named("0x1000", "sub_1000")), + observe("2", named("0x2000", "sub_2000")), + ); + expect(generated.function_match.status).toBe("ambiguous"); + expect(generated.status).toBe("unknown"); + expect( + generated.dimensions.find(({ dimension }) => dimension === "identity"), + ).toMatchObject({ status: "unknown" }); + + const stable = compareFunctions( + observe("3", named("0x1000", "firstFunction")), + observe("4", named("0x2000", "secondFunction")), + ); + expect(stable.function_match.status).toBe("mismatched"); + expect(stable.status).toBe("changed"); +}; + describe("function comparison normalized identity", () => { it("retains unresolved call details alongside missing facet coverage", () => { const value: Record = { @@ -187,47 +230,14 @@ describe("function comparison normalized identity", () => { ), ).toMatchObject({ status: "unknown", left_count: null, right_count: null }); }); - it("rejects malformed facet availability instead of treating it as absence", () => { - expect( - functionDossierSchema.safeParse({ - ...dossier("return 0;", "0x1000"), - facet_availability: { cfg: { available: false, reason: "" } }, - }).success, - ).toBe(false); - expect( - functionDossierSchema.safeParse({ - ...dossier("return 0;", "0x1000"), - facet_availability: { cfg: undefined }, - }).success, - ).toBe(false); - }); - it("keeps differing generated names ambiguous and stable names mismatched", () => { - const named = (base: "0x1000" | "0x2000", name: string) => - functionDossierSchema.parse({ - ...dossier("return 0;", base), - procedure: { - ...procedure(base, name), - signature: "int helper(void)", - locals: [], - }, - }); - const generated = compareFunctions( - observe("1", named("0x1000", "sub_1000")), - observe("2", named("0x2000", "sub_2000")), - ); - expect(generated.function_match.status).toBe("ambiguous"); - expect(generated.status).toBe("unknown"); - expect( - generated.dimensions.find(({ dimension }) => dimension === "identity"), - ).toMatchObject({ status: "unknown" }); - - const stable = compareFunctions( - observe("3", named("0x1000", "firstFunction")), - observe("4", named("0x2000", "secondFunction")), - ); - expect(stable.function_match.status).toBe("mismatched"); - expect(stable.status).toBe("changed"); - }); + it( + "rejects malformed facet availability instead of treating it as absence", + rejectsMalformedFacetAvailability, + ); + it( + "keeps differing generated names ambiguous and stable names mismatched", + keepsGeneratedNamesAmbiguous, + ); it.each(["sub_deallocate", "sub_deadbeef_handler", "fcn.dispatch"])( "recognizes the explicit symbol %s without a generated-name prefix false positive", diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index d126cfbfd..1a35dd7c7 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -1459,7 +1459,7 @@ it.each(["idb_close", "idb_list", "connection_close"])( }); expect(result.error.cleanupIncomplete).toBe(true); } finally { - producer.beforeCall = undefined; + delete producer.beforeCall; producer.close = originalClose; await client.close(); } diff --git a/tests/composition/idaProvider.test.ts b/tests/composition/idaProvider.test.ts index f6dd1dd0d..1fd17171d 100644 --- a/tests/composition/idaProvider.test.ts +++ b/tests/composition/idaProvider.test.ts @@ -22,6 +22,73 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +const reobservesExternallyMutableState = async (): Promise => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const path = join(root, "registration.json"); + await writeFile(path, JSON.stringify({ command: "fixture" })); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const producer = new RecordingIdaMcp(target); + const session = createTestBinarySession( + new IdaProvider(config.value, {}, () => producer), + ); + expect((await session.open(target.path)).ok).toBe(true); + const query = { procedure: "main", document: target.path }; + const first = await session.execute("procedure_pseudo_code", query); + expect(first.ok && first.value.result).toContain("42"); + const snapshot = session.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.entries).toEqual([]); + const profile = session.analysisProfile(); + if (profile === undefined) throw new Error("Missing profile"); + const cached = createAnalysisExecution( + "stale output", + IDA_PROVIDER_IDENTITY, + { + analysisProfile: profile, + subject: { + path: target.path, + sha256: target.sha256, + format: target.format, + ...(target.architecture === undefined + ? {} + : { architecture: target.architecture }), + }, + }, + ); + snapshot.value.entries.push( + createAnalysisSnapshotEntry({ + target: snapshotTarget(target), + binding: snapshotBinding(profile), + operation: "procedure_pseudo_code", + parameters: query, + execution: cached, + }), + ); + snapshot.value.evidence_bundle = createEvidenceBundle([ + ...snapshot.value.evidence_bundle.records, + createEvidence(target, IDA_PROVIDER_IDENTITY, { + operation: "procedure_pseudo_code", + parameters: query, + result: cached.result, + rawResult: cached.rawResult, + analysisProfile: profile, + }), + ]); + expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true); + expect(session.allowsSnapshotReplay("procedure_pseudo_code")).toBe(false); + expect(session.allowsSnapshotReplay("binary_overview")).toBe(false); + producer.pseudocode = "int main(void) { return 99; }"; + const second = await session.execute("procedure_pseudo_code", query); + expect(second.ok && second.value.result).toContain("99"); + expect( + producer.calls.filter(({ name }) => name === "decompile_function"), + ).toHaveLength(2); + await session.close(); + expect(producer.closes).toBe(1); +}; + describe("IDA provider composition", () => { it("discovers without launching and commits adapter semantics without persisting credentials", async () => { const { root, target } = await createIdaTarget(); @@ -59,72 +126,10 @@ describe("IDA provider composition", () => { .find(({ operation }) => operation === "procedure_callers")?.available, ).toBe(false); }); - it("re-observes externally mutable state even when a document is explicit and a snapshot is imported", async () => { - const { root, target } = await createIdaTarget(); - roots.push(root); - const path = join(root, "registration.json"); - await writeFile(path, JSON.stringify({ command: "fixture" })); - const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); - if (!config.ok) throw config.error; - const producer = new RecordingIdaMcp(target); - const session = createTestBinarySession( - new IdaProvider(config.value, {}, () => producer), - ); - expect((await session.open(target.path)).ok).toBe(true); - const query = { procedure: "main", document: target.path }; - const first = await session.execute("procedure_pseudo_code", query); - expect(first.ok && first.value.result).toContain("42"); - const snapshot = session.exportAnalysisSnapshot(); - if (!snapshot.ok) throw snapshot.error; - expect(snapshot.value.entries).toEqual([]); - const profile = session.analysisProfile(); - if (profile === undefined) throw new Error("Missing profile"); - const cached = createAnalysisExecution( - "stale output", - IDA_PROVIDER_IDENTITY, - { - analysisProfile: profile, - subject: { - path: target.path, - sha256: target.sha256, - format: target.format, - ...(target.architecture === undefined - ? {} - : { architecture: target.architecture }), - }, - }, - ); - snapshot.value.entries.push( - createAnalysisSnapshotEntry({ - target: snapshotTarget(target), - binding: snapshotBinding(profile), - operation: "procedure_pseudo_code", - parameters: query, - execution: cached, - }), - ); - snapshot.value.evidence_bundle = createEvidenceBundle([ - ...snapshot.value.evidence_bundle.records, - createEvidence(target, IDA_PROVIDER_IDENTITY, { - operation: "procedure_pseudo_code", - parameters: query, - result: cached.result, - rawResult: cached.rawResult, - analysisProfile: profile, - }), - ]); - expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true); - expect(session.allowsSnapshotReplay("procedure_pseudo_code")).toBe(false); - expect(session.allowsSnapshotReplay("binary_overview")).toBe(false); - producer.pseudocode = "int main(void) { return 99; }"; - const second = await session.execute("procedure_pseudo_code", query); - expect(second.ok && second.value.result).toContain("99"); - expect( - producer.calls.filter(({ name }) => name === "decompile_function"), - ).toHaveLength(2); - await session.close(); - expect(producer.closes).toBe(1); - }); + it( + "re-observes externally mutable state even when a document is explicit and a snapshot is imported", + reobservesExternallyMutableState, + ); it("rejects unsupported target kinds and reports incomplete upstream profiles on first analysis", async () => { const { root, target } = await createIdaTarget(); roots.push(root); From 1e38a038c61096a9213ee4f81c20f36211ae75a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Andrade?= <95101505+viniciusdsandrade@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:34:54 -0300 Subject: [PATCH 89/92] fix(process): release handles and close worker on incomplete cleanup (#2321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(process): unreference handles on incomplete provider process stop Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> * fix(javascript): close worker and release handles on incomplete cleanup Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> * fix(javascript): defer worker stdin close until incomplete cleanup Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> * test(javascript): assert cleanup error instance on inspection uncertainty Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> --------- Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../analysis/JavaScriptAnalysisWorker.ts | 25 +++- src/process/ProviderProcess.ts | 22 ++++ src/process/ProviderProcessOutput.test.ts | 37 ++++++ src/windows/WindowsOwnedProcess.ts | 14 ++ .../javascriptSourceAnalysisWorker.test.ts | 121 ++++++++++++++++++ 5 files changed, 217 insertions(+), 2 deletions(-) diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 6a587915b..de7ac6c42 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -83,6 +83,7 @@ export class JavaScriptAnalysisWorker { readonly #resultBudgetBytes: number; /** Null when the runtime enforces the V8 heap flags instead. */ readonly #processMemoryLimitBytes: number | null; + readonly #onStdinError = (): void => {}; readonly #onStdout = (chunk: Buffer | string): void => { try { this.#pending += @@ -784,20 +785,40 @@ export class JavaScriptAnalysisWorker { if (this.#supervisor !== undefined) { const supervisor = this.#supervisor; const stopped = await supervisor.stop(); + supervisor.launch.process.stdout?.off("data", this.#onStdout); if (stopped.status === "incomplete") { + try { + const stdin = supervisor.launch.process.stdin; + if ( + stdin !== undefined && + stdin !== null && + !stdin.destroyed && + !stdin.writableEnded + ) { + stdin.off("error", this.#onStdinError); + stdin.once("error", this.#onStdinError); + stdin.end(); + } + } catch { + // Cooperative protocol stdin may already be closed or broken. + } + this.#resetState(); pendingCleanup.add(this); return this.#cleanupError(stopped.reason, original); } - supervisor.launch.process.stdout?.off("data", this.#onStdout); supervisor.dispose(); this.#supervisor = undefined; } + this.#resetState(); + return undefined; + } + + #resetState(): void { this.#ready = false; this.#frames = []; this.#pending = ""; this.#decoder = new StringDecoder("utf8"); this.#protocolFailure = undefined; - return undefined; } } diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index d7b95613f..8e650898b 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -61,6 +61,8 @@ export interface ProviderProcessHandle extends Pick< /** True once an enforced process memory limit reported an allocation failure. */ readonly memoryLimitReached?: boolean; kill(signal?: NodeJS.Signals | number): boolean; + unref?(): void; + ref?(): void; } /** Process handle returned by a provider-specific launcher. */ @@ -371,13 +373,33 @@ export class ProviderProcessSupervisor { this.launch.process.off("error", this.#onError); } + /** Release event-loop references so incomplete cleanup does not prevent host exit. */ + unref(): void { + const process = this.launch.process; + process.unref?.(); + (process.stdin as { unref?: () => void } | null | undefined)?.unref?.(); + (process.stdout as { unref?: () => void } | null | undefined)?.unref?.(); + (process.stderr as { unref?: () => void } | null | undefined)?.unref?.(); + } + + /** Restore event-loop references when retrying supervised process cleanup. */ + ref(): void { + const process = this.launch.process; + process.ref?.(); + (process.stdin as { ref?: () => void } | null | undefined)?.ref?.(); + (process.stdout as { ref?: () => void } | null | undefined)?.ref?.(); + (process.stderr as { ref?: () => void } | null | undefined)?.ref?.(); + } + async #stop( options: ProviderProcessStopOptions, ): Promise { + this.ref(); let completed = false; try { const result = await this.#stopWithin(options); completed = result.status !== "incomplete"; + if (!completed) this.unref(); return result; } finally { if (completed) this.dispose(); diff --git a/src/process/ProviderProcessOutput.test.ts b/src/process/ProviderProcessOutput.test.ts index 28dc87dff..3ca2621f0 100644 --- a/src/process/ProviderProcessOutput.test.ts +++ b/src/process/ProviderProcessOutput.test.ts @@ -121,3 +121,40 @@ it("reports a reached process memory limit only when the handle observed one", ( supervisor.dispose(); } }); + +it("unreferences process and stream handles when stop reports incomplete cleanup", async () => { + const unrefProcess = vi.fn(); + const unrefStdin = vi.fn(); + const unrefStdout = vi.fn(); + const unrefStderr = vi.fn(); + const producer = Object.assign(new EventEmitter(), { + stdout: Object.assign(new PassThrough(), { unref: unrefStdout }), + stdin: Object.assign(new PassThrough(), { unref: unrefStdin }), + stderr: Object.assign(new PassThrough(), { unref: unrefStderr }), + exitCode: null, + signalCode: null, + kill: () => false, + unref: unrefProcess, + }); + const supervisor = new ProviderProcessSupervisor({ + process: producer, + ownsProcessLifetime: true, + cleanup: async () => ({ + cleaned: false, + reason: "process table could not be inspected: spawn EPERM", + }), + }); + try { + const result = await supervisor.stop(); + expect(result).toMatchObject({ + status: "incomplete", + reason: "process table could not be inspected: spawn EPERM", + }); + expect(unrefProcess).toHaveBeenCalledTimes(1); + expect(unrefStdin).toHaveBeenCalledTimes(1); + expect(unrefStdout).toHaveBeenCalledTimes(1); + expect(unrefStderr).toHaveBeenCalledTimes(1); + } finally { + supervisor.dispose(); + } +}); diff --git a/src/windows/WindowsOwnedProcess.ts b/src/windows/WindowsOwnedProcess.ts index 1b96db867..b6a530461 100644 --- a/src/windows/WindowsOwnedProcess.ts +++ b/src/windows/WindowsOwnedProcess.ts @@ -67,6 +67,7 @@ export class WindowsOwnedProcess extends EventEmitter { #closed = false; #failure: string | undefined; #timer: NodeJS.Timeout | undefined; + #unreferenced = false; #cleanup: Promise | undefined; readonly #memoryLimitBytes: number | undefined; #memoryLimitReached = false; @@ -222,6 +223,18 @@ export class WindowsOwnedProcess extends EventEmitter { return this.#cleanup; } + /** Release event-loop references so unsettled jobs do not prevent host exit. */ + unref(): void { + this.#unreferenced = true; + this.#timer?.unref(); + } + + /** Restore event-loop references when retrying process lifecycle observation. */ + ref(): void { + this.#unreferenced = false; + this.#timer?.ref(); + } + async #stop(): Promise { if (this.#closed) return { cleaned: true, signaled: false }; let timer: NodeJS.Timeout | undefined; @@ -255,6 +268,7 @@ export class WindowsOwnedProcess extends EventEmitter { #schedule(): void { if (this.#closed || this.#timer !== undefined) return; this.#timer = setTimeout(() => this.#poll(), 25); + if (this.#unreferenced) this.#timer.unref(); } #poll(): void { diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index 0e41a11bb..04ba5201f 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -1,7 +1,13 @@ import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; import { expect, it } from "vitest"; import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; +import type { OwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; @@ -151,6 +157,121 @@ it("preserves a failed owned cleanup retry when the next caller cancels before a } }); +it("ends worker stdin and unreferences process handles when cleanup reports inspection uncertainty", async () => { + let stdinEnded = false; + let unreferenced = false; + let permitCleanup = false; + let childStdin: typeof process.stdin | undefined; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 128, timeoutMs: 30_000 }, + async (request) => { + const launched = await spawnOwnedProviderProcess(request); + const originalUnref = launched.process.unref; + launched.process.unref = () => { + unreferenced = true; + originalUnref?.call(launched.process); + }; + childStdin = launched.process.stdin as unknown as typeof process.stdin; + launched.process.stdin?.once("finish", () => { + stdinEnded = true; + }); + return { + ...launched, + cleanup: async () => + permitCleanup + ? launched.cleanup === undefined + ? cleanupOwnedProcessGroup(launched.ownership) + : launched.cleanup() + : { + cleaned: false, + reason: "process table could not be inspected: spawn EPERM", + }, + }; + }, + ); + try { + const file = source("app.mjs", "export const observed = 7;"); + const result = await worker.analyze(file, 100); + if (!result.ok) throw result.error.error; + const closing = worker.close(); + await expect(closing).rejects.toBeInstanceOf(ProviderCleanupError); + await expect(closing).rejects.toMatchObject({ + cause: { + message: "process table could not be inspected: spawn EPERM", + }, + }); + expect(childStdin?.writableEnded || stdinEnded).toBe(true); + expect(unreferenced).toBe(true); + const errorListeners = childStdin?.listenerCount("error"); + for (let retry = 0; retry < 3; retry++) + await expect(worker.close()).rejects.toBeInstanceOf(ProviderCleanupError); + expect(childStdin?.listenerCount("error")).toBeLessThanOrEqual( + errorListeners ?? 0, + ); + permitCleanup = true; + await worker.close(); + } finally { + permitCleanup = true; + await worker.close().catch(() => {}); + } +}); + +it("lets a separate host exit while its live worker cleanup remains incomplete", async () => { + const root = await createTestTempDirectory("rea-worker-incomplete-host-"); + const ownershipPath = join(root, "ownership.json"); + const workerUrl = new URL( + "../../../dist/javascript/analysis/JavaScriptAnalysisWorker.js", + import.meta.url, + ).href; + const launcherUrl = new URL( + "../../../dist/process/ProviderProcess.js", + import.meta.url, + ).href; + const script = ` +import { writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { JavaScriptAnalysisWorker } from ${JSON.stringify(workerUrl)}; +import { spawnOwnedProviderProcess } from ${JSON.stringify(launcherUrl)}; +const worker = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 10000 }, async request => { + const entry = pathToFileURL(request.arguments.at(-1)).href; + const launched = await spawnOwnedProviderProcess({ ...request, arguments: [ + ...request.arguments.slice(0, -1), "--input-type=module", "-e", + "const interval = setInterval(() => {}, 1000); setTimeout(() => clearInterval(interval), 60000); await import(" + JSON.stringify(entry) + ");" + ] }); + // The launcher is deliberately orphaned when this host exits; cleanup still + // requires its run token and executable identity, rather than its old parent. + const { expectedParentPid, ...ownership } = launched.ownership; + await writeFile(${JSON.stringify(ownershipPath)}, JSON.stringify(ownership)); + return { ...launched, cleanup: async () => ({ cleaned: false, reason: "fixture inspection unavailable" }) }; +}); +const analyzed = await worker.analyze(${JSON.stringify(source("app.mjs", "export const value = 1;"))}, 100); +if (!analyzed.ok) throw analyzed.error.error; +try { await worker.close(); throw new Error("cleanup unexpectedly completed"); } +catch (error) { + if (error.cleanupIncomplete !== true) throw error; + process.stdout.write(JSON.stringify({ code: "cleanup_incomplete", reason: error.cause.message }) + "\\n"); +} +`; + try { + const result = await promisify(execFile)( + process.execPath, + ["--input-type=module", "-e", script], + { timeout: 10_000 }, + ); + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "cleanup_incomplete", + reason: "fixture inspection unavailable", + }); + } finally { + const ownership = JSON.parse( + await readFile(ownershipPath, "utf8"), + ) as OwnedProcessGroup; + expect(await cleanupOwnedProcessGroup(ownership)).toMatchObject({ + cleaned: true, + }); + } +}); + // The handshake mirrors the real server: only Node.js reports an enforced V8 heap limit. const resourceFailureWorker = (heapLimit: string) => new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }, (request) => From edd99398797051f8910e777bf5bf94aea5756f4a Mon Sep 17 00:00:00 2001 From: The one -Niu <912906590@qq.com> Date: Tue, 13 Oct 2026 22:35:45 +0800 Subject: [PATCH 90/92] fix(browser): give the DevToolsActivePort wait one deadline owner (#2265) * Single owner for the Chrome DevToolsActivePort startup deadline Cold Chrome startup tolerance was spelled three ways for one wait: the helper fell back to 15,000 ms, the runtime fixture lifecycle passed a literal 60,000, and the real-browser verifier kept a local 60,000 constant. A per-caller literal quietly changes how long a lane tolerates a cold browser before it fails, and the failure reported only elapsed_ms, so a reader could not see which ceiling was hit. Export BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS as the owner, use it for the helper fallback and both call sites, and report startup_timeout_ms in BrowserStartupError. A source guard keeps callers from restating the number. * Pin the DevToolsActivePort deadline guard to the startup call argument --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../lib/browser-runtime-fixture-lifecycle.mjs | 7 ++- scripts/verify-real-browser.mjs | 8 ++- src/browser/BrowserProcessStartup.ts | 29 ++++++++-- .../browser/browserProcessStartup.test.ts | 55 +++++++++++++++++++ 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/scripts/lib/browser-runtime-fixture-lifecycle.mjs b/scripts/lib/browser-runtime-fixture-lifecycle.mjs index d39617f58..88a76e573 100644 --- a/scripts/lib/browser-runtime-fixture-lifecycle.mjs +++ b/scripts/lib/browser-runtime-fixture-lifecycle.mjs @@ -8,7 +8,10 @@ import { spawnOwnedProviderProcess } from "../../dist/process/ProviderProcess.js import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js"; import { liveProcesses } from "../../dist/process/ProcessOwnershipProcessTree.js"; import { CdpConnection } from "../../dist/browser/CdpConnection.js"; -import { waitForBrowserDevtoolsPort } from "../../dist/browser/BrowserProcessStartup.js"; +import { + BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, + waitForBrowserDevtoolsPort, +} from "../../dist/browser/BrowserProcessStartup.js"; /** Launch the synthetic fixture browser and await CDP in its own verified process group. */ export async function startRuntimeFixtureBrowser(executable, origin) { @@ -50,7 +53,7 @@ export async function startRuntimeFixtureBrowser(executable, origin) { stderr: () => stderr, // Match the broader real-browser verifier's allowance for cold Chrome // startup on shared runners; early process failures still reject at once. - timeoutMs: 60_000, + timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, }); return { port, diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 48c9bec08..27c0c5e52 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -9,7 +9,10 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js"; -import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js"; +import { + BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, + waitForBrowserDevtoolsPort, +} from "../dist/browser/BrowserProcessStartup.js"; import { createBrowserScenarioProvider } from "../dist/composition/browserScenario.js"; import { inspectWebPageInputSchema, @@ -47,7 +50,6 @@ import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs"; import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs"; import { verifyScenarioStorage } from "./lib/browser-scenario-storage-e2e.mjs"; -const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000; const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret"; const SCENARIO_URL_SECRET_VALUE = "rea-browser-url-verifier-secret"; process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE; @@ -92,7 +94,7 @@ try { executable, activePortPath: join(profile, "DevToolsActivePort"), stderr: () => stderr, - timeoutMs: REAL_BROWSER_STARTUP_TIMEOUT_MS, + timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, }); const endpoint = `http://127.0.0.1:${String(port)}`; const provider = new CdpBrowserProvider(); diff --git a/src/browser/BrowserProcessStartup.ts b/src/browser/BrowserProcessStartup.ts index 85eae0553..48367c432 100644 --- a/src/browser/BrowserProcessStartup.ts +++ b/src/browser/BrowserProcessStartup.ts @@ -14,6 +14,7 @@ export class BrowserStartupError extends Error { readonly failure: BrowserStartupFailure; readonly executable: string; readonly elapsedMs: number; + readonly startupTimeoutMs: number; readonly exitCode: number | null; readonly signalCode: NodeJS.Signals | null; readonly stderr: string; @@ -23,6 +24,7 @@ export class BrowserStartupError extends Error { readonly failure: BrowserStartupFailure; readonly executable: string; readonly elapsedMs: number; + readonly startupTimeoutMs: number; readonly exitCode: number | null; readonly signalCode: NodeJS.Signals | null; readonly stderr: string; @@ -33,12 +35,22 @@ export class BrowserStartupError extends Error { this.failure = diagnostics.failure; this.executable = diagnostics.executable; this.elapsedMs = diagnostics.elapsedMs; + this.startupTimeoutMs = diagnostics.startupTimeoutMs; this.exitCode = diagnostics.exitCode; this.signalCode = diagnostics.signalCode; this.stderr = diagnostics.stderr; } } +/** + * Single owner of the wait for Chrome to publish its DevToolsActivePort. + * + * Callers that need a different budget pass `timeoutMs`; nobody else should + * restate this number, because a per-caller literal silently changes how long + * a cold Chrome launch is tolerated before the failure names its ceiling. + */ +export const BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS = 60_000; + /** Owned child process and startup policy for Chrome CDP discovery. */ export interface BrowserStartupOptions { readonly child: ChildProcess; @@ -53,23 +65,23 @@ export interface BrowserStartupOptions { export const waitForBrowserDevtoolsPort = async ( options: BrowserStartupOptions, ): Promise => { - const timeoutMs = options.timeoutMs ?? 15_000; + const timeoutMs = options.timeoutMs ?? BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS; const pollIntervalMs = options.pollIntervalMs ?? 25; const startedAt = performance.now(); - const terminal = childTermination(options, startedAt); + const terminal = childTermination(options, startedAt, timeoutMs); const deadline = startedAt + timeoutMs; while (true) { const terminalState = currentTerminalState(options.child); if (terminalState !== null) - throw startupError(options, startedAt, terminalState); + throw startupError(options, startedAt, timeoutMs, terminalState); const port = await readDevtoolsPort(options.activePortPath); if (port !== null) return port; const remainingMs = deadline - performance.now(); if (remainingMs <= 0) - throw startupError(options, startedAt, { failure: "timeout" }); + throw startupError(options, startedAt, timeoutMs, { failure: "timeout" }); const outcome = await Promise.race([ terminal, @@ -88,11 +100,12 @@ type TerminalState = const childTermination = ( options: BrowserStartupOptions, startedAt: number, + startupTimeoutMs: number, ): Promise => new Promise((resolve) => { options.child.once("error", (cause: unknown) => { resolve( - startupError(options, startedAt, { + startupError(options, startedAt, startupTimeoutMs, { failure: "spawn-error", cause, }), @@ -103,6 +116,7 @@ const childTermination = ( startupError( options, startedAt, + startupTimeoutMs, options.child.signalCode === null ? { failure: "exited" } : { failure: "signalled" }, @@ -120,6 +134,7 @@ const currentTerminalState = (child: ChildProcess): TerminalState | null => { const startupError = ( options: BrowserStartupOptions, startedAt: number, + startupTimeoutMs: number, state: TerminalState, ): BrowserStartupError => new BrowserStartupError( @@ -127,6 +142,7 @@ const startupError = ( failure: state.failure, executable: options.executable, elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)), + startupTimeoutMs, exitCode: options.child.exitCode, signalCode: options.child.signalCode, stderr: options.stderr(), @@ -156,9 +172,10 @@ const browserStartupMessage = ( diagnostics: { readonly executable: string; readonly elapsedMs: number; + readonly startupTimeoutMs: number; readonly exitCode: number | null; readonly signalCode: NodeJS.Signals | null; readonly stderr: string; }, ): string => - `Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "" : diagnostics.stderr}`; + `Chrome CDP startup ${failure}; executable=${diagnostics.executable}; elapsed_ms=${String(diagnostics.elapsedMs)}; startup_timeout_ms=${String(diagnostics.startupTimeoutMs)}; exit_code=${diagnostics.exitCode === null ? "null" : String(diagnostics.exitCode)}; signal=${diagnostics.signalCode ?? "null"}; stderr=${diagnostics.stderr.length === 0 ? "" : diagnostics.stderr}`; diff --git a/tests/boundary/browser/browserProcessStartup.test.ts b/tests/boundary/browser/browserProcessStartup.test.ts index 91c4f58e9..b1cfaeafe 100644 --- a/tests/boundary/browser/browserProcessStartup.test.ts +++ b/tests/boundary/browser/browserProcessStartup.test.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect } from "vitest"; @@ -9,6 +10,7 @@ import { } from "../../support/process/processFixture.js"; import { + BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, BrowserStartupError, waitForBrowserDevtoolsPort, } from "../../../src/browser/BrowserProcessStartup.js"; @@ -86,15 +88,68 @@ describe("browser process startup", () => { failure: "timeout", exitCode: null, signalCode: null, + startupTimeoutMs: 20, }); expect(String(failure)).toContain("stderr="); + expect(String(failure)).toContain("startup_timeout_ms=20"); } finally { child.kill("SIGKILL"); expect(await waitForExit(child, 5_000)).toBe(true); } }); + + it("names the owner's deadline when a caller omits one", async ({ + processes, + }) => { + const root = await temporaryRoot(); + const child = processes.spawn(process.execPath, [ + "-e", + "setTimeout(() => process.exit(3), 10)", + ]); + + const failure = await waitForBrowserDevtoolsPort({ + child, + executable: process.execPath, + activePortPath: join(root, "DevToolsActivePort"), + stderr: () => "owner default fixture", + pollIntervalMs: 5, + }).catch((cause: unknown) => cause); + + expect(failure).toBeInstanceOf(BrowserStartupError); + expect(failure).toMatchObject({ + failure: "exited", + startupTimeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, + }); + expect(String(failure)).toContain( + `startup_timeout_ms=${String(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS)}`, + ); + }); + + it("keeps one owner for the DevToolsActivePort deadline", async () => { + expect(BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS).toBe(60_000); + const callers = [ + "../../../scripts/verify-real-browser.mjs", + "../../../scripts/lib/browser-runtime-fixture-lifecycle.mjs", + ]; + for (const caller of callers) { + const source = await readFile(new URL(caller, import.meta.url), "utf8"); + expect(devtoolsPortDeadlines(source)).toEqual([ + ["BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS"], + ]); + } + }); }); const temporaryRoot = async (): Promise => { return createTestTempDirectory("rea-browser-startup-"); }; + +const devtoolsPortDeadlines = (source: string): string[][] => { + const calls = /waitForBrowserDevtoolsPort\(\{(?[\s\S]*?)\}\)/gu; + const deadlines = /\btimeoutMs:\s*(?[^\n,]+)/gu; + return [...source.matchAll(calls)].map((call) => + [...String(call.groups?.body).matchAll(deadlines)].map((deadline) => + String(deadline.groups?.value).trim(), + ), + ); +}; From e103a89889d4fe5291d8f9125c62fbf1485b621c Mon Sep 17 00:00:00 2001 From: xin zhuang <65798732+1052326311@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:36:06 +0800 Subject: [PATCH 91/92] fix(artifacts): preserve NIB key and class name identity (#2109) Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/artifacts/apple/NibArchive.test.ts | 16 +++++++ .../filesystem/interfaceBuilderBundle.test.ts | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/artifacts/apple/NibArchive.test.ts b/src/artifacts/apple/NibArchive.test.ts index 10579d011..665617d28 100644 --- a/src/artifacts/apple/NibArchive.test.ts +++ b/src/artifacts/apple/NibArchive.test.ts @@ -36,6 +36,22 @@ const archiveWithOverlappingObjectRanges = (objectCount: number): Buffer => { }; describe("NIBArchive decoder", () => { + it("keeps leading U+FEFF in length-prefixed keys and class names", () => { + const archive = encodeArchive({ + classes: ["\uFEFFWidget"], + objects: [{ classIndex: 0, values: { count: 1, "\uFEFFcount": 2 } }], + }); + expect(decodeNibArchive(archive).objects).toEqual([ + { + id: 0, + class_name: "\uFEFFWidget", + values: { count: 1, "\uFEFFcount": 2 }, + }, + ]); + const invalid = Buffer.from(archive); + invalid[invalid.indexOf(Buffer.from("\uFEFFcount"))] = 0xff; + expect(() => decodeNibArchive(invalid)).toThrow(/not valid UTF-8/u); + }); it("decodes bounded object, key, class, and reference tables", () => { const archive = encodeArchive({ classes: ["NSView\0", "NSString\0"], diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index d584aaf3c..21ec633c5 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -696,6 +696,49 @@ describe("NIB projection byte amplification", () => { }); }); +cliTest( + "compiled CLI preserves distinct NIB keys and class identity", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-nib-name-identity-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(bundle, "Contents", "MacOS"), { recursive: true }); + await writeFile( + join(bundle, "Contents", "MacOS", "App"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(bundle, "Contents", "Info.plist"), + "CFBundleExecutableApp", + ); + await writeFile( + join(resources, "Identity.nib"), + encodeNibArchiveFixture({ + classes: ["\uFEFFWidget"], + objects: [{ classIndex: 0, values: { count: 1, "\uFEFFcount": 2 } }], + }), + ); + const output = await cli.run({ + arguments: ["decode-interface-builder", bundle, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + const result = interfaceBuilderAnalysisSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + class_name: "\uFEFFWidget", + nibValues: { count: 1, "\uFEFFcount": 2 }, + }), + }), + ); + expect(JSON.stringify(result)).toContain("\uFEFFWidget"); + }, +); + cliTest( "compiled CLI preserves a good archive and reports repeated key and data omissions", async ({ cli }) => { From 94072364c7f8410b0e86e72c912c2611572b278f Mon Sep 17 00:00:00 2001 From: Dragolone <160818051+Dragolone@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:37:47 +1100 Subject: [PATCH 92/92] fix(sqlite): keep triggers whose declaration spells the table in another case (#2807) * fix(sqlite): keep triggers whose declaration spells the table in another case SQLite stores a trigger's target in sqlite_schema.tbl_name exactly as written in CREATE TRIGGER and resolves it by ASCII case folding, while an index stores the table's own name. The selected_table scope filtered tbl_name with COLLATE BINARY, so a trigger declared ON users for table Users was omitted while the schema still reported complete. Match dependents with COLLATE NOCASE, and report each trigger's table as the stored table or view name so the selected-table contract still holds. * fix(sqlite): refuse an inexact table before the case-folded schema scan With the NOCASE expansion, a selection such as users for table Users expanded the stored table's declarations before readRows reported the missing exact name, so large trigger definitions could surface as an output-limit failure instead of the selection error. Check the exact table name against PRAGMA table_list first and share the selection failure with readRows. * fix(sqlite): fold dependent names only for a row-selectable table An exact view, virtual or possible shadow table passed the exact-name guard, so the NOCASE scan could still expand a differently cased trigger past the reply budget before readRows refused the selection. Fold case only when the selection is an ordinary table that is not a possible shadow table, and keep the exact filter otherwise so every refused selection fails as it did before the case-folding change. * fix(sqlite): resolve trigger tables without folding unrelated names The stored-name lookup case-folded every PRAGMA table_list name up front, so a selected-table inspection of a tiny table paid for large unrelated identifiers. A selected-table scan only returns the selected table's dependents, so report the selected name directly; other scopes use an exact stored name when one matches and fold the stored names once, only when a trigger spells its target differently. * perf(sqlite): fold each table name once when matching shadow parents The extracted shadow-parent helper folded the table name inside the find callback, once per unresolved virtual table, and the selected table was checked both before and during the schema scan. Fold the name once per call as before, and reuse the selected table's result in the scan. --- src/sqlite/SqliteDatabaseInspection.ts | 63 ++++++++++++++---- .../sqlite/sqliteDatabaseInspection.test.ts | 65 +++++++++++++++++++ 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index 510fbaafc..941634dd5 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -109,6 +109,11 @@ class OutputBudget { } const quoteIdentifier = (name: string): string => `"${name.replaceAll('"', '""')}"`; +const missingSelectedTable = (name: string): SqliteInspectionFailure => + new SqliteInspectionFailure( + "selection", + `Selected ordinary table does not exist with that exact name: ${name}`, + ); // SQLite folds only ASCII letters when matching virtual/shadow table names. const sqliteIdentifierKey = (name: string): string => name.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); @@ -236,12 +241,49 @@ const readSchema = ( metadata.set(value.name, value); } } + // A trigger stores its target as written in CREATE TRIGGER, which SQLite + // resolves by ASCII case folding; report the stored table or view name. + // A selected-table scan returns only the selected table's dependents, and + // other scopes fold stored names once, only when a spelling differs. + let foldedNames: Map | undefined; + const triggerTable = (written: string): string => { + if (selectedName !== undefined) return selectedName; + if (metadata.has(written)) return written; + foldedNames ??= new Map( + [...metadata.keys()].map((name) => [sqliteIdentifierKey(name), name]), + ); + return foldedNames.get(sqliteIdentifierKey(written)) ?? written; + }; const unresolvedVirtualTables = [...metadata.values()] .filter((value) => value.type === "virtual" && value.ncol === 0) .map((value) => ({ name: value.name, prefix: sqliteIdentifierKey(`${value.name}_`), })); + const shadowParentOf = ( + name: string, + listed: z.output, + ): (typeof unresolvedVirtualTables)[number] | undefined => { + if (listed.type !== "table") return undefined; + const identifier = sqliteIdentifierKey(name); + return unresolvedVirtualTables.find((parent) => + identifier.startsWith(parent.prefix), + ); + }; + const selected = + selectedName === undefined ? undefined : metadata.get(selectedName); + if (selectedName !== undefined && selected === undefined) + throw missingSelectedTable(selectedName); + const selectedShadowParent = + selectedName === undefined || selected === undefined + ? undefined + : shadowParentOf(selectedName, selected); + // NOCASE folds only ASCII letters, as SQLite does when it resolves the + // table named by an index or trigger. Fold only for an ordinary table whose + // rows can be selected; a selection refused later keeps the exact filter, + // so its failure does not depend on declarations under another spelling. + const foldDependents = + selected?.type === "table" && selectedShadowParent === undefined; const schema: SqliteDatabase["schema"] = { scope, completeness: "complete", @@ -254,7 +296,9 @@ const readSchema = ( // duplicate large names or definitions beyond SQLite's native record limit. const declaration = scope === "inventory" ? "" : ", CAST(sql AS BLOB) AS sql"; const selection = - scope === "selected_table" ? " WHERE tbl_name = ? COLLATE BINARY" : ""; + scope === "selected_table" + ? ` WHERE tbl_name = ? COLLATE ${foldDependents ? "NOCASE" : "BINARY"}` + : ""; const statement = db.prepare( `SELECT type, CAST(name AS BLOB) AS name, CAST(tbl_name AS BLOB) AS tbl_name, rootpage${declaration} FROM main.sqlite_schema${selection}`, ); @@ -280,13 +324,10 @@ const readSchema = ( "format", `SQLite schema/table inventory mismatch: ${value.name}`, ); - const identifier = sqliteIdentifierKey(value.name); const possibleShadowParent = - listed.type === "table" - ? unresolvedVirtualTables.find((parent) => - identifier.startsWith(parent.prefix), - ) - : undefined; + value.name === selectedName + ? selectedShadowParent + : shadowParentOf(value.name, listed); const table: SqliteDatabase["schema"]["tables"][number] = { name: value.name, ...(scope === "inventory" ? {} : { sql: value.sql }), @@ -349,7 +390,7 @@ const readSchema = ( } else { const trigger = { name: value.name, - table: value.tbl_name, + table: triggerTable(value.tbl_name), ...(scope === "inventory" ? {} : { sql: value.sql }), }; budget.retain(trigger); @@ -370,11 +411,7 @@ const readRows = ( ): SqliteDatabase["rows"] => { const { encoding, budget } = context; if (input.table === undefined) return null; - if (table === undefined) - throw new SqliteInspectionFailure( - "selection", - `Selected ordinary table does not exist with that exact name: ${input.table}`, - ); + if (table === undefined) throw missingSelectedTable(input.table); if (table.kind === "unknown") throw new SqliteInspectionFailure( "selection", diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index ba94792a5..26fab7d6a 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -130,6 +130,71 @@ it("accounts for JSON escape expansion of a stored declaration in the reply limi ]); }); +it("attributes triggers declared with another ASCII case to the stored table name", async () => { + const path = await createDatabase(` + CREATE TABLE Users(id INTEGER PRIMARY KEY, email TEXT); + CREATE INDEX users_email ON users(email); + CREATE TRIGGER audit_insert AFTER INSERT ON users BEGIN SELECT 1; END; + CREATE TRIGGER audit_delete AFTER DELETE ON USERS BEGIN SELECT 1; END; + CREATE VIEW recent AS SELECT 1; + CREATE TRIGGER recent_insert INSTEAD OF INSERT ON Recent BEGIN SELECT 1; END; + `); + const selected = inspectSqliteDatabaseSnapshot(path, { + path, + table: "Users", + }); + expect(selected.schema).toMatchObject({ + scope: "selected_table", + completeness: "complete", + tables: [{ name: "Users" }], + indexes: [{ name: "users_email", table: "Users" }], + views: [], + }); + expect( + selected.schema.triggers.map(({ name, table }) => ({ name, table })), + ).toEqual([ + { name: "audit_insert", table: "Users" }, + { name: "audit_delete", table: "Users" }, + ]); + const inventory = inspectSqliteDatabaseSnapshot(path, { path }); + expect(inventory.schema.triggers).toEqual([ + { name: "audit_insert", table: "Users" }, + { name: "audit_delete", table: "Users" }, + { name: "recent_insert", table: "recent" }, + ]); +}); + +it.each([ + { + selection: "a differently cased table", + schema: "CREATE TABLE Users(id INTEGER PRIMARY KEY)", + trigger: "AFTER INSERT ON Users", + table: "users", + }, + { + selection: "an exact view", + schema: "CREATE VIEW Recent AS SELECT 1 AS id", + trigger: "INSTEAD OF INSERT ON recent", + table: "Recent", + }, +])( + "refuses $selection before expanding declarations under another spelling", + async ({ schema, trigger, table }) => { + const path = await createDatabase(` + ${schema}; + CREATE TRIGGER expanded ${trigger} BEGIN /*${"\u0001".repeat(3 * 1024 * 1024)}*/ SELECT 1; END; + `); + expect(() => + inspectSqliteDatabaseSnapshot(path, { path, table }), + ).toThrowError( + expect.objectContaining({ + reason: "selection", + message: `Selected ordinary table does not exist with that exact name: ${table}`, + }), + ); + }, +); + it.each(["UTF-8", "UTF-16le", "UTF-16be"])( "preserves exact %s schema text when decoding native bytes", async (encoding) => {