fix(capture): retain safe caller path selections (#9626)

* fix(cli)!: retain repeated WASM input paths

BREAKING CHANGE: caller_input_paths values may now be ordered arrays of strings as well as strings. Clients must accept both forms.

* fix(capture): retain safe caller path selections

* fix(evidence): retain auxiliary selections in direct summaries

* test(wasm): separate SDK schema and acquired-input coverage
This commit is contained in:
morluto authored and GitHub committed 2026-11-09 20:12:45 +08:00
1 parent dda2d40295
commit bf271ba3fb
10 files changed
+326 -130

No files matched your search

+3 -1
View File
@@ -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,
@@ -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: {
+70 -58
View File
@@ -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<Result<Evidence, AnalysisError>> {
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<Evidence, AnalysisError> {
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 },
),
);
}
+3 -1
View File
@@ -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,
+6
View File
@@ -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",
+21
View File
@@ -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();
});
+12 -4
View File
@@ -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);
@@ -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);
},
);
@@ -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();
}
},
);
@@ -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();
}
},
);