Merge pull request #85 from morluto/codex/rea-low-mid-batch

feat(analysis): harden agent workflows and evidence boundaries
This commit is contained in:
morluto 2026-07-13 11:52:36 +08:00 committed by GitHub
commit 883db912da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1054 additions and 158 deletions

View File

@ -26,7 +26,7 @@
## Tool Contract Impact
<!-- If this PR adds, removes, or modifies MCP tools, list them here. -->
<!-- The current inventory is 42 tools (31 direct + 8 enhanced + 3 session). -->
<!-- The current inventory is 68 tools (33 direct + 10 enhanced + 5 native + 2 artifact + 18 session). -->
## Related Issues

View File

@ -170,12 +170,12 @@ Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evid
### CLI or coding agent?
| If you want to… | Use |
| --------------------------------------------------------- | ---------------------------------------------- |
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
| Validate or canonicalize an Evidence v2 bundle | `rea evidence-import` or `rea evidence-export` |
| Import source as historical reference | `rea import-reference-source` |
| If you want to… | Use |
| --------------------------------------------------------- | -------------------------------------------------------------- |
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
| Import source as historical reference | `rea import-reference-source` |
Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
@ -183,6 +183,7 @@ Filesystem evidence commands and MCP file tools are disabled until the operator
export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
rea evidence-import /absolute/path/to/evidence/bundle.json
rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
```
Historical source import requires a separate allowlist and never treats source as current behavioral authority:
@ -276,6 +277,8 @@ REA is growing into a toolkit for understanding software across static artifacts
Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
## Using REA with other coding agents
Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
@ -320,9 +323,20 @@ The agent workflow above is the easiest way to use REA. For a one-off overview f
```bash
npx -y rea-agents analyze /Applications/Notes.app
npx -y rea-agents inspect /Applications/Notes.app
npx -y rea-agents inspect /Applications/Notes.app --detail detailed --limit 20
npx -y rea-agents search /Applications/Notes.app "offline"
npx -y rea-agents function /Applications/Notes.app 0x1000
npx -y rea-agents xrefs /Applications/Notes.app 0x1000
npx -y rea-agents trace /Applications/Notes.app "offline"
npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
npx -y rea-agents capabilities
npx -y rea-agents providers
```
Run `npx -y rea-agents --help` for direct decompilation and other options.
Run `npx -y rea-agents --help` for direct decompilation, bounded search and
other options. `analyze` and `inspect` share the same overview workflow;
`function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP.
Or install the `rea` command globally:

View File

@ -199,6 +199,7 @@ flowchart LR
```bash
npx -y rea-agents analyze /Applications/Notes.app
npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
```
شغّل `npx -y rea-agents --help` لمعرفة خيارات فك الترجمة المباشر والخيارات الأخرى.
@ -243,7 +244,7 @@ export HOPPER_LOADER_ARGS_JSON='["-l", "Mach-O", "--aarch68"]'
<details>
<summary><strong>هل يعمل REA على Linux أو Windows؟</strong></summary>
ليس حاليًا. يعتمد المحول الحالي على تطبيق Hopper لنظام macOS وواجهة Python الخاصة به.
نعم، يعمل REA على macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux ‏64 بت؛ Windows غير مدعوم حاليًا. يعتمد التحليل العميق على تطبيق Hopper وواجهة Python الخاصة به.
</details>

View File

@ -274,9 +274,15 @@ def _search_page(document, kind, params):
raise ValueError("Invalid regex pattern") from error
matches = lambda value: expression.search(value) is not None
matching = [item for item in _search_inventory(document, kind) if matches(item[1])]
selected = matching[offset:offset + limit]
total = len(matching)
selected = []
total = 0
page_end = offset + limit
for item in _search_inventory(document, kind):
if not matches(item[1]):
continue
if offset <= total < page_end:
selected.append(item)
total += 1
next_offset = offset + len(selected)
has_more = next_offset < total
return {
@ -654,7 +660,7 @@ def _serve_connection(connection):
should_stop = request["method"] == "shutdown"
response = {"id": request_id, "result": _json_safe(result)}
except Exception as error:
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512]}}
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}}
file.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
file.flush()
if should_stop:
@ -663,6 +669,14 @@ def _serve_connection(connection):
connection.close()
def _diagnostic_type(error):
if isinstance(error, PermissionError):
return "authorization"
if isinstance(error, (ValueError, TypeError, KeyError)):
return "invalid_request"
return "bridge_exception"
def _run():
"""Own a permission-restricted, single-client Unix socket for this bridge."""
if os.path.exists(REA_SOCKET):

View File

