From 7aa1b51282ba3fe2a659330ee615bd0de8dffaaf Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 21 Oct 2026 23:19:12 -0700 Subject: [PATCH] feat(diagnostics): report a byte entropy profile for one local file (#2331) * feat(diagnostics): report a byte entropy profile for one local file The diff adds the MCP tool inspect_byte_entropy and the CLI command inspect-byte-entropy, both implemented by ByteEntropyService. One absolute regular file is read offline into observed Evidence: file-wide SHA-256 and Shannon entropy, then fixed blocks and adjacent runs for an optional window, using a power-of-two block size from 256 to 65536 (default 4096), with high at 7.2 or above and low at 1.0 or below. A window past EOF, a missing path, or a non-file is an input error; a size or identity change during the read is an artifact-changed error; empty input reports entropy 0; cancellation is honored before and during the read. Summary detail retains the complete series and returns file entropy, counts, and the ten longest runs for inspect_analysis_view, while the registered effect remains read-only Evidence and the diagnostics docs record the operation and its errors. * fix(diagnostics): flag zero-size files as not shown empty Generated files such as Linux /proc entries report size 0 while holding content, so inspect_byte_entropy read nothing and returned an empty-looking report. A file that reports size 0 now carries a limitation that the result does not show the file is empty. The result stays a success because the tool reads the declared size, which the file-stability checks depend on. * fix(mcp): reject unsupported diagnostic summaries before inspection * fix(ci): repair integrated checks and Hopper test baseline * test(entropy): use shared MCP schema verification after merge * fix(catalog): list inspect_byte_entropy under its own provider Adding inspect_byte_entropy to BINARY_DIAGNOSTICS_TOOL_CONTRACTS put it under pwntools-elf in the generated product catalog, because the pwntools group took every binary diagnostics tool except inspect_pe_resources. The service reports provider byte-entropy. Add a BYTE_ENTROPY_PROVIDER identity beside PE_RESOURCES_PROVIDER, use it in ByteEntropyService, give it its own catalog group, and name the pwntools tools (inspect_binary_layout, inspect_recorded_crash) explicitly so a new diagnostics tool is not attributed to pwntools by default. The catalog test pins both groups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KFTE4BHbGYzgf8Uhzcppdh * fix(cli): resolve the byte-entropy path with absoluteCallerPath main replaced the node:path resolve import in binaryDiagnosticsCommands.ts with absoluteCallerPath (#4067), so the byte-entropy command's resolve(args.path) no longer type-checks once the PR is merged with main. Use absoluteCallerPath like the other commands, and add src/filesystem/callerPath.ts matching main so the branch builds on its own. * fix(diagnostics): retain entropy read cleanup ownership * docs(diagnostics): explain entropy output capacity * fix(diagnostics): preserve entropy cancellation outcomes * fix(diagnostics): close the entropy input and warn on large reports Public inspect_byte_entropy input now uses publicInputStrictObject before detail is added, so unknown keys stay rejected on the advertised schema. The tool description warns that complete output can become very large and points callers to a shorter length or a larger block_size. Co-authored-by: morluto * fix(diagnostics): keep byte-entropy registration mergeable with main Register inspect_byte_entropy beside layout inspection and reject unretained summaries before the file read, without editing workflows. Co-authored-by: morluto * fix(diagnostics): register byte entropy with the shared tool options Use toolRegistrationOptions so the handler typechecks on this branch and on main, and still refuse unretained summaries before the file read. Co-authored-by: morluto --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: morluto --- .../references/native-and-artifacts.md | 2 + docs/binary-diagnostics.md | 84 ++++ docs/user-visible-errors.csv | 2 + scripts/lib/catalog-builders.mjs | 11 +- src/application/CapabilityInventory.ts | 1 + src/application/InvestigationProviders.ts | 7 + .../ByteEntropyService.test.ts | 373 ++++++++++++++++++ .../binaryDiagnostics/ByteEntropyService.ts | 355 +++++++++++++++++ src/cli/binaryDiagnosticsCommands.ts | 40 ++ src/cliCommandNames.ts | 1 + src/composition/binaryDiagnostics.ts | 7 + .../binaryDiagnosticsToolContracts.test.ts | 33 ++ .../native/binaryDiagnosticsToolContracts.ts | 36 ++ src/contracts/toolContractExamples.ts | 1 + src/contracts/toolEffects.test.ts | 1 + src/contracts/toolEffects.ts | 1 + src/contracts/toolOutputSchemaGroups.ts | 9 + src/domain/analysisView/analysisView.ts | 29 ++ src/domain/analysisView/byteEntropyView.ts | 66 ++++ src/domain/native/byteEntropy.test.ts | 53 +++ src/domain/native/byteEntropy.ts | 101 +++++ src/domain/publicInput.test.ts | 16 + src/domain/publicInput.ts | 9 + src/server/createServer.ts | 6 +- src/server/registerBinaryDiagnosticsTools.ts | 23 +- src/server/registerByteEntropyTool.ts | 68 ++++ .../filesystem/productCatalog.test.ts | 14 + .../mcp/analysisSummaryDetailMcp.test.ts | 87 ++++ .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 77 ++++ 29 files changed, 1510 insertions(+), 3 deletions(-) create mode 100644 src/application/binaryDiagnostics/ByteEntropyService.test.ts create mode 100644 src/application/binaryDiagnostics/ByteEntropyService.ts create mode 100644 src/contracts/native/binaryDiagnosticsToolContracts.test.ts create mode 100644 src/domain/analysisView/byteEntropyView.ts create mode 100644 src/domain/native/byteEntropy.test.ts create mode 100644 src/domain/native/byteEntropy.ts create mode 100644 src/domain/publicInput.test.ts create mode 100644 src/domain/publicInput.ts create mode 100644 src/server/registerByteEntropyTool.ts diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index c35d62a3cc..9566ad11e7 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -368,6 +368,8 @@ For headerless DOS COM, explicitly open with `format: "dos-com"` (CLI analysis: the entry is `0x10100`, file offset zero, with imposed real-mode segment context. PSP/stack/device state remains unmodeled. See [DOS guide](https://github.com/morluto/rea/blob/main/docs/ghidra-dos.md). +Use `inspect_byte_entropy` to triage high- and low-entropy ranges before extraction. + For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`, `arm`, `arm64`, `6502`, or `m68k`), and `entry_point` (`0x` hex). File offset 0 is mapped at diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index e11e1a79f8..a17368b748 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -215,3 +215,87 @@ Large Evidence envelopes may require a caller-configured MCP transport receive buffer because envelope copies add overhead. Requests honor cancellation during snapshot reading, directory traversal and payload hashing. The operation reads regular files only and rejects symbolic links. + +## Byte entropy triage + +`inspect_byte_entropy` / `inspect-byte-entropy` profiles any local regular file +without an analysis engine, execution, extraction or network access. + +```sh +rea inspect-byte-entropy ./firmware.bin --block-size 4096 --json +rea inspect-byte-entropy ./firmware.bin --offset 4096 --length 65536 --json +``` + +```json +{ + "name": "inspect_byte_entropy", + "arguments": { "path": "/artifacts/firmware.bin", "detail": "summary" } +} +``` + +Complete Evidence includes `artifact` (original path, byte size and SHA-256), +`block_size`, `window`, `file_entropy`, `thresholds`, `blocks`, `runs` and +`limitations`. For a 4096-byte zero-filled file, the entropy fields are: + +```json +{ + "block_size": 4096, + "window": { "offset": 0, "length": 4096 }, + "file_entropy": 0, + "thresholds": { "high": 7.2, "low": 1 }, + "blocks": [{ "offset": 0, "length": 4096, "entropy": 0 }], + "runs": [{ "offset": 0, "length": 4096, "classification": "low" }] +} +``` + +Offsets and lengths are decimal byte counts. Blocks begin at the requested +`offset` (default zero); omitted `length` extends to EOF. Explicit windows past +EOF fail rather than silently clipping. The final block may be shorter. +`block_size` must be a power of two from 256 through 65536, default 4096. A +window may produce at most 32,768 block rows; larger requests fail before the +file scan with a capacity error. Choose a shorter window or larger block size. +SHA-256 and `file_entropy` always cover the entire file, including when a window +is selected. Empty files and empty windows are accepted; empty entropy is zero +by convention. The tool reads the size the file reports. A file that reports size +0, such as a Linux `/proc` entry, returns no blocks with a limitation that the +result does not show the file is empty. File identity, size and timestamps are checked across the read; +detected changes produce an error rather than Evidence. + +Shannon entropy is reported in bits per byte from 0 to 8, rounded to four decimal +places. Adjacent blocks with reported entropy at least 7.2 form `high` runs; +blocks at most 1.0 form `low` runs. Other blocks interrupt runs. Entropy does not +prove encryption, and this measure cannot distinguish encrypted from compressed +data. Small blocks can underestimate entropy; the result is a triage clue. + +MCP `detail: "summary"` retains complete Evidence in the session and returns +file entropy, block/run counts, thresholds, the selected window, and the ten +longest runs (ties ordered by file offset). Use its `parent_evidence_id` to +retrieve complete Evidence, or request its summary with `inspect_analysis_view`. +The CLI returns complete Evidence through the same service. Summary detail +requires a session capable of retaining Evidence. + +Reads use one block-sized buffer and a 256-entry histogram. Complete output +can become very large because it includes one row per selected block, and +summary delivery still retains that full list. Use a shorter length or a +larger block size to reduce it. Full Evidence and its serialization require +memory proportional to the number of selected blocks. The 32,768-row ceiling bounds the actual retained block objects and +their serialized representation: a cap-sized run below produced 1,514,877 +serialized bytes and increased V8 heap use by 21.2 MiB on the measured host. +The cap therefore bounds this output independently of file size; it is not a +portable process-memory guarantee. Narrow the window or increase block size to +reduce retained rows further. + +Local service measurement (macOS arm64, Node 24.18.0, 100,000,000-byte file, +4096-byte blocks, file already in the OS cache): first process run took 1.113 s +wall / 1.148 s CPU with 145.3 MiB peak RSS; a second run in the same process took +1.015 s / 1.035 s CPU, with cumulative peak RSS 155.8 MiB. Each read 100,000,000 +bytes and produced 24,415 blocks. Measurements include Evidence construction, +summary projection and JSON serialization, with no child processes. Complete +Evidence serialized to 1,122,303 bytes and its summary to 2,705 bytes. A +cap-sized 134,217,728-byte sparse file with 4096-byte blocks produced 32,768 +rows and 1,514,877 serialized bytes; V8 heap use increased by 21.2 MiB and RSS +by 18.1 MiB in that process. This separate measurement used the same macOS +arm64 host and Node version. Cold disk I/O, token counts and an agent +investigation were not measured; there was no previous equivalent tool to +benchmark. These figures do not establish a portable peak-memory bound, +constant memory for retained Evidence or faster agent task completion. diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index 6d93f8877e..86f414258c 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -116,3 +116,5 @@ Then rerun this installer.",implemented,verified by shell branch inspection 90,install.sh:version mismatch,Installed rea reports a different version,installed version ACTUAL does not match EXPECTED.,Low risk; gives exact integrity mismatch,Keep current copy,unchanged,verified by exact installer test 90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Canonical invalid_request with the SDK diagnostic plus recovery.kind=inspect_input_contract and a bounded instruction to read the advertised schema or call tools/list",Keeps field/type correction while telling the agent how to recover; diagnostics over 4 KiB stay on the existing retained-error path,Normalize ordinary validation failures and retain oversized diagnostics,verified by real in-memory and stdio MCP client/server calls with wrong argument types 90.6,incur:unknown CLI command,Caller enters an unknown REA command,"'COMMAND' is not a command for 'rea'. Suggested command: rea --help",Low risk; identifies input and exact discovery action,Keep framework-generated copy,unchanged,verified by real packaged dispatcher invocation +91,src/application/binaryDiagnostics/ByteEntropyService.ts,Invalid file or window,"Expected a regular file.; Selected file could not be found.; Selected byte window extends beyond the end of the file.; File size exceeds exact numeric offset representation.",Invalid selection cannot produce complete entropy Evidence,Choose a readable regular file and a window within its byte size,implemented,entropy service and MCP boundary tests +92,src/domain/analysisView/byteEntropyView.ts,Non-summary entropy view,Byte entropy Evidence supports the summary view; retrieve parent Evidence for the complete block series.,Unsupported view selection,Use the summary view or retrieve complete parent Evidence,implemented,typed input rejection diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index d4b51ead22..2ac0bb9894 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -238,7 +238,9 @@ export const providerCatalog = (sources) => { identity: sources.pwntoolsProvider.PWNTOOLS_PROVIDER_IDENTITY, contracts: sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter( - ({ name }) => name !== "inspect_pe_resources", + ({ name }) => + name === "inspect_binary_layout" || + name === "inspect_recorded_crash", ), }, { @@ -248,6 +250,13 @@ export const providerCatalog = (sources) => { ({ name }) => name === "inspect_pe_resources", ), }, + { + identity: sources.artifactProviders.BYTE_ENTROPY_PROVIDER, + contracts: + sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_byte_entropy", + ), + }, { identity: sources.captureProvider.TSHARK_PROVIDER_IDENTITY, contracts: sources.captureContracts.CAPTURE_TOOL_CONTRACTS, diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index d0eabbb0d9..cd3394ddd8 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -350,6 +350,7 @@ export const compactToolAvailability = ( const availabilityFor = (context: AvailabilityContext): Availability => { // These built-in workflows accept a caller-selected endpoint; they do not load the optional passive provider. if ( + context.name === "inspect_byte_entropy" || context.name === "observe_web_execution" || context.name === "inspect_web_event_listeners" ) diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 0758d86dbe..00fcd5642d 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -48,6 +48,13 @@ export const PE_RESOURCES_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for execution-free Shannon byte-entropy profiling. */ +export const BYTE_ENTROPY_PROVIDER = Object.freeze({ + id: "byte-entropy", + name: "REA byte entropy", + version: "1", +} as const); + /** Provider identity for deterministic managed-code comparison workflows. */ export const MANAGED_WORKFLOW_PROVIDER = { id: "rea-dotnet-workflows", diff --git a/src/application/binaryDiagnostics/ByteEntropyService.test.ts b/src/application/binaryDiagnostics/ByteEntropyService.test.ts new file mode 100644 index 0000000000..f51f9e8d34 --- /dev/null +++ b/src/application/binaryDiagnostics/ByteEntropyService.test.ts @@ -0,0 +1,373 @@ +import { createHash } from "node:crypto"; +import { open, stat, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { byteEntropySchema } from "../../domain/native/byteEntropy.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { + ByteEntropyService, + MAX_BYTE_ENTROPY_BLOCKS, +} from "./ByteEntropyService.js"; + +it("keeps whole-file identity and entropy while selecting unaligned blocks and a partial tail", async () => { + const root = await createTestTempDirectory("rea-entropy-"); + const path = join(root, "bytes"); + const bytes = Buffer.from(Array.from({ length: 1024 }, (_, i) => i % 256)); + await writeFile(path, bytes); + const result = await new ByteEntropyService().inspect({ + path, + offset: 3, + length: 513, + block_size: 256, + }); + if (!result.ok) throw result.error; + const report = byteEntropySchema.parse(result.value.normalized_result); + expect(report.blocks).toEqual([ + { offset: 3, length: 256, entropy: 8 }, + { offset: 259, length: 256, entropy: 8 }, + { offset: 515, length: 1, entropy: 0 }, + ]); + expect(report.file_entropy).toBe(8); + expect(report.artifact).toEqual({ + path, + bytes: 1024, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + expect(result.value.subject?.digest.sha256).toBe(report.artifact.sha256); + expect(result.value.confidence).toBe("observed"); +}); + +it("rejects missing files, non-files and windows beyond EOF, but accepts empty windows", async () => { + const root = await createTestTempDirectory("rea-entropy-input-"); + const path = join(root, "empty"); + await writeFile(path, ""); + const service = new ByteEntropyService(); + for (const input of [ + { path: join(root, "missing") }, + { path: root }, + { path, offset: 1 }, + { path, length: 1 }, + { path: "relative" }, + ]) { + const result = await service.inspect(input); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + } + const result = await service.inspect({ path }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + blocks: [], + runs: [], + file_entropy: 0, + window: { offset: 0, length: 0 }, + }); + expect( + byteEntropySchema.parse(result.value.normalized_result).limitations, + ).toContainEqual(expect.stringContaining("reports a size of 0")); +}); + +it.skipIf(process.platform !== "linux")( + "marks a generated /proc file that reports size 0 as not shown empty", + async () => { + const result = await new ByteEntropyService().inspect({ + path: "/proc/version", + }); + if (!result.ok) throw result.error; + const report = byteEntropySchema.parse(result.value.normalized_result); + expect(report.artifact.bytes).toBe(0); + expect(report.limitations).toContainEqual( + expect.stringContaining("Linux /proc entries"), + ); + }, +); + +it("adds no zero-size limitation to a file with content", async () => { + const root = await createTestTempDirectory("rea-entropy-nonempty-"); + const path = join(root, "one"); + await writeFile(path, "x"); + const result = await new ByteEntropyService().inspect({ path }); + if (!result.ok) throw result.error; + expect( + byteEntropySchema + .parse(result.value.normalized_result) + .limitations.join(" "), + ).not.toContain("reports a size of 0"); +}); + +it("honors cancellation before and during streaming", async () => { + const root = await createTestTempDirectory("rea-entropy-cancel-"); + const path = join(root, "bytes"); + await writeFile(path, Buffer.alloc(1024 * 1024)); + const service = new ByteEntropyService(); + expect( + await service.inspect({ path }, { signal: AbortSignal.abort() }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + const controller = new AbortController(); + let reads = 0; + const streaming = new ByteEntropyService({ + stat, + open: async (...args) => { + const file = await open(...args); + const read = file.read.bind(file); + vi.spyOn(file, "read").mockImplementation(async (...readArgs) => { + const result = await read(...readArgs); + reads += 1; + controller.abort(); + return result; + }); + return file; + }, + }); + const pending = streaming.inspect( + { path, block_size: 256 }, + { signal: controller.signal }, + ); + expect(await pending).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(reads).toBe(1); +}); + +it("maps cancellation during open to the cancelled algebra with its original cause", async () => { + const root = await createTestTempDirectory("rea-entropy-open-cancel-"); + const path = join(root, "bytes"); + await writeFile(path, Buffer.alloc(512)); + const controller = new AbortController(); + const reason = new Error("cancelled while opening"); + const service = new ByteEntropyService({ + stat, + open: async (...args) => { + const file = await open(...args); + controller.abort(reason); + return file; + }, + }); + + const result = await service.inspect( + { path, block_size: 256 }, + { signal: controller.signal }, + ); + + if (result.ok) throw new Error("Expected cancellation during open"); + expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(result.error.cause).toBe(reason); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cancelled", + }); + await service.close(); +}); + +it("keeps finalizer cancellation primary when close also fails, then resolves its cleanup owner", async () => { + const root = await createTestTempDirectory("rea-entropy-finalizer-cancel-"); + const path = join(root, "bytes"); + await writeFile(path, Buffer.alloc(512)); + const controller = new AbortController(); + const reason = new Error("cancelled during close"); + const closeFailure = new Error("injected close failure"); + let failClose = true; + const service = new ByteEntropyService({ + stat, + open: async (...args) => { + const file = await open(...args); + const close = file.close.bind(file); + vi.spyOn(file, "close").mockImplementation(async () => { + if (failClose) { + controller.abort(reason); + throw closeFailure; + } + await close(); + }); + return file; + }, + }); + + const result = await service.inspect( + { path, block_size: 256 }, + { signal: controller.signal }, + ); + + if (result.ok) throw new Error("Expected cancellation during finalization"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cleanupIncomplete: true, + secondaryFailures: [ + { _tag: "AnalysisOutputError", cleanupIncomplete: true }, + ], + }); + expect(result.error.cause).toBe(reason); + expect(result.error.secondaryFailures[0]?.cause).toBeDefined(); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + retryable: false, + details: { + cleanup: "incomplete", + execution_failure: "cancelled", + }, + }); + + failClose = false; + await service.close(); + expect(result.error.cleanupIncomplete).toBe(false); +}); + +it("reports a changed artifact when its parent directory moves during streaming", async () => { + const root = await createTestTempDirectory("rea-entropy-moved-"); + const path = join(root, "bytes"); + await writeFile(path, Buffer.alloc(512)); + let moved = false; + const service = new ByteEntropyService({ + stat, + open: async (...args) => { + const file = await open(...args); + const read = file.read.bind(file); + vi.spyOn(file, "read").mockImplementation(async (...readArgs) => { + const result = await read(...readArgs); + if (!moved) { + moved = true; + await rename(root, `${root}-moved`); + } + return result; + }); + return file; + }, + }); + try { + expect(await service.inspect({ path, block_size: 256 })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisArtifactChangedError" }, + }); + } finally { + if (moved) await rename(`${root}-moved`, root); + } +}); + +it("refuses a block series above the output-row limit before scanning", async () => { + const root = await createTestTempDirectory("rea-entropy-block-limit-"); + const path = join(root, "bytes"); + await writeFile(path, Buffer.alloc(MAX_BYTE_ENTROPY_BLOCKS * 256 + 1)); + let reads = 0; + const service = new ByteEntropyService({ + stat, + open: async (...args) => { + const file = await open(...args); + vi.spyOn(file, "read").mockImplementation(async () => { + reads += 1; + return { bytesRead: 0, buffer: Buffer.alloc(0) }; + }); + return file; + }, + }); + + const result = await service.inspect({ path, block_size: 256 }); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + resource: "capacity", + reportedLimits: { + max_blocks: MAX_BYTE_ENTROPY_BLOCKS, + requested_blocks: MAX_BYTE_ENTROPY_BLOCKS + 1, + }, + }, + }); + if (result.ok) throw new Error("Expected a capacity refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { + action: expect.stringMatching(/shorter window|increase block_size/i), + }, + }); + expect(reads).toBe(0); + await service.close(); +}); + +it("retains a failed descriptor close and retries it before opening another file", async () => { + const root = await createTestTempDirectory("rea-entropy-close-retry-"); + const path = join(root, "bytes"); + await writeFile(path, Buffer.alloc(512)); + let failClose = true; + let opens = 0; + const service = new ByteEntropyService({ + stat, + open: async (...args) => { + opens += 1; + const file = await open(...args); + const close = file.close.bind(file); + vi.spyOn(file, "close").mockImplementation(async () => { + if (failClose) throw new Error("injected close failure"); + await close(); + }); + return file; + }, + }); + + const first = await service.inspect({ path, block_size: 256 }); + if (first.ok) throw new Error("Expected the first close to fail"); + expect(first.error).toMatchObject({ + _tag: "AnalysisOutputError", + cleanupIncomplete: true, + }); + expect(opens).toBe(1); + + const retryDenied = await service.inspect({ path, block_size: 256 }); + expect(retryDenied).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError", cleanupIncomplete: true }, + }); + expect(opens).toBe(1); + + failClose = false; + const recovered = await service.inspect({ path, block_size: 256 }); + expect(recovered.ok).toBe(true); + expect(first.error.cleanupIncomplete).toBe(false); + expect(opens).toBe(2); + await service.close(); +}); + +it("preserves a primary read failure through failed close and resolves it on retry", async () => { + const root = await createTestTempDirectory("rea-entropy-primary-close-"); + const path = join(root, "bytes"); + await writeFile(path, Buffer.alloc(512)); + const readFailure = new Error("injected read failure"); + let failClose = true; + let failRead = true; + const service = new ByteEntropyService({ + stat, + open: async (...args) => { + const file = await open(...args); + const read = file.read.bind(file); + const close = file.close.bind(file); + vi.spyOn(file, "read").mockImplementation(async (...readArgs) => { + if (failRead) throw readFailure; + return await read(...readArgs); + }); + vi.spyOn(file, "close").mockImplementation(async () => { + if (failClose) throw new Error("injected close failure"); + await close(); + }); + return file; + }, + }); + + const first = await service.inspect({ path, block_size: 256 }); + if (first.ok) throw new Error("Expected the read and close to fail"); + expect(first.error).toMatchObject({ + cleanupIncomplete: true, + cleanupPrimary: { _tag: "AnalysisOutputError" }, + }); + expect(first.error.cleanupPrimary?.cause).toBe(readFailure); + expect(first.error.cause).toBeDefined(); + + failClose = false; + failRead = false; + const recovered = await service.inspect({ path, block_size: 256 }); + expect(recovered.ok).toBe(true); + expect(first.error.cleanupIncomplete).toBe(false); + await service.close(); +}); diff --git a/src/application/binaryDiagnostics/ByteEntropyService.ts b/src/application/binaryDiagnostics/ByteEntropyService.ts new file mode 100644 index 0000000000..b259520362 --- /dev/null +++ b/src/application/binaryDiagnostics/ByteEntropyService.ts @@ -0,0 +1,355 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { open, stat } from "node:fs/promises"; +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import { BYTE_ENTROPY_PROVIDER } from "../InvestigationProviders.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisOutputError, + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { + computeBlockEntropy, + entropyFromCounts, + inspectByteEntropyInputSchema, + mergeEntropyRuns, + type ByteEntropy, +} from "../../domain/native/byteEntropy.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { + withArtifactFileHandle, + bindArtifactFileCleanup, + ArtifactFileCleanupFailure, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; + +/** Bound retained result rows before constructing the Evidence representation. */ +export const MAX_BYTE_ENTROPY_BLOCKS = 32_768; + +/** Generated files such as Linux /proc entries report size 0 but still hold bytes. */ +const ZERO_SIZE_LIMITATION = + "The file reports a size of 0, so no bytes were read. Generated files such as Linux /proc entries can report 0 while holding content; this result does not show that the file is empty."; + +const OPERATION = "inspect_byte_entropy"; +const invalid = (field: string, message: string) => + new AnalysisInputError(OPERATION, undefined, [ + { path: [field], reason: "invalid_value", message }, + ]); + +/** Offline byte observations shared by CLI and MCP, with one file-wide read. */ +export class ByteEntropyService { + readonly #resources: ArtifactResourceScope; + + constructor( + private readonly files: Pick< + typeof import("node:fs/promises"), + "open" | "stat" + > = { open, stat }, + resources: ArtifactResourceScope = new ArtifactResourceScope(), + ) { + this.#resources = resources; + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + try { + return await this.#resources.run(() => this.#inspect(rawInput, options)); + } catch (cause: unknown) { + const failure = new AnalysisOutputError( + OPERATION, + "A retained byte-entropy file descriptor could not be released before inspection.", + { + cause, + ...(cause instanceof ArtifactReaderFailure && + cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : {}), + }, + ); + bindArtifactFileCleanup(cause, failure); + return err(failure); + } + } + + close(): Promise { + return this.#resources.close(); + } + + async #inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const parsed = inspectByteEntropyInputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput, { + cause: parsed.error, + }), + ); + const input = parsed.data; + if (!isAbsolute(input.path)) + return err( + invalid("path", "Expected an absolute filesystem path on this host."), + ); + let admitted = false; + try { + const before = await this.files.stat(input.path, { bigint: true }); + if (!before.isFile()) throw invalid("path", "Expected a regular file."); + admitted = true; + const file = await this.files.open( + input.path, + constants.O_RDONLY | constants.O_NONBLOCK, + ); + const report = await withArtifactFileHandle( + input.path, + file, + this.#resources, + options?.signal, + async (file) => { + const initial = await file.stat({ bigint: true }); + const changed = () => + new AnalysisArtifactChangedError( + OPERATION, + input.path, + "File identity, size or timestamps changed during entropy inspection.", + ); + const same = (value: typeof initial) => + value.isFile() && + value.dev === initial.dev && + value.ino === initial.ino && + value.size === initial.size && + value.mtimeNs === initial.mtimeNs && + value.ctimeNs === initial.ctimeNs; + if (!same(before)) throw changed(); + const size = Number(initial.size); + if (!Number.isSafeInteger(size)) + throw invalid( + "path", + "File size exceeds exact numeric offset representation.", + ); + const length = input.length ?? size - input.offset; + if (input.offset > size || length > size - input.offset) + throw invalid( + "length", + "Selected byte window extends beyond the end of the file.", + ); + const selectedBlocks = Math.ceil(length / input.block_size); + if (selectedBlocks > MAX_BYTE_ENTROPY_BLOCKS) + throw new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `Selected window would produce ${String(selectedBlocks)} blocks; the limit is ${String(MAX_BYTE_ENTROPY_BLOCKS)}. Choose a shorter window or a larger block size.`, + { + max_blocks: MAX_BYTE_ENTROPY_BLOCKS, + requested_blocks: selectedBlocks, + block_size: input.block_size, + window_length: length, + }, + { + retryable: false, + remediationAction: + "Reduce the selected window length or increase block_size so the selected window contains at most 32768 blocks.", + }, + ); + const end = input.offset + length; + const blocks: ByteEntropy["blocks"] = []; + const buffer = Buffer.alloc(input.block_size); + const counts = new Float64Array(256); + const hash = createHash("sha256"); + let position = 0; + while (position < size) { + options?.signal?.throwIfAborted(); + const selected = position >= input.offset && position < end; + const boundary = + position < input.offset ? input.offset : selected ? end : size; + const wanted = Math.min(input.block_size, boundary - position); + let filled = 0; + while (filled < wanted) { + options?.signal?.throwIfAborted(); + const { bytesRead } = await file.read( + buffer, + filled, + wanted - filled, + position + filled, + ); + if (bytesRead === 0) throw changed(); + filled += bytesRead; + } + const bytes = buffer.subarray(0, filled); + hash.update(bytes); + for (const byte of bytes) counts[byte] = (counts[byte] ?? 0) + 1; + if (selected) + blocks.push({ + offset: position, + length: filled, + entropy: computeBlockEntropy(bytes), + }); + position += filled; + } + if ( + !same(await file.stat({ bigint: true })) || + !same(await this.files.stat(input.path, { bigint: true })) + ) + throw changed(); + options?.signal?.throwIfAborted(); + const thresholds = { high: 7.2, low: 1.0 }; + return { + artifact: { + path: input.path, + bytes: size, + sha256: hash.digest("hex"), + }, + block_size: input.block_size, + window: { offset: input.offset, length }, + file_entropy: entropyFromCounts(counts, size), + thresholds, + blocks, + runs: mergeEntropyRuns(blocks, thresholds), + limitations: [ + "Entropy does not prove encryption; compressed and encrypted bytes cannot be distinguished by this measure.", + "Entropy is rounded to four decimal places; thresholds classify those reported values. Small blocks can underestimate entropy.", + "The file-wide hash and entropy cover the entire file; blocks and runs cover only the selected window. Empty input has entropy 0 by convention.", + ...(size === 0 ? [ZERO_SIZE_LIMITATION] : []), + ], + }; + }, + ); + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + return ok( + createEvidence( + { path: input.path, format: "file", sha256: report.artifact.sha256 }, + BYTE_ENTROPY_PROVIDER, + { + operation: OPERATION, + parameters: { + path: input.path, + block_size: input.block_size, + offset: input.offset, + ...(input.length === undefined ? {} : { length: input.length }), + }, + result: report, + rawResult: null, + confidence: "observed", + limitations: report.limitations, + locations: [{ kind: "artifact-path", path: input.path }], + }, + ), + ); + } catch (cause) { + if (cause instanceof ArtifactFileCleanupFailure) { + const cleanup = new AnalysisOutputError( + OPERATION, + "Could not confirm closing the selected file.", + { cause, cleanup: cause.cleanup }, + ); + bindArtifactFileCleanup(cause, cleanup); + const outcome = cause.fileCleanupFailure.outcome; + if ( + outcome.kind === "failed" && + outcome.cause instanceof ArtifactReaderFailure && + outcome.cause.reason === "cancelled" + ) { + const cancelled = new AnalysisCancelledError(OPERATION, { + cause: outcome.cause.cancellation?.reason, + cleanup: cause.cleanup, + }).retainSecondaryFailure(cleanup); + bindArtifactFileCleanup(cause, cancelled); + return err(cancelled); + } + if (options?.signal?.aborted && outcome.kind === "completed") { + const cancelled = new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + cleanup: cause.cleanup, + }).retainSecondaryFailure(cleanup); + bindArtifactFileCleanup(cause, cancelled); + return err(cancelled); + } + if (outcome.kind === "failed") { + const primary = + outcome.cause instanceof AnalysisError + ? outcome.cause + : outcome.cause instanceof ArtifactReaderFailure + ? new AnalysisOutputError(OPERATION, outcome.cause.message, { + cause: outcome.cause, + }) + : new AnalysisOutputError( + OPERATION, + "Could not read complete byte entropy evidence.", + { cause: outcome.cause }, + ); + return err( + analysisErrorWithCleanupFailure( + primary, + cleanup, + OPERATION, + ).bindCleanupDependencies([primary, cleanup]), + ); + } + return err(cleanup); + } + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "cancelled") { + const cancelled = new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + bindArtifactFileCleanup(cause, cancelled); + return err(cancelled); + } + const retry = new AnalysisOutputError( + OPERATION, + "A previous byte-entropy file descriptor is still owned; cleanup must succeed before another file can be inspected.", + { + cause, + ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), + }, + ); + bindArtifactFileCleanup(cause, retry); + return err(retry); + } + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION, { cause })); + if (cause instanceof AnalysisError) return err(cause); + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return err( + new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }), + ); + if (cause.code === "ENOENT" || cause.code === "ENOTDIR") + return err( + admitted + ? new AnalysisArtifactChangedError( + OPERATION, + input.path, + "Selected file path changed during entropy inspection.", + { cause }, + ) + : invalid("path", "Selected file could not be found."), + ); + } + return err( + new AnalysisOutputError( + OPERATION, + "Could not read complete byte entropy evidence.", + { cause }, + ), + ); + } + } +} diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 2e38126d66..7b1eb9173f 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -1,5 +1,7 @@ +import { inspectByteEntropyInputSchema } from "../domain/native/byteEntropy.js"; import { z } from "incur"; import { + createByteEntropyService, createRecordedCrashService, createBinaryLayoutService, createPeResourcesService, @@ -22,6 +24,44 @@ export const registerBinaryDiagnosticsCommands = ( logger: Logger, environment: Readonly, ): void => { + const entropy = createByteEntropyService(); + cli.command(CLI_COMMANDS.inspectByteEntropy, { + description: + "Inspect file-wide and block byte entropy without executing the file", + args: z.object({ path: z.string().describe("Local file") }), + options: z.object({ + blockSize: inspectByteEntropyInputSchema.shape.block_size.describe( + "Power-of-two block size from 256 to 65536 bytes (default 4096)", + ), + offset: inspectByteEntropyInputSchema.shape.offset.describe( + "Window start as a decimal file byte offset (default 0)", + ), + length: inspectByteEntropyInputSchema.shape.length.describe( + "Window length in bytes; omit to extend through EOF", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectByteEntropy, async () => { + return await finishAnalysisCommand( + () => + entropy.inspect( + { + path: absoluteCallerPath(args.path), + block_size: options.blockSize, + offset: options.offset, + length: options.length, + }, + { signal }, + ), + entropy, + "rea-byte-entropy", + "inspect_byte_entropy", + signal, + ); + }), + ), + }); cli.command(CLI_COMMANDS.inspectPeResources, { description: "Inspect PE resource identities, payload ranges, hashes and icon-group relationships", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 91ff0fb0eb..27eeca5bdd 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -49,6 +49,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectAssetCatalog: "inspect-asset-catalog", inspectManagedArtifact: "inspect-managed-artifact", inspectRecordedCrash: "inspect-recorded-crash", + inspectByteEntropy: "inspect-byte-entropy", inspectBinaryLayout: "inspect-binary-layout", listCutterSessions: "list-cutter-sessions", cutterCommand: "cutter-command", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index afadee6c50..4d9cfc69cb 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -1,9 +1,11 @@ +import { ByteEntropyService } from "../application/binaryDiagnostics/ByteEntropyService.js"; import { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; import { PwntoolsRecordedCrashProvider } from "../native/pwntools/PwntoolsRecordedCrashProvider.js"; import { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; import { PwntoolsLayoutProvider } from "../native/pwntools/PwntoolsLayoutProvider.js"; import { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import { PeResourcesProvider } from "../native/pe/PeResourcesProvider.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; /** Portable PE inspection with no engine, installation or target acquisition. */ export const createPeResourcesService = (): PeResourcesService => @@ -20,3 +22,8 @@ export const createRecordedCrashService = ( environment: Readonly = process.env, ): RecordedCrashService => new RecordedCrashService(new PwntoolsRecordedCrashProvider(environment)); + +/** Provider-free local byte analysis. */ +export const createByteEntropyService = ( + resources?: ArtifactResourceScope, +): ByteEntropyService => new ByteEntropyService(undefined, resources); diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.test.ts b/src/contracts/native/binaryDiagnosticsToolContracts.test.ts new file mode 100644 index 0000000000..4a9befdd4c --- /dev/null +++ b/src/contracts/native/binaryDiagnosticsToolContracts.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vitest"; + +import { toolContract } from "../toolContracts.js"; + +const entropy = toolContract("inspect_byte_entropy"); + +it("warns that complete byte-entropy output can become very large", () => { + expect(entropy.description).toContain( + "Complete output can become very large because it includes one row per selected block, and summary detail still retains that full list.", + ); + expect(entropy.description).toContain( + "Use a shorter length or a larger block_size", + ); +}); + +it("keeps the public entropy input closed after detail is added", () => { + expect(entropy.inputSchema.parse({ path: "/file" })).toMatchObject({ + path: "/file", + block_size: 4096, + offset: 0, + detail: "complete", + }); + expect( + entropy.inputSchema.safeParse({ + path: "/file", + detail: "summary", + extra: true, + }).success, + ).toBe(false); + expect( + entropy.inputSchema.safeParse({ path: "/file", block_size: 257 }).success, + ).toBe(false); +}); diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index dad3769415..0e31d8ef35 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -1,3 +1,6 @@ +import { inspectByteEntropyInputSchema } from "../../domain/native/byteEntropy.js"; +import { publicInputStrictObject } from "../../domain/publicInput.js"; +import { byteEntropyOutputSchema } from "../toolOutputSchemaGroups.js"; import { inspectRecordedCrashInputSchema, recordedCrashSchema, @@ -23,8 +26,41 @@ import { export const inspectBinaryLayoutRequestSchema = inspectBinaryLayoutInputSchema.extend({ detail: analysisDetailSchema }); +/** + * Public `inspect_byte_entropy` input. The shared constructor runs before + * `detail` is added so the advertised input stays closed. + */ +export const inspectByteEntropyRequestSchema = publicInputStrictObject( + inspectByteEntropyInputSchema.shape, +).extend({ detail: analysisDetailSchema }); + /** Offline binary diagnostics are independent of macOS process/UI providers. */ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ + { + name: "inspect_byte_entropy", + ...toolContractMetadata("inspect_byte_entropy"), + kind: "native-provider", + description: + "Inspect Shannon byte entropy for one explicit local file offline, without execution, extraction or external tools. Returns file-wide SHA-256 and entropy, fixed-block entropy for an optional offset/length window, and adjacent high (>=7.2) and low (<=1.0) runs with exact file offsets. Block size is a power of two from 256 to 65536 (default 4096). Complete output can become very large because it includes one row per selected block, and summary detail still retains that full list. Use a shorter length or a larger block_size; the selected window is limited to 32,768 output blocks. Entropy cannot distinguish compression from encryption. Summary detail returns file entropy plus the ten longest runs; retrieve parent Evidence for the full series. The entire file is read even for a window.", + inputSchema: inspectByteEntropyRequestSchema, + outputSchema: byteEntropyOutputSchema, + examples: [ + { + title: "Profile firmware bytes before extraction", + input: { path: "/artifacts/firmware.bin" }, + }, + { + title: "Summarize a selected byte window", + input: { + path: "/artifacts/firmware.bin", + offset: 4096, + length: 65536, + block_size: 4096, + detail: "summary", + }, + }, + ], + }, { name: "inspect_pe_resources", ...toolContractMetadata("inspect_pe_resources"), diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index af218b43df..5cb0e3f25f 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -14,6 +14,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< > = { ...UNKNOWN_CONTRACT_EXAMPLES, ...INVESTIGATION_EXAMPLES, + inspect_byte_entropy: { path: "/artifacts/firmware.bin" }, goto_address: { address: "0x1000" }, procedure_address: { procedure: "main" }, procedure_assembly: { procedure: "main" }, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 29cd1a04e6..0403a6d963 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -25,6 +25,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { "spawned targets remain suspended until resume_frida_session", inspect_native_load_image: "states that it does not execute the target", resolve_native_call_targets: "states that it does not execute code", + inspect_byte_entropy: "states that it profiles the file without execution", inspect_pe_resources: "states that it does not execute the image", inspect_desktop_application: "states that directory triage performs no target execution", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index b10480a7cb..24d8e2989a 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -251,6 +251,7 @@ export const TOOL_EFFECTS: Readonly> = { executesForeignCode: true, idempotent: false, }), + inspect_byte_entropy: evidence, inspect_pe_resources: evidence, inspect_analysis_view: evidence, inspect_firmware_regions: effects({ diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 31aa34853f..16add16138 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,3 +1,5 @@ +import { byteEntropySchema } from "../domain/native/byteEntropy.js"; +import { byteEntropySummaryViewResultSchema } from "../domain/analysisView/analysisView.js"; import { nativeFunctionMatchingSchema } from "../domain/native/functionMatching.js"; import { nativeFunctionMatchingSummaryViewResultSchema } from "../domain/analysisView/analysisView.js"; import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js"; @@ -409,8 +411,15 @@ export const enhancedOutputSchemas: Readonly> = { trace_call_path: callPathTraceOutput, }; +/** Complete entropy Evidence or its retained summary, shared by the named contract. */ +export const byteEntropyOutputSchema = analysisDetailOutputSchemaOf( + byteEntropySchema, + byteEntropySummaryViewResultSchema, +); + /** Exact Evidence schemas for provider-neutral native inspection. */ export const nativeOutputSchemas: Readonly> = { + inspect_byte_entropy: byteEntropyOutputSchema, observe_native_ui: evidenceResultOf(nativeUiResultSchema), capture_native_ui_scenario: evidenceResultOf(nativeUiResultSchema), observe_native_calls: evidenceResultOf(nativeCallObservationResultSchema), diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 2b047660de..b43aa8da3e 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,8 @@ +import { byteEntropySchema } from "../native/byteEntropy.js"; +import { + byteEntropySummarySchema, + projectByteEntropyView, +} from "./byteEntropyView.js"; import { parseAnalysisViewResult, analysisViewResultInputError, @@ -418,6 +423,7 @@ const javascriptSummarySchema = z.strictObject({ const viewResultBase = { parent_evidence_id: prefixedDigestSchema("ev"), parent_operation: z.enum([ + "inspect_byte_entropy", "inspect_binary_layout", "inspect_go_binary", "gdb_console", @@ -458,6 +464,12 @@ const summaryViewResultBase = { ...viewResultBase, } as const; +export const byteEntropySummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + summary: byteEntropySummarySchema, + parent_operation: z.literal("inspect_byte_entropy"), +}); + /** Opt-in command delivery keeps output once and retains the complete parent. */ export const gdbCommandSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -740,6 +752,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ view: environmentStateViewRequestSchema, }), z.discriminatedUnion("parent_operation", [ + byteEntropySummaryViewResultSchema, gdbCommandSummaryViewResultSchema, objdumpSummaryViewResultSchema, fridaSessionStatusSummaryViewResultSchema, @@ -1115,6 +1128,22 @@ export const projectAnalysisView = ( const projected = projectPcapCaptureView(parent, view); return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; } + if (parent.operation === "inspect_byte_entropy") { + const report = byteEntropySchema.safeParse(parent.normalizedResult); + if (!report.success) + return err( + analysisViewInputError([ + { + path: ["source"], + reason: "invalid_value", + message: + "Parent Evidence result does not match inspect_byte_entropy.", + }, + ]), + ); + const projected = projectByteEntropyView(parent, report.data, view); + return projected.ok ? ok(sealAnalysisView(projected.value)) : projected; + } if (parent.operation === "inspect_binary_layout") { const layout = safeParseOwnedBinaryLayout(parent.normalizedResult); if (!layout.success) diff --git a/src/domain/analysisView/byteEntropyView.ts b/src/domain/analysisView/byteEntropyView.ts new file mode 100644 index 0000000000..c00e15a8a1 --- /dev/null +++ b/src/domain/analysisView/byteEntropyView.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import { AnalysisInputError } from "../analysisErrorCore.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { byteEntropySchema, type ByteEntropy } from "../native/byteEntropy.js"; +import { err, ok, type Result } from "../result.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import { completeWithinViewCoverage } from "./analysisViewCoverage.js"; + +export const byteEntropySummarySchema = byteEntropySchema + .omit({ blocks: true, runs: true, limitations: true }) + .extend({ + block_count: z.number().int().nonnegative(), + run_count: z.number().int().nonnegative(), + top_runs: byteEntropySchema.shape.runs, + }); + +/** Rank ten longest runs for triage; the full series remains in parent Evidence. */ +export const projectByteEntropyView = ( + parent: AnalysisViewParent, + report: ByteEntropy, + view: Exclude, +): Result => { + if (view.kind !== "summary") + return err( + new AnalysisInputError("inspect_analysis_view", undefined, [ + { + path: ["view"], + reason: "invalid_value", + message: + "Byte entropy Evidence supports the summary view; retrieve parent Evidence for the complete block series.", + }, + ]), + ); + return ok({ + kind: "summary", + view, + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_byte_entropy", + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.artifact.path, sha256: report.artifact.sha256 }, + summary: { + artifact: report.artifact, + block_size: report.block_size, + window: report.window, + file_entropy: report.file_entropy, + thresholds: report.thresholds, + block_count: report.blocks.length, + run_count: report.runs.length, + top_runs: [...report.runs] + .sort((a, b) => b.length - a.length || a.offset - b.offset) + .slice(0, 10), + }, + coverage: completeWithinViewCoverage( + report.blocks.length, + report.blocks.length, + ), + limitations: [ + ...new Set([...parent.limitations, ...report.limitations]), + ].sort(), + unknowns: [], + }); +}; diff --git a/src/domain/native/byteEntropy.test.ts b/src/domain/native/byteEntropy.test.ts new file mode 100644 index 0000000000..02288a1448 --- /dev/null +++ b/src/domain/native/byteEntropy.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "vitest"; +import { + computeBlockEntropy, + inspectByteEntropyInputSchema, + mergeEntropyRuns, +} from "./byteEntropy.js"; + +it("computes known Shannon answers including partial and empty blocks", () => { + expect(computeBlockEntropy(new Uint8Array(4096))).toBe(0); + expect( + computeBlockEntropy(Uint8Array.from({ length: 256 }, (_, i) => i)), + ).toBe(8); + expect(computeBlockEntropy(Uint8Array.of(0, 1))).toBe(1); + expect(computeBlockEntropy(Uint8Array.of(0, 0, 0, 1))).toBe(0.8113); + expect(computeBlockEntropy(new Uint8Array())).toBe(0); +}); + +it("merges adjacent threshold edges but not unclassified blocks or gaps", () => { + expect( + mergeEntropyRuns( + [ + { offset: 7, length: 256, entropy: 1 }, + { offset: 263, length: 256, entropy: 0 }, + { offset: 519, length: 256, entropy: 7.2 }, + { offset: 775, length: 256, entropy: 7.1999 }, + { offset: 1031, length: 256, entropy: 8 }, + { offset: 1400, length: 2, entropy: 8 }, + ], + { high: 7.2, low: 1 }, + ), + ).toEqual([ + { offset: 7, length: 512, classification: "low" }, + { offset: 519, length: 256, classification: "high" }, + { offset: 1031, length: 256, classification: "high" }, + { offset: 1400, length: 2, classification: "high" }, + ]); +}); + +it("rejects unsafe windows, unknown fields and unsupported block sizes", () => { + for (const options of [ + { block_size: 257 }, + { block_size: 128 }, + { block_size: 131072 }, + { offset: -1 }, + { length: Number.MAX_SAFE_INTEGER + 1 }, + { extra: true }, + ]) { + expect( + inspectByteEntropyInputSchema.safeParse({ path: "/file", ...options }) + .success, + ).toBe(false); + } +}); diff --git a/src/domain/native/byteEntropy.ts b/src/domain/native/byteEntropy.ts new file mode 100644 index 0000000000..052d3fdd6e --- /dev/null +++ b/src/domain/native/byteEntropy.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; +import { publicInputStrictObject } from "../publicInput.js"; + +const offsetSchema = z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER); +const entropySchema = z.number().min(0).max(8); + +/** Select blocks relative to an explicit byte window; identity remains file-wide. */ +export const inspectByteEntropyInputSchema = publicInputStrictObject({ + path: localPathStringSchema, + block_size: z + .number() + .int() + .min(256) + .max(65536) + .refine( + (value) => (value & (value - 1)) === 0, + "Block size must be a power of two.", + ) + .default(4096), + offset: offsetSchema.default(0), + length: offsetSchema.optional(), +}); +const blockSchema = z.strictObject({ + offset: offsetSchema, + length: offsetSchema, + entropy: entropySchema, +}); +const runSchema = z.strictObject({ + offset: offsetSchema, + length: offsetSchema, + classification: z.enum(["high", "low"]), +}); +export const byteEntropySchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string(), + bytes: offsetSchema, + sha256: z.string().regex(/^[a-f0-9]{64}$/), + }), + block_size: z.number().int().min(256).max(65536), + window: z.strictObject({ offset: offsetSchema, length: offsetSchema }), + file_entropy: entropySchema, + thresholds: z.strictObject({ high: entropySchema, low: entropySchema }), + blocks: z.array(blockSchema), + runs: z.array(runSchema), + limitations: z.array(z.string()), +}); +export type ByteEntropy = z.infer; + +/** Shannon entropy in bits per byte, rounded once at the reporting boundary. */ +export const entropyFromCounts = ( + counts: ArrayLike, + length: number, +): number => { + if (length === 0) return 0; + let entropy = 0; + for (let i = 0; i < counts.length; i++) { + const count = counts[i] ?? 0; + if (count > 0) { + const probability = count / length; + entropy -= probability * Math.log2(probability); + } + } + return Math.round(entropy * 10000) / 10000; +}; + +export const computeBlockEntropy = (bytes: Uint8Array): number => { + const counts = new Float64Array(256); + for (const byte of bytes) counts[byte] = (counts[byte] ?? 0) + 1; + return entropyFromCounts(counts, bytes.length); +}; + +/** Merge only adjacent classified blocks, using the reported rounded entropy. */ +export const mergeEntropyRuns = ( + blocks: readonly ByteEntropy["blocks"][number][], + thresholds: ByteEntropy["thresholds"], +): ByteEntropy["runs"] => { + const runs: ByteEntropy["runs"] = []; + for (const block of blocks) { + const classification = + block.entropy >= thresholds.high + ? "high" + : block.entropy <= thresholds.low + ? "low" + : null; + if (classification === null || block.length === 0) continue; + const previous = runs.at(-1); + if ( + previous?.classification === classification && + previous.offset + previous.length === block.offset + ) + previous.length += block.length; + else + runs.push({ offset: block.offset, length: block.length, classification }); + } + return runs; +}; diff --git a/src/domain/publicInput.test.ts b/src/domain/publicInput.test.ts new file mode 100644 index 0000000000..04b46983bd --- /dev/null +++ b/src/domain/publicInput.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { publicInputStrictObject } from "./publicInput.js"; + +it("rejects unknown keys on a public tool input", () => { + const schema = publicInputStrictObject({ + path: z.string(), + block_size: z.number().default(4096), + }); + expect(schema.parse({ path: "/file" })).toEqual({ + path: "/file", + block_size: 4096, + }); + expect(schema.safeParse({ path: "/file", extra: true }).success).toBe(false); +}); diff --git a/src/domain/publicInput.ts b/src/domain/publicInput.ts new file mode 100644 index 0000000000..09093bc6ae --- /dev/null +++ b/src/domain/publicInput.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +/** + * Closed object schema for a public CLI or MCP tool input. + * + * Unknown keys fail here so public input policy has one owner. Result and + * internal objects stay on their own constructors. + */ +export const publicInputStrictObject = z.strictObject; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 8796072739..67f7a4e0d9 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -46,7 +46,10 @@ import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js"; import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; import { FridaInstrumentationManager } from "../frida/FridaInstrumentationManager.js"; import { registerFridaTools } from "./registerFridaTools.js"; -import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js"; +import { + createByteEntropyService, + createBinaryLayoutService, +} from "../composition/binaryDiagnostics.js"; import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; import { createCatalogIdentity } from "../catalogIdentity.js"; import { EvidenceMcpServer } from "./EvidenceMcpServer.js"; @@ -770,6 +773,7 @@ const registerConfiguredAnalysisTools = ( registerBinaryDiagnosticsTools( server, options.binaryLayout ?? createBinaryLayoutService(environment), + createByteEntropyService(artifactResources), toolLogger, recordEvidence, evidenceById, diff --git a/src/server/registerBinaryDiagnosticsTools.ts b/src/server/registerBinaryDiagnosticsTools.ts index b786495d90..f6e748aac4 100644 --- a/src/server/registerBinaryDiagnosticsTools.ts +++ b/src/server/registerBinaryDiagnosticsTools.ts @@ -1,6 +1,10 @@ +import type { ByteEntropyService } from "../application/binaryDiagnostics/ByteEntropyService.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import { summarizeRetainedAnalysis } from "../application/analysisView/AnalysisViewService.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; @@ -12,16 +16,26 @@ import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js" import { err } from "../domain/result.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; +import { registerByteEntropyTool } from "./registerByteEntropyTool.js"; /** Bind offline layout inspection to its named contract and session Evidence owner. */ export const registerBinaryDiagnosticsTools = ( server: EvidenceMcpServer, service: BinaryLayoutService, + entropyService: ByteEntropyService, logger: Logger, recordEvidence?: EvidenceWriter["recordEvidence"], evidenceById?: EvidenceLookup, withAdmittedAnalysis?: WithAdmittedAnalysis, ): void => { + registerByteEntropyTool( + server, + entropyService, + logger, + recordEvidence, + evidenceById, + withAdmittedAnalysis, + ); const contract = toolContract("inspect_binary_layout"); server.registerTool( contract.name, @@ -51,6 +65,13 @@ export const registerBinaryDiagnosticsTools = ( logger, contract.name, async () => { + if (detail === "summary") { + const ready = requireAnalysisViewRetention(contract.name, { + recordEvidence, + evidenceById, + }); + if (!ready.ok) return ready; + } const inspected = await service.inspect(request, { signal: context.mcpReq.signal, }); diff --git a/src/server/registerByteEntropyTool.ts b/src/server/registerByteEntropyTool.ts new file mode 100644 index 0000000000..55cf8c06e5 --- /dev/null +++ b/src/server/registerByteEntropyTool.ts @@ -0,0 +1,68 @@ +import type { ByteEntropyService } from "../application/binaryDiagnostics/ByteEntropyService.js"; +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; + +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { runAdmittedToolOperation } from "./admittedToolOperation.js"; + +/** Reject an unretained byte-entropy summary before reading the selected file. */ +export const registerByteEntropyTool = ( + server: EvidenceMcpServer, + entropyService: ByteEntropyService, + logger: Logger, + recordEvidence: EvidenceWriter["recordEvidence"] | undefined, + evidenceById: EvidenceLookup | undefined, + withAdmittedAnalysis: WithAdmittedAnalysis | undefined, +): void => { + const contract = toolContract("inspect_byte_entropy"); + const retention = { recordEvidence, evidenceById }; + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async ({ detail, ...request }, context) => + runAdmittedToolOperation( + server, + withAdmittedAnalysis, + contract.name, + context.mcpReq.signal, + async () => { + const result = await logToolExecution( + logger, + contract.name, + async () => { + if (detail === "summary") { + const ready = requireAnalysisViewRetention( + contract.name, + retention, + ); + if (!ready.ok) return ready; + } + const inspected = await entropyService.inspect(request, { + signal: context.mcpReq.signal, + }); + return detail === "summary" && inspected.ok + ? summarizeRetainedAnalysis(inspected.value, retention) + : inspected; + }, + ); + if (!result.ok) + return server.delivery.toCallToolResult(result, contract); + const recorded = recordEvidence?.(result.value); + return server.delivery.toEvidenceToolResult( + result.value, + contract, + recorded, + ); + }, + ), + ); +}; diff --git a/tests/boundary/filesystem/productCatalog.test.ts b/tests/boundary/filesystem/productCatalog.test.ts index ade073ba83..3660cfca96 100644 --- a/tests/boundary/filesystem/productCatalog.test.ts +++ b/tests/boundary/filesystem/productCatalog.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { BYTE_ENTROPY_PROVIDER } from "../../../src/application/InvestigationProviders.js"; import { SUPPORTED_CLIENT_DEFINITIONS } from "../../../src/application/SupportedClients.js"; import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js"; import { createCli } from "../../../src/cli.js"; @@ -22,6 +23,7 @@ import { import { OBJDUMP_PROVIDER_IDENTITY } from "../../../src/objdump/ObjdumpCommand.js"; import { RIZIN_PROVIDER_IDENTITY } from "../../../src/rizin/RizinCommand.js"; import { GDB_PROVIDER_IDENTITY } from "../../../src/gdb/GdbSessionManager.js"; +import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js"; import { documentationFactIssues, skillReferenceIssues, @@ -100,6 +102,18 @@ describe("canonical product catalog", () => { .map(({ name }) => name) .sort(), ); + expect( + catalog.providers.find(({ id }) => id === BYTE_ENTROPY_PROVIDER.id), + ).toEqual({ + id: "byte-entropy", + name: "REA byte entropy", + version: "1", + capabilities: ["inspect_byte_entropy"], + }); + expect( + catalog.providers.find(({ id }) => id === PWNTOOLS_PROVIDER_IDENTITY.id) + ?.capabilities, + ).toEqual(["inspect_binary_layout", "inspect_recorded_crash"]); expect( JSON.parse(await readFile("docs/public/product-catalog.json", "utf8")), ).toEqual(catalog); diff --git a/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts b/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts index 925740b942..e888740e94 100644 --- a/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts +++ b/tests/boundary/mcp/analysisSummaryDetailMcp.test.ts @@ -1,3 +1,6 @@ +import { runCliJson } from "../../fixtures/cliJsonProcess.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { byteEntropySummaryViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -171,3 +174,87 @@ it("returns the summary of retained binary layout Evidence", async () => { normalized_result: { kind: "page", parent_evidence_id: parentId }, }); }); + +it("retains complete entropy Evidence, summarizes runs and matches CLI JSON", async () => { + const root = await createTestTempDirectory("rea-entropy-summary-"); + const path = join(root, "bytes"); + await writeFile(path, Buffer.alloc(65536)); + const client = await connect(true); + const input = { path, block_size: 256, offset: 7, length: 32769 }; + const complete = await client.callTool({ + name: "inspect_byte_entropy", + arguments: input, + }); + expect(complete.isError).not.toBe(true); + const evidence = parseEvidence(complete.structuredContent); + const cli = parseEvidence( + await runCliJson([ + "inspect-byte-entropy", + path, + "--block-size", + "256", + "--offset", + "7", + "--length", + "32769", + "--json", + ]), + ); + expect(cli.normalized_result).toEqual(evidence.normalized_result); + expect(cli.evidence_id).toBe(evidence.evidence_id); + const summary = await client.callTool({ + name: "inspect_byte_entropy", + arguments: { ...input, detail: "summary" }, + }); + expect(summary.isError).not.toBe(true); + const view = byteEntropySummaryViewResultSchema.parse( + parseEvidence(summary.structuredContent).normalized_result, + ); + expect(view).toMatchObject({ + parent_evidence_id: evidence.evidence_id, + summary: { + file_entropy: 0, + block_count: 129, + run_count: 1, + top_runs: [{ offset: 7, length: 32769, classification: "low" }], + }, + }); + const retained = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: view.parent_evidence_id, + }, + view: { kind: "summary" }, + }, + }); + expect(retained.isError).not.toBe(true); + expect(parseEvidence(retained.structuredContent).normalized_result).toEqual( + view, + ); + expect(textBytes(summary.content)).toBeLessThan(textBytes(complete.content)); + expect( + ( + await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: view.parent_evidence_id, + }, + view: { kind: "page", collection: "sections", offset: 0, limit: 1 }, + }, + }) + ).isError, + ).toBe(true); + const fixed = await connect(false); + expect( + ( + await fixed.callTool({ + name: "inspect_byte_entropy", + arguments: { path, detail: "summary" }, + }) + ).isError, + ).toBe(true); +}); diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index c34dd4db3d..1131b45b05 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -1,3 +1,10 @@ +import { randomBytes } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { byteEntropySchema } from "../../../src/domain/native/byteEntropy.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { TOOL_EFFECTS } from "../../../src/contracts/toolEffects.js"; import { advertisedMcpToolSchemas, assertRecordedMcpEvidence, @@ -104,3 +111,73 @@ it("advertises exact valid schemas and records inline layout without a binary ta ).toBe(true); expect(providerCalls).toBe(1); }); + +it("profiles real bytes with exact offsets, valid advertised schemas and read-only effects", async () => { + const root = await createTestTempDirectory("rea-entropy-mcp-"); + const path = join(root, "firmware.bin"); + await writeFile( + path, + Buffer.concat([ + Buffer.alloc(4096), + randomBytes(4096), + Buffer.from("ordinary text ".repeat(400)), + ]), + ); + const session = createTestBinarySession(() => { + throw new Error("No provider needed"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "entropy-contract", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + client, + "inspect_byte_entropy", + ); + expect(ajv.validate(inputSchema, { path, surprise: true })).toBe(false); + const response = await client.callTool({ + name: "inspect_byte_entropy", + arguments: { path }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = parseEvidence(response.structuredContent); + const report = byteEntropySchema.parse(evidence.normalized_result); + expect(report.runs).toEqual([ + { offset: 0, length: 4096, classification: "low" }, + { offset: 4096, length: 4096, classification: "high" }, + ]); + expect( + report.blocks.map(({ offset, length }) => ({ offset, length })), + ).toEqual([ + { offset: 0, length: 4096 }, + { offset: 4096, length: 4096 }, + { offset: 8192, length: 4096 }, + { offset: 12288, length: 1504 }, + ]); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + expect(TOOL_EFFECTS.inspect_byte_entropy).toMatchObject({ + launchesProcess: false, + writesFilesystem: false, + accessesNetwork: false, + mutatesTarget: false, + }); + expect( + ( + await client.callTool({ + name: "inspect_byte_entropy", + arguments: { path, offset: 999999 }, + }) + ).isError, + ).toBe(true); +});