feat(ghidra): admit explicit DOS COM analysis (#702)

* feat(ghidra): admit explicit DOS COM analysis

Carry caller-selected COM interpretation through CLI, MCP and target
identity, seed real-mode entry/context before Ghidra analysis, and verify
complete loaded bytes, source mappings and measured register context.
Keep original binaries unchanged and PSP/runtime coverage explicit.

Assisted-by: Nitori (gensokyo-skills:nitori-reverse-engineering)

* fix(verification): validate COM package selection before allocating fixtures
This commit is contained in:
N0zoM1z0 2026-10-06 16:26:40 +08:00 committed by GitHub
parent d565ecdc25
commit 5bcb6645b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1104 additions and 75 deletions

View File

@ -166,6 +166,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
sessionDefaultAddressSpace =
currentProgram.getAddressFactory().getDefaultAddressSpace();
if (currentProgram.getExecutableFormat().equals("Raw Binary") &&
!currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO)
.getBoolean("REA DOS COM prepared", false)) {
throw new IllegalStateException("REA DOS COM entry/context preparation failed");
}
if (!Application.getApplicationVersion().equals(descriptor.providerVersion)) {
throw new IllegalStateException("Ghidra provider version does not match the session");
}
@ -496,6 +501,24 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonArray entryPoints = new JsonArray();
for (Address entry : entries) entryPoints.add(canonicalAddress(entry));
result.add("entry_points", entryPoints);
JsonArray contexts = new JsonArray();
for (Address entry : entries) {
JsonObject context = new JsonObject();
context.addProperty("address", canonicalAddress(entry));
JsonArray registers = new JsonArray();
for (String name : new String[] { "CS", "DS", "ES", "SS" }) {
Register register = currentProgram.getRegister(name);
if (register == null) continue;
JsonObject row = new JsonObject();
row.addProperty("name", name);
BigInteger value = currentProgram.getProgramContext().getValue(register, entry, false);
row.add("value_hex", value == null ? JsonNull.INSTANCE : GSON.toJsonTree("0x" + value.toString(16)));
registers.add(row);
}
context.add("registers", registers);
contexts.add(context);
}
result.add("entry_context", contexts);
return result;
}

View File

@ -0,0 +1,32 @@
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Program;
import java.math.BigInteger;
/** Seed the declared DOS COM analysis context before default auto-analysis. */
public final class ReaGhidraPrepareCom extends GhidraScript {
@Override
public void run() throws Exception {
if (!currentProgram.getLanguageID().getIdAsString().equals("x86:LE:16:Real Mode") ||
!currentProgram.getExecutableFormat().equals("Raw Binary")) {
throw new IllegalArgumentException("DOS COM preparation requires raw x86 real-mode import");
}
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress("1000:0100");
Address start = currentProgram.getMinAddress();
Address end = currentProgram.getMaxAddress();
if (!entry.equals(start) || end.subtract(start) + 1 > 0xff00) {
throw new IllegalArgumentException("DOS COM import must map 1..65280 bytes at 1000:0100");
}
for (String name : new String[] { "CS", "DS", "ES", "SS" }) {
monitor.checkCancelled();
currentProgram.getProgramContext().setValue(
currentProgram.getRegister(name), start, end, BigInteger.valueOf(0x1000)
);
}
addEntryPoint(entry);
if (!disassemble(entry) || createFunction(entry, "entry") == null) {
throw new IllegalStateException("DOS COM entry cannot be decoded as a function");
}
currentProgram.getOptions(Program.PROGRAM_INFO).setBoolean("REA DOS COM prepared", true);
}
}

View File

@ -1,6 +1,6 @@
# DOS MZ analysis with Ghidra
# DOS MZ and COM analysis with Ghidra
REA can analyze DOS MZ executables through the bring-your-own Ghidra adapter.
REA can analyze DOS MZ executables and explicitly selected COM images through the bring-your-own Ghidra adapter.
The Linux x64 verification lane exercises actual 16-bit disassembly and
decompilation through the CLI and stdio MCP. It requires the same Ghidra
12.1.4 and 64-bit JDK 21 installation as other Ghidra sessions; no DOS emulator
@ -41,6 +41,43 @@ The profile also commits `function_body_evidence: complete-inclusive-ranges-v1`,
so snapshots from older length-only Ghidra results do not satisfy the current
profile.
## Headerless COM images
COM has no identifying header. Select its interpretation explicitly; neither the
`.com` suffix nor arbitrary unrecognized bytes are enough to admit it.
```bash
rea inspect /absolute/path/to/legacy.com --target-format dos-com --provider ghidra --json
rea function /absolute/path/to/legacy.com 0x10100 --target-format dos-com --provider ghidra --json
rea inspect-native-load-image /absolute/path/to/legacy.com --target-format dos-com --provider ghidra --json
```
```json
{
"name": "open_binary",
"arguments": {
"path": "/absolute/path/to/legacy.com",
"format": "dos-com",
"provider_id": "ghidra"
}
}
```
The caller's interpretation applies to the complete file, even if its filename
suggests another format. Files must contain 1..65280 bytes: the 64 KiB COM segment
minus the 256-byte PSP prefix. Ghidra uses `BinaryLoader` with real-mode language
and maps file offset zero at `1000:0100` (linear `0x10100`). A packaged pre-analysis
script seeds the entry function and CS/DS/ES/SS context `0x1000`. These settings
and the preparation policy are committed in the analysis profile; a failed
preparation prevents the resident bridge from serving results.
Load-image verification checks all original and modified file bytes, complete
source mapping/memory digests, absence of relocations, entry and measured register
context. The context is imposed for analysis, not observed from execution. PSP
memory, initial stack contents/SP, DOS interrupts and PC-98 devices are not
modeled. COM has no MZ header, relocation table or appended-overlay model; all
selected bytes belong to its analysis module. The original file stays unchanged.
## Addresses and function extent
Returned default-space addresses are linear byte coordinates. A Ghidra
@ -89,7 +126,7 @@ range's file offset and analysis address; it does not locate the first differing
byte inside that range. Mapping ends are **inclusive**, unlike the exclusive ends
from `list_segments`. Appended overlays remain in source-file identity and are
excluded from initialized module coverage. Uninitialized allocations are reported;
their contents and DOS runtime semantics are not verified. Other formats return
their contents and DOS runtime semantics are not verified. Formats other than MZ and explicit COM return
`unsupported` with measured observations, not a successful MZ verification.
`read_bytes` returns initialized provider memory, including loader fixups; an

View File

@ -7,7 +7,7 @@
"client": "2.3.1",
"core": "2.3.1"
},
"skill_version": "26"
"skill_version": "27"
},
"tools": {
"total": 117,
@ -558,9 +558,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "29258155c0d2a5e174bf63b777c35689cec70bd016c85046c62ebaad8e8a57a9",
"tools_sha256": "eec70858c8de9e04fc3a87f38ad9e2f0b75ed8717afae80d1837a348f1232086",
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
"combined_sha256": "61b0a8d47bf5bf791e15b7ffe774f8f54e8371742283e110a8d307df308eb110",
"combined_sha256": "55ea35002d0e7ac5267b58794116927ef784d3d0a64c015fbf523ce680fd1960",
"providers_sha256": "c1a6849b2524347e6cf9e004a17ba1805f86114b2da72eb689a075afbbb1aada"
}
}