@ -0,0 +1,59 @@
# Static-analysis provider evaluation
Status: research for issue #26; no provider implementation is approved by this note.
REA should add another analysis provider only after the provider-neutral capability
contracts, Evidence v2 metadata, ownership rules, and multi-fixture conformance
suite are complete. A provider is not a drop-in replacement for Hopper: every
capability must be mapped explicitly, with truthful `unavailable` results where
the engine cannot provide equivalent semantics.
## Shortlist
| Provider | License / automation surface | What it brings | REA fit and blockers |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Broad static analysis, many processors and formats, scripting, and project/database workflows | Strong candidate for a full provider. Requires a Java/JDK runtime, isolated project/cache directories, process supervision, and a stable adapter for Ghidra's project-oriented model. |
| [Rizin](https://github.com/rizinorg/rizin) / [rz-pipe](https://github.com/rizinorg/rz-pipe) | Rizin repository contains LGPL-3.0 and GPL-3.0 components; `rizin`, `rz-bin`, and language bridges through `rzpipe` | Portable CLI analysis, disassembly/debugging, many architectures and file formats, JSON command output | Good candidate for a process-backed Linux provider and fast metadata fallback. License/component inventory must be preserved; command output needs version-pinned parsers and semantic conformance before evidence is trusted. |
| [LIEF](https://github.com/lief-project/LIEF) | Apache-2.0; C++, Python, and other bindings | Deterministic parsing and modification of ELF, PE, Mach-O, COFF, and related executable formats; headers, sections, symbols, relocations, and functions | Best near-term complement, not a decompiler replacement. It can cover format metadata and artifact evidence without a long-lived analysis process; function semantics, pseudocode, CFG, and cross-reference parity remain out of scope unless separately demonstrated. |
| [Binary Ninja](https://docs.binary.ninja/dev/index.html) | API/documentation components are MIT, while the analysis product is licensed by edition; commercial, Ultimate, or Headless license is required for headless automation | Python/Core/C++/Rust APIs, headless loading, IL layers, function analysis, plugins, and configurable analysis | Strong technical fit for a native provider, especially function dossiers. Commercial licensing, license-secret handling, native runtime packaging, and multithreaded lifecycle rules are material deployment blockers. |
## Recommended order
1. Use LIEF first for provider-neutral binary metadata where the current
contracts already describe headers, sections, symbols, and relocations.
2. Evaluate Rizin as a separate process provider for portable disassembly and
command-backed queries, with JSON-only parsing and fixture assertions.
3. Evaluate Ghidra or Binary Ninja for decompilation and richer function
semantics only after the conformance fixtures can compare evidence by
capability rather than by provider-specific output text.
## Required admission gate
Before implementation, an adapter proposal must provide:
- a capability matrix covering supported, unsupported, and degraded results;
- provider identity, version, target digest, authority, limitations, and
deterministic locations in every Evidence v2 record;
- bounded subprocess or library lifetime, cancellation, timeouts, and cleanup;
- sanitized diagnostics that do not expose license material, host paths, or
provider tracebacks;
- the same source-owned fixture corpus across architectures and formats;
- repeatable checks for function identity, addresses, strings, names, xrefs,
CFG, and pseudocode wherever those capabilities are claimed.
This keeps issue #26 a decision record rather than prematurely committing REA
to an engine whose semantics or redistribution terms have not been verified.
## Primary-source notes
- Ghidra documents headless batch mode and its Java/Python runtime requirements
in its [Getting Started guide](https://github.com/NationalSecurityAgency/ghidra/blob/master/GhidraDocs/GettingStarted.md).
- Rizin lists supported formats, architectures, tools, and `rzpipe` bridges in
its [repository README](https://github.com/rizinorg/rizin); `rz-pipe` documents
JSON command transport and pipe backends.
- LIEF's [format tutorial](https://lief.re/doc/stable/tutorials/01_play_with_formats.html)
and [Python API](https://lief.re/doc/latest/api/binary_abstraction/python.html)
document parsing and format-agnostic binary access.
- Binary Ninja documents its [headless automation model](https://docs.binary.ninja/dev/batch.html),
[edition licenses](https://docs.binary.ninja/about/license.html), and the
[MIT license for API/documentation components](https://docs.binary.ninja/about/open-source.html).

View File

@ -3,7 +3,11 @@ import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { sha256 } from "./lib/conformance-fixtures.mjs";
import {
generateLargeFixture,
sha256,
sourceDigest,
} from "./lib/conformance-fixtures.mjs";
const exec = promisify(execFile);
const manifestPath = resolve(
@ -16,6 +20,23 @@ const root = dirname(manifestPath);
for (const fixture of manifest.fixtures) {
const artifactPath = join(root, fixture.artifact);
const sources = [];
for (const source of fixture.sources ?? []) {
const content =
source.path === "generated/large.c"
? generateLargeFixture()
: await readFile(join(process.cwd(), "tests/conformance", source.path));
if (sha256(content) !== source.sha256)
throw new Error(
`${fixture.name}: source hash mismatch for ${source.path}`,
);
sources.push({ path: source.path, content });
}
if (
fixture.sourceSha256 !== undefined &&
sourceDigest(sources) !== fixture.sourceSha256
)
throw new Error(`${fixture.name}: source manifest digest mismatch`);
const artifact = await readFile(artifactPath);
if (sha256(artifact) !== fixture.artifactSha256)
throw new Error(`${fixture.name}: artifact hash mismatch`);

View File

@ -122,7 +122,15 @@ try {
!help.includes("inventory-artifact") ||
!help.includes("extract-artifact") ||
!help.includes("import-reference-source") ||
!help.includes("compare") ||
!llms.includes("decompile") ||
!llms.includes("function") ||
!llms.includes("search") ||
!llms.includes("inspect") ||
!llms.includes("xrefs") ||
!llms.includes("trace") ||
!llms.includes("capabilities") ||
!llms.includes("providers") ||
!llms.includes("inventory-artifact") ||
doctor.healthy !== expectedDoctorHealth ||
doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true
@ -157,6 +165,87 @@ try {
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
);
const inspected = json(
await run(
cli,
[
"inspect",
process.execPath,
"--detail",
"detailed",
"--limit",
"1",
"--json",
],
environment,
),
);
if (
inspected.operation !== "binary_overview" ||
inspected.normalized_result?.detail !== "detailed"
)
throw new Error("packaged inspect CLI failed");
const functionResult = json(
await run(
cli,
["function", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
functionResult.operation !== "analyze_function" ||
functionResult.provider?.id !== "hopper" ||
functionResult.normalized_result?.procedure?.address !== "0x1000"
)
throw new Error(
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
);
const xrefs = json(
await run(
cli,
["xrefs", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
xrefs.operation !== "xrefs" ||
JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"])
)
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
const trace = json(
await run(
cli,
["trace", process.execPath, "fixture", "--json"],
environment,
),
);
if (trace.operation !== "trace_feature")
throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`);
const capabilities = json(
await run(cli, ["capabilities", "--json"], environment),
);
if (!Array.isArray(capabilities.capabilities))
throw new Error("packaged capabilities CLI failed");
const providers = json(await run(cli, ["providers", "--json"], environment));
if (
!Array.isArray(providers.providers) ||
providers.providers.some(({ id }) => typeof id !== "string")
)
throw new Error("packaged providers CLI failed");
const searchResult = json(
await run(
cli,
["search", process.execPath, "fixture", "--json"],
environment,
),
);
if (
searchResult.operation !== "search_strings" ||
searchResult.normalized_result?.items?.length !== 1
)
throw new Error(
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
);
const referenceImport = json(
await run(
cli,
@ -201,14 +290,25 @@ try {
environment,
),
);
const comparedBundle = json(
await run(
cli,
["compare", sourceBundlePath, canonicalBundlePath, "--json"],
environment,
),
);
if (
importedBundle.imported !== 1 ||
importedBundle.total !== 1 ||
exportedBundle.records !== 1 ||
(await readFile(canonicalBundlePath, "utf8")) !==
serializeEvidenceBundle(sourceBundle)
serializeEvidenceBundle(sourceBundle) ||
comparedBundle.status !== "unchanged" ||
comparedBundle.summary?.records_unchanged !== 1
)
throw new Error("packaged CLI evidence bundle round trip failed");
throw new Error(
"packaged CLI evidence bundle comparison round trip failed",
);
await run(cli, ["mcp", "add"], environment);
const mcpRegistration = await readFile(npxLog, "utf8");
if (!mcpRegistration.includes("add-mcp npx -y rea-agents mcp --name rea"))

View File

@ -1,4 +1,6 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { readdir, realpath } from "node:fs/promises";
import { promisify } from "node:util";
@ -8,6 +10,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import {
firstProcedureAddress,
requireDistinctTargetHashes,
requireAddressArray,
requireFunctionDossier,
requirePseudocode,
@ -253,6 +256,11 @@ const [targetA, targetB] = await Promise.all([
]);
if (targetA === targetB)
throw new Error("Real-Hopper verification requires two distinct targets");
const [targetHashA, targetHashB] = await Promise.all([
sha256File(targetA),
sha256File(targetB),
]);
requireDistinctTargetHashes(targetHashA, targetHashB);
const serverEnvironment = { ...process.env };
delete serverEnvironment.HOPPER_TARGET_PATH;
delete serverEnvironment.HOPPER_SECOND_TARGET_PATH;
@ -377,6 +385,7 @@ try {
diagnosticCount,
dynamicSession: true,
targets: [targetA, targetB],
targetHashes: [targetHashA, targetHashB],
switched: true,
secondOverview: verifiedSecondOverview,
};
@ -422,3 +431,9 @@ await new Promise((resolve, reject) => {
},
);
});
async function sha256File(path) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
}

View File

@ -316,6 +316,14 @@ export class BinarySession implements BinarySessionPort {
name: this.#providerIdentity.name,
version: this.#providerIdentity.version,
};
const providers = new Map<string, ProviderIdentity>();
if (this.#capabilities === undefined) providers.set(provider.id, provider);
else
for (const descriptor of this.#capabilities.values())
providers.set(descriptor.provider.id, descriptor.provider);
const providerList = [...providers.values()]
.sort((left, right) => left.id.localeCompare(right.id))
.map(({ id, name, version }) => ({ id, name, version }));
const capabilities =
this.#capabilities === undefined
? []
@ -348,10 +356,11 @@ export class BinarySession implements BinarySessionPort {
limitations: [...descriptor.limitations],
}));
return target === undefined
? { open: false, provider, capabilities }
? { open: false, provider, providers: providerList, capabilities }
: {
open: true,
provider,
providers: providerList,
capabilities,
path: target.path,
sha256: target.sha256,

View File

@ -13,6 +13,15 @@ const WORKFLOW_PROVIDER = {
version: "1",
} as const;
type DirectAnalysisTool =
| "binary_overview"
| "procedure_pseudo_code"
| "analyze_function"
| "search_strings"
| "search_procedures"
| "xrefs"
| "trace_feature";
/**
* Open one binary, execute one tool, and always release the bridge session.
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
@ -20,7 +29,7 @@ const WORKFLOW_PROVIDER = {
*/
export const runDirectAnalysis = async (
path: string,
tool: "binary_overview" | "procedure_pseudo_code",
tool: DirectAnalysisTool,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger = silentLogger,
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
@ -33,13 +42,24 @@ export const runProviderAnalysis = async (
logger: Logger = silentLogger,
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
/** Describe configured providers without opening a target or launching Hopper. */
export const runSessionStatus = async (
logger: Logger = silentLogger,
): Promise<JsonValue> => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const session = createBinarySession(config.value, logger);
try {
return session.status();
} finally {
await session.close();
}
};
const runAnalysis = async (
path: string,
tool:
| NativeToolName
| ArtifactToolName
| "binary_overview"
| "procedure_pseudo_code",
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger,
): Promise<JsonValue> => {
@ -51,16 +71,26 @@ const runAnalysis = async (
const opened = await session.open(path);
if (!opened.ok)
return { error: opened.error._tag, message: opened.error.message };
if (tool === "binary_overview") {
if (
tool === "binary_overview" ||
tool === "analyze_function" ||
tool === "trace_feature"
) {
const result = await new EnhancedTools(session).execute(tool, arguments_);
return result.ok
? createEvidence(opened.value, WORKFLOW_PROVIDER, {
operation: tool,
parameters: arguments_,
result: result.value,
confidence: "derived",
limitations: ["Derived by an REA composed workflow."],
})
? createEvidence(
opened.value,
tool === "analyze_function"
? session.providerIdentity(tool)
: WORKFLOW_PROVIDER,
{
operation: tool,
parameters: arguments_,
result: result.value,
confidence: "derived",
limitations: ["Derived by an REA composed workflow."],
},
)
: { error: result.error._tag, message: result.error.message };
}
const result = await session.execute(tool, arguments_);

View File

@ -6,6 +6,7 @@ import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import {
AnalysisInputError,
AnalysisCancelledError,
AnalysisOutputError,
type AnalysisError,
} from "../domain/errors.js";
@ -472,6 +473,7 @@ export class EnhancedTools {
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
): Promise<Result<JsonValue, AnalysisError>> {
if (signal?.aborted === true) return err(new AnalysisCancelledError(name));
const execution = await this.analysis.execute(
name,
arguments_,

View File

@ -1,10 +1,15 @@
import type { AnalysisError } from "../domain/errors.js";
import {
EvidenceIntegrityError,
type AnalysisError,
} from "../domain/errors.js";
import type {
EvidenceFilePolicy,
EvidenceBundle,
} from "../domain/evidenceBundle.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import { compareBundles } from "../domain/bundleComparison.js";
import { EvidenceLedger } from "./EvidenceLedger.js";
import {
readEvidenceBundle,
@ -40,6 +45,35 @@ export const exportEvidenceBundleCommand = async (
);
};
/** Compare two validated canonical Evidence v2 bundles without session state. */
export const compareEvidenceBundlesCommand = async (input: {
readonly leftPath: string;
readonly rightPath: string;
readonly offset: number;
readonly limit: number;
readonly policy: EvidenceFilePolicy;
}): Promise<Result<JsonValue, AnalysisError>> => {
const [left, right] = await Promise.all([
readEvidenceBundle(input.leftPath, input.policy),
readEvidenceBundle(input.rightPath, input.policy),
]);
if (!left.ok) return left;
if (!right.ok) return right;
try {
return ok(
jsonValueSchema.parse(
compareBundles(left.value, right.value, [], input.offset, input.limit),
),
);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Evidence bundle comparison failed", {
cause,
}),
);
}
};
const createLedger = (): EvidenceLedger =>
new EvidenceLedger({ maxRecords: 10_000, maxBytes: 64 * 1024 * 1024 });

View File

@ -8,6 +8,10 @@ export interface OwnedProcessGroup {
readonly runId: string;
readonly leaderPid: number;
readonly processGroupId: number;
/** Expected launcher identity, checked only while the leader exists. */
readonly expectedCommand?: string;
/** Expected launcher parent, checked only while the leader exists. */
readonly expectedParentPid?: number;
}
interface ProcessGroupMember {
@ -86,6 +90,25 @@ export const cleanupOwnedProcessGroup = async (
return { cleaned: false, reason: "process group could not be inspected" };
}
if (members.length === 0) return { cleaned: true, signaled: false };
const leader = members.find((member) => member.pid === ownership.leaderPid);
if (leader !== undefined) {
if (
ownership.expectedParentPid !== undefined &&
leader.parentPid !== ownership.expectedParentPid
)
return {
cleaned: false,
reason: "owned launcher parent identity did not match",
};
if (
ownership.expectedCommand !== undefined &&
!commandMatches(leader.command, ownership.expectedCommand)
)
return {
cleaned: false,
reason: "owned launcher command identity did not match",
};
}
for (const member of members) {
let environment: Readonly<Record<string, string>>;
try {
@ -113,3 +136,12 @@ export const cleanupOwnedProcessGroup = async (
}
return { cleaned: true, signaled: true };
};
const commandMatches = (actual: string, expected: string): boolean => {
const normalizedActual = actual.trim();
const normalizedExpected = expected.trim();
return (
normalizedActual === normalizedExpected ||
normalizedActual.startsWith(`${normalizedExpected} `)
);
};

View File

@ -27,6 +27,23 @@ export const firstProcedureAddress = (input: unknown): string => {
return first.address;
};
/** Reject two target paths that resolve to the same binary content. */
export const requireDistinctTargetHashes = (
firstHash: unknown,
secondHash: unknown,
): void => {
if (
typeof firstHash !== "string" ||
typeof secondHash !== "string" ||
firstHash.length === 0 ||
secondHash.length === 0
) {
throw new TypeError("Target hashes must be non-empty strings");
}
if (firstHash === secondHash)
throw new TypeError("Real-Hopper verification requires distinct binaries");
};
/** Reject empty and success-shaped embedded decompilation failures. */
export const requirePseudocode = (
input: unknown,

View File

@ -5,17 +5,16 @@ import { runDoctor } from "./application/Doctor.js";
import {
runDirectAnalysis,
runProviderAnalysis,
runSessionStatus,
} from "./application/DirectAnalysis.js";
import { runSetup } from "./application/Setup.js";
import { runUninstall } from "./application/Uninstall.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import { createLogger, parseLogLevel, type Logger } from "./logger.js";
import { logCliCommand } from "./cliLogging.js";
import { parseConfig } from "./config.js";
import {
exportEvidenceBundleCommand,
importEvidenceBundleCommand,
} from "./application/EvidenceBundleCommands.js";
import { importReferenceSource } from "./application/ReferenceSourceImport.js";
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
/**
* Build the one-shot Incur CLI without starting Hopper at import time.
@ -49,6 +48,27 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
},
});
registerCoreCommands(cli, logger);
registerFunctionCommand(cli, logger);
registerSearchCommand(cli, logger);
registerXrefsCommand(cli, logger);
registerTraceCommand(cli, logger);
registerCapabilityCommands(cli, logger);
registerNativeCommands(cli, logger);
registerArtifactCommands(cli, logger);
registerEvidenceCommands(cli, logger);
registerReferenceSourceCommand(cli, logger);
return cli;
};
const registerCoreCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
const overviewOptions = z.object({
detail: z.enum(["concise", "detailed"]).default("concise"),
limit: z.number().int().min(1).max(50).default(10),
});
cli.command("setup", {
description: "Install requirements and configure coding agents",
options: z.object({
@ -86,9 +106,31 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
args: z.object({
path: z.string().describe("App, program, or Hopper database path"),
}),
run: ({ args }) =>
options: overviewOptions,
run: ({ args, options }) =>
logCliCommand(logger, "analyze", () =>
runDirectAnalysis(args.path, "binary_overview", {}, logger),
runDirectAnalysis(
args.path,
"binary_overview",
{ detail: options.detail, limit: options.limit },
logger,
),
),
});
cli.command("inspect", {
description: "Inspect an app overview with evidence",
args: z.object({
path: z.string().describe("App, program, or Hopper database path"),
}),
options: overviewOptions,
run: ({ args, options }) =>
logCliCommand(logger, "inspect", () =>
runDirectAnalysis(
args.path,
"binary_overview",
{ detail: options.detail, limit: options.limit },
logger,
),
),
});
cli.command("decompile", {
@ -107,11 +149,154 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
),
),
});
registerNativeCommands(cli, logger);
registerArtifactCommands(cli, logger);
registerEvidenceCommands(cli, logger);
registerReferenceSourceCommand(cli, logger);
return cli;
};
const registerXrefsCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("xrefs", {
description: "List bounded references to an analyzed address",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Hexadecimal address"),
}),
run: ({ args }) =>
logCliCommand(logger, "xrefs", () =>
runDirectAnalysis(
args.path,
"xrefs",
{ address: args.address },
logger,
),
),
});
};
const registerTraceCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("trace", {
description: "Trace a bounded literal feature through analyzed references",
args: z.object({
path: z.string().describe("App or program path"),
query: z.string().min(1).describe("Literal feature query"),
}),
options: z.object({
caseSensitive: z.boolean().default(false),
limit: z.number().int().min(1).max(100).default(20),
maxOperations: z.number().int().min(1).max(100).default(20),
}),
alias: {
caseSensitive: "case-sensitive",
maxOperations: "max-operations",
},
run: ({ args, options }) =>
logCliCommand(logger, "trace", () =>
runDirectAnalysis(
args.path,
"trace_feature",
{
query: args.query,
case_sensitive: options.caseSensitive,
limit: options.limit,
max_operations: options.maxOperations,
},
logger,
),
),
});
};
const registerCapabilityCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
for (const command of ["capabilities", "providers"] as const) {
cli.command(command, {
description:
command === "capabilities"
? "List provider capabilities and side effects"
: "List configured analysis providers",
run: () => logCliCommand(logger, command, () => runSessionStatus(logger)),
});
}
};
const registerFunctionCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("function", {
description: "Analyze one bounded function with evidence",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure name or address"),
}),
options: z.object({
includeAssembly: z.boolean().default(false),
limit: z.number().int().min(1).max(500).default(100),
maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000),
maxInstructions: z.number().int().min(1).max(5_000).default(500),
}),
alias: {
includeAssembly: "include-assembly",
maxPseudocodeChars: "max-pseudocode-chars",
maxInstructions: "max-instructions",
},
run: ({ args, options }) =>
logCliCommand(logger, "function", () =>
runDirectAnalysis(
args.path,
"analyze_function",
{
procedure: args.address,
include_assembly: options.includeAssembly,
limit: options.limit,
max_pseudocode_chars: options.maxPseudocodeChars,
max_instructions: options.maxInstructions,
},
logger,
),
),
});
};
const registerSearchCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("search", {
description: "Search bounded analyzed strings or procedure names",
args: z.object({
path: z.string().describe("App or program path"),
pattern: z.string().min(1).describe("Literal text or regex pattern"),
}),
options: z.object({
kind: z.enum(["strings", "procedures"]).default("strings"),
mode: z.enum(["literal", "regex"]).default("literal"),
caseSensitive: z.boolean().default(false),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(100).default(100),
}),
alias: { caseSensitive: "case-sensitive" },
run: ({ args, options }) =>
logCliCommand(logger, "search", () =>
runDirectAnalysis(
args.path,
options.kind === "strings" ? "search_strings" : "search_procedures",
{
pattern: args.pattern,
mode: options.mode,
case_sensitive: options.caseSensitive,
offset: options.offset,
limit: options.limit,
},
logger,
),
),
});
};
const registerReferenceSourceCommand = (
@ -253,83 +438,3 @@ const registerNativeCommands = (
),
});
};
const registerEvidenceCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("evidence-import", {
description: "Validate and import a bounded local Evidence v2 bundle",
args: z.object({
path: z.string().describe("Evidence bundle JSON path"),
}),
run: ({ args }) =>
logCliCommand(logger, "evidence-import", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const imported = await importEvidenceBundleCommand(
args.path,
config.value.evidenceFilePolicy,
);
return imported.ok
? imported.value
: { error: imported.error._tag, message: imported.error.message };
}),
});
cli.command("evidence-export", {
description: "Validate and atomically export canonical Evidence v2 JSON",
args: z.object({
source: z.string().describe("Existing evidence bundle JSON path"),
output: z.string().describe("Canonical output JSON path"),
}),
options: z.object({
overwrite: z.boolean().default(false).describe("Replace output file"),
}),
run: ({ args, options }) =>
logCliCommand(logger, "evidence-export", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const exported = await exportEvidenceBundleCommand(
args.source,
args.output,
options.overwrite,
config.value.evidenceFilePolicy,
);
return exported.ok
? exported.value
: { error: exported.error._tag, message: exported.error.message };
}),
});
};
const logCliCommand = async <Value>(
logger: Logger,
command: string,
execute: () => Promise<Value>,
): Promise<Value> => {
const startedAt = performance.now();
try {
const value = await execute();
logger.info(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "ok",
},
"CLI command completed",
);
return value;
} catch (cause: unknown) {
logger.error(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "error",
},
"CLI command failed",
);
throw cause;
}
};

View File

@ -0,0 +1,89 @@
import { Cli, z } from "incur";
import {
compareEvidenceBundlesCommand,
exportEvidenceBundleCommand,
importEvidenceBundleCommand,
} from "./application/EvidenceBundleCommands.js";
import { parseConfig } from "./config.js";
import { logCliCommand } from "./cliLogging.js";
import type { Logger } from "./logger.js";
/** Register filesystem-gated Evidence v2 commands. */
export const registerEvidenceCommands = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,
): void => {
cli.command("evidence-import", {
description: "Validate and import a bounded local Evidence v2 bundle",
args: z.object({
path: z.string().describe("Evidence bundle JSON path"),
}),
run: ({ args }) =>
logCliCommand(logger, "evidence-import", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const imported = await importEvidenceBundleCommand(
args.path,
config.value.evidenceFilePolicy,
);
return imported.ok
? imported.value
: { error: imported.error._tag, message: imported.error.message };
}),
});
cli.command("evidence-export", {
description: "Validate and atomically export canonical Evidence v2 JSON",
args: z.object({
source: z.string().describe("Existing evidence bundle JSON path"),
output: z.string().describe("Canonical output JSON path"),
}),
options: z.object({
overwrite: z.boolean().default(false).describe("Replace output file"),
}),
run: ({ args, options }) =>
logCliCommand(logger, "evidence-export", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const exported = await exportEvidenceBundleCommand(
args.source,
args.output,
options.overwrite,
config.value.evidenceFilePolicy,
);
return exported.ok
? exported.value
: { error: exported.error._tag, message: exported.error.message };
}),
});
cli.command("compare", {
aliases: ["compare-bundles"],
description: "Compare two canonical Evidence v2 bundles",
args: z.object({
left: z.string().describe("Left Evidence bundle JSON path"),
right: z.string().describe("Right Evidence bundle JSON path"),
}),
options: z.object({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(500).default(100),
}),
run: ({ args, options }) =>
logCliCommand(logger, "compare", async () => {
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const compared = await compareEvidenceBundlesCommand({
leftPath: args.left,
rightPath: args.right,
offset: options.offset,
limit: options.limit,
policy: config.value.evidenceFilePolicy,
});
return compared.ok
? compared.value
: { error: compared.error._tag, message: compared.error.message };
}),
});
};

32
src/cliLogging.ts Normal file
View File

@ -0,0 +1,32 @@
import type { Logger } from "./logger.js";
/** Log one CLI command with duration and a stable success or failure status. */
export const logCliCommand = async <Value>(
logger: Logger,
command: string,
execute: () => Promise<Value>,
): Promise<Value> => {
const startedAt = performance.now();
try {
const value = await execute();
logger.info(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "ok",
},
"CLI command completed",
);
return value;
} catch (cause: unknown) {
logger.error(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: "error",
},
"CLI command failed",
);
throw cause;
}
};

View File

@ -87,12 +87,14 @@ const providerCapability = z.object({
}),
limitations: z.array(z.string()),
});
const providerIdentity = z.object({
id: z.string(),
name: z.string(),
version: z.string().nullable(),
});
const sessionProvider = z.object({
provider: z.object({
id: z.string(),
name: z.string(),
version: z.string().nullable(),
}),
provider: providerIdentity,
providers: z.array(providerIdentity),
capabilities: z.array(providerCapability),
});
const nullableText = z.string().nullable();

View File

@ -216,6 +216,12 @@ export class HopperProtocolError extends HopperError {
readonly _tag = "HopperProtocolError";
}
export type HopperDiagnosticType =
| "remote"
| "authorization"
| "invalid_request"
| "bridge_exception";
/** Hopper's JSON-RPC endpoint returned an expected remote error response. */
export class HopperRemoteError extends HopperError {
readonly _tag = "HopperRemoteError";
@ -223,6 +229,7 @@ export class HopperRemoteError extends HopperError {
constructor(
readonly code: number,
readonly safeMessage: string,
readonly diagnosticType: HopperDiagnosticType = "remote",
) {
super(`Hopper request failed (${String(code)}): ${safeMessage}`);
}
@ -322,7 +329,11 @@ const safeDetails = (
if (error instanceof HopperTimeoutError)
return { timeoutMs: error.timeoutMs };
if (error instanceof HopperRemoteError)
return { code: error.code, safeMessage: error.safeMessage };
return {
code: error.code,
safeMessage: error.safeMessage,
diagnosticType: error.diagnosticType,
};
if (error instanceof HopperProcessError) return { exitCode: error.exitCode };
return {};
};

View File

@ -37,11 +37,31 @@ const segmentSchema = z.object({
writable: z.boolean().nullable().default(null),
executable: z.boolean().nullable().default(null),
});
const addressedPageSchema = z.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
next_offset: z.number().int().min(0).nullable(),
has_more: z.boolean(),
});
const addressedPageSchema = z
.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
offset: z.number().int().min(0),
limit: z.number().int().min(1),
total: z.number().int().min(0),
next_offset: z.number().int().min(0).nullable(),
has_more: z.boolean(),
})
.superRefine((value, context) => {
if (value.has_more && value.next_offset === null) {
context.addIssue({
code: "custom",
message: "a page with more results must provide next_offset",
path: ["next_offset"],
});
}
if (!value.has_more && value.next_offset !== null) {
context.addIssue({
code: "custom",
message: "a complete page must not provide next_offset",
path: ["next_offset"],
});
}
});
const unavailableSchema = z
.object({ available: z.literal(false), reason: z.string() })
.strict();

View File

@ -141,22 +141,18 @@ export class HopperApplicationLauncher implements BridgeLauncher {
{ encoding: "utf8", mode: 0o600 },
);
} catch (cause: unknown) {
await cleanupOwnedProcessGroup({
runId: session.runId,
leaderPid: pid,
processGroupId: pid,
});
await cleanupOwnedProcessGroup(
ownedProcessGroup(session, pid, this.options.launcherPath),
);
return err(new HopperStartError({ cause }));
}
return ok({
process: child,
ownsProcessLifetime: true,
cleanup: () =>
cleanupOwnedProcessGroup({
runId: session.runId,
leaderPid: pid,
processGroupId: pid,
}),
cleanupOwnedProcessGroup(
ownedProcessGroup(session, pid, this.options.launcherPath),
),
});
} catch (cause: unknown) {
return err(new HopperStartError({ cause }));
@ -164,6 +160,18 @@ export class HopperApplicationLauncher implements BridgeLauncher {
}
}
const ownedProcessGroup = (
session: BridgeSession,
pid: number,
launcherPath: string,
) => ({
runId: session.runId,
leaderPid: pid,
processGroupId: pid,
expectedCommand: launcherPath,
expectedParentPid: process.pid,
});
const prepareHopperApplication = async (
launcherPath: string,
signal?: AbortSignal,

View File

@ -11,7 +11,18 @@ const responseSchema = z.union([
}),
z.object({
id: z.number().int().nonnegative(),
error: z.object({ code: z.number().int(), message: z.string() }),
error: z.object({
code: z.number().int(),
message: z.string(),
type: z
.enum([
"remote",
"authorization",
"invalid_request",
"bridge_exception",
])
.default("remote"),
}),
}),
]);
@ -46,5 +57,11 @@ export const responseResult = (
response: HopperResponse,
): Result<JsonValue, HopperRemoteError> =>
"error" in response
? err(new HopperRemoteError(response.error.code, response.error.message))
? err(
new HopperRemoteError(
response.error.code,
response.error.message,
response.error.type,
),
)
: ok(response.result);

View File

@ -79,6 +79,7 @@ describe("binary session", () => {
expect(provider.capabilities()[0]?.operation).toBe("address_name");
expect(session.status()).toMatchObject({
provider: { id: "fixture", name: "Fixture", version: "1" },
providers: [{ id: "fixture", name: "Fixture", version: "1" }],
capabilities: [
{
operation: "address_name",

View File

@ -73,6 +73,11 @@ describe("Hopper bridge truthfulness", () => {
expect(bridgeSource).toContain("MAX_SEARCH_PATTERN_LENGTH = 256");
expect(bridgeSource).toContain("MAX_SEARCH_VALUE_LENGTH = 4096");
expect(bridgeSource).toContain('"value_truncated"');
expect(bridgeSource).toContain("page_end = offset + limit");
expect(bridgeSource).toContain("if offset <= total < page_end:");
expect(bridgeSource).not.toContain(
"matching = [item for item in _search_inventory(document, kind)",
);
});
it("caches sorted search inventories and invalidates them after renames", () => {
@ -80,4 +85,10 @@ describe("Hopper bridge truthfulness", () => {
expect(bridgeSource).toContain("tuple(sorted(values.items()");
expect(bridgeSource).toContain("_invalidate_search_inventory(document)");
});
it("classifies bridge exceptions without forwarding tracebacks", () => {
expect(bridgeSource).toContain("def _diagnostic_type(error):");
expect(bridgeSource).toContain('"type": _diagnostic_type(error)');
expect(bridgeSource).not.toContain("traceback.format_exc");
});
});

View File

@ -3,6 +3,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { EnhancedTools } from "../src/application/EnhancedTools.js";
import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
import { observed as ok } from "./fixtures/analysisExecution.js";
import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js";
@ -304,6 +305,38 @@ describe("enhanced MCP tools", () => {
expect(result).toMatchObject({ count: 2 });
});
it("does not start another page after cancellation", async () => {
const controller = new AbortController();
let calls = 0;
const analysis: AnalysisOperationPort = {
execute: () => {
calls += 1;
controller.abort();
return Promise.resolve(
ok({
items: [{ address: "0x1", value: "_TtC5First" }],
offset: 0,
limit: 500,
total: 2,
next_offset: 500,
has_more: true,
}),
);
},
};
const result = await new EnhancedTools(analysis).execute(
"swift_classes",
{},
controller.signal,
);
expect(calls).toBe(1);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
});
it("follows name pagination for exhaustive Objective-C discovery", async () => {
const offsets: number[] = [];
const client = await connect({
@ -534,4 +567,36 @@ describe("enhanced MCP tools", () => {
"AnalysisOutputError",
);
});
it("accepts a final dossier page whose total exceeds returned", async () => {
const malformedPort = fixturePort();
const client = await connect({
execute: async (name, arguments_, options) => {
const result = await malformedPort.execute(name, arguments_, options);
if (!result.ok || name !== "analyze_function") return result;
const dossier = result.value.result;
if (
typeof dossier !== "object" ||
dossier === null ||
Array.isArray(dossier)
)
return result;
return ok({
...dossier,
callers: {
items: [],
total: 1,
returned: 0,
truncated: false,
next_offset: null,
},
});
},
});
const result = await client.callTool({
name: "analyze_function",
arguments: { procedure: "0x1" },
});
expect(result.isError).not.toBe(true);
});
});

View File

@ -15,6 +15,7 @@ import {
readEvidenceBundle,
writeEvidenceBundle,
} from "../src/application/EvidenceBundleFiles.js";
import { compareEvidenceBundlesCommand } from "../src/application/EvidenceBundleCommands.js";
import { createEvidence } from "../src/domain/evidence.js";
import {
createEvidenceBundle,
@ -66,6 +67,21 @@ describe("evidence bundle filesystem adapter", () => {
ok: true,
value: evidenceBundle,
});
expect(
await compareEvidenceBundlesCommand({
leftPath: path,
rightPath: path,
offset: 0,
limit: 100,
policy: policy(directory),
}),
).toMatchObject({
ok: true,
value: {
status: "unchanged",
summary: { records_unchanged: 1 },
},
});
expect(
await writeEvidenceBundle(evidenceBundle, path, false, policy(directory)),
).toMatchObject({

View File

@ -26,9 +26,69 @@ const enhancedFixtureResult = (method) => {
has_more: false,
};
case "list_strings":
return ["fixture"];
return {
items: [{ address: "0x1000", value: "fixture" }],
offset: 0,
limit: 500,
total: 1,
next_offset: null,
has_more: false,
};
case "procedure_pseudo_code":
return "return 0;";
case "analyze_function": {
const empty = () => ({
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
});
return {
procedure: {
address: "0x1000",
name: "fixture",
signature: null,
locals: [],
},
pseudocode: {
text: "return 0;",
total_chars: 9,
returned_chars: 9,
truncated: false,
next_offset: null,
},
assembly: { ...empty(), items: ["ret"], total: 1, returned: 1 },
comments: empty(),
callers: empty(),
callees: empty(),
incoming_references: empty(),
outgoing_references: empty(),
referenced_strings: empty(),
referenced_names: empty(),
basic_blocks: {
...empty(),
items: [{ start: "0x1000", end: "0x1001", successors: [] }],
total: 1,
returned: 1,
},
instruction_scan: { scanned: 1, truncated: false },
};
}
case "search_strings":
case "search_procedures":
return {
items: [
{ address: "0x1000", value: "fixture", value_truncated: false },
],
offset: 0,
limit: 100,
total: 1,
next_offset: null,
has_more: false,
};
case "xrefs":
return ["0x1000"];
default:
return undefined;
}
@ -79,7 +139,11 @@ const server = createServer((socket) => {
} else if (request.method === "remote_error") {
send({
id: request.id,
error: { code: -32001, message: "safe fake failure" },
error: {
code: -32001,
message: "safe fake failure",
type: "bridge_exception",
},
});
} else if (request.method === "current_document") {
send({ id: request.id, result: "fixture" });

View File

@ -131,6 +131,17 @@ describe("HopperClient", () => {
if (!result.ok) expect(result.error._tag).toBe(expectedTag);
});
it("preserves a sanitized bridge exception diagnostic", async () => {
const client = await startClient();
const result = await client.callTool("remote_error");
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error).toMatchObject({
_tag: "HopperRemoteError",
diagnosticType: "bridge_exception",
});
});
it("cancels and ignores late responses without corrupting the session", async () => {
const client = await startClient();
const controller = new AbortController();

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
parseDocuments,
parseAddressedPage,
parseListCount,
parseNames,
parseProcedures,
@ -62,6 +63,29 @@ describe("Hopper boundary values", () => {
});
});
it.each([
{
items: [],
offset: 0,
limit: 100,
total: 1,
next_offset: null,
has_more: true,
},
{
items: [],
offset: 0,
limit: 100,
total: 0,
next_offset: 100,
has_more: false,
},
])("rejects contradictory page continuation metadata", (page) => {
const result = parseAddressedPage(page);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("HopperProtocolError");
});
it.each([
["procedures", () => parseProcedures(["not-a-map"])],
["names", () => parseNames([{ address: 1, name: "bad" }])],

View File

@ -67,4 +67,40 @@ describe("owned process-group cleanup", () => {
signaled: false,
});
});
it("fails closed when the launcher command identity changes", async () => {
const { adapter, signalGroup } = host({
100: { REA_PROCESS_RUN_ID: "run-token" },
});
expect(
await cleanupOwnedProcessGroup(
{
...ownership,
expectedCommand: "/owned/hopper",
expectedParentPid: 1,
},
adapter,
),
).toEqual({
cleaned: false,
reason: "owned launcher command identity did not match",
});
expect(signalGroup).not.toHaveBeenCalled();
});
it("fails closed when the launcher parent identity changes", async () => {
const { adapter, signalGroup } = host({
100: { REA_PROCESS_RUN_ID: "run-token" },
});
expect(
await cleanupOwnedProcessGroup(
{ ...ownership, expectedCommand: "fixture", expectedParentPid: 999 },
adapter,
),
).toEqual({
cleaned: false,
reason: "owned launcher parent identity did not match",
});
expect(signalGroup).not.toHaveBeenCalled();
});
});

View File

@ -19,6 +19,38 @@ const readmes = [
"README_ar.md",
] as const;
const expectedToolCounts = [
OFFICIAL_TOOL_CONTRACTS.length,
ENHANCED_TOOL_CONTRACTS.length,
NATIVE_TOOL_CONTRACTS.length,
ARTIFACT_TOOL_CONTRACTS.length,
SESSION_TOOL_CONTRACTS.length,
] as const;
const toolCountsFromReadme = (content: string, path: string): number[] => {
const lines = content.split(/\r?\n/u);
const headingIndex = lines.findIndex((line) => /^## .*68/u.test(line));
if (headingIndex === -1)
throw new Error(`Missing 68-tool heading in ${path}`);
const counts: number[] = [];
for (const line of lines.slice(headingIndex + 1)) {
if (line.startsWith("## ")) break;
if (!line.trim().startsWith("|") || /^\|\s*-/.test(line)) continue;
const cells = line.split("|").map((cell) => cell.trim());
const countCell = cells[2];
if (countCell === undefined)
throw new Error(`Malformed table row in ${path}`);
const count = Number(countCell);
if (!Number.isInteger(count)) {
if (counts.length === 0) continue;
throw new Error(`Non-numeric tool count in ${path}`);
}
counts.push(count);
}
return counts;
};
describe("localized README product facts", () => {
it.each(readmes)(
"keeps commands and requirements aligned in %s",
@ -34,17 +66,13 @@ describe("localized README product facts", () => {
expect(content).toContain('"args": ["-y", "rea-agents", "mcp"]');
expect(content).toContain("Node.js 24");
expect(content).toContain("macOS 12");
expect(content).toContain("Ubuntu 24.04");
expect(content).toContain("Fedora 41");
expect(content).toContain("Arch Linux");
if (path === "README_ar.md")
expect(content).toContain("Windows غير مدعوم حاليًا");
expect(content).toContain(`MCP_tools-${String(TOOL_CONTRACTS.length)}`);
for (const count of [
OFFICIAL_TOOL_CONTRACTS.length,
ENHANCED_TOOL_CONTRACTS.length,
NATIVE_TOOL_CONTRACTS.length,
ARTIFACT_TOOL_CONTRACTS.length,
SESSION_TOOL_CONTRACTS.length,
])
expect(content).toMatch(
new RegExp(`\\|\\s*${String(count)}\\s*\\|`, "u"),
);
expect(toolCountsFromReadme(content, path)).toEqual(expectedToolCounts);
},
);

View File

@ -2,12 +2,20 @@ import { describe, expect, it } from "vitest";
import {
firstProcedureAddress,
requireDistinctTargetHashes,
requireAddressArray,
requireFunctionDossier,
requirePseudocode,
} from "../src/application/RealHopperAssertions.js";
describe("real Hopper semantic assertions", () => {
it("rejects two target paths containing the same binary", () => {
expect(() => requireDistinctTargetHashes("same", "same")).toThrow(
/distinct binaries/u,
);
expect(() => requireDistinctTargetHashes("", "other")).toThrow();
expect(() => requireDistinctTargetHashes("first", "second")).not.toThrow();
});
it("extracts only a real procedure address from a structured page", () => {
expect(
firstProcedureAddress({