Merge pull request #85 from morluto/codex/rea-low-mid-batch
feat(analysis): harden agent workflows and evidence boundaries
This commit is contained in:
commit
883db912da
|
|
@ -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
|
||||
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -170,12 +170,12 @@ Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evid
|
|||
|
||||
### CLI or coding agent?
|
||||
|
||||
| If you want to… | Use |
|
||||
| --------------------------------------------------------- | ---------------------------------------------- |
|
||||
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
|
||||
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
|
||||
| Validate or canonicalize an Evidence v2 bundle | `rea evidence-import` or `rea evidence-export` |
|
||||
| Import source as historical reference | `rea import-reference-source` |
|
||||
| If you want to… | Use |
|
||||
| --------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
|
||||
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
|
||||
| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
|
||||
| Import source as historical reference | `rea import-reference-source` |
|
||||
|
||||
Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
|
||||
|
||||
|
|
@ -183,6 +183,7 @@ Filesystem evidence commands and MCP file tools are disabled until the operator
|
|||
export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
|
||||
rea evidence-import /absolute/path/to/evidence/bundle.json
|
||||
rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
|
||||
rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
|
||||
```
|
||||
|
||||
Historical source import requires a separate allowlist and never treats source as current behavioral authority:
|
||||
|
|
@ -276,6 +277,8 @@ REA is growing into a toolkit for understanding software across static artifacts
|
|||
|
||||
Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
|
||||
|
||||
See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
|
||||
|
||||
## Using REA with other coding agents
|
||||
|
||||
Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
|
||||
|
|
@ -320,9 +323,20 @@ The agent workflow above is the easiest way to use REA. For a one-off overview f
|
|||
|
||||
```bash
|
||||
npx -y rea-agents analyze /Applications/Notes.app
|
||||
npx -y rea-agents inspect /Applications/Notes.app
|
||||
npx -y rea-agents inspect /Applications/Notes.app --detail detailed --limit 20
|
||||
npx -y rea-agents search /Applications/Notes.app "offline"
|
||||
npx -y rea-agents function /Applications/Notes.app 0x1000
|
||||
npx -y rea-agents xrefs /Applications/Notes.app 0x1000
|
||||
npx -y rea-agents trace /Applications/Notes.app "offline"
|
||||
npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
|
||||
npx -y rea-agents capabilities
|
||||
npx -y rea-agents providers
|
||||
```
|
||||
|
||||
Run `npx -y rea-agents --help` for direct decompilation and other options.
|
||||
Run `npx -y rea-agents --help` for direct decompilation, bounded search and
|
||||
other options. `analyze` and `inspect` share the same overview workflow;
|
||||
`function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP.
|
||||
|
||||
Or install the `rea` command globally:
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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_);
|
||||
|
|
|
|||
|
|
@ -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_,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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} `)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
287
src/cli.ts
287
src/cli.ts
|
|
@ -5,17 +5,16 @@ import { runDoctor } from "./application/Doctor.js";
|
|||
import {
|
||||
runDirectAnalysis,
|
||||
runProviderAnalysis,
|
||||
runSessionStatus,
|
||||
} from "./application/DirectAnalysis.js";
|
||||
import { runSetup } from "./application/Setup.js";
|
||||
import { runUninstall } from "./application/Uninstall.js";
|
||||
import { PRODUCT_IDENTITY } from "./identity.js";
|
||||
import { createLogger, parseLogLevel, type Logger } from "./logger.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import {
|
||||
exportEvidenceBundleCommand,
|
||||
importEvidenceBundleCommand,
|
||||
} from "./application/EvidenceBundleCommands.js";
|
||||
import { importReferenceSource } from "./application/ReferenceSourceImport.js";
|
||||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
|
||||
/**
|
||||
* Build the one-shot Incur CLI without starting Hopper at import time.
|
||||
|
|
@ -49,6 +48,27 @@ export const createCli = (): ReturnType<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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" }])],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue