diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index 5db46d0593..2ee2d05c5d 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -1,5 +1,6 @@ import { registerPlaywrightTraceCommand } from "./playwrightTraceCommand.js"; import { registerApiContractCommand } from "./apiContractCommand.js"; +import { registerNtfsJournalCommand } from "./ntfsJournalCommand.js"; import { Cli, z } from "incur"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -77,6 +78,7 @@ export const registerApplicationCommands = ( registerSourceToBundleCommand(cli, logger); registerStaticObservedEndpointsCommand(cli, logger); registerApiContractCommand(cli, logger); + registerNtfsJournalCommand(cli, logger); registerPlaywrightTraceCommand(cli, logger); registerEvidenceJsonCommand({ cli, diff --git a/src/cli/ntfsJournalCommand.ts b/src/cli/ntfsJournalCommand.ts new file mode 100644 index 0000000000..eb338de4e4 --- /dev/null +++ b/src/cli/ntfsJournalCommand.ts @@ -0,0 +1,79 @@ +import { z } from "incur"; +import type { Logger } from "pino"; + +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { projectArtifactCloseFailure } from "../artifacts/StableArtifactFailure.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { localPathStringSchema } from "../domain/localPath.js"; +import { + NTFS_JOURNAL_OPERATION as operation, + NTFS_JOURNAL_PROVIDER, + ntfsJournalInputSchema, +} from "../domain/windows/ntfsJournalQuery.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { deliverCliAnalysisEvidence } from "./analysisEvidenceDelivery.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { cliDecimalField } from "./options.js"; +import type { CliInstance } from "./types.js"; + +export const registerNtfsJournalCommand = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectNtfsJournal, { + description: "Inspect paired offline NTFS MFT and USN journal acquisitions", + args: z.object({ + mftPath: localPathStringSchema.describe( + "Acquired NTFS Master File Table stream", + ), + journalPath: localPathStringSchema.describe( + "Acquired NTFS change journal data stream", + ), + }), + options: z.object({ + startFileOffset: cliDecimalField( + ntfsJournalInputSchema.shape.start_file_offset, + ), + maxRows: cliDecimalField(ntfsJournalInputSchema.shape.max_rows), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectNtfsJournal, async () => { + const resources = new ArtifactResourceScope(); + const { inspectNtfsJournalEvidence } = + await import("../application/ntfs/NtfsJournalService.js"); + return deliverCliAnalysisEvidence({ + callerInputPath: args.journalPath, + callerInputPaths: { mft_path: args.mftPath }, + run: () => + inspectNtfsJournalEvidence( + { + mft_path: absoluteCallerPath(args.mftPath), + journal_path: absoluteCallerPath(args.journalPath), + start_file_offset: options.startFileOffset, + max_rows: options.maxRows, + }, + resources, + signal, + ), + service: { + close: () => + resources.close().catch((cause: unknown) => { + throw projectArtifactCloseFailure( + cause, + NTFS_JOURNAL_PROVIDER.id, + operation, + ); + }), + }, + providerId: NTFS_JOURNAL_PROVIDER.id, + operation, + signal, + detail: "complete", + evidenceOutput: undefined, + }); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 83e3aa2d73..83e959fd5b 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -70,6 +70,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectIl2cppMetadata: "inspect-il2cpp-metadata", inspectModelArtifact: "inspect-model-artifact", inspectApiContract: "inspect-api-contract", + inspectNtfsJournal: "inspect-ntfs-journal", inspectPlaywrightTrace: "inspect-playwright-trace", inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index 2ec7040a37..bbcb1cd1a0 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -1,5 +1,6 @@ import { PLAYWRIGHT_TRACE_TOOL_CONTRACTS } from "./playwrightTraceToolContracts.js"; import { API_CONTRACT_TOOL_CONTRACTS } from "./apiContractToolContracts.js"; +import { NTFS_JOURNAL_TOOL_CONTRACTS } from "./ntfsJournalToolContracts.js"; import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js"; import { z } from "zod"; import { @@ -95,6 +96,7 @@ const retained = (evidence_id: string) => ({ /** Provider-neutral graph workflow contracts shared by MCP and CLI adapters. */ export const APPLICATION_TOOL_CONTRACTS = [ ...API_CONTRACT_TOOL_CONTRACTS, + ...NTFS_JOURNAL_TOOL_CONTRACTS, ...PLAYWRIGHT_TRACE_TOOL_CONTRACTS, { name: "search_guidance", diff --git a/src/contracts/ntfsJournalToolContracts.ts b/src/contracts/ntfsJournalToolContracts.ts new file mode 100644 index 0000000000..866c302d64 --- /dev/null +++ b/src/contracts/ntfsJournalToolContracts.ts @@ -0,0 +1,30 @@ +import { + NTFS_JOURNAL_OPERATION, + ntfsJournalInputSchema, + ntfsJournalResultSchema, +} from "../domain/windows/ntfsJournalQuery.js"; +import type { ToolContract } from "./toolContractTypes.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; + +export const NTFS_JOURNAL_TOOL_CONTRACTS = [ + { + name: NTFS_JOURNAL_OPERATION, + ...toolContractMetadata(NTFS_JOURNAL_OPERATION), + kind: "application", + description: + "Correlate USN V2 records from a local acquired NTFS journal with a caller-paired MFT from the same volume. Reads at most 64 MiB per file. Returns source-order events, explicit coverage rows, both SHA-256 identities, and nextFileOffset for another page using the same inputs. Full file references include sequence numbers. Other journal versions and unverifiable references remain explicit. Reads only the selected acquisition files.", + inputSchema: ntfsJournalInputSchema, + outputSchema: evidenceResultOf(ntfsJournalResultSchema), + examples: [ + { + title: "Read a page of a paired NTFS acquisition", + input: { + mft_path: "/artifacts/$MFT", + journal_path: "/artifacts/UsnJrnl-J", + max_rows: 50, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 7e79ab262d..cd5283e1b5 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -159,6 +159,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_il2cpp_metadata: evidence, inspect_model_artifact: evidence, inspect_api_contract: evidence, + inspect_ntfs_journal: evidence, inspect_playwright_trace: evidence, inspect_gpu_code_objects: evidence, inspect_rust_binary: evidence, diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index 4f7770c893..150cc73c1a 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -1,5 +1,6 @@ import { registerPlaywrightTraceTool } from "./registerApplicationTools/playwrightTrace.js"; import { registerApiContractTool } from "./registerApplicationTools/apiContract.js"; +import { registerNtfsJournalTool } from "./registerApplicationTools/ntfsJournal.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { registerTraceFeatureTool } from "./registerApplicationTools/traceFeature.js"; @@ -28,6 +29,7 @@ export const registerApplicationTools = ( registerCompareApplicationVersionsTool(server, options); registerCompareEndpointsTool(server, options); registerApiContractTool(server, options); + registerNtfsJournalTool(server, options); registerPlaywrightTraceTool(server, options); registerCompareSourceToBundleTool(server, options); registerCompareJavaScriptExportShapesTool(server, options); diff --git a/src/server/registerApplicationTools/ntfsJournal.ts b/src/server/registerApplicationTools/ntfsJournal.ts new file mode 100644 index 0000000000..db62156a96 --- /dev/null +++ b/src/server/registerApplicationTools/ntfsJournal.ts @@ -0,0 +1,54 @@ +import { inspectNtfsJournalEvidence } from "../../application/ntfs/NtfsJournalService.js"; +import { toolContract } from "../../contracts/toolContracts.js"; +import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; +import { runAdmittedToolOperation } from "../admittedToolOperation.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { logToolExecution } from "../toolLogging.js"; +import { deliverApplicationEvidence } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = toolContract("inspect_ntfs_journal"); + +export const registerNtfsJournalTool = ( + server: EvidenceMcpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => + runAdmittedToolOperation( + server, + options.withAdmittedAnalysis, + contract.name, + context.mcpReq.signal, + async () => { + const signal = context.mcpReq.signal; + const result = await logToolExecution( + options.logger, + contract.name, + () => + inspectNtfsJournalEvidence( + input, + options.artifactResources, + signal, + ), + ); + if (!result.ok) + return server.delivery.toPartialErrorToolResult( + result.error, + contract.returnsTargetContent, + options.recordEvidence, + ); + return deliverApplicationEvidence( + server, + options, + contract, + { evidence: result.value, sources: [] }, + undefined, + signal, + ); + }, + ), + ); +}; diff --git a/tests/boundary/mcp/ntfsJournal.test.ts b/tests/boundary/mcp/ntfsJournal.test.ts new file mode 100644 index 0000000000..ed7ca75bb1 --- /dev/null +++ b/tests/boundary/mcp/ntfsJournal.test.ts @@ -0,0 +1,80 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createBinarySession } from "../../../src/composition/binary.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { ntfsJournalResultSchema } from "../../../src/domain/windows/ntfsJournalQuery.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { serveCli } from "../cli/cliServeHarness.js"; + +mcpTest( + "CLI and MCP inspect the same paired NTFS acquisition", + async ({ mcp, onTestFinished }) => { + const directory = await createTestTempDirectory("rea-ntfs-journal-"); + const mftPath = join(directory, "records"); + const journalPath = join(directory, "journal"); + const record = Buffer.alloc(1024); + record.write("FILE"); + record.writeUInt16LE(48, 4); + record.writeUInt16LE(3, 6); + record.writeUInt16LE(2, 16); + record.writeUInt16LE(56, 20); + record.writeUInt32LE(64, 24); + record.writeUInt32LE(1024, 28); + record.writeUInt32LE(0xffff_ffff, 56); + const journal = Buffer.alloc(64); + journal.writeUInt32LE(64); + journal.writeUInt16LE(2, 4); + journal.writeBigUInt64LE(0x0002_0000_0000_0000n, 8); + journal.writeUInt16LE(60, 58); + await writeFile(mftPath, record); + await writeFile(journalPath, journal); + + const cli = await serveCli([ + "inspect-ntfs-journal", + mftPath, + journalPath, + "--json", + ]); + expect(cli.exitCode, cli.stdout).toBe(0); + const fromCli = parseEvidence(JSON.parse(cli.stdout)); + const expected = ntfsJournalResultSchema.parse(fromCli.normalized_result); + expect(expected).toMatchObject({ + mft: { path: mftPath, bytes: 1024 }, + journal: { path: journalPath, bytes: 64 }, + rows: [ + { + kind: "event", + event: { + mft: { file: { kind: "matched", record: { sequenceNumber: 2 } } }, + }, + }, + ], + nextFileOffset: null, + }); + + const config = parseConfig({}); + if (!config.ok) throw config.error; + const session = createBinarySession(config.value, silentLogger, {}); + onTestFinished(async () => { + await session.close(); + }); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const reply = await client.callTool({ + name: "inspect_ntfs_journal", + arguments: { mft_path: mftPath, journal_path: journalPath }, + }); + expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true); + expect(parseEvidence(reply.structuredContent).normalized_result).toEqual( + expected, + ); + }, +);