View File

@ -153,6 +153,13 @@ the stable observation comparison. Linux x64 is verified; macOS DOS remains
unverified. This lane is separate from host-native and optional cross-format
verification. See [DOS analysis](ghidra-dos.md).
`npm run verify:ghidra:com` uses a generated headerless fixture with no compiler,
DOS emulator or game data. It exercises explicit admission, BinaryLoader entry
preparation, measured register context, whole-file byte readback, source offsets,
unmapped PSP/partial reads, actual decompilation, CLI/MCP parity and owned cleanup.
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
runtime or PC-98 device execution.
## Apple Interface Builder archives
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_8903d94ebb803743a2028ca1ea3909e2927e05f3d34f9a03eb96388434e4b629",
"manifest_id": "ecm_cbd9c43ea4e600851d7d3035788008fa1ec06dd7072b6c8057692d02e8a76781",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "d2bbf020f2865b25daad721a1b05c9aed5f3fe6b16c1efa5c9652f7c3e1b775a"
"sha256": "65e107a997c825a5ddc661b1d8f788fc1890a35ed3b55754033b33c8fe20b0c3"
}
],
"claims": [

View File

@ -44,6 +44,7 @@
"bridge/hopper_bridge.py",
"bridge/native/ReaNativeUI.swift",
"bridge/ghidra/ReaGhidraBridge.java",
"bridge/ghidra/ReaGhidraPrepareCom.java",
"scripts/rea.mjs",
"scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs",
@ -120,6 +121,7 @@
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
"verify:interface-builder": "npm run build:cached && node scripts/verify-interface-builder.mjs",
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",

View File

@ -0,0 +1,277 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, 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 { functionDossierSchema } from "../dist/domain/hopperValues.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { buildDosComFixture } from "../tests/conformance/ghidra/dos-com-fixture.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
// This lane intentionally has no game paths, binary seeds or compiler dependency.
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-com.mjs");
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"verify:ghidra:com supports Linux/macOS hosts only; Windows P0 is PE x86-64 only.",
);
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"verify:ghidra:com prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra).",
);
const installation = inspectGhidraInstallation({
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
if (installation.status !== "available")
throw new Error(
`verify:ghidra:com prerequisite unavailable: ${JSON.stringify(installation)}`,
);
const captureDirectory = process.env.REA_COM_PROOF_CAPTURE_DIR;
if (captureDirectory !== undefined) {
assert.ok(
isAbsolute(captureDirectory),
"REA_COM_PROOF_CAPTURE_DIR must be absolute",
);
await mkdir(captureDirectory, { recursive: true, mode: 0o700 });
}
const packageRoot = process.env.REA_COM_PROOF_PACKAGE_ROOT;
if (packageRoot !== undefined && !isAbsolute(packageRoot))
throw new Error("REA_COM_PROOF_PACKAGE_ROOT must be absolute");
const entrypoint =
packageRoot === undefined
? fileURLToPath(new URL("./rea.mjs", import.meta.url))
: join(packageRoot, "scripts", "rea.mjs");
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-com-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const fixture = buildDosComFixture();
const targetPath = join(workspace, "fixture.com");
await writeFile(targetPath, fixture.bytes, { mode: 0o600 });
const sha256 = digest(fixture.bytes);
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { JAVA_HOME: process.env.JAVA_HOME }),
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-com-proof", version: "1" });
let opened = false;
let stderr = "";
let report;
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
try {
await client.connect(transport);
const untyped = await client.callTool({
name: "open_binary",
arguments: { path: targetPath, provider_id: "ghidra" },
});
assert.equal(
untyped.isError,
true,
"Headerless bytes were guessed as COM without explicit interpretation",
);
const target = await call("open_binary", {
path: targetPath,
provider_id: "ghidra",
format: "dos-com",
});
opened = true;
assert.equal(target.format, "dos-com");
assert.equal(target.architecture, "x86");
assert.equal(target.sha256, sha256);
const image = await call("inspect_native_load_image");
assert.equal(
image.status,
"verified",
JSON.stringify(image.checks?.filter((c) => !c.matched)),
);
assert.equal(image.format, "dos-com");
assert.equal(image.header_bytes, 0);
assert.equal(image.module_bytes, fixture.bytes.length);
assert.equal(image.entry.linear_address, fixture.entry);
assert.equal(image.entry.offset, 256);
assert.equal(image.observations.entry_context[0].registers.length, 4);
const memory = await call("read_bytes", {
address: fixture.entry,
length: fixture.bytes.length,
});
assert.equal(memory.bytes_hex, fixture.bytes.toString("hex"));
assert.equal(memory.complete, true);
const mapping = await call("address_to_file_offset", {
address: fixture.near,
});
assert.equal(mapping.file_offset, 32);
const psp = await call("read_bytes", { address: "0x10000", length: 16 });
assert.equal(psp.returned_bytes, 0);
assert.equal(psp.complete, false);
const tail = await call("read_bytes", { address: "0x1013e", length: 8 });
assert.equal(tail.returned_bytes, 2);
assert.equal(tail.complete, false);
const procedures = await call("list_procedures");
assert.ok(procedures.some((p) => p.address === fixture.entry));
assert.ok(procedures.some((p) => p.address === fixture.near));
const dossier = functionDossierSchema.parse(
await call("analyze_function", { procedure: fixture.entry }),
);
assert.ok(dossier.pseudocode.trim().length > 0);
assert.equal(dossier.procedure.body.available, true);
const instruction = await call("inspect_native_instruction", {
address: fixture.entry,
});
assert.equal(
instruction.bytes.replaceAll(" ", "").toLowerCase(),
fixture.entry_hex,
);
const cliImage = await cli("inspect-native-load-image");
const stable = (x) => ({
...x,
observations: {
...x.observations,
source_files: x.observations.source_files.map(
({ name, ...file }) => file,
),
},
});
assert.deepEqual(stable(cliImage.normalized_result), stable(image));
assert.deepEqual(
(
await cli("read-bytes", fixture.entry, [
"--length",
String(fixture.bytes.length),
])
).normalized_result,
memory,
);
assert.deepEqual(
(await cli("address-to-file-offset", fixture.near)).normalized_result,
mapping,
);
if (captureDirectory !== undefined)
await writeFile(
join(captureDirectory, "image.json"),
JSON.stringify(image, null, 2),
{ mode: 0o600 },
);
await call("close_binary");
opened = false;
assert.equal(digest(await readFile(targetPath)), sha256);
report = {
ok: true,
mocked: false,
lane: "ghidra-com",
packaged_artifact: packageRoot !== undefined,
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: { format: "dos-com", sha256, bytes: fixture.bytes.length },
load_image: {
status: image.status,
checks: image.checks.length,
entry: fixture.entry,
entry_context: image.observations.entry_context,
},
transport: { cli: true, stdio_mcp: true },
memory_evidence: {
complete_file_read: true,
source_offset: 32,
psp_unmapped: true,
partial_read_bytes: 2,
},
functions: { entry: true, near: true, pseudocode_nonempty: true },
original_unchanged: true,
limitations: image.limitations,
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);
throw error;
} finally {
if (opened)
await call("close_binary").catch((error) =>
process.stderr.write(String(error)),
);
await client.close();
await transport.close();
await rm(workspace, { recursive: true, force: true });
}
assert.ok(report);
const verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
process.stdout.write(
JSON.stringify({
verifier_run: verifierRun,
...report,
cleanup: "complete",
}) + "\n",
);
function digest(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
async function call(name, args = {}) {
const value = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(value.isError, true, JSON.stringify(value));
if (name === "open_binary") return value.structuredContent.result;
if (name === "close_binary") return value.structuredContent;
const evidence = parseEvidence(value.structuredContent.evidence);
assert.equal(evidence.subject.digest.sha256, sha256);
assert.deepEqual(evidence.normalized_result, value.structuredContent.result);
return value.structuredContent.result;
}
async function cli(command, address, options = []) {
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
command,
targetPath,
...(address === undefined ? [] : [address]),
...options,
"--target-format",
"dos-com",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.subject.digest.sha256, sha256);
assert.equal(evidence.subject.format, "dos-com");
assert.equal(evidence.analysis_profile.parameters.loader, "BinaryLoader");
assert.equal(
evidence.analysis_profile.parameters.language_id,
"x86:LE:16:Real Mode",
);
assert.equal(evidence.analysis_profile.parameters.entry_offset, "0x100");
assert.equal(digest(await readFile(targetPath)), sha256);
return evidence;
}

View File

@ -2,9 +2,9 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "26"
version: "27"
tool_count: 117
catalog_digest: "61b0a8d47bf5bf791e15b7ffe774f8f54e8371742283e110a8d307df308eb110"
catalog_digest: "55ea35002d0e7ac5267b58794116927ef784d3d0a64c015fbf523ce680fd1960"
---
# REA

View File

@ -55,3 +55,8 @@ hosts, with an owned temporary mount directory and cleanup. It needs no approval
flag. Unsupported hosts or mount failures remain explicit limitations; do not
claim child inventory when only root identity is available. Extraction is a
separate filesystem-writing operation.
For headerless DOS COM, explicitly open with `format: "dos-com"` (CLI
`--target-format dos-com`). Inspect the load image before trusting function
analysis: the entry is `0x10100`, file offset zero, with imposed real-mode segment
context. PSP/stack/device state remains unmodeled. See [DOS guide](../../../docs/ghidra-dos.md).

View File

@ -1,3 +1,4 @@
import type { ExecutableFormatHint } from "../domain/dosCom.js";
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import {
@ -17,6 +18,7 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
export interface BinarySessionOpenOptions {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
readonly formatHint?: ExecutableFormatHint;
readonly snapshot?: AnalysisSnapshot;
readonly providerId?: AnalysisProviderSelector;
}
@ -54,6 +56,7 @@ export const resolveSessionOpen = async (
process.cwd(),
process.arch,
options.targetKind,
options.formatHint,
);
if (!parsed.ok) return parsed;
const target = parsed.value;

View File

@ -1,3 +1,4 @@
import type { ExecutableFormatHint } from "../domain/dosCom.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Evidence } from "../domain/evidence.js";
@ -29,6 +30,7 @@ export interface BinarySessionPort extends AnalysisOperationPort {
options?: {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
readonly formatHint?: ExecutableFormatHint;
readonly snapshot?: AnalysisSnapshot;
readonly providerId?: AnalysisProviderSelector;
},

View File

@ -16,6 +16,10 @@ import { isPathWithinRoot } from "../domain/localPath.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
import {
validateDosComLength,
type ExecutableFormatHint,
} from "../domain/dosCom.js";
import { mzWindowsHeaderOffset } from "../domain/dosMz.js";
import {
hasZipSignature,
@ -40,6 +44,7 @@ export const parseBinaryTarget = async (
cwd = process.cwd(),
hostArchitecture: NodeJS.Architecture = process.arch,
targetKind?: BinaryTarget["kind"],
formatHint?: ExecutableFormatHint,
): Promise<Result<BinaryTarget, BinaryTargetError>> => {
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
try {
@ -52,6 +57,26 @@ export const parseBinaryTarget = async (
try {
if (!(await handle.stat()).isFile())
return err(new BinaryTargetError(path, "target is not a regular file"));
if (formatHint === "dos-com") {
if (targetKind !== undefined && targetKind !== "executable")
return err(
new BinaryTargetError(
path,
"DOS COM format requires an executable target kind",
),
);
const length = validateDosComLength((await handle.stat()).size);
if (!length.ok) return err(new BinaryTargetError(path, length.error));
return ok({
path,
sourcePath: canonical,
sha256: await sha256Handle(handle),
kind: "executable",
format: "dos-com",
architecture: "x86",
availableArchitectures: ["x86"],
});
}
if (
targetKind === "database" ||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
@ -126,7 +151,7 @@ const detectArtifactFormat = async (
): Promise<
| Exclude<
BinaryTarget["format"],
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz"
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com"
>
| undefined
> => {
@ -168,7 +193,7 @@ const namedArtifactFormat = (
const isArchiveFormat = (
format: Exclude<
BinaryTarget["format"],
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz"
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com"
>,
): format is Extract<BinaryTarget, { kind: "archive" }>["format"] =>
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(format);

View File

@ -1,3 +1,4 @@
import type { ExecutableFormatHint } from "../domain/dosCom.js";
import { parseConfig } from "../config.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { EnhancedTools } from "./EnhancedTools.js";
@ -75,6 +76,7 @@ export const runDirectAnalysis = async (
readonly snapshotPath?: string | undefined;
readonly signal?: AbortSignal;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
} = {},
): Promise<JsonValue> =>
withProcessCancellation(options.signal, (signal) =>
@ -82,6 +84,9 @@ export const runDirectAnalysis = async (
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
...(options.providerId === undefined
? {}
: { providerId: options.providerId }),
@ -156,6 +161,7 @@ const runAnalysis = async (
readonly snapshotPath: string | undefined;
readonly signal: AbortSignal;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
/**
* Environment the configuration is read from. Defaults to the process
* environment so existing callers are unchanged, but a caller may supply
@ -174,11 +180,17 @@ const runAnalysis = async (
const prepared = await prepareSnapshot({
path,
snapshotPath,
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
});
if (!prepared.ok) return cliError(prepared.error);
const { snapshot } = prepared.value;
const opened = await session.open(path, {
signal,
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
...(snapshot === undefined ? {} : { snapshot }),
...(options.providerId === undefined
? {}
@ -320,6 +332,7 @@ const withProcessCancellation = async <Value>(
const prepareSnapshot = async (options: {
readonly path: string;
readonly formatHint?: ExecutableFormatHint;
readonly snapshotPath: string | undefined;
}): Promise<
Result<{ readonly snapshot?: AnalysisSnapshot }, AnalysisError>
@ -329,7 +342,13 @@ const prepareSnapshot = async (options: {
return ok({});
const loaded = await readAnalysisSnapshot(snapshotPath);
if (!loaded.ok) return loaded;
const target = await parseBinaryTarget(path);
const target = await parseBinaryTarget(
path,
process.cwd(),
process.arch,
undefined,
options.formatHint,
);
if (!target.ok) return target;
if (!snapshotMatchesTarget(loaded.value.target, target.value))
return err(

View File

@ -6,7 +6,11 @@ import { runDirectAnalysis } from "../application/DirectAnalysis.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "../logger.js";
import { directAnalysisOptions, providerSelectionOption } from "./options.js";
import {
directAnalysisOptions,
formatSelectionOption,
providerSelectionOption,
} from "./options.js";
import type { CliInstance } from "./types.js";
import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js";
@ -19,14 +23,22 @@ export const registerCoreAnalysisCommands = (
description:
"Verify loaded native bytes, source mappings, relocations and entry",
args: z.object({ path: z.string().describe("Local executable path") }),
options: z.object({ provider: providerSelectionOption }),
options: z.object({
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectNativeLoadImage, () =>
runDirectAnalysis(
args.path,
"inspect_native_load_image",
{},
directAnalysisOptions(logger, undefined, options.provider),
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
),
),
});
@ -43,6 +55,7 @@ export const registerCoreAnalysisCommands = (
.min(1)
.default(256)
.describe("Requested byte count"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
@ -51,7 +64,12 @@ export const registerCoreAnalysisCommands = (
args.path,
"read_bytes",
{ address: args.address, length: options.length },
directAnalysisOptions(logger, undefined, options.provider),
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
),
),
});
@ -61,14 +79,22 @@ export const registerCoreAnalysisCommands = (
path: z.string().describe("Local executable path"),
address: z.string().describe("Exact provider memory address"),
}),
options: z.object({ provider: providerSelectionOption }),
options: z.object({
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.addressToFileOffset, () =>
runDirectAnalysis(
args.path,
"address_to_file_offset",
{ address: args.address },
directAnalysisOptions(logger, undefined, options.provider),
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
),
),
});
@ -129,6 +155,7 @@ export const registerCoreAnalysisCommands = (
.max(5000)
.default(1000)
.describe("Maximum nodes on this page"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
alias: {
@ -153,7 +180,12 @@ export const registerCoreAnalysisCommands = (
offset: options.offset,
limit: options.limit,
},
directAnalysisOptions(logger, undefined, options.provider),
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
),
),
});
@ -165,6 +197,7 @@ export const registerCoreAnalysisCommands = (
options: z.object({
type: z.string().optional().describe("Exact database type category path"),
address: z.string().describe("Exact native address").optional(),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
@ -178,7 +211,12 @@ export const registerCoreAnalysisCommands = (
? {}
: { address: options.address }),
},
directAnalysisOptions(logger, undefined, options.provider),
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
),
),
});
@ -195,14 +233,22 @@ export const registerCoreAnalysisCommands = (
path: z.string().describe("Local native executable or app path"),
address: z.string().describe("Exact native address"),
}),
options: z.object({ provider: providerSelectionOption }),
options: z.object({
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
logCliCommand(logger, command, () =>
runDirectAnalysis(
args.path,
operation,
{ address: args.address },
directAnalysisOptions(logger, undefined, options.provider),
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
),
),
});
@ -239,6 +285,7 @@ const registerNativeDispatchMetadataCommand = (
.min(1)
.optional()
.describe("Load or update the local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
alias: { maxRecords: "max-records" },
@ -248,7 +295,12 @@ const registerNativeDispatchMetadataCommand = (
args.path,
"inspect_native_dispatch_metadata",
{ max_records: options.maxRecords },
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});
@ -292,6 +344,7 @@ const registerNativeUiActionCommand = (
.max(5_000)
.default(500)
.describe("Maximum returned graph edges"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
alias: {
@ -310,7 +363,12 @@ const registerNativeUiActionCommand = (
max_nodes: options.maxNodes,
max_edges: options.maxEdges,
},
directAnalysisOptions(logger, undefined, options.provider),
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
),
),
});
@ -323,6 +381,7 @@ const registerCoreCommands = (cli: CliInstance, logger: Logger): void => {
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
});
cli.command(CLI_COMMANDS.analyze, {
@ -348,7 +407,12 @@ const registerCoreCommands = (cli: CliInstance, logger: Logger): void => {
args.path,
"binary_overview",
{},
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});
@ -364,6 +428,7 @@ const registerCoreCommands = (cli: CliInstance, logger: Logger): void => {
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
@ -372,7 +437,12 @@ const registerCoreCommands = (cli: CliInstance, logger: Logger): void => {
args.path,
"procedure_pseudo_code",
{ procedure: args.address },
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});
@ -382,6 +452,7 @@ const runRoutedOverview = async (
path: string,
options: {
readonly snapshot?: string | undefined;
readonly "target-format"?: "dos-com" | undefined;
readonly provider?: string | undefined;
},
logger: Logger,
@ -389,6 +460,7 @@ const runRoutedOverview = async (
if (
options.provider === undefined &&
options.snapshot === undefined &&
options["target-format"] === undefined &&
(await isJavaScriptApplicationPath(path))
)
return runCliJavaScriptApplicationAnalysis({
@ -398,7 +470,12 @@ const runRoutedOverview = async (
path,
"binary_overview",
{},
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
);
};
@ -429,6 +506,7 @@ const registerXrefsCommand = (cli: CliInstance, logger: Logger): void => {
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
@ -437,7 +515,12 @@ const registerXrefsCommand = (cli: CliInstance, logger: Logger): void => {
args.path,
"xrefs",
{ address: args.address },
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});
@ -460,6 +543,7 @@ const registerTraceCommand = (cli: CliInstance, logger: Logger): void => {
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
alias: {
@ -474,7 +558,12 @@ const registerTraceCommand = (cli: CliInstance, logger: Logger): void => {
query: args.query,
case_sensitive: options.caseSensitive,
},
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});
@ -493,6 +582,7 @@ const registerFunctionCommand = (cli: CliInstance, logger: Logger): void => {
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
@ -501,7 +591,12 @@ const registerFunctionCommand = (cli: CliInstance, logger: Logger): void => {
args.path,
"analyze_function",
{ procedure: args.address },
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});
@ -520,6 +615,7 @@ const registerNativeApiCommand = (cli: CliInstance, logger: Logger): void => {
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
@ -528,7 +624,12 @@ const registerNativeApiCommand = (cli: CliInstance, logger: Logger): void => {
args.path,
"inspect_native_api",
{ procedure: args.address },
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});
@ -550,6 +651,7 @@ const registerInstructionsCommand = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
@ -560,7 +662,12 @@ const registerInstructionsCommand = (
{
procedure: args.address,
},
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});
@ -591,6 +698,7 @@ const registerSearchCommand = (cli: CliInstance, logger: Logger): void => {
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
alias: { caseSensitive: "case-sensitive" },
@ -604,7 +712,12 @@ const registerSearchCommand = (cli: CliInstance, logger: Logger): void => {
mode: options.mode,
case_sensitive: options.caseSensitive,
},
directAnalysisOptions(logger, options.snapshot, options.provider),
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
),
),
});

View File

@ -1,3 +1,7 @@
import {
executableFormatHintSchema,
type ExecutableFormatHint,
} from "../domain/dosCom.js";
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
import type { Logger } from "../logger.js";
@ -8,12 +12,19 @@ export const providerSelectionOption = analysisProviderSelectorSchema
"Bind deep analysis to a provider ID or use deterministic auto selection",
);
/** Explicit interpretation for headerless native executable formats. */
export const formatSelectionOption = executableFormatHintSchema
.optional()
.describe("Explicit headerless DOS COM interpretation");
export const directAnalysisOptions = (
logger: Logger,
snapshotPath: string | undefined,
providerId: AnalysisProviderSelector | undefined,
formatHint?: ExecutableFormatHint,
) => ({
logger,
snapshotPath,
...(formatHint === undefined ? {} : { formatHint }),
...(providerId === undefined ? {} : { providerId }),
});

View File

@ -38,7 +38,7 @@ const official = <Name extends string, Schema extends z.ZodObject>(
export const OFFICIAL_TOOL_CONTRACTS = [
official(
"inspect_native_load_image",
"Verify the provider's loaded DOS MZ image against its immutable target snapshot: complete original/modified source-byte digests, header/module file mappings and memory digests, relocations and external entry. Returns measured observations and independent checks with mismatch coordinates. Overlay and uninitialized coverage remain explicit; other formats return unsupported. Does not execute or modify the target.",
"Verify the provider's loaded DOS MZ or explicitly selected COM image against its immutable target snapshot: complete original/modified source-byte digests, header/module file mappings and memory digests, relocations and external entry. Returns measured observations and independent checks with mismatch coordinates. COM verification includes its imposed entry register context and does not claim PSP/runtime coverage. Overlay and uninitialized coverage remain explicit; other formats return unsupported. Does not execute or modify the target.",
z.strictObject({}),
),
official(

View File

@ -1,9 +1,15 @@
import { executableFormatHintSchema } from "../domain/dosCom.js";
import { z } from "zod";
import { analysisProviderSelectorSchema } from "./providerSelection.js";
/** Input contract for opening a target with an optional staged snapshot. */
export const openBinaryInputSchema = z.object({
path: z.string().min(1),
format: executableFormatHintSchema
.optional()
.describe(
"Explicit headerless DOS COM interpretation; omission preserves header-based detection",
),
provider_id: analysisProviderSelectorSchema.optional(),
snapshot_path: z.string().min(1).optional(),
});

View File

@ -55,7 +55,7 @@ export const exportEvidenceBundleInputSchema = z.strictObject({
export const SESSION_TOOL_CONTRACTS = [
session(
"open_binary",
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly.",
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image; omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly.",
openBinaryInputSchema,
),
session(

View File

@ -49,6 +49,7 @@ export const targetFormatSchema = z.enum([
"elf",
"pe",
"dos-mz",
"dos-com",
"zip",
"ipa",
"apk",

View File

@ -36,6 +36,7 @@ const formatSchema = z.enum([
"elf",
"pe",
"dos-mz",
"dos-com",
"zip",
"ipa",
"apk",

View File

@ -49,6 +49,7 @@ const artifactFormatSchema = z.enum([
"elf",
"pe",
"dos-mz",
"dos-com",
"hopper",
"analysis-database",
"ipa",

View File

@ -7,7 +7,7 @@ export type { BinaryArchitecture, BinaryTarget } from "./binaryTargetTypes.js";
/** Format and architecture facts recovered from an executable header. */
export type ExecutableMetadata =
| {
readonly format: "mach-o" | "elf" | "dos-mz";
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly architecture: BinaryArchitecture;
readonly availableArchitectures: readonly BinaryArchitecture[];
}

View File

@ -26,7 +26,7 @@ type ExecutableTarget = BinaryTargetIdentity & {
*/
export type BinaryTarget =
| (ExecutableTarget & {
readonly format: "mach-o" | "elf" | "dos-mz";
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly executableRole?: never;
readonly managed?: never;
})

18
src/domain/dosCom.test.ts Normal file
View File

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { executableFormatHintSchema, validateDosComLength } from "./dosCom.js";
describe("explicit DOS COM interpretation", () => {
it.each([1, 0xff00])("admits the segment extent %i", (length) => {
expect(validateDosComLength(length).ok).toBe(true);
});
it.each([0, -1, 0xff01, 1.5, NaN, Infinity])(
"rejects invalid extent %i",
(length) => {
expect(validateDosComLength(length).ok).toBe(false);
},
);
it("does not turn arbitrary format names into raw COM", () => {
expect(executableFormatHintSchema.safeParse("dos-com").success).toBe(true);
expect(executableFormatHintSchema.safeParse("raw").success).toBe(false);
});
});

15
src/domain/dosCom.ts Normal file
View File

@ -0,0 +1,15 @@
import { z } from "zod";
import { err, ok, type Result } from "./result.js";
/** Headerless DOS COM interpretation explicitly selected by the caller. */
export const executableFormatHintSchema = z.literal("dos-com");
/** Explicit executable interpretation for formats with no identifying header. */
export type ExecutableFormatHint = z.infer<typeof executableFormatHintSchema>;
/** Validate the single 64 KiB COM segment, excluding its 256-byte PSP prefix. */
export const validateDosComLength = (length: number): Result<null, string> =>
Number.isSafeInteger(length) && length >= 1 && length <= 0xff00
? ok(null)
: err(
"DOS COM requires 1..65280 file bytes (64 KiB segment minus the 256-byte PSP prefix)",
);

View File

@ -27,6 +27,7 @@ const subjectSchema = z.object({
"elf",
"pe",
"dos-mz",
"dos-com",
"zip",
"ipa",
"apk",

View File

@ -55,6 +55,20 @@ export const nativeLoadImageObservationSchema = z.strictObject({
}),
),
entry_points: z.array(address),
entry_context: z.array(
z.strictObject({
address,
registers: z.array(
z.strictObject({
name: z.string().min(1),
value_hex: z
.string()
.regex(/^0x[a-f0-9]+$/u)
.nullable(),
}),
),
}),
),
});
/** Independent comparison, preserving both expectations and measured values. */
@ -77,7 +91,7 @@ export const nativeLoadImageSchema = z.discriminatedUnion("status", [
}),
z.strictObject({
status: z.enum(["verified", "mismatch"]),
format: z.literal("dos-mz"),
format: z.enum(["dos-mz", "dos-com"]),
target_sha256: digest,
load_segment: integer.max(0xffff),
header_bytes: integer,
@ -100,3 +114,5 @@ export type NativeLoadImage = z.infer<typeof nativeLoadImageSchema>;
export type NativeLoadImageObservation = z.infer<
typeof nativeLoadImageObservationSchema
>;
/** One independently compared native import fact. */
export type NativeLoadImageCheck = z.infer<typeof nativeLoadImageCheckSchema>;

View File

@ -238,7 +238,11 @@ const familiesFor = (format: string, path: string): RuntimeFamily[] => {
if (format === "javascript-bundle") families.add("javascript");
if (format === "jvm-class" || /(?:^|\/)[^/]+\.class$/iu.test(path))
families.add("jvm");
if (["elf", "mach-o", "mach-o-universal", "pe", "dos-mz"].includes(format))
if (
["elf", "mach-o", "mach-o-universal", "pe", "dos-mz", "dos-com"].includes(
format,
)
)
families.add("native");
if (format === "webassembly" || /(?:^|\/)[^/]+\.wasm$/iu.test(path))
families.add("webassembly");

File diff suppressed because one or more lines are too long

View File

@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "26",
skillVersion: "27",
} as const;

View File

@ -27,6 +27,8 @@ export const resolveGhidraAnalysisProfile = (
);
const provider = { ...identity, version: installation.providerVersion };
const dosMz = target.format === "dos-mz";
const dosCom = target.format === "dos-com";
const dos = dosMz || dosCom;
return Promise.resolve(
ok({
profile: createAnalysisProfile(provider, {
@ -37,26 +39,47 @@ export const resolveGhidraAnalysisProfile = (
...(target.availableArchitectures ?? []),
].sort(),
import_mode: "ephemeral-read-only",
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",
...(dosMz
? { load_image_evidence: "independent-mz-mapping-relocations-v1" }
...(dos
? {
load_image_evidence: dosCom
? "independent-com-mapping-context-v1"
: "independent-mz-mapping-relocations-v1",
}
: {}),
jump_table_evidence: "typed-case-default-blocks-v1",
decompiler_jump_loads: true,
loader: dosMz ? "MzLoader" : "auto-from-header",
language_id: dosMz ? "x86:LE:16:Real Mode" : "auto-from-header",
compiler_spec_id: dosMz ? "default" : "auto-default",
...(dosMz
loader: dosMz
? "MzLoader"
: dosCom
? "BinaryLoader"
: "auto-from-header",
language_id: dos ? "x86:LE:16:Real Mode" : "auto-from-header",
compiler_spec_id: dos ? "default" : "auto-default",
...(dos
? {
load_segment: "0x1000",
address_coordinates: "linear-byte-offset",
}
: {}),
...(dosCom
? {
entry_offset: "0x100",
register_context: {
CS: "0x1000",
DS: "0x1000",
ES: "0x1000",
SS: "0x1000",
},
entry_seed: "external-entry-and-function-before-analysis-v1",
}
: {}),
analyzer_preset: "ghidra-default",
}),
compatibility: {
languageId: dosMz ? "x86:LE:16:Real Mode" : "auto",
compilerSpecId: dosMz ? "default" : "auto",
languageId: dos ? "x86:LE:16:Real Mode" : "auto",
compilerSpecId: dos ? "default" : "auto",
},
}),
);

View File

@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { attestGhidraDosComLoadImage } from "./GhidraLoadImageValues.js";
import { fixtureDosComLoadImage } from "./GhidraLoadImage.fixture.js";
import type { NativeLoadImageObservation } from "../domain/nativeLoadImage.js";
const mutations: ReadonlyArray<
readonly [string, (o: NativeLoadImageObservation) => void]
> = [
[
"memory bytes",
(o) => {
if (o.mappings[0]) o.mappings[0].sha256 = "f".repeat(64);
},
],
[
"file mapping",
(o) => {
if (o.mappings[0]) o.mappings[0].file_offset = 1;
},
],
[
"missing mapping",
(o) => {
o.mappings = [];
},
],
[
"duplicate mapping",
(o) => {
if (o.mappings[0]) o.mappings.push(structuredClone(o.mappings[0]));
},
],
[
"entry",
(o) => {
o.entry_points = ["0x10000"];
},
],
[
"missing context",
(o) => {
o.entry_context = [];
},
],
[
"wrong context",
(o) => {
if (o.entry_context[0]?.registers[0])
o.entry_context[0].registers[0].value_hex = "0x0";
},
],
[
"language",
(o) => {
o.language_id = "x86:LE:32:default";
},
],
[
"source identity",
(o) => {
if (o.source_files[0]) o.source_files[0].original_sha256 = "f".repeat(64);
},
],
[
"modified source",
(o) => {
if (o.source_files[0]) o.source_files[0].modified_sha256 = "f".repeat(64);
},
],
[
"unexpected fixup",
(o) => {
o.relocations.push({
address: "0x10100",
segment: 0x1000,
offset: 0x100,
status: "APPLIED",
type: 0,
values: [],
original_bytes_hex: null,
memory_bytes_hex: null,
});
},
],
];
describe("COM independent load-image checks", () => {
it("verifies all bytes and the imposed entry context without a PSP claim", () => {
const f = fixtureDosComLoadImage();
const result = attestGhidraDosComLoadImage(
f.bytes,
f.sha256,
f.observation,
);
expect(result).toMatchObject({
ok: true,
value: {
status: "verified",
format: "dos-com",
header_bytes: 0,
module_bytes: 4,
overlay_bytes: 0,
entry: {
relative_segment: 0,
offset: 0x100,
linear_address: "0x10100",
},
},
});
});
it.each(mutations)("retains mismatch evidence for %s", (_name, damage) => {
const f = fixtureDosComLoadImage();
damage(f.observation);
const result = attestGhidraDosComLoadImage(
f.bytes,
f.sha256,
f.observation,
);
expect(result).toMatchObject({
ok: true,
value: { status: "mismatch", observations: f.observation },
});
});
it("rejects stale snapshot identity", () => {
const f = fixtureDosComLoadImage();
expect(
attestGhidraDosComLoadImage(f.bytes, "f".repeat(64), f.observation).ok,
).toBe(false);
});
});

View File

@ -63,6 +63,7 @@ export interface GhidraHeadlessLauncherOptions {
readonly comSpec?: string;
/** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */
readonly dosMz?: true;
readonly dosCom?: true;
}
/** Launch Ghidra without copying scripts into or modifying its installation. */
@ -111,6 +112,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
descriptorPath: paths.descriptorPath,
ghidraLogPath: paths.ghidraLogPath,
scriptLogPath: paths.scriptLogPath,
...(this.options.dosCom === undefined
? {}
: { dosCom: this.options.dosCom }),
...(this.options.dosMz === undefined
? {}
: { dosMz: this.options.dosMz }),
@ -267,6 +271,7 @@ export interface GhidraHeadlessArgumentOptions {
readonly ghidraLogPath: string;
readonly scriptLogPath: string;
readonly dosMz?: true;
readonly dosCom?: true;
}
/** Build the complete read-only headless invocation in deterministic order. */
@ -286,7 +291,18 @@ export const ghidraHeadlessArguments = (
"-cspec",
"default",
]
: []),
: options.dosCom === true
? [
"-loader",
"BinaryLoader",
"-loader-baseAddr",
"1000:0100",
"-processor",
"x86:LE:16:Real Mode",
"-cspec",
"default",
]
: []),
"-readOnly",
"-deleteProject",
"-log",
@ -295,6 +311,9 @@ export const ghidraHeadlessArguments = (
options.scriptLogPath,
"-scriptPath",
dirname(options.bridgeScriptPath),
...(options.dosCom === true
? ["-preScript", "ReaGhidraPrepareCom.java"]
: []),
"-postScript",
basename(options.bridgeScriptPath),
options.descriptorPath,

View File

@ -92,7 +92,63 @@ export const fixtureDosLoadImage = (): {
memory_bytes_hex: "0410",
},
],
entry_context: [],
entry_points: ["0x10000"],
},
};
};
/** Source-owned headerless COM image and measured real-mode import facts. */
export const fixtureDosComLoadImage = (): {
bytes: Buffer;
sha256: string;
observation: NativeLoadImageObservation;
} => {
const bytes = Buffer.from("b83412c3", "hex");
const digest = hash(bytes);
return {
bytes,
sha256: digest,
observation: {
executable_format: "Raw Binary",
language_id: "x86:LE:16:Real Mode",
compiler_spec_id: "default",
image_base: "0x0",
default_address_space: "ram",
source_files: [
{
name: "fixture.com",
size: bytes.length,
original_sha256: digest,
modified_sha256: digest,
},
],
mappings: [
{
block: "ram",
start: "0x10100",
end: "0x10103",
address_space: "ram",
initialized: true,
loaded: true,
overlay: false,
length: bytes.length,
source_file_index: 0,
file_offset: 0,
sha256: digest,
},
],
relocations: [],
entry_points: ["0x10100"],
entry_context: [
{
address: "0x10100",
registers: ["CS", "DS", "ES", "SS"].map((name) => ({
name,
value_hex: "0x1000",
})),
},
],
},
};
};

View File

@ -2,10 +2,12 @@ import { createHash } from "node:crypto";
import {
nativeLoadImageSchema,
type NativeLoadImage,
type NativeLoadImageCheck,
type NativeLoadImageObservation,
} from "../domain/nativeLoadImage.js";
import { parseDosMzHeader, type DosMzHeader } from "../domain/dosMz.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { validateDosComLength } from "../domain/dosCom.js";
import { err, ok, type Result } from "../domain/result.js";
const sha256 = (bytes: Buffer): string =>
@ -58,7 +60,7 @@ export const attestGhidraDosLoadImage = (
.toString("hex"),
});
}
const checks: Extract<NativeLoadImage, { format: "dos-mz" }>["checks"] = [];
const checks: NativeLoadImageCheck[] = [];
const check = (
name: string,
expected: JsonValue,
@ -120,7 +122,7 @@ export const attestGhidraDosLoadImage = (
rows(observations.relocations),
);
checkMappings(header, loadSegment, bytes, modified, observations, check);
checkMappings(header, loadSegment * 16, bytes, modified, observations, check);
return ok(
nativeLoadImageSchema.parse({
status: checks.every((item) => item.matched) ? "verified" : "mismatch",
@ -143,8 +145,8 @@ export const attestGhidraDosLoadImage = (
};
const checkMappings = (
header: DosMzHeader,
loadSegment: number,
header: Pick<DosMzHeader, "headerBytes" | "imageBytes">,
loadBase: number,
bytes: Buffer,
modified: Buffer,
observations: NativeLoadImageObservation,
@ -212,19 +214,13 @@ const checkMappings = (
? null
: headerMapping
? `HEADER:${address(start)}`
: address(loadSegment * 16 + start - header.headerBytes);
: address(loadBase + start - header.headerBytes);
const expectedEnd =
start === null
? null
: headerMapping
? `HEADER:${address(start + mapping.length - 1)}`
: address(
loadSegment * 16 +
start -
header.headerBytes +
mapping.length -
1,
);
: address(loadBase + start - header.headerBytes + mapping.length - 1);
check(`${at}.start`, expectedStart, mapping.start, start, mapping.start);
check(`${at}.end`, expectedEnd, mapping.end, start, mapping.end);
check(
@ -247,3 +243,85 @@ const checkMappings = (
),
);
};
/** Verify the explicit DOS COM interpretation against measured bytes and context. */
export const attestGhidraDosComLoadImage = (
bytes: Buffer,
targetSha256: string,
observations: NativeLoadImageObservation,
): Result<NativeLoadImage, string> => {
if (sha256(bytes) !== targetSha256)
return err("Ghidra COM snapshot digest does not match the admitted target");
const length = validateDosComLength(bytes.length);
if (!length.ok) return length;
const checks: NativeLoadImageCheck[] = [];
const check = (
name: string,
expected: JsonValue,
observed: JsonValue,
fileOffset: number | null = null,
location: string | null = null,
): void => {
checks.push({
name,
matched: JSON.stringify(expected) === JSON.stringify(observed),
expected,
observed,
file_offset: fileOffset,
address: location,
});
};
const source = observations.source_files[0];
check("executable_format", "Raw Binary", observations.executable_format);
check("language_id", "x86:LE:16:Real Mode", observations.language_id);
check("compiler_spec_id", "default", observations.compiler_spec_id);
check("image_base", "0x0", observations.image_base);
check("default_address_space", "ram", observations.default_address_space);
check("source_file_count", 1, observations.source_files.length);
check("source_file_size", bytes.length, source?.size ?? null);
check("original_file_sha256", targetSha256, source?.original_sha256 ?? null);
check("modified_file_sha256", targetSha256, source?.modified_sha256 ?? null);
check("entry_points", ["0x10100"], [...observations.entry_points].sort());
check(
"entry_context",
[
{
address: "0x10100",
registers: ["CS", "DS", "ES", "SS"].map((name) => ({
name,
value_hex: "0x1000",
})),
},
],
observations.entry_context,
);
check("relocation_records", [], observations.relocations);
checkMappings(
{ headerBytes: 0, imageBytes: bytes.length },
0x10100,
bytes,
bytes,
observations,
check,
);
return ok(
nativeLoadImageSchema.parse({
status: checks.every((item) => item.matched) ? "verified" : "mismatch",
format: "dos-com",
target_sha256: targetSha256,
load_segment: 0x1000,
header_bytes: 0,
module_bytes: bytes.length,
overlay_bytes: 0,
entry: { relative_segment: 0, offset: 0x100, linear_address: "0x10100" },
observations,
checks,
limitations: [
"DOS COM 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, entry and measured CS/DS/ES/SS entry context. Context 0x1000 is imposed for analysis; it is not an observed running process.",
"The PSP, initial stack contents/registers, DOS interrupts, PC-98 devices and runtime 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.",
],
}),
);
};

View File

@ -20,7 +20,10 @@ import {
nativeLoadImageObservationSchema,
nativeLoadImageSchema,
} from "../domain/nativeLoadImage.js";
import { attestGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
import {
attestGhidraDosLoadImage,
attestGhidraDosComLoadImage,
} from "./GhidraLoadImageValues.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
@ -219,6 +222,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
),
...(target.format === "dos-mz" ? { dosMz: true } : {}),
...(target.format === "dos-com" ? { dosCom: true } : {}),
platform: installation.platform,
}),
targetPath: target.path,
@ -229,7 +233,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
: "unix-socket",
providerVersion: prerequisites.value.providerVersion,
profileDigest: committedProfile.digest,
...(target.format === "dos-mz"
...(["dos-mz", "dos-com"].includes(target.format)
? {
expectedLanguageId: "x86:LE:16:Real Mode",
expectedCompilerSpecId: "default",
@ -287,12 +291,12 @@ export class GhidraProvider implements AnalysisProviderCandidate {
const observations = nativeLoadImageObservationSchema.parse(
result.value,
);
if (target.format !== "dos-mz") {
if (target.format !== "dos-mz" && target.format !== "dos-com") {
normalized = jsonValueSchema.parse(
nativeLoadImageSchema.parse({
status: "unsupported",
reason:
"Independent load-image verification currently supports DOS MZ targets only.",
"Independent load-image verification currently supports DOS MZ and explicit DOS COM targets only.",
observations,
limitations: [
"Other formats expose measured mappings and source identities; no format-specific verification was performed.",
@ -312,11 +316,11 @@ export class GhidraProvider implements AnalysisProviderCandidate {
const snapshot = await client.readTargetSnapshot();
if (!snapshot.ok)
return err(projectSessionError(operation, snapshot.error));
const attested = attestGhidraDosLoadImage(
snapshot.value,
target.sha256 ?? "",
observations,
);
const attested = (
target.format === "dos-com"
? attestGhidraDosComLoadImage
: attestGhidraDosLoadImage
)(snapshot.value, target.sha256 ?? "", observations);
if (!attested.ok)
return err(
new ProviderAdapterError("ghidra", operation, {

View File

@ -57,7 +57,7 @@ export const limitationsFor = (operation: string): readonly string[] => {
case "inspect_native_load_image":
return [
...common,
"Independent verification currently supports DOS MZ only. Measured source mappings use inclusive end addresses and complete region digests; no target execution or project mutation occurs.",
"Independent verification supports DOS MZ and explicitly selected COM, including measured COM entry context. Measured source mappings use inclusive end addresses and complete region digests; no target execution or project mutation occurs.",
];
case "read_bytes":
return [

View File

@ -89,6 +89,7 @@ export const hopperLoaderArgsForTarget = (
case "pe":
return ok(["-l", "WinPE", flag]);
case "dos-mz":
case "dos-com":
return err(
new ProviderAdapterError("hopper", "resolve_analysis_profile"),
);

View File

@ -157,11 +157,12 @@ export class HopperProvider implements AnalysisProviderCandidate {
reason: null,
diagnostics,
};
if (target.format === "dos-mz")
if (target.format === "dos-mz" || target.format === "dos-com")
return {
status: "unsupported",
code: "target_format_unsupported",
reason: "DOS MZ analysis requires the Ghidra 16-bit real-mode adapter.",
reason:
"DOS MZ/COM analysis requires the Ghidra 16-bit real-mode adapter.",
diagnostics,
};
return {

View File

@ -173,6 +173,7 @@ const registerOpenLifecycleTool = ({
const opened = await logToolExecution(logger, openContract.name, () =>
session.open(input.path, {
signal: context.mcpReq.signal,
...(input.format === undefined ? {} : { formatHint: input.format }),
...(input.provider_id === undefined
? {}
: { providerId: input.provider_id }),

View File

@ -0,0 +1,55 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("DOS COM admission", () => {
it.each(["fixture.com", "fixture.hop", "fixture.js", "fixture.bin"])(
"requires explicit interpretation for %s",
async (name) => {
const directory = await createTestTempDirectory("rea-com-target-");
const path = join(directory, name);
const bytes = Buffer.from("b83412c3", "hex");
await writeFile(path, bytes);
const target = await parseBinaryTarget(
path,
directory,
"arm64",
undefined,
"dos-com",
);
expect(target).toMatchObject({
ok: true,
value: {
path,
format: "dos-com",
kind: "executable",
architecture: "x86",
availableArchitectures: ["x86"],
sha256: createHash("sha256").update(bytes).digest("hex"),
},
});
expect(await readFile(path)).toEqual(bytes);
const detected = await parseBinaryTarget(path);
expect(detected.ok && detected.value.format).not.toBe("dos-com");
},
);
it.each([0, 0xff01])("rejects invalid file extent %i", async (length) => {
const directory = await createTestTempDirectory("rea-com-target-");
const path = join(directory, "fixture.com");
await writeFile(path, Buffer.alloc(length));
expect(
await parseBinaryTarget(path, directory, "x64", undefined, "dos-com"),
).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } });
});
it("rejects contradictory target kind", async () => {
const directory = await createTestTempDirectory("rea-com-target-");
const path = join(directory, "fixture.com");
await writeFile(path, Buffer.from("c3", "hex"));
expect(
await parseBinaryTarget(path, directory, "x64", "database", "dos-com"),
).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } });
});
});

View File

@ -141,6 +141,7 @@ const outputFor = (name: string): JsonValue => {
source_files: [],
mappings: [],
relocations: [],
entry_context: [],
entry_points: [],
},
limitations: ["Transport fixture only"],

View File

@ -64,6 +64,35 @@ afterEach(async () => {
);
});
describe("Ghidra COM loader", () => {
it("loads explicit COM at its PSP-relative entry before auto-analysis", () => {
const arguments_ = ghidraHeadlessArguments({
projectRoot: "/tmp/project",
targetPath: "/tmp/target.com",
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
descriptorPath: "/tmp/session.json",
ghidraLogPath: "/tmp/ghidra.log",
scriptLogPath: "/tmp/script.log",
dosCom: true,
});
expect(arguments_.slice(4, 12)).toEqual([
"-loader",
"BinaryLoader",
"-loader-baseAddr",
"1000:0100",
"-processor",
"x86:LE:16:Real Mode",
"-cspec",
"default",
]);
const prepare = arguments_.indexOf("-preScript");
expect(arguments_[prepare + 1]).toBe("ReaGhidraPrepareCom.java");
expect(prepare).toBeLessThan(arguments_.indexOf("-postScript"));
expect(arguments_).toContain("-readOnly");
expect(arguments_).toContain("-deleteProject");
});
});
describe("Ghidra headless launcher", () => {
it("forces the admitted real-mode language and loader for DOS MZ", () => {
const arguments_ = ghidraHeadlessArguments({

View File

@ -0,0 +1,13 @@
/** Generate a public source-owned COM fixture; no external compiler or game input. */
export const buildDosComFixture = () => {
const bytes = Buffer.alloc(64, 0x90);
bytes.set(Buffer.from("b83412e81a00cd20", "hex"));
bytes.set(Buffer.from("40c3", "hex"), 32);
return {
bytes,
entry: "0x10100",
near: "0x10120",
entry_hex: "b83412",
load_segment: "0x1000",
};
};