diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 37413f48e2..cbd508cac3 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -138,7 +138,9 @@ export const deliverAnalysisEvidence = async ( // Later inspect_analysis_view requests are separate questions and do not // inherit the parent's selected input path. const selected = - detail === "summary" && evidence.caller_input_path !== undefined + detail === "summary" && + (evidence.caller_input_path !== undefined || + evidence.caller_input_paths !== undefined) ? withEvidenceCallerInputPath( delivered.value, evidence.caller_input_path, diff --git a/src/application/WebNetworkCaptureService.test.ts b/src/application/WebNetworkCaptureService.test.ts index 69428e6f1a..4674ef12da 100644 --- a/src/application/WebNetworkCaptureService.test.ts +++ b/src/application/WebNetworkCaptureService.test.ts @@ -210,18 +210,23 @@ it("projects completed raw capture facts on failure through the caller's selecti }); const result = await new WebNetworkCaptureService({ inspect: () => Promise.resolve({ ok: false, error }), - }).inspect({ - capture_path: report.artifact.path, - format: "har", - record_ordinals: [0], - sensitive_values: ["private-marker"], - }); + }).inspect( + { + capture_path: report.artifact.path, + format: "har", + record_ordinals: [0], + sensitive_values: ["private-marker"], + }, + undefined, + { capture: "./capture.har" }, + ); if (result.ok) throw new Error("Cancellation must remain the primary result"); expect(result.error._tag).toBe("AnalysisCancelledError"); expect(result.error.cause).toBe(reason); const partial = parseEvidence(result.error.partialObservation); expect(partial).toMatchObject({ operation: "inspect_web_network_capture", + caller_input_path: "./capture.har", parameters: { record_ordinals: [0] }, subject: { local_path: "" }, normalized_result: { diff --git a/src/application/WebNetworkCaptureService.ts b/src/application/WebNetworkCaptureService.ts index e1ee683d01..3b16ad32c7 100644 --- a/src/application/WebNetworkCaptureService.ts +++ b/src/application/WebNetworkCaptureService.ts @@ -10,7 +10,11 @@ import { AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; -import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { + createEvidence, + withEvidenceCallerInputPath, + type Evidence, +} from "../domain/evidence.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -39,6 +43,7 @@ export class WebNetworkCaptureService { async inspect( rawInput: unknown, options?: ExecutionOptions, + callerPaths?: { readonly capture: string; readonly descriptor?: string }, ): Promise> { if (options?.signal?.aborted) return err( @@ -87,7 +92,11 @@ export class WebNetworkCaptureService { ); let partialFailure: AnalysisError | undefined; if (partial.success) { - const projected = this.#projectCapture(input, partial.data); + const projected = this.#projectCapture( + input, + partial.data, + callerPaths, + ); if (projected.ok) loaded.error.partialObservation = projected.value; else partialFailure = projected.error; } else if (isCapturePartialCandidate(loaded.error.partialObservation)) { @@ -128,7 +137,7 @@ export class WebNetworkCaptureService { "Capture adapter returned malformed historical evidence.", ), ); - const projected = this.#projectCapture(input, report.data); + const projected = this.#projectCapture(input, report.data, callerPaths); if (!projected.ok) return failure(projected.error); return completeProviderEvidence(projected.value, options?.signal); } @@ -136,6 +145,7 @@ export class WebNetworkCaptureService { #projectCapture( input: InspectWebNetworkCaptureInput, value: WebNetworkCapture, + callerPaths?: { readonly capture: string; readonly descriptor?: string }, ): Result { const artifactSha256 = value.artifact.sha256; if (artifactSha256 === null) @@ -160,6 +170,8 @@ export class WebNetworkCaptureService { const safeValue = excludeCaptureCoordinates(value, input.sensitive_values); const redactText = (text: string): string => redactExplicitText(text, input.sensitive_values); + const isSensitive = (text: string): boolean => + input.sensitive_values.some((literal) => text.includes(literal)); const selected = input.record_ordinals ?? value.records.map(({ ordinal }) => ordinal); const records = []; @@ -189,16 +201,8 @@ export class WebNetworkCaptureService { limitations: record.limitations.map(redactText), }); } - const safePath = input.sensitive_values.some((literal) => - input.capture_path.includes(literal), - ) - ? "" - : input.capture_path; - const safeSha256 = input.sensitive_values.some((literal) => - artifactSha256.includes(literal), - ) - ? null - : artifactSha256; + const safePath = isSensitive(input.capture_path) ? "" : input.capture_path; + const safeSha256 = isSensitive(artifactSha256) ? null : artifactSha256; const subjectUnavailableReason = redactText( "The explicitly sensitive artifact digest is excluded; its cryptographic identity is unknown in this Evidence.", ); @@ -212,11 +216,7 @@ export class WebNetworkCaptureService { ].map(redactText); const parameters = jsonObjectSchema.parse({ capture_path: safePath, - ...(input.sensitive_values.some((literal) => - input.format.includes(literal), - ) - ? {} - : { format: input.format }), + ...(isSensitive(input.format) ? {} : { format: input.format }), record_ordinals: selected, ...(input.protobuf === undefined ? {} @@ -247,48 +247,60 @@ export class WebNetworkCaptureService { ? {} : { capture_timeout_ms: input.capture_timeout_ms }), }); - return ok( - createEvidence( - safeSha256 === null - ? undefined - : { - path: safePath, - sha256: safeSha256, - format: "file", - }, - value.decoder, - { - operation: OPERATION, - parameters, - result: { - ...safeValue, - artifact: { - ...value.artifact, - path: safePath, - sha256: safeSha256, - }, - records, - limitations: - safeSha256 === null - ? [...limitations, subjectUnavailableReason] - : limitations, + const evidence = createEvidence( + safeSha256 === null + ? undefined + : { + path: safePath, + sha256: safeSha256, + format: "file", }, - ...(safeValue.format !== "qlog" - ? {} - : { - analysisProfile: createAnalysisProfile(value.decoder, { - qlog_profile: safeValue.qlog.profile, - clock: safeValue.qlog.clock, - scope: safeValue.qlog.scope, - }), - }), - confidence: "observed", - authority: "historical-reference", - limitations, - ...(safeSha256 === null ? { subjectUnavailableReason } : {}), - locations: - safePath === "" ? [] : [{ kind: "artifact-path", path: safePath }], + value.decoder, + { + operation: OPERATION, + parameters, + result: { + ...safeValue, + artifact: { + ...value.artifact, + path: safePath, + sha256: safeSha256, + }, + records, + limitations: + safeSha256 === null + ? [...limitations, subjectUnavailableReason] + : limitations, }, + ...(safeValue.format !== "qlog" + ? {} + : { + analysisProfile: createAnalysisProfile(value.decoder, { + qlog_profile: safeValue.qlog.profile, + clock: safeValue.qlog.clock, + scope: safeValue.qlog.scope, + }), + }), + confidence: "observed", + authority: "historical-reference", + limitations, + ...(safeSha256 === null ? { subjectUnavailableReason } : {}), + locations: + safePath === "" ? [] : [{ kind: "artifact-path", path: safePath }], + }, + ); + if (callerPaths === undefined) return ok(evidence); + const safeCallerPath = (path: string | undefined) => + path === undefined || isSensitive(path) ? undefined : path; + const callerPath = safeCallerPath(callerPaths.capture); + const descriptor = safeCallerPath(callerPaths.descriptor); + return ok( + withEvidenceCallerInputPath( + evidence, + callerPath, + descriptor === undefined + ? undefined + : { descriptor_set_path: descriptor }, ), ); } diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index 25ff79417c..c85828afa1 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -258,7 +258,9 @@ export const publishCliAnalysisEvidence = async ( // This projection answers the same direct invocation as its parent. Later // inspect_analysis_view calls own their own input and do not inherit it. const selected = - detail === "summary" && complete.caller_input_path !== undefined + detail === "summary" && + (complete.caller_input_path !== undefined || + complete.caller_input_paths !== undefined) ? withEvidenceCallerInputPath( delivered.value, complete.caller_input_path, diff --git a/src/cli/webNetworkCaptureCommands.ts b/src/cli/webNetworkCaptureCommands.ts index 9cffd44753..44f5089654 100644 --- a/src/cli/webNetworkCaptureCommands.ts +++ b/src/cli/webNetworkCaptureCommands.ts @@ -111,6 +111,12 @@ export const registerWebNetworkCaptureCommands = ( : { capture_timeout_ms: options.captureTimeoutMs }), }, { signal }, + { + capture: args.capturePath, + ...(options.protobufSchema === undefined + ? {} + : { descriptor: options.protobufSchema }), + }, ), service, providerId: "web-network-capture", diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index 588f48bc33..c1b0277e02 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -922,3 +922,24 @@ it("borrows variable-size collections for admission", () => { expect(envelope.locations).toBe(locations); expect(envelope.evidence_links).toBe(evidenceLinks); }); + +it("retains safe auxiliary paths when the direct caller path is unavailable", () => { + const original = createEvidence(TARGET, PROVIDER, { + operation: "inspect", + parameters: {}, + result: null, + }); + const rebound = withEvidenceCallerInputPath(original, undefined, { + descriptor_set_path: "./schema.pb", + }); + expect(rebound.caller_input_path).toBeUndefined(); + expect(rebound.caller_input_paths).toEqual({ + descriptor_set_path: "./schema.pb", + }); + expect(rebound.evidence_id).toBe(original.evidence_id); + expect(() => + withEvidenceCallerInputPath(original, undefined, { + descriptor_set_path: "bad\0path", + }), + ).toThrow(); +}); diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index fc07594dc4..96f9d76e1b 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -378,10 +378,11 @@ export const rebindEvidenceToTargetPath = ( /** Bind the exact selected request path without changing semantic identity or stored history. */ export const withEvidenceCallerInputPath = ( evidence: Evidence, - rawPath: string, + rawPath: string | undefined, additionalPaths?: CallerInputPaths, ): Evidence => { - const callerInputPath = nulFreeStringSchema.parse(rawPath); + const callerInputPath = + rawPath === undefined ? undefined : nulFreeStringSchema.parse(rawPath); const owned = immutableEvidence(evidence); const paths = additionalPaths === undefined @@ -389,12 +390,19 @@ export const withEvidenceCallerInputPath = ( : callerInputPathsSchema.parse(additionalPaths); const samePaths = paths === undefined || isDeepStrictEqual(owned.caller_input_paths, paths); - if (owned.caller_input_path === callerInputPath && samePaths) return owned; + if ( + (callerInputPath === undefined || + owned.caller_input_path === callerInputPath) && + samePaths + ) + return owned; // The authenticated source owns its immutable payloads. Only this small // envelope changes, so replay does not copy a document-sized result. const rebound = { ...owned, - caller_input_path: callerInputPath, + ...(callerInputPath === undefined + ? {} + : { caller_input_path: callerInputPath }), ...(paths === undefined ? {} : { caller_input_paths: paths }), }; return rememberReboundEvidence(owned, rebound); diff --git a/tests/acceptance/tools/protobufCapture.test.ts b/tests/acceptance/tools/protobufCapture.test.ts index f68462ad61..62de66e78e 100644 --- a/tests/acceptance/tools/protobufCapture.test.ts +++ b/tests/acceptance/tools/protobufCapture.test.ts @@ -17,6 +17,7 @@ import { protobufFrame, protobufCaptureBody, } from "../../fixtures/protobufCapture.js"; +import { expectCliMcpEvidenceParity } from "../../fixtures/cliMcpEvidenceParity.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { webNetworkCaptureSchema, @@ -678,7 +679,7 @@ captureTest.for( protobuf: { framing: "auto" }, }); expect(reply.isError, JSON.stringify(reply)).not.toBe(true); - expect(reply.structuredContent).toEqual(response.json); + expectCliMcpEvidenceParity(response.json, reply.structuredContent, path); expect(await readFile(path, "utf8")).toBe(original); }, ); diff --git a/tests/boundary/cli/wasmAcquiredInputsCli.test.ts b/tests/boundary/cli/wasmAcquiredInputsCli.test.ts index a9b1f91594..a8b0ee8f72 100644 --- a/tests/boundary/cli/wasmAcquiredInputsCli.test.ts +++ b/tests/boundary/cli/wasmAcquiredInputsCli.test.ts @@ -133,65 +133,80 @@ ${body} }); const { client, call } = await connectLocalToolsMcp(environment); - const schema = await advertisedMcpToolSchemas( - client, - "inspect_wasm_artifact", - ); - const valid = withEvidenceCallerInputPath( - createEvidence( - undefined, - { id: "fixture-wabt", name: "fixture", version: "1" }, - { - operation: "inspect_wasm_artifact", - parameters: {}, - result: wasmArtifactFixture(), - confidence: "observed", - }, - ), - "./selected.wasm", - { - candidate_paths: ["./candidate.wasm", "./candidate.wasm"], - glue_paths: ["./glue.js"], - }, - ); - expect( - schema.ajv.validate(schema.outputSchema, valid), - JSON.stringify(schema.ajv.errors), - ).toBe(true); - const response = await call("inspect_wasm_artifact", { - path: selected, - candidate_paths: [candidate], - glue_paths: [glue], - }); - const mcpError = parseMcpToolError(response).error; - expect(mcpError.code).toBe("invalid_request"); - const partialReference = z - .object({ - kind: z.literal("retained-evidence"), - evidence_id: z.string(), - }) - .parse( - z - .object({ partial_observation: z.unknown() }) - .passthrough() - .parse(mcpError.details).partial_observation, + try { + const response = await call("inspect_wasm_artifact", { + path: selected, + candidate_paths: [candidate], + glue_paths: [glue], + }); + const mcpError = parseMcpToolError(response).error; + expect(mcpError.code).toBe("invalid_request"); + const partialReference = z + .object({ + kind: z.literal("retained-evidence"), + evidence_id: z.string(), + }) + .parse( + z + .object({ partial_observation: z.unknown() }) + .passthrough() + .parse(mcpError.details).partial_observation, + ); + const retainedReply = await call("get_evidence_bundle", { + detail: "complete", + }); + expect(retainedReply.isError).not.toBe(true); + const retainedBundle = toolContract( + "get_evidence_bundle", + ).outputSchema.parse(retainedReply.structuredContent).result; + if ("kind" in retainedBundle) + throw new Error("Expected complete retained Evidence"); + const retained = parseEvidence( + retainedBundle.records.find( + (record) => record.evidence_id === partialReference.evidence_id, + ), ); - const retainedReply = await call("get_evidence_bundle", { - detail: "complete", - }); - expect(retainedReply.isError).not.toBe(true); - const retainedBundle = toolContract( - "get_evidence_bundle", - ).outputSchema.parse(retainedReply.structuredContent).result; - if ("kind" in retainedBundle) - throw new Error("Expected complete retained Evidence"); - const retained = parseEvidence( - retainedBundle.records.find( - (record) => record.evidence_id === partialReference.evidence_id, - ), - ); - expect(retained.evidence_id).toBe(partialReference.evidence_id); - expect(retained.normalized_result).toMatchObject(expected); - await client.ping(); + expect(retained.evidence_id).toBe(partialReference.evidence_id); + expect(retained.normalized_result).toMatchObject(expected); + await client.ping(); + } finally { + await client.close(); + } + }, +); + +cliTest( + "advertises ordered WASM caller selections through the SDK output schema", + async () => { + const { client } = await connectLocalToolsMcp(); + try { + const schema = await advertisedMcpToolSchemas( + client, + "inspect_wasm_artifact", + ); + const valid = withEvidenceCallerInputPath( + createEvidence( + undefined, + { id: "fixture-wabt", name: "fixture", version: "1" }, + { + operation: "inspect_wasm_artifact", + parameters: {}, + result: wasmArtifactFixture(), + confidence: "observed", + }, + ), + "./selected.wasm", + { + candidate_paths: ["./candidate.wasm", "./candidate.wasm"], + glue_paths: ["./glue.js"], + }, + ); + expect( + schema.ajv.validate(schema.outputSchema, valid), + JSON.stringify(schema.ajv.errors), + ).toBe(true); + } finally { + await client.close(); + } }, ); diff --git a/tests/boundary/cli/webCaptureCallerPaths.test.ts b/tests/boundary/cli/webCaptureCallerPaths.test.ts new file mode 100644 index 0000000000..e9a6aaa03e --- /dev/null +++ b/tests/boundary/cli/webCaptureCallerPaths.test.ts @@ -0,0 +1,124 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { deliverAnalysisEvidence } from "../../../src/application/EvidenceBundleCommands.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + protobufCaptureDescriptor, + protobufHar, +} from "../../fixtures/protobufCapture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "retains selected capture paths without exposing declared sensitive values", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-capture-selected-"); + const cwd = join(root, "secret-parent"); + await mkdir(cwd); + await writeFile(join(cwd, "capture.har"), protobufHar()); + await writeFile(join(cwd, "capture.pb"), protobufCaptureDescriptor()); + const args = [ + "inspect-web-network-capture", + "./capture.har", + "har", + "--record", + "0", + "--protobuf-schema", + "./capture.pb", + "--protobuf-message", + "capture.Message", + "--json", + ]; + const result = await cli.run({ cwd, arguments: args }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = parseEvidence(result.json); + expect(evidence.caller_input_path).toBe("./capture.har"); + expect(evidence.caller_input_paths).toEqual({ + descriptor_set_path: "./capture.pb", + }); + const parentExcluded = await cli.run({ + cwd, + arguments: [...args, "--sensitive-value", "secret-parent"], + }); + expect(parentExcluded.exitCode, parentExcluded.stderr).toBe(0); + expect(JSON.stringify(parentExcluded.json)).not.toContain("secret-parent"); + expect(parseEvidence(parentExcluded.json).caller_input_path).toBe( + "./capture.har", + ); + const originalExcluded = await cli.run({ + cwd, + arguments: [...args, "--sensitive-value", "capture.har"], + }); + expect(originalExcluded.exitCode, originalExcluded.stderr).toBe(0); + expect(JSON.stringify(originalExcluded.json)).not.toContain("capture.har"); + const safe = parseEvidence(originalExcluded.json); + expect(safe.caller_input_path).toBeUndefined(); + expect(safe.caller_input_paths).toEqual({ + descriptor_set_path: "./capture.pb", + }); + const summaryExcluded = await cli.run({ + cwd, + arguments: [ + ...args, + "--sensitive-value", + "capture.har", + "--detail", + "summary", + "--evidence_output", + "./safe-evidence.json", + ], + }); + expect(summaryExcluded.exitCode, summaryExcluded.stderr).toBe(0); + const summary = parseEvidence( + (summaryExcluded.json as { summary: unknown }).summary, + ); + expect(summary.caller_input_path).toBeUndefined(); + expect(summary.caller_input_paths).toEqual({ + descriptor_set_path: "./capture.pb", + }); + expect(JSON.stringify(summaryExcluded.json)).not.toContain("capture.har"); + const applicationSummary = await deliverAnalysisEvidence( + safe, + join(cwd, "application-evidence.json"), + "summary", + ); + if (!applicationSummary.ok) throw applicationSummary.error; + expect(applicationSummary.value.caller_input_path).toBeUndefined(); + expect(applicationSummary.value.caller_input_paths).toEqual({ + descriptor_set_path: "./capture.pb", + }); + const descriptorExcluded = await cli.run({ + cwd, + arguments: [...args, "--sensitive-value", "capture.pb"], + }); + expect(descriptorExcluded.exitCode, descriptorExcluded.stderr).toBe(0); + expect(JSON.stringify(descriptorExcluded.json)).not.toContain("capture.pb"); + const safeDescriptor = parseEvidence(descriptorExcluded.json); + expect(safeDescriptor.caller_input_path).toBe("./capture.har"); + expect(safeDescriptor.caller_input_paths).toBeUndefined(); + const { client, call } = await connectLocalToolsMcp(); + try { + const response = await call("inspect_web_network_capture", { + capture_path: join(cwd, "capture.har"), + format: "har", + record_ordinals: [0], + protobuf: { + schema: { + descriptor_set_path: join(cwd, "capture.pb"), + message_type: "capture.Message", + }, + }, + sensitive_values: ["secret-parent"], + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(JSON.stringify(response)).not.toContain("secret-parent"); + expect( + parseEvidence(response.structuredContent).caller_input_path, + ).toBeUndefined(); + } finally { + await client.close(); + } + }, +);