mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(contracts): preserve local inputs and comparison uncertainty
This commit is contained in:
@@ -4,13 +4,14 @@ import type { ToolContract } from "../toolContracts.js";
|
||||
import { managedOutputSchemas } from "../toolOutputSchemas.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
|
||||
import { isAbsoluteLocalPath } from "../../domain/localPath.js";
|
||||
import {
|
||||
isAbsoluteLocalPath,
|
||||
localPathStringSchema,
|
||||
} from "../../domain/localPath.js";
|
||||
|
||||
/** Exact caller boundary for execution-free PE/CLI triage and identity. */
|
||||
const managedTargetInputSchema = z.strictObject({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/Example.dll or C:\\Example.dll)",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { ToolContract } from "../toolContracts.js";
|
||||
import { nativeOutputSchemas } from "../toolOutputSchemas.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { localPathStringSchema } from "../../domain/localPath.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
|
||||
|
||||
@@ -69,7 +70,8 @@ export const swiftSymbolsSchema = z
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[^\n]*$/u, "Each Swift symbol must be one line."),
|
||||
.regex(/^[^\n]*$/u, "Each Swift symbol must be one line.")
|
||||
.regex(/^[^\0]*$/u, "Swift symbols cannot contain NUL."),
|
||||
)
|
||||
.min(1);
|
||||
|
||||
@@ -104,7 +106,7 @@ export const NATIVE_TOOL_CONTRACTS = [
|
||||
"inspect_plist",
|
||||
"Parse Info.plist from the active artifact by default, or pass any local plist path. Returns normalized JSON rather than plutil text.",
|
||||
z.object({
|
||||
path: z.string().min(1).optional(),
|
||||
path: localPathStringSchema.optional(),
|
||||
}),
|
||||
),
|
||||
native(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { executableFormatHintSchema } from "../domain/dosCom.js";
|
||||
import { isAbsoluteLocalPath } from "../domain/localPath.js";
|
||||
import {
|
||||
isAbsoluteLocalPath,
|
||||
localPathStringSchema,
|
||||
} from "../domain/localPath.js";
|
||||
import { z } from "zod";
|
||||
import { analysisProviderSelectorSchema } from "./providerSelection.js";
|
||||
|
||||
/** Caller-supplied analysis snapshot path; must name its destination absolutely. */
|
||||
const snapshotPathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
const snapshotPathSchema = localPathStringSchema
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
message:
|
||||
"snapshot_path must be an absolute local filesystem path (for example /tmp/rea/analysis.json or C:\\rea\\analysis.json)",
|
||||
@@ -16,9 +17,7 @@ const snapshotPathSchema = z
|
||||
);
|
||||
|
||||
/** Caller-supplied analysis target path; must name the target absolutely. */
|
||||
const binaryTargetPathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
const binaryTargetPathSchema = localPathStringSchema
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/fixture.bin or C:\\analysis\\fixture.bin)",
|
||||
|
||||
@@ -147,6 +147,29 @@ it.each(cases)("$name requires an absolute local path", (pathCase) => {
|
||||
expect(pathCase.omitted(), pathCase.name).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves whitespace in selected browser and Electron filesystem names", () => {
|
||||
const executable = "/opt/browser with trailing space ";
|
||||
expect(
|
||||
browserScenarioBrowserSchema.parse({
|
||||
mode: "launch",
|
||||
executable_path: executable,
|
||||
}),
|
||||
).toMatchObject({ executable_path: executable });
|
||||
const application = "/tmp/application ";
|
||||
const root = "/tmp/application root ";
|
||||
expect(
|
||||
electronActiveObservationInputSchema.parse({
|
||||
executable_path: executable,
|
||||
application_path: application,
|
||||
application_root: root,
|
||||
}),
|
||||
).toMatchObject({
|
||||
executable_path: executable,
|
||||
application_path: application,
|
||||
application_root: root,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps evidence bundle export overwrite disabled by default", () => {
|
||||
expect(
|
||||
exportEvidenceBundleInputSchema.safeParse({ path: "/tmp/bundle.json" }),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { isAbsoluteLocalPath } from "../domain/localPath.js";
|
||||
import {
|
||||
isAbsoluteLocalPath,
|
||||
localPathStringSchema,
|
||||
} from "../domain/localPath.js";
|
||||
|
||||
/** Optional expectations used to compare the live session with its caller. */
|
||||
export const binarySessionInputSchema = z.strictObject({
|
||||
@@ -9,9 +12,7 @@ export const binarySessionInputSchema = z.strictObject({
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.optional(),
|
||||
expected_server_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
expected_server_path: localPathStringSchema
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
message:
|
||||
"expected_server_path must be an absolute local filesystem path (for example /opt/rea/dist/main.js)",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { isAbsoluteLocalPath } from "../domain/localPath.js";
|
||||
import {
|
||||
isAbsoluteLocalPath,
|
||||
localPathStringSchema,
|
||||
} from "../domain/localPath.js";
|
||||
import {
|
||||
sessionOutputSchemas,
|
||||
requireOutputSchema,
|
||||
@@ -31,7 +34,10 @@ import { examplesFor } from "./toolContractHelpers.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
const session = <Name extends string, Schema extends z.ZodObject>(
|
||||
const session = <
|
||||
Name extends keyof typeof sessionOutputSchemas,
|
||||
Schema extends z.ZodObject,
|
||||
>(
|
||||
name: Name,
|
||||
description: string,
|
||||
inputSchema: Schema,
|
||||
@@ -48,9 +54,7 @@ const session = <Name extends string, Schema extends z.ZodObject>(
|
||||
|
||||
/** Session-owned Evidence bundle export options. */
|
||||
export const exportEvidenceBundleInputSchema = z.strictObject({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/rea/evidence.json or C:\\rea\\evidence.json)",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { isAbsoluteLocalPath } from "../domain/localPath.js";
|
||||
import {
|
||||
isAbsoluteLocalPath,
|
||||
localPathStringSchema,
|
||||
} from "../domain/localPath.js";
|
||||
|
||||
import { artifactComparisonInputSchema } from "../domain/artifactComparison.js";
|
||||
import { bundleComparisonInputSchema } from "../domain/bundleComparison.js";
|
||||
import { callPathInputSchema } from "../domain/callPath.js";
|
||||
import { changedBehaviorInputSchema } from "../domain/changedBehavior.js";
|
||||
import { functionComparisonInputSchema } from "../domain/functionComparison.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { processScenarioSchema } from "../domain/process/processCapture.js";
|
||||
import { processTraceSpecificationSchema } from "../domain/process/processTraceComparison.js";
|
||||
import { recordUnknownInputSchema } from "../domain/residualUnknown.js";
|
||||
@@ -38,9 +40,7 @@ export const addressContextInputSchema = z.strictObject({
|
||||
|
||||
/** Session-owned Evidence bundle import options. */
|
||||
export const importEvidenceBundleInputSchema = z.strictObject({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/evidence.json or C:\\rea\\evidence.json)",
|
||||
|
||||
@@ -373,7 +373,7 @@ export const managedWorkflowOutputSchemas: Readonly<
|
||||
};
|
||||
|
||||
/** Exact structured-content schemas for target lifecycle operations. */
|
||||
export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
export const sessionOutputSchemas = {
|
||||
open_binary: lifecycleResultOf(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
@@ -469,4 +469,4 @@ export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
unknown: residualUnknownSchema,
|
||||
}),
|
||||
),
|
||||
};
|
||||
} satisfies Readonly<Record<string, z.ZodObject>>;
|
||||
|
||||
@@ -25,7 +25,8 @@ import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
/** Inline result with its complete Evidence record. */
|
||||
export const inlineEvidenceRecordSchema = evidenceEnvelopeSchema;
|
||||
|
||||
export const evidenceResultOf = (schema: z.ZodType) =>
|
||||
/** Wrap a result and its Evidence while preserving the result schema type. */
|
||||
export const evidenceResultOf = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
z.strictObject({
|
||||
result: schema,
|
||||
evidence_id: prefixedDigestSchema("ev"),
|
||||
@@ -33,14 +34,18 @@ export const evidenceResultOf = (schema: z.ZodType) =>
|
||||
});
|
||||
|
||||
const resultOf = evidenceResultOf;
|
||||
export const lifecycleResultOf = (schema: z.ZodType) =>
|
||||
/** Wrap a lifecycle result while preserving its exact schema type. */
|
||||
export const lifecycleResultOf = <Schema extends z.ZodType>(schema: Schema) =>
|
||||
z.object({ result: schema });
|
||||
|
||||
/** Resolve a required named output schema or reject contract drift. */
|
||||
export const requireOutputSchema = (
|
||||
schemas: Readonly<Record<string, z.ZodObject>>,
|
||||
name: string,
|
||||
): z.ZodObject => {
|
||||
export const requireOutputSchema = <
|
||||
Schemas extends Readonly<Record<string, z.ZodObject>>,
|
||||
Name extends keyof Schemas & string,
|
||||
>(
|
||||
schemas: Schemas,
|
||||
name: Name,
|
||||
): Schemas[Name] => {
|
||||
const schema = schemas[name];
|
||||
if (schema === undefined)
|
||||
throw new Error(`Missing output schema for ${name}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { isAbsoluteLocalPath } from "./localPath.js";
|
||||
import { isAbsoluteLocalPath, localPathStringSchema } from "./localPath.js";
|
||||
import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js";
|
||||
|
||||
import {
|
||||
@@ -12,14 +12,13 @@ export const scenarioIdentifierSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
|
||||
|
||||
const browserExecutablePathSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
const browserExecutablePathSchema = localPathStringSchema.refine(
|
||||
isAbsoluteLocalPath,
|
||||
{
|
||||
message:
|
||||
"executable_path must be an absolute local filesystem path (for example /opt/chromium/chrome or C:\\chromium\\chrome.exe)",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const browserScenarioBaseUrlSchema = z
|
||||
.string()
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ResidualUnknown } from "./residualUnknown.js";
|
||||
import { digestSchema } from "./../domain/digests.js";
|
||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
import { comparisonStatusSchema } from "./comparisonStatus.js";
|
||||
import { localPathStringSchema } from "./localPath.js";
|
||||
|
||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||
const classificationSchema = z.enum([
|
||||
@@ -24,8 +25,8 @@ const classificationSchema = z.enum([
|
||||
|
||||
/** Strict input for canonical Evidence bundle comparison. */
|
||||
export const bundleComparisonInputSchema = z.strictObject({
|
||||
left_bundle_path: z.string().min(1),
|
||||
right_bundle_path: z.string().min(1),
|
||||
left_bundle_path: localPathStringSchema,
|
||||
right_bundle_path: localPathStringSchema,
|
||||
record_pairs: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -42,7 +42,18 @@ const comparison = (
|
||||
? "rea.process-comparison"
|
||||
: "rea.artifact-comparison",
|
||||
operation,
|
||||
parameters: input.salt === undefined ? {} : { salt: input.salt },
|
||||
parameters: {
|
||||
...(process
|
||||
? {
|
||||
left_evidence_id: left.evidence_id,
|
||||
right_evidence_id: right.evidence_id,
|
||||
}
|
||||
: {
|
||||
left_evidence_ids: [left.evidence_id],
|
||||
right_evidence_ids: [right.evidence_id],
|
||||
}),
|
||||
...(input.salt === undefined ? {} : { salt: input.salt }),
|
||||
},
|
||||
result,
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
@@ -273,13 +284,17 @@ describe("changed behavior aggregation", () => {
|
||||
processResult({ status: "changed", terminal: "changed" }),
|
||||
{
|
||||
salt: comparisonIndex,
|
||||
evidenceLinks: Array.from(
|
||||
{ length: 200 },
|
||||
(_, linkIndex) =>
|
||||
`ev_${(comparisonIndex * 200 + linkIndex + 1)
|
||||
.toString(16)
|
||||
.padStart(64, "0")}`,
|
||||
),
|
||||
evidenceLinks: [
|
||||
left.evidence_id,
|
||||
right.evidence_id,
|
||||
...Array.from(
|
||||
{ length: 200 },
|
||||
(_, linkIndex) =>
|
||||
`ev_${(comparisonIndex * 200 + linkIndex + 1)
|
||||
.toString(16)
|
||||
.padStart(64, "0")}`,
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -287,7 +302,7 @@ describe("changed behavior aggregation", () => {
|
||||
const result = findChangedBehavior(comparisons);
|
||||
|
||||
expect(result.summary.observed_changes).toBe(comparisons.length);
|
||||
expect(result.evidence_links).toHaveLength(comparisons.length * 201);
|
||||
expect(result.findings.items[0]?.evidence_links).toHaveLength(201);
|
||||
expect(result.evidence_links).toHaveLength(comparisons.length * 201 + 2);
|
||||
expect(result.findings.items[0]?.evidence_links).toHaveLength(203);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import { artifactComparisonResultSchema } from "./artifactComparison.js";
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js";
|
||||
import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { functionComparisonResultSchema } from "./functionComparison.js";
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||
|
||||
/** Input for aggregating existing comparison Evidence. */
|
||||
export const changedBehaviorInputSchema = z.strictObject({
|
||||
comparisons: z.array(evidenceSchema).default([]),
|
||||
comparisons: z.array(evidenceSchema).min(1),
|
||||
});
|
||||
|
||||
const findingSchema = z.object({
|
||||
@@ -34,7 +35,7 @@ const findingSchema = z.object({
|
||||
]),
|
||||
status: comparisonStatusSchema,
|
||||
source_comparison_id: evidenceIdSchema,
|
||||
evidence_links: z.array(evidenceIdSchema).min(3),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -56,7 +57,7 @@ export const changedBehaviorResultSchema = z
|
||||
findings: z.object({
|
||||
items: z.array(findingSchema),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
@@ -185,11 +186,14 @@ const parseComparisonEvidence = (input: unknown): Evidence => {
|
||||
throw new TypeError(
|
||||
"Comparison Evidence operation, predicate, and provider disagree",
|
||||
);
|
||||
if (evidence.evidence_links.length < 2)
|
||||
if (
|
||||
!comparisonSourceEvidenceIds(evidence).every((id) =>
|
||||
evidence.evidence_links.includes(id),
|
||||
)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Comparison Evidence must cite both source observations",
|
||||
"Comparison Evidence must cite both selected source observations",
|
||||
);
|
||||
assertUnique(evidence.evidence_links, "source Evidence links");
|
||||
parseResult(evidence);
|
||||
return evidence;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
import { prefixedDigestSchema } from "./digests.js";
|
||||
import type { Evidence } from "./evidence.js";
|
||||
|
||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||
const sideParametersSchema = z.object({
|
||||
left_evidence_id: evidenceIdSchema.optional(),
|
||||
right_evidence_id: evidenceIdSchema.optional(),
|
||||
left_evidence_ids: z.array(evidenceIdSchema).min(1).optional(),
|
||||
right_evidence_ids: z.array(evidenceIdSchema).min(1).optional(),
|
||||
});
|
||||
|
||||
/** Resolve each selected side and validate aliases and duplicates within that role. */
|
||||
export const comparisonSourceEvidenceSides = (
|
||||
evidence: Evidence,
|
||||
): { left: string[]; right: string[] } => {
|
||||
const parameters = sideParametersSchema.parse(evidence.parameters);
|
||||
if (
|
||||
(parameters.left_evidence_id === undefined) !==
|
||||
(parameters.right_evidence_id === undefined) ||
|
||||
(parameters.left_evidence_ids === undefined) !==
|
||||
(parameters.right_evidence_ids === undefined)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Comparison Evidence source representation omitted one side",
|
||||
);
|
||||
const side = (single: string | undefined, multiple: string[] | undefined) => {
|
||||
if (
|
||||
single !== undefined &&
|
||||
multiple !== undefined &&
|
||||
(multiple.length !== 1 || multiple[0] !== single)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Comparison Evidence source representations disagree",
|
||||
);
|
||||
const selected = single === undefined ? (multiple ?? []) : [single];
|
||||
if (selected.length === 0)
|
||||
throw new TypeError("Comparison Evidence omitted its source parameters");
|
||||
if (new Set(selected).size !== selected.length)
|
||||
throw new TypeError(
|
||||
"Comparison Evidence repeats a source within one side",
|
||||
);
|
||||
return selected;
|
||||
};
|
||||
return {
|
||||
left: side(parameters.left_evidence_id, parameters.left_evidence_ids),
|
||||
right: side(parameters.right_evidence_id, parameters.right_evidence_ids),
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve the source closure without counting observations shared by both roles twice. */
|
||||
export const comparisonSourceEvidenceIds = (evidence: Evidence): string[] => {
|
||||
const sides = comparisonSourceEvidenceSides(evidence);
|
||||
return uniqueSorted([...sides.left, ...sides.right]);
|
||||
};
|
||||
@@ -1,14 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
import { isAbsoluteLocalPath } from "../localPath.js";
|
||||
import { isAbsoluteLocalPath, localPathStringSchema } from "../localPath.js";
|
||||
|
||||
const bytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
const range = z.strictObject({ offset: bytes, length: bytes.positive() });
|
||||
const target = {
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
path: localPathStringSchema
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
message:
|
||||
"path must be an absolute local filesystem path (for example /tmp/firmware.bin or C:\\firmware\\firmware.bin)",
|
||||
@@ -23,9 +21,10 @@ export const firmwareInputSchemas = {
|
||||
inspect_firmware_regions: z.strictObject(target),
|
||||
extract_firmware: z.strictObject({
|
||||
...target,
|
||||
output_directory: z
|
||||
.string()
|
||||
.min(1)
|
||||
output_directory: localPathStringSchema
|
||||
.refine(isAbsoluteLocalPath, {
|
||||
message: "output_directory must be an absolute local filesystem path",
|
||||
})
|
||||
.describe("Absolute, absent output directory; parent must exist"),
|
||||
range: range
|
||||
.optional()
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
} from "./javascriptSemanticGraph.js";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { prefixedDigestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
const countSchema = z.number().int().min(0);
|
||||
|
||||
/** Public target-free request for static JavaScript application analysis. */
|
||||
export const analyzeJavaScriptApplicationInputSchema = z.strictObject({
|
||||
input_path: z.string().min(1),
|
||||
input_path: localPathStringSchema,
|
||||
format: z.enum(["auto", "asar", "directory"]).default("auto"),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
/** Recover derived readable modules from one explicitly selected local script. */
|
||||
export const javascriptRecoveryInputSchema = z.strictObject({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute path to a UTF-8 JavaScript file; never executed"),
|
||||
output_directory: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute absent output directory; parent must exist"),
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute path to a UTF-8 JavaScript file; never executed",
|
||||
),
|
||||
output_directory: localPathStringSchema.describe(
|
||||
"Absolute absent output directory; parent must exist",
|
||||
),
|
||||
extraction_mode: z
|
||||
.enum(["structural", "heuristic", "inspection"])
|
||||
.default("structural")
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { isAbsolute, relative, sep } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Preserve selected filesystem names while rejecting OS-invalid NUL bytes. */
|
||||
export const localPathStringSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[^\0]*$/u, "Local filesystem paths cannot contain NUL");
|
||||
|
||||
/** True when the value is an absolute filesystem path on the host platform. */
|
||||
export const isAbsoluteLocalPath = (value: string): boolean =>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
const hexSchema = z.string().regex(/^0x[0-9a-f]+$/u);
|
||||
/** Objective-C class names and selectors never contain spaces or brackets. */
|
||||
const objcName = z
|
||||
const nativeString = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[^\0]*$/u,
|
||||
"Native launch and breakpoint strings cannot contain NUL",
|
||||
);
|
||||
/** Objective-C class names and selectors never contain spaces or brackets. */
|
||||
const objcName = nativeString
|
||||
.min(1)
|
||||
.regex(/^[^\s[\]]+$/u, "Use the bare name without spaces or brackets");
|
||||
|
||||
const functionBreakpoint = {
|
||||
kind: z.literal("function"),
|
||||
name: z
|
||||
.string()
|
||||
name: nativeString
|
||||
.min(1)
|
||||
.describe(
|
||||
"Exact symbol name, such as `open`, a C++ or mangled Swift symbol, or `-[NSString length]`.",
|
||||
@@ -28,8 +33,7 @@ const objcMethodBreakpoint = {
|
||||
export const nativeCallBreakpointSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
...functionBreakpoint,
|
||||
module: z
|
||||
.string()
|
||||
module: nativeString
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -51,7 +55,7 @@ export const nativeCallBreakpointSchema = z.discriminatedUnion("kind", [
|
||||
*/
|
||||
export const nativeCallObservationInputSchema = z.strictObject({
|
||||
breakpoints: z.array(nativeCallBreakpointSchema).min(1),
|
||||
arguments: z.array(z.string()).default([]),
|
||||
arguments: z.array(nativeString).default([]),
|
||||
environment: z
|
||||
.record(
|
||||
z
|
||||
@@ -62,7 +66,7 @@ export const nativeCallObservationInputSchema = z.strictObject({
|
||||
)
|
||||
.default({})
|
||||
.describe("Overrides on top of the environment REA runs with."),
|
||||
working_directory: z.string().min(1).optional(),
|
||||
working_directory: localPathStringSchema.optional(),
|
||||
duration_ms: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { processScenarioSchema } from "./processScenario.js";
|
||||
|
||||
describe("direct process scenarios", () => {
|
||||
it("defaults to one command with inherited environment and no filesystem scan", () => {
|
||||
expect(processScenarioSchema.parse({ executable: "node" })).toMatchObject({
|
||||
executable: "node",
|
||||
arguments: [],
|
||||
working_directory: ".",
|
||||
environment: {},
|
||||
filesystem_observation_paths: [],
|
||||
events: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts caller-selected environment and explicit filesystem observations", () => {
|
||||
const environment = { "app.setting": "value", "1": "numbered" };
|
||||
const scenario = processScenarioSchema.parse({
|
||||
executable: "node",
|
||||
arguments: ["./task.mjs"],
|
||||
working_directory: "./project",
|
||||
environment,
|
||||
filesystem_observation_paths: ["state"],
|
||||
});
|
||||
expect(scenario.environment).toEqual(environment);
|
||||
expect(scenario.filesystem_observation_paths).toEqual(["state"]);
|
||||
});
|
||||
|
||||
it("rejects malformed environment names and adapter-reserved identity", () => {
|
||||
for (const name of ["", "KEY=VALUE", "KEY\0VALUE", "REA_PROCESS_RUN_ID"]) {
|
||||
const result = processScenarioSchema.safeParse({
|
||||
executable: "node",
|
||||
environment: { [name]: "value" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (name === "" && !result.success) {
|
||||
const invalidKey = result.error.issues[0];
|
||||
expect(invalidKey?.code).toBe("invalid_key");
|
||||
if (invalidKey?.code === "invalid_key") {
|
||||
expect(invalidKey.issues[0]?.message).toBe(
|
||||
"Environment names must not be empty",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (name === "REA_PROCESS_RUN_ID" && !result.success) {
|
||||
const invalidKey = result.error.issues[0];
|
||||
expect(invalidKey?.code).toBe("invalid_key");
|
||||
if (invalidKey?.code === "invalid_key") {
|
||||
expect(invalidKey.issues[0]?.message).toBe(
|
||||
"REA_PROCESS_RUN_ID is reserved by the process adapter",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
processScenarioSchema.safeParse({
|
||||
executable: "node",
|
||||
environment: { "REA_PROCESS_RUN_ID\n": "caller-value" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects NUL in strings passed to child-process APIs", () => {
|
||||
const nul = "\0";
|
||||
for (const input of [
|
||||
{ executable: `node${nul}` },
|
||||
{ executable: "node", arguments: [nul] },
|
||||
{ executable: "node", working_directory: `.${nul}` },
|
||||
{ executable: "node", environment: { KEY: nul } },
|
||||
{ executable: "node", filesystem_observation_paths: [nul] },
|
||||
])
|
||||
expect(processScenarioSchema.safeParse(input).success).toBe(false);
|
||||
expect(
|
||||
processScenarioSchema.safeParse({
|
||||
executable: "node",
|
||||
events: [{ type: "input", at_ms: 0, data: nul }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps bounded capture controls and ordered scheduled interaction", () => {
|
||||
const scenario = processScenarioSchema.parse({
|
||||
executable: "node",
|
||||
events: [
|
||||
{ type: "input", at_ms: 10, data: "answer", sensitive: true },
|
||||
{ type: "resize", at_ms: 20, columns: 100, rows: 30 },
|
||||
{ type: "signal", at_ms: 30, signal: "SIGTERM" },
|
||||
],
|
||||
limits: { output_bytes: 2_000_000, filesystem_depth: 0 },
|
||||
});
|
||||
expect(scenario.events).toHaveLength(3);
|
||||
expect(scenario.events[0]).toMatchObject({ sensitive: true });
|
||||
expect(scenario.limits.output_bytes).toBe(2_000_000);
|
||||
expect(scenario.limits.filesystem_depth).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects events after timeout and unknown orchestration fields", () => {
|
||||
expect(
|
||||
processScenarioSchema.safeParse({
|
||||
executable: "node",
|
||||
timeout_ms: 10,
|
||||
events: [{ type: "input", at_ms: 11, data: "late" }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
for (const [field, value] of [
|
||||
["command_shims", []],
|
||||
["replay", {}],
|
||||
["reactive", {}],
|
||||
["checkpoints", []],
|
||||
] as const)
|
||||
expect(
|
||||
processScenarioSchema.safeParse({ executable: "node", [field]: value })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,7 @@ export const processComparisonContract = (
|
||||
* Defaults are part of the evidence contract and must remain deterministic.
|
||||
*/
|
||||
export const processScenarioSchema = z
|
||||
.object({
|
||||
.strictObject({
|
||||
executable: childProcessString.min(1),
|
||||
arguments: z.array(childProcessString).default([]),
|
||||
working_directory: childProcessString.default("."),
|
||||
@@ -100,7 +100,7 @@ export const processScenarioSchema = z
|
||||
.array(childProcessString.min(1))
|
||||
.default([]),
|
||||
terminal: z
|
||||
.object({
|
||||
.strictObject({
|
||||
columns: z.number().int().min(1).max(65_535).default(80),
|
||||
rows: z.number().int().min(1).max(65_535).default(24),
|
||||
scrollback: z.number().int().min(0).default(1_000),
|
||||
@@ -109,19 +109,19 @@ export const processScenarioSchema = z
|
||||
events: z
|
||||
.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
z.strictObject({
|
||||
...timedEventBase,
|
||||
type: z.literal("input"),
|
||||
data: z.string(),
|
||||
sensitive: z.boolean().default(false),
|
||||
}),
|
||||
z.object({
|
||||
z.strictObject({
|
||||
...timedEventBase,
|
||||
type: z.literal("resize"),
|
||||
columns: z.number().int().min(1).max(65_535),
|
||||
rows: z.number().int().min(1).max(65_535),
|
||||
}),
|
||||
z.object({
|
||||
z.strictObject({
|
||||
...timedEventBase,
|
||||
type: z.literal("signal"),
|
||||
signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]),
|
||||
@@ -133,7 +133,7 @@ export const processScenarioSchema = z
|
||||
idle_timeout_ms: positiveBudget.default(30_000),
|
||||
settle_ms: z.number().int().safe().nonnegative().default(100),
|
||||
limits: z
|
||||
.object({
|
||||
.strictObject({
|
||||
output_bytes: positiveBudget.default(1_000_000),
|
||||
files: positiveBudget.default(10_000),
|
||||
file_bytes: positiveBudget.default(10_000_000),
|
||||
@@ -149,15 +149,19 @@ export const processScenarioSchema = z
|
||||
filesystem_depth: 16,
|
||||
}),
|
||||
normalization: z
|
||||
.object({
|
||||
.strictObject({
|
||||
paths: z.boolean().default(false),
|
||||
pids: z.boolean().default(true),
|
||||
ports: z.boolean().default(true),
|
||||
time_bucket_ms: positiveBudget.default(10),
|
||||
patterns: z
|
||||
.array(
|
||||
z.object({
|
||||
pattern: z.string(),
|
||||
z.strictObject({
|
||||
pattern: z
|
||||
.string()
|
||||
.describe(
|
||||
"Literal text to replace in captured output; not a regular expression.",
|
||||
),
|
||||
replacement: z.string(),
|
||||
}),
|
||||
)
|
||||
@@ -171,7 +175,6 @@ export const processScenarioSchema = z
|
||||
patterns: [],
|
||||
}),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((scenario, context) => {
|
||||
for (let index = 1; index < scenario.events.length; index += 1) {
|
||||
const event = scenario.events[index];
|
||||
|
||||
@@ -322,10 +322,6 @@ describe("function reconstruction malformed source IDs", () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"same singular source on both sides",
|
||||
{ ...singular, right_evidence_id: left.evidence_id },
|
||||
],
|
||||
[
|
||||
"duplicate left plural source",
|
||||
{ ...plural, left_evidence_ids: [left.evidence_id, left.evidence_id] },
|
||||
@@ -334,17 +330,9 @@ describe("function reconstruction malformed source IDs", () => {
|
||||
"duplicate right plural source",
|
||||
{ ...plural, right_evidence_ids: [right.evidence_id, right.evidence_id] },
|
||||
],
|
||||
[
|
||||
"same plural source on both sides",
|
||||
{ ...plural, right_evidence_ids: [left.evidence_id] },
|
||||
],
|
||||
[
|
||||
"overlapping plural sides",
|
||||
{ ...plural, right_evidence_ids: [right.evidence_id, left.evidence_id] },
|
||||
],
|
||||
])("rejects %s with the existing uniqueness guard", (_name, parameters) => {
|
||||
])("rejects %s", (_name, parameters) => {
|
||||
expect(admittedVerification(comparison(parameters))).toThrow(
|
||||
/unique and two-sided/u,
|
||||
/repeats a source within one side/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { artifactComparisonResultSchema } from "./artifactComparison.js";
|
||||
import { artifactInventoryResultSchema } from "./artifactGraph.js";
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
import { comparisonSourceEvidenceSides } from "./comparisonSourceEvidence.js";
|
||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { parseEvidenceBundle } from "./evidenceBundle.js";
|
||||
import { functionComparisonResultSchema } from "./functionComparison.js";
|
||||
@@ -36,13 +37,10 @@ import {
|
||||
} from "./reconstructionUnknowns.js";
|
||||
import type { ResidualUnknown } from "./residualUnknown.js";
|
||||
|
||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||
|
||||
export {
|
||||
reconstructionVerificationInputSchema,
|
||||
reconstructionVerificationResultSchema,
|
||||
} from "./reconstructionVerificationSchemas.js";
|
||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
export type { ReconstructionVerificationResult } from "./reconstructionVerificationSchemas.js";
|
||||
|
||||
const providers = {
|
||||
@@ -130,12 +128,8 @@ const evaluateClaim = (
|
||||
`Missing comparison Evidence ${claim.comparison_evidence_id}`,
|
||||
);
|
||||
validateComparisonIdentity(claim, comparison);
|
||||
const sides = sourceSides(claim, comparison);
|
||||
const allSourceIds = [...sides.left, ...sides.right];
|
||||
if (new Set(allSourceIds).size !== allSourceIds.length)
|
||||
throw new TypeError(
|
||||
"Comparison source Evidence must be unique and two-sided",
|
||||
);
|
||||
const sides = comparisonSourceEvidenceSides(comparison);
|
||||
const allSourceIds = uniqueSorted([...sides.left, ...sides.right]);
|
||||
if (!sameSet(comparison.evidence_links, allSourceIds))
|
||||
throw new TypeError(
|
||||
"Comparison Evidence closure disagrees with its parameters",
|
||||
@@ -224,52 +218,6 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => {
|
||||
throw new TypeError("Comparison Evidence identity or authority disagrees");
|
||||
};
|
||||
|
||||
const sourceSides = (
|
||||
claim: Claim,
|
||||
evidence: Evidence,
|
||||
): { left: string[]; right: string[] } => {
|
||||
const parameters = evidence.parameters;
|
||||
let singularSides: { left: string[]; right: string[] } | undefined;
|
||||
if (
|
||||
claim.kind === "behavioral" ||
|
||||
(claim.kind === "structural-function" &&
|
||||
("left_evidence_id" in parameters || "right_evidence_id" in parameters))
|
||||
) {
|
||||
const parsed = z
|
||||
.object({
|
||||
left_evidence_id: evidenceIdSchema,
|
||||
right_evidence_id: evidenceIdSchema,
|
||||
})
|
||||
.passthrough()
|
||||
.parse(parameters);
|
||||
singularSides = {
|
||||
left: [parsed.left_evidence_id],
|
||||
right: [parsed.right_evidence_id],
|
||||
};
|
||||
if (
|
||||
claim.kind === "behavioral" ||
|
||||
!("left_evidence_ids" in parameters || "right_evidence_ids" in parameters)
|
||||
)
|
||||
return singularSides;
|
||||
}
|
||||
const parsed = z
|
||||
.object({
|
||||
left_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
right_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
})
|
||||
.passthrough()
|
||||
.parse(parameters);
|
||||
if (
|
||||
singularSides !== undefined &&
|
||||
(parsed.left_evidence_ids.length !== 1 ||
|
||||
parsed.right_evidence_ids.length !== 1 ||
|
||||
parsed.left_evidence_ids[0] !== singularSides.left[0] ||
|
||||
parsed.right_evidence_ids[0] !== singularSides.right[0])
|
||||
)
|
||||
throw new TypeError("Function comparison source Evidence aliases disagree");
|
||||
return { left: parsed.left_evidence_ids, right: parsed.right_evidence_ids };
|
||||
};
|
||||
|
||||
const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => {
|
||||
if (claim.kind === "behavioral") {
|
||||
const result = processCaptureComparisonSchema.parse(
|
||||
@@ -335,6 +283,10 @@ const authorityLimitations = (
|
||||
const requiredAuthority =
|
||||
claim.kind === "behavioral" ? "controlled-replay" : "shipped-artifact";
|
||||
const limitations: string[] = [];
|
||||
if (sides.left.some((id) => sides.right.includes(id)))
|
||||
limitations.push(
|
||||
"A source observation appears on both comparison sides; independent original and reconstruction observations are unavailable.",
|
||||
);
|
||||
if (
|
||||
sources.some(
|
||||
(source) =>
|
||||
@@ -393,5 +345,5 @@ const count = (
|
||||
status: ClaimResult["status"],
|
||||
): number => items.filter((item) => item.status === status).length;
|
||||
const sameSet = (left: readonly string[], right: readonly string[]): boolean =>
|
||||
left.length === right.length &&
|
||||
uniqueSorted(left).length === uniqueSorted(right).length &&
|
||||
uniqueSorted(left).every((id, index) => id === uniqueSorted(right)[index]);
|
||||
|
||||
@@ -89,7 +89,7 @@ export const reconstructionClaimResultSchema = z.object({
|
||||
comparison_evidence_id: evidenceIdSchema,
|
||||
left_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
right_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2),
|
||||
unknown_ids: z.array(unknownIdSchema),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
@@ -118,7 +118,7 @@ export const reconstructionVerificationResultSchema = z
|
||||
unknown_ids: z.array(unknownIdSchema),
|
||||
}),
|
||||
),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
|
||||
import { artifactComparisonResultSchema } from "./artifactComparison.js";
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js";
|
||||
import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { functionComparisonResultSchema } from "./functionComparison.js";
|
||||
import {
|
||||
@@ -392,66 +393,22 @@ const assertComparisonIdentity = (evidence: Evidence): void => {
|
||||
evidence.provider.version !== "1") ||
|
||||
evidence.confidence !== "derived" ||
|
||||
evidence.authority !== "analyst-inference" ||
|
||||
evidence.subject !== null ||
|
||||
evidence.evidence_links.length < 2 ||
|
||||
new Set(evidence.evidence_links).size !== evidence.evidence_links.length
|
||||
evidence.subject !== null
|
||||
)
|
||||
throw new TypeError(
|
||||
"Comparison Evidence operation, predicate, provider, or citations disagree",
|
||||
);
|
||||
const parameterLinks = comparisonParameterLinks(evidence);
|
||||
if (!sameSet(parameterLinks, evidence.evidence_links))
|
||||
const expectedLinks = comparisonSourceEvidenceIds(evidence);
|
||||
const actualLinks = uniqueSorted(evidence.evidence_links);
|
||||
if (
|
||||
expectedLinks.length !== actualLinks.length ||
|
||||
expectedLinks.some((id, index) => id !== actualLinks[index])
|
||||
)
|
||||
throw new TypeError(
|
||||
"Comparison Evidence closure disagrees with its source parameters",
|
||||
);
|
||||
};
|
||||
|
||||
const comparisonParameterLinks = (evidence: Evidence): string[] => {
|
||||
if (evidence.operation === "compare_process_captures") {
|
||||
const parameters = z
|
||||
.object({
|
||||
left_evidence_id: evidenceIdSchema,
|
||||
right_evidence_id: evidenceIdSchema,
|
||||
})
|
||||
.passthrough()
|
||||
.parse(evidence.parameters);
|
||||
return [parameters.left_evidence_id, parameters.right_evidence_id];
|
||||
}
|
||||
const parameters = z
|
||||
.object({
|
||||
left_evidence_id: evidenceIdSchema.optional(),
|
||||
right_evidence_id: evidenceIdSchema.optional(),
|
||||
left_evidence_ids: z.array(evidenceIdSchema).min(1).optional(),
|
||||
right_evidence_ids: z.array(evidenceIdSchema).min(1).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.parse(evidence.parameters);
|
||||
const left =
|
||||
parameters.left_evidence_id === undefined
|
||||
? (parameters.left_evidence_ids ?? [])
|
||||
: [parameters.left_evidence_id];
|
||||
const right =
|
||||
parameters.right_evidence_id === undefined
|
||||
? (parameters.right_evidence_ids ?? [])
|
||||
: [parameters.right_evidence_id];
|
||||
if (left.length === 0 || right.length === 0)
|
||||
throw new TypeError("Comparison Evidence omitted its source parameters");
|
||||
return [...left, ...right];
|
||||
};
|
||||
|
||||
const sameSet = (
|
||||
left: readonly string[],
|
||||
right: readonly string[],
|
||||
): boolean => {
|
||||
if (left.length !== right.length) return false;
|
||||
const rightSet = new Set(right);
|
||||
return (
|
||||
rightSet.size === right.length &&
|
||||
new Set(left).size === left.length &&
|
||||
left.every((item) => rightSet.has(item))
|
||||
);
|
||||
};
|
||||
|
||||
const observedPattern = (
|
||||
staticStatus: ComparisonStatus,
|
||||
runtimeStatus: ComparisonStatus,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
|
||||
describe("process capture error projection", () => {
|
||||
it("reports incomplete cleanup resources without exposing its cause", () => {
|
||||
const projected = projectAnalysisError(
|
||||
new ProcessCaptureError("terminal cleanup failed", {
|
||||
cause: new Error("secret-token"),
|
||||
reason: "cleanup_incomplete",
|
||||
cleanupResources: ["process_group"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(projected).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: { cleanup: "incomplete", resources: ["process_group"] },
|
||||
});
|
||||
expect(JSON.stringify(projected)).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("projects caller cancellation as completed cleanup", () => {
|
||||
expect(projectAnalysisError(processCaptureCancelled())).toMatchObject({
|
||||
code: "cancelled",
|
||||
category: "cancelled",
|
||||
details: { operation: "process_capture", cleanup: "complete" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,14 +31,14 @@ export class ProcessCaptureError extends AnalysisError {
|
||||
|
||||
constructor(message: string, options?: ProcessCaptureErrorOptions) {
|
||||
super(message, options);
|
||||
this.userMessage = options?.userMessage;
|
||||
this.userMessage = options?.userMessage ?? message;
|
||||
this.userCategory = options?.userCategory;
|
||||
this.reason =
|
||||
options?.reason ??
|
||||
(options?.userCategory === "cancelled" ? "cancelled" : "capture_failed");
|
||||
this.cleanupIncomplete = this.reason === "cleanup_incomplete";
|
||||
this.cleanupResources = options?.cleanupResources ?? [];
|
||||
this.executionFailure = options?.executionFailure;
|
||||
this.executionFailure = options?.executionFailure ?? message;
|
||||
this.partialObservation = options?.partialObservation;
|
||||
this.cleanupReport = options?.cleanupReport;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,12 @@ export const registerArtifactComparisonTool = (
|
||||
recordDerivedEvidence(
|
||||
session,
|
||||
evidence,
|
||||
artifactUnknownInput(left, right, comparison.status),
|
||||
artifactUnknownInput(
|
||||
left,
|
||||
right,
|
||||
comparison.status,
|
||||
evidence.evidence_id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -67,16 +72,21 @@ const artifactUnknownInput = (
|
||||
left: { readonly evidence_id: string },
|
||||
right: { readonly evidence_id: string },
|
||||
status: ReturnType<typeof compareArtifacts>["status"],
|
||||
comparisonEvidenceId: string,
|
||||
): RecordUnknownInput | undefined => {
|
||||
if (status === "unchanged" || left.evidence_id === right.evidence_id)
|
||||
return undefined;
|
||||
if (status === "unchanged") return undefined;
|
||||
const contradictory =
|
||||
(status === "changed" || status === "contradiction") &&
|
||||
left.evidence_id !== right.evidence_id;
|
||||
return {
|
||||
question: `Artifact comparison is ${status}`,
|
||||
question: `Artifact comparison is ${status} (comparison ${comparisonEvidenceId})`,
|
||||
severity:
|
||||
status === "unknown" || status === "truncated" ? "high" : "medium",
|
||||
domain: "artifact-comparison",
|
||||
supporting_evidence_ids: [left.evidence_id],
|
||||
contradicting_evidence_ids: [right.evidence_id],
|
||||
supporting_evidence_ids: contradictory
|
||||
? [left.evidence_id]
|
||||
: [...new Set([left.evidence_id, right.evidence_id])],
|
||||
contradicting_evidence_ids: contradictory ? [right.evidence_id] : [],
|
||||
required_authority: "shipped-artifact",
|
||||
required_confidence: "observed",
|
||||
required_environment: null,
|
||||
|
||||
@@ -87,6 +87,7 @@ export const registerFunctionComparisonTool = (
|
||||
status: comparison.status,
|
||||
leftIds,
|
||||
rightIds,
|
||||
comparisonEvidenceId: evidence.evidence_id,
|
||||
}),
|
||||
);
|
||||
return toEvidenceToolResult(evidence, contract, recorded);
|
||||
@@ -98,18 +99,23 @@ const functionUnknownInput = ({
|
||||
status,
|
||||
leftIds,
|
||||
rightIds,
|
||||
comparisonEvidenceId,
|
||||
}: {
|
||||
status: ReturnType<typeof compareFunctions>["status"];
|
||||
leftIds: readonly string[];
|
||||
rightIds: readonly string[];
|
||||
comparisonEvidenceId: string;
|
||||
}): RecordUnknownInput | undefined => {
|
||||
if (status === "unchanged") return undefined;
|
||||
const supportingIds = [...new Set([...leftIds, ...rightIds])];
|
||||
const contradictingIds = rightIds.filter((id) => !leftIds.includes(id));
|
||||
const contradictory = status === "changed";
|
||||
return {
|
||||
question: `Function comparison is ${status}`,
|
||||
question: `Function comparison is ${status} (comparison ${comparisonEvidenceId})`,
|
||||
severity: status === "changed" ? "medium" : "high",
|
||||
domain: "function-comparison",
|
||||
supporting_evidence_ids: [...leftIds],
|
||||
contradicting_evidence_ids: [...rightIds],
|
||||
supporting_evidence_ids: contradictory ? [...leftIds] : supportingIds,
|
||||
contradicting_evidence_ids: contradictory ? contradictingIds : [],
|
||||
required_authority: "shipped-artifact",
|
||||
required_confidence: "observed",
|
||||
required_environment: null,
|
||||
|
||||
@@ -182,14 +182,19 @@ const comparisonUnknownInput = (
|
||||
.filter(([, status]) => status !== "unchanged")
|
||||
.map(([scope]) => scope)
|
||||
.join(", ");
|
||||
const contradictory =
|
||||
comparison.status === "changed" &&
|
||||
parsed.left_evidence_id !== parsed.right_evidence_id;
|
||||
// Unknown identity uses the question, not its supporting records. Bind the
|
||||
// question to this comparison so distinct capture pairs and policies coexist.
|
||||
return {
|
||||
question: `Process captures disagree across: ${differingScopes} (comparison ${parsed.comparison_evidence_id})`,
|
||||
question: `${contradictory ? "Process captures disagree" : `Process comparison is ${comparison.status}`} across: ${differingScopes} (comparison ${parsed.comparison_evidence_id})`,
|
||||
severity: "high",
|
||||
domain: "process-comparison",
|
||||
supporting_evidence_ids: [parsed.left_evidence_id],
|
||||
contradicting_evidence_ids: [parsed.right_evidence_id],
|
||||
supporting_evidence_ids: contradictory
|
||||
? [parsed.left_evidence_id]
|
||||
: [...new Set([parsed.left_evidence_id, parsed.right_evidence_id])],
|
||||
contradicting_evidence_ids: contradictory ? [parsed.right_evidence_id] : [],
|
||||
required_authority: "controlled-replay",
|
||||
required_confidence: "observed",
|
||||
required_environment: null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runDerivedOperation } from "./runDerivedOperation.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
|
||||
const context = (
|
||||
controller: AbortController,
|
||||
@@ -84,7 +85,28 @@ describe("derived MCP operation boundary", () => {
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
operation: "compare_artifacts",
|
||||
issues: [
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_value",
|
||||
message: "malformed comparison evidence",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a tagged integrity failure instead of replacing it with invalid input", async () => {
|
||||
const failure = new EvidenceIntegrityError(
|
||||
"Source Evidence does not match its digest",
|
||||
);
|
||||
const result = await runDerivedOperation(
|
||||
context(new AbortController()),
|
||||
"compare_artifacts",
|
||||
() => {
|
||||
throw failure;
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ ok: false, error: failure });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ServerContext } from "@modelcontextprotocol/server";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { describeValidationFailure } from "../domain/evidenceBundle.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { mcpProgressReporter, type McpProgressContext } from "./mcpProgress.js";
|
||||
|
||||
@@ -18,7 +21,7 @@ export const runDerivedOperation = async <Value>(
|
||||
context: DerivedOperationContext,
|
||||
operation: string,
|
||||
compute: () => Value,
|
||||
): Promise<Result<Value, AnalysisCancelledError | AnalysisInputError>> => {
|
||||
): Promise<Result<Value, AnalysisError>> => {
|
||||
const progress = mcpProgressReporter(context);
|
||||
await progress.report({
|
||||
phase: "prepare",
|
||||
@@ -34,11 +37,21 @@ export const runDerivedOperation = async <Value>(
|
||||
value = compute();
|
||||
} catch (cause: unknown) {
|
||||
// Typed causes already name the failed constraint; keep their issues.
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
cause instanceof AnalysisInputError
|
||||
? cause
|
||||
: new AnalysisInputError(operation, { cause }),
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{ cause },
|
||||
cause instanceof TypeError || cause instanceof z.ZodError
|
||||
? [
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_value",
|
||||
message: describeValidationFailure(cause),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
await progress.report({
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../../src/domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
|
||||
const connect = async () => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Evidence comparison must not launch a provider");
|
||||
});
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "comparison-unknowns", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return { client, session };
|
||||
};
|
||||
|
||||
const observeAgain = (
|
||||
source: Evidence,
|
||||
observation: number,
|
||||
result = source.normalized_result,
|
||||
) =>
|
||||
createEvidence(
|
||||
source.subject === null
|
||||
? undefined
|
||||
: {
|
||||
path: `/tmp/fixture-${observation}`,
|
||||
sha256: source.subject.digest.sha256,
|
||||
format: source.subject.format,
|
||||
},
|
||||
source.provider,
|
||||
{
|
||||
predicateType: source.predicate_type,
|
||||
operation: source.operation,
|
||||
parameters: { observation },
|
||||
result,
|
||||
confidence: source.confidence,
|
||||
authority: source.authority,
|
||||
},
|
||||
);
|
||||
|
||||
it("retains one open unknown for an incomplete artifact compared with itself", async () => {
|
||||
const { client, session } = await connect();
|
||||
const source = ARTIFACT_COMPARISON_EXAMPLE.left;
|
||||
const result = jsonValueSchema.parse(source.normalized_result);
|
||||
if (result === null || Array.isArray(result) || typeof result !== "object")
|
||||
throw new Error("Expected an inventory object");
|
||||
const incomplete = observeAgain(source, 1, {
|
||||
...result,
|
||||
nodes: [],
|
||||
occurrences: [],
|
||||
});
|
||||
const response = await client.callTool({
|
||||
name: "compare_artifacts",
|
||||
arguments: { left: incomplete, right: incomplete },
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
result: { status: "truncated" },
|
||||
});
|
||||
expect(session.listUnknowns({ domain: "artifact-comparison" })).toMatchObject(
|
||||
[
|
||||
{
|
||||
status: "open",
|
||||
supporting_evidence_ids: [incomplete.evidence_id],
|
||||
contradicting_evidence_ids: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
tool: "compare_artifacts",
|
||||
domain: "artifact-comparison",
|
||||
pair: ARTIFACT_COMPARISON_EXAMPLE,
|
||||
},
|
||||
{
|
||||
tool: "compare_functions",
|
||||
domain: "function-comparison",
|
||||
pair: FUNCTION_COMPARISON_EXAMPLE,
|
||||
},
|
||||
])(
|
||||
"keeps independent $domain observations and repeated comparisons idempotent",
|
||||
async ({ tool, domain, pair }) => {
|
||||
const { client, session } = await connect();
|
||||
const right = observeAgain(pair.right, 2);
|
||||
for (const source of [pair.right, right]) {
|
||||
const response = await client.callTool({
|
||||
name: tool,
|
||||
arguments: { left: pair.left, right: source },
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
result: { status: "changed" },
|
||||
});
|
||||
}
|
||||
expect(session.listUnknowns({ domain })).toHaveLength(2);
|
||||
const before = session.exportEvidenceBundle();
|
||||
const repeated = await client.callTool({
|
||||
name: tool,
|
||||
arguments: { left: pair.left, right },
|
||||
});
|
||||
expect(repeated.isError, JSON.stringify(repeated)).not.toBe(true);
|
||||
const compared = parseEvidence(
|
||||
z.object({ evidence: z.unknown() }).parse(repeated.structuredContent)
|
||||
.evidence,
|
||||
);
|
||||
expect(session.evidenceById(compared.evidence_id)).toEqual(compared);
|
||||
expect(session.exportEvidenceBundle()).toEqual(before);
|
||||
},
|
||||
);
|
||||
@@ -70,6 +70,14 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
expect(JSON.stringify(spanning.content)).toContain(
|
||||
"Each Swift symbol must be one line.",
|
||||
);
|
||||
const invalidNativeString = await client.callTool({
|
||||
name: "demangle_swift",
|
||||
arguments: { symbols: ["$s4main\0FooV"] },
|
||||
});
|
||||
expect(invalidNativeString.isError).toBe(true);
|
||||
expect(JSON.stringify(invalidNativeString.content)).toContain(
|
||||
"Swift symbols cannot contain NUL.",
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { itWithCaptureCapability } from "./processCaptureCapability.js";
|
||||
|
||||
import { captureProcessScenario } from "../../../src/process/capture/ProcessHarness.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { snapshotRoots } from "../../../src/process/capture/FilesystemSnapshot.js";
|
||||
import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js";
|
||||
import {
|
||||
@@ -264,6 +265,13 @@ itWithCaptureCapability(
|
||||
expect(result.error.executionFailure).toBe(
|
||||
"fixture final snapshot failure",
|
||||
);
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: { cleanup: "incomplete", resources: ["owned_process_group"] },
|
||||
});
|
||||
expect(projected).not.toHaveProperty("stack");
|
||||
expect(projected).not.toHaveProperty("cause");
|
||||
expect(partial).toBeDefined();
|
||||
if (partial === undefined || !("observations" in partial))
|
||||
throw new Error("expected incomplete process observations");
|
||||
@@ -489,6 +497,11 @@ itWithCaptureCapability(
|
||||
);
|
||||
|
||||
if (result.ok) throw new Error("expected initial snapshot cancellation");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
category: "cancelled",
|
||||
details: { operation: "process_capture", cleanup: "complete" },
|
||||
});
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "cancelled",
|
||||
userCategory: "cancelled",
|
||||
|
||||
Reference in New Issue
Block a user