refactor(tools): return complete analysis results inline
This commit is contained in:
parent
c34efca4cd
commit
2a6eb41147
|
|
@ -5,9 +5,7 @@ const {
|
|||
} = require("./electron-active-hook-boundaries.cjs");
|
||||
|
||||
const MAX_EVENT_NAME_LENGTH = 128;
|
||||
const MAX_CHANNEL_LENGTH = 1_024;
|
||||
const MAX_IDENTIFIER_LENGTH = 256;
|
||||
const MAX_ARGUMENT_SHAPES = 32;
|
||||
const ipcEventKinds = new Set([
|
||||
"main-handler-invocation",
|
||||
"main-event-invocation",
|
||||
|
|
@ -101,14 +99,12 @@ const record = (event) => {
|
|||
event: null,
|
||||
phase: "observed",
|
||||
channel: null,
|
||||
channel_truncated: false,
|
||||
direction: null,
|
||||
sender: null,
|
||||
receiver: null,
|
||||
frame: null,
|
||||
target: null,
|
||||
argument_shapes: [],
|
||||
argument_shapes_truncated: false,
|
||||
result_shape: null,
|
||||
process_type: processType(),
|
||||
source: "electron-active-hook",
|
||||
|
|
@ -125,21 +121,14 @@ const record = (event) => {
|
|||
...raw,
|
||||
correlation_id: boundedString(raw.correlation_id, MAX_IDENTIFIER_LENGTH),
|
||||
event: boundedString(raw.event, MAX_EVENT_NAME_LENGTH),
|
||||
channel: boundedString(raw.channel, MAX_CHANNEL_LENGTH),
|
||||
channel_truncated:
|
||||
raw.channel_truncated === true ||
|
||||
(typeof raw.channel === "string" &&
|
||||
raw.channel.length > MAX_CHANNEL_LENGTH),
|
||||
channel: typeof raw.channel === "string" ? raw.channel : null,
|
||||
sender: boundedString(raw.sender, MAX_IDENTIFIER_LENGTH),
|
||||
receiver: boundedString(raw.receiver, MAX_IDENTIFIER_LENGTH),
|
||||
frame: boundedString(raw.frame, MAX_IDENTIFIER_LENGTH),
|
||||
target: boundedString(raw.target, MAX_IDENTIFIER_LENGTH),
|
||||
argument_shapes: argumentShapes
|
||||
.slice(0, MAX_ARGUMENT_SHAPES)
|
||||
.map((value) => boundedString(value, 64) ?? "unknown"),
|
||||
argument_shapes_truncated:
|
||||
raw.argument_shapes_truncated === true ||
|
||||
argumentShapes.length > MAX_ARGUMENT_SHAPES,
|
||||
argument_shapes: argumentShapes.map(
|
||||
(value) => boundedString(value, 64) ?? "unknown",
|
||||
),
|
||||
result_shape: boundedString(raw.result_shape, 64),
|
||||
process_type: boundedString(raw.process_type, MAX_IDENTIFIER_LENGTH),
|
||||
artifact_path: boundedString(raw.artifact_path, 16_384),
|
||||
|
|
@ -157,10 +146,7 @@ const recordIpc = (kind, channel, args, details = {}) =>
|
|||
return record({
|
||||
kind,
|
||||
channel: typeof channel === "string" ? channel : null,
|
||||
argument_shapes: values
|
||||
.slice(0, MAX_ARGUMENT_SHAPES)
|
||||
.map((value) => shape(value)),
|
||||
argument_shapes_truncated: values.length > MAX_ARGUMENT_SHAPES,
|
||||
argument_shapes: values.map((value) => shape(value)),
|
||||
...details,
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -88,12 +88,7 @@ const verifyPackagedElectronApplication = async ({
|
|||
const applicationAnalysis = json(
|
||||
await run(
|
||||
cli,
|
||||
[
|
||||
"analyze-javascript-application",
|
||||
applicationRoot,
|
||||
"--approved",
|
||||
"--json",
|
||||
],
|
||||
["analyze-javascript-application", applicationRoot, "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
|
|
@ -106,11 +101,7 @@ const verifyPackagedElectronApplication = async ({
|
|||
)
|
||||
throw new Error("packaged JavaScript application analysis CLI failed");
|
||||
const routedApplicationAnalysis = json(
|
||||
await run(
|
||||
cli,
|
||||
["analyze", applicationRoot, "--approved", "--json"],
|
||||
environment,
|
||||
),
|
||||
await run(cli, ["analyze", applicationRoot, "--json"], environment),
|
||||
);
|
||||
assertRoutedApplicationAnalysis(routedApplicationAnalysis);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const exec = promisify(execFile);
|
|||
const packedHopperBridge = "package/bridge/hopper_bridge.py";
|
||||
const packedGhidraBridge = "package/bridge/ghidra/ReaGhidraBridge.java";
|
||||
|
||||
/** Verify that the packed production bridge rejects a catastrophic regex. */
|
||||
/** Verify regex search through the packaged production bridge. */
|
||||
export async function verifyPackedBridge({
|
||||
root,
|
||||
workspace,
|
||||
|
|
@ -38,20 +38,32 @@ export async function verifyPackedBridge({
|
|||
join(root, "tests/fixtures/bridgeSearchProbe.py"),
|
||||
join(workspace, packedHopperBridge),
|
||||
JSON.stringify({
|
||||
action: "match",
|
||||
pattern: "(a|aa){1,35}b",
|
||||
value: "a".repeat(35),
|
||||
case_sensitive: true,
|
||||
action: "search",
|
||||
items: [
|
||||
["0x1000", "REA_GHIDRA_INVENTORY_ENTRY"],
|
||||
["0x2000", "unrelated"],
|
||||
["0x3000", "REA_GHIDRA_LEAF_VALUE"],
|
||||
],
|
||||
params: {
|
||||
pattern: "^REA_GHIDRA_(?:INVENTORY_ENTRY|LEAF_VALUE)$",
|
||||
mode: "regex",
|
||||
case_sensitive: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).stdout,
|
||||
);
|
||||
const matches = probe.result;
|
||||
if (
|
||||
probe.ok !== false ||
|
||||
probe.diagnostic_type !== "invalid_request" ||
|
||||
probe.message !== "Regex exceeds the 10000-path backtracking budget"
|
||||
probe.ok !== true ||
|
||||
!Array.isArray(matches) ||
|
||||
matches.length !== 2 ||
|
||||
matches[0]?.address !== "0x1000" ||
|
||||
matches[0]?.value !== "REA_GHIDRA_INVENTORY_ENTRY" ||
|
||||
matches[1]?.address !== "0x3000" ||
|
||||
matches[1]?.value !== "REA_GHIDRA_LEAF_VALUE"
|
||||
)
|
||||
throw new Error(
|
||||
`packaged Hopper bridge omitted regex bounds: ${JSON.stringify(probe)}`,
|
||||
`packaged Hopper bridge regex search drifted: ${JSON.stringify(probe)}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,19 +73,6 @@ async function verifyBridgeRejections(client) {
|
|||
{ document: null, procedure: 17 },
|
||||
"invalid_request",
|
||||
),
|
||||
unboundedRegex: await expectRemoteFailure(
|
||||
client,
|
||||
"search_strings",
|
||||
{
|
||||
pattern: "a+",
|
||||
mode: "regex",
|
||||
case_sensitive: true,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
document: null,
|
||||
},
|
||||
"invalid_request",
|
||||
),
|
||||
undeclaredMutation: await expectRemoteFailure(
|
||||
client,
|
||||
"set_comment",
|
||||
|
|
@ -122,7 +109,7 @@ export async function verifyInventoryOperations({
|
|||
document: null,
|
||||
});
|
||||
if (stringSearch.total !== 2 || stringSearch.items.length !== 2)
|
||||
throw new Error("Ghidra bounded regex string search drifted");
|
||||
throw new Error("Ghidra regex string search drifted");
|
||||
const stringItem = strings.find(
|
||||
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ const browserScenarioParameters = (
|
|||
step_id,
|
||||
action,
|
||||
})),
|
||||
action_timeouts_ms: scenario.actions.map((item) =>
|
||||
"timeout_ms" in item ? (item.timeout_ms ?? null) : null,
|
||||
),
|
||||
secret_declarations: scenario.secrets.map(
|
||||
({ secret_id, environment_variable, purpose, redaction }) => ({
|
||||
secret_id,
|
||||
|
|
@ -30,7 +33,6 @@ const browserScenarioParameters = (
|
|||
}),
|
||||
),
|
||||
capture: scenario.capture,
|
||||
limits: { ...scenario.limits },
|
||||
});
|
||||
|
||||
/** Create Evidence without retaining resolved scenario secret values. */
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ describe("capability inventory contract", () => {
|
|||
{ open: false, capabilities: [] },
|
||||
{
|
||||
processCaptureEnabled: false,
|
||||
evidenceFileRoots: 0,
|
||||
},
|
||||
);
|
||||
const unavailable = inventory.find(({ available }) => !available);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { buildCapabilityInventory } from "./CapabilityInventory.js";
|
|||
|
||||
const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
|
||||
processCaptureEnabled: true,
|
||||
evidenceFileRoots: 1,
|
||||
browserObservationEnabled: true,
|
||||
browserScenarioEnabled: true,
|
||||
electronObservationEnabled: true,
|
||||
|
|
@ -169,7 +168,6 @@ describe("capability inventory: caller guidance", () => {
|
|||
const inventory = buildCapabilityInventory(status(), {
|
||||
...enabledPolicy,
|
||||
processCaptureEnabled: false,
|
||||
evidenceFileRoots: 0,
|
||||
browserObservationEnabled: false,
|
||||
electronObservationEnabled: false,
|
||||
v8InspectorObservationEnabled: false,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
|
|||
type ProviderDescriptor = ProviderCapability;
|
||||
export type AvailabilityPolicy = {
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly evidenceFileRoots: number;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
readonly browserScenarioEnabled?: boolean;
|
||||
readonly electronObservationEnabled?: boolean;
|
||||
|
|
@ -229,11 +228,6 @@ const policyAvailability = ({
|
|||
if (name === "plan_managed_runtime_correlation")
|
||||
return { reason: "available", remediation: null };
|
||||
if (kind === "application") return { reason: "available", remediation: null };
|
||||
if (name === "import_evidence_bundle" && policy.evidenceFileRoots === 0)
|
||||
return {
|
||||
reason: "policy_disabled",
|
||||
remediation: "Configure or grant an evidence_read root.",
|
||||
};
|
||||
const electron = electronPolicyAvailability(name, kind, policy);
|
||||
if (electron !== null) return electron;
|
||||
if (kind === "runtime-provider")
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const electronObservationIdentity = (
|
|||
observation_sha256: sha256Text(
|
||||
`${context.snapshot.manifest.root_sha256}\0${scope}\0${key}`,
|
||||
),
|
||||
scope: scope.slice(0, 4_096),
|
||||
scope,
|
||||
});
|
||||
|
||||
/** Add a direct AST syntax relationship. */
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import type {
|
|||
JavaScriptSourceMapObservation,
|
||||
JavaScriptSourceMapOriginal,
|
||||
} from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import { JAVASCRIPT_APPLICATION_RESOURCE_LIMITS } from "./JavaScriptArtifactReconstructionInput.js";
|
||||
import { analyzeJavaScriptJsonModule } from "./JavaScriptJsonModules.js";
|
||||
|
||||
interface MutableArtifactAnalysis {
|
||||
|
|
@ -44,22 +43,14 @@ interface MutableArtifactAnalysis {
|
|||
|
||||
interface ArtifactAnalysisContext {
|
||||
readonly state: MutableArtifactAnalysis;
|
||||
readonly deadline: number;
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
/** Analyze every selected source under shared AST and parser safety bounds. */
|
||||
/** Analyze every text source from the artifact without a result quota. */
|
||||
export const analyzeJavaScriptArtifactFiles = (
|
||||
fileSet: JavaScriptArtifactFileSet,
|
||||
now: () => number,
|
||||
): JavaScriptArtifactAnalysis => {
|
||||
const state = emptyArtifactAnalysis();
|
||||
const context = {
|
||||
state,
|
||||
deadline:
|
||||
now() + JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxParseMilliseconds,
|
||||
now,
|
||||
};
|
||||
const context = { state };
|
||||
for (const file of fileSet.files) analyzeArtifactFile(file, context);
|
||||
return finalizeArtifactAnalysis(state);
|
||||
};
|
||||
|
|
@ -87,7 +78,7 @@ const finalizeArtifactAnalysis = (
|
|||
"Static paths and relationships may remain unresolved when expressions are dynamic or obfuscated.",
|
||||
...(truncatedScopes === 0
|
||||
? []
|
||||
: ["One or more static-analysis scopes reached an approved bound."]),
|
||||
: ["Some static-analysis scopes were truncated."]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
@ -105,13 +96,6 @@ const analyzeArtifactFile = (
|
|||
state.files.push({ file, javascript: null, semantic: null });
|
||||
return;
|
||||
}
|
||||
const remainingNodes =
|
||||
JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxAstNodes - state.visitedNodes;
|
||||
if (remainingNodes <= 0 || context.now() > context.deadline) {
|
||||
state.files.push({ file, javascript: null, semantic: null });
|
||||
state.truncatedScopes += 1;
|
||||
return;
|
||||
}
|
||||
const parsed = parseJavaScriptSource(file.text.value);
|
||||
if (parsed === null) {
|
||||
const analysis = failedJavaScriptStaticAnalysis();
|
||||
|
|
@ -123,9 +107,9 @@ const analyzeArtifactFile = (
|
|||
file.text.value,
|
||||
parsed,
|
||||
{
|
||||
maxAstNodes: remainingNodes,
|
||||
deadline: context.deadline,
|
||||
now: context.now,
|
||||
maxAstNodes: Number.POSITIVE_INFINITY,
|
||||
deadline: Number.POSITIVE_INFINITY,
|
||||
now: () => 0,
|
||||
},
|
||||
);
|
||||
const staticFindings = findingCount(analysis);
|
||||
|
|
@ -156,10 +140,7 @@ const addSourceMap = (
|
|||
file: JavaScriptArtifactFile,
|
||||
context: ArtifactAnalysisContext,
|
||||
): void => {
|
||||
const sourceMap = parseSourceMap(file, {
|
||||
deadline: context.deadline,
|
||||
now: context.now,
|
||||
});
|
||||
const sourceMap = parseSourceMap(file);
|
||||
context.state.sourceMaps.push(sourceMap);
|
||||
};
|
||||
|
||||
|
|
@ -265,43 +246,23 @@ const htmlBaseHref = (text: string): string | null => {
|
|||
|
||||
const parseSourceMap = (
|
||||
file: JavaScriptArtifactFile,
|
||||
context: {
|
||||
readonly deadline: number;
|
||||
readonly now: () => number;
|
||||
},
|
||||
): JavaScriptSourceMapObservation => {
|
||||
if (!file.text.included) return unavailableSourceMap(file);
|
||||
if (context.now() > context.deadline)
|
||||
return truncatedSourceMap(
|
||||
file,
|
||||
null,
|
||||
"Parse deadline reached before source-map decoding.",
|
||||
);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(file.text.value);
|
||||
} catch {
|
||||
return invalidSourceMap(file, "Source map is not valid JSON.");
|
||||
}
|
||||
if (context.now() > context.deadline)
|
||||
return truncatedSourceMap(
|
||||
file,
|
||||
null,
|
||||
"Parse deadline elapsed during source-map decoding.",
|
||||
);
|
||||
const maps = flattenSourceMaps(value);
|
||||
if (maps === undefined)
|
||||
return invalidSourceMap(file, "Source map is not a bounded version 3 map.");
|
||||
return collectSourceMapOriginals(file, maps, context);
|
||||
return invalidSourceMap(file, "Source map is not a version 3 map.");
|
||||
return collectSourceMapOriginals(file, maps);
|
||||
};
|
||||
|
||||
const collectSourceMapOriginals = (
|
||||
file: JavaScriptArtifactFile,
|
||||
maps: readonly Readonly<Record<string, unknown>>[],
|
||||
context: {
|
||||
readonly deadline: number;
|
||||
readonly now: () => number;
|
||||
},
|
||||
): JavaScriptSourceMapObservation => {
|
||||
const sources: JavaScriptSourceMapOriginal[] = [];
|
||||
for (const map of maps) {
|
||||
|
|
@ -313,15 +274,6 @@ const collectSourceMapOriginals = (
|
|||
: [];
|
||||
const root = typeof map.sourceRoot === "string" ? map.sourceRoot : "";
|
||||
for (const [index, raw] of names.entries()) {
|
||||
if (sources.length % 1_024 === 0 && context.now() > context.deadline)
|
||||
return {
|
||||
path: file.path,
|
||||
sha256: file.sha256,
|
||||
status: "truncated",
|
||||
sources,
|
||||
omitted_sources: null,
|
||||
limitation: "Parse deadline elapsed during source-map traversal.",
|
||||
};
|
||||
if (typeof raw !== "string")
|
||||
return invalidSourceMap(
|
||||
file,
|
||||
|
|
@ -357,8 +309,7 @@ const unavailableSourceMap = (
|
|||
status: "invalid",
|
||||
sources: [],
|
||||
omitted_sources: 0,
|
||||
limitation:
|
||||
"Source-map text was unavailable within the selected input limits.",
|
||||
limitation: "Source-map text could not be decoded as UTF-8.",
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -381,19 +332,6 @@ const flattenSourceMaps = (
|
|||
return maps;
|
||||
};
|
||||
|
||||
const truncatedSourceMap = (
|
||||
file: JavaScriptArtifactFile,
|
||||
omitted: number | null,
|
||||
limitation: string,
|
||||
): JavaScriptSourceMapObservation => ({
|
||||
path: file.path,
|
||||
sha256: file.sha256,
|
||||
status: "truncated",
|
||||
sources: [],
|
||||
omitted_sources: omitted,
|
||||
limitation,
|
||||
});
|
||||
|
||||
const invalidSourceMap = (
|
||||
file: JavaScriptArtifactFile,
|
||||
limitation: string,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
} from "../artifacts/ArtifactReader.js";
|
||||
import { streamChunkToBuffer } from "../artifacts/StreamBytes.js";
|
||||
import type { ArtifactInventorySnapshot } from "./ArtifactInventory.js";
|
||||
import { JAVASCRIPT_APPLICATION_RESOURCE_LIMITS } from "./JavaScriptArtifactReconstructionInput.js";
|
||||
|
||||
/** Relevant file categories projected from the complete artifact inventory. */
|
||||
export type JavaScriptArtifactFileKind =
|
||||
|
|
@ -25,7 +24,7 @@ export type JavaScriptArtifactFileKind =
|
|||
| "source-map"
|
||||
| "native-addon";
|
||||
|
||||
/** One content-addressed local artifact file and its bounded text availability. */
|
||||
/** One content-addressed local artifact file and its text availability. */
|
||||
export interface JavaScriptArtifactFile {
|
||||
readonly path: string;
|
||||
readonly container_sha256: string;
|
||||
|
|
@ -38,11 +37,7 @@ export interface JavaScriptArtifactFile {
|
|||
| { readonly included: true; readonly value: string }
|
||||
| {
|
||||
readonly included: false;
|
||||
readonly reason:
|
||||
| "not-applicable"
|
||||
| "file-limit"
|
||||
| "byte-limit"
|
||||
| "invalid-utf8";
|
||||
readonly reason: "not-applicable" | "invalid-utf8";
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -58,10 +53,7 @@ export interface JavaScriptArtifactContainer {
|
|||
export interface JavaScriptArtifactFileSet {
|
||||
readonly files: readonly JavaScriptArtifactFile[];
|
||||
readonly containers: readonly JavaScriptArtifactContainer[];
|
||||
readonly text_files_selected: number;
|
||||
readonly text_bytes_read: number;
|
||||
readonly omitted_text_files: number;
|
||||
readonly limit_omitted_text_files: number;
|
||||
readonly invalid_utf8_files: number;
|
||||
}
|
||||
|
||||
|
|
@ -73,15 +65,9 @@ interface ExpectedFile {
|
|||
readonly kind: JavaScriptArtifactFileKind;
|
||||
}
|
||||
|
||||
interface Selection {
|
||||
readonly selected: boolean;
|
||||
readonly reason: "file-limit" | "byte-limit" | null;
|
||||
}
|
||||
|
||||
interface ReadContext {
|
||||
readonly expected: ReadonlyMap<string, ExpectedFile>;
|
||||
readonly expectedContainers: ReadonlyMap<string, JavaScriptArtifactContainer>;
|
||||
readonly selections: ReadonlyMap<string, Selection>;
|
||||
readonly registry: ArtifactPathRegistry;
|
||||
readonly files: JavaScriptArtifactFile[];
|
||||
readonly containers: JavaScriptArtifactContainer[];
|
||||
|
|
@ -94,10 +80,9 @@ interface ReadTextInput {
|
|||
readonly reader: ArtifactReader;
|
||||
readonly entry: ArtifactEntry;
|
||||
readonly expected: ExpectedFile;
|
||||
readonly selection: Selection | undefined;
|
||||
}
|
||||
|
||||
/** Read only selected textual entries through an already-inventoried reader. */
|
||||
/** Read all relevant textual entries through an already-inventoried reader. */
|
||||
export const readJavaScriptArtifactFiles = async (
|
||||
reader: ArtifactReader,
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
|
|
@ -105,11 +90,9 @@ export const readJavaScriptArtifactFiles = async (
|
|||
): Promise<JavaScriptArtifactFileSet> => {
|
||||
const inventory = expectedInventory(snapshot);
|
||||
const expected = inventory.files;
|
||||
const selections = selectTextFiles(expected);
|
||||
const context: ReadContext = {
|
||||
expected,
|
||||
expectedContainers: inventory.containers,
|
||||
selections,
|
||||
registry: new ArtifactPathRegistry(),
|
||||
files: [],
|
||||
containers: [],
|
||||
|
|
@ -122,22 +105,12 @@ export const readJavaScriptArtifactFiles = async (
|
|||
compareCodePoints(left.path, right.path),
|
||||
);
|
||||
assertExpectedFilesWereVisited(expected, files);
|
||||
const selected = [...selections.values()].filter(
|
||||
({ selected: isSelected }) => isSelected,
|
||||
).length;
|
||||
return {
|
||||
files,
|
||||
containers: context.containers.sort((left, right) =>
|
||||
compareCodePoints(left.path, right.path),
|
||||
),
|
||||
text_files_selected: selected,
|
||||
text_bytes_read: context.textBytes,
|
||||
omitted_text_files: [...selections.values()].filter(
|
||||
({ selected: isSelected }) => !isSelected,
|
||||
).length,
|
||||
limit_omitted_text_files: [...selections.values()].filter(
|
||||
({ reason }) => reason === "file-limit" || reason === "byte-limit",
|
||||
).length,
|
||||
invalid_utf8_files: context.invalidUtf8,
|
||||
};
|
||||
};
|
||||
|
|
@ -195,12 +168,10 @@ const visitReader = async (
|
|||
}
|
||||
const expected = context.expected.get(path);
|
||||
if (expected === undefined) continue;
|
||||
const selection = context.selections.get(path);
|
||||
const text = await readTextIfSelected(context, {
|
||||
const text = await readText(context, {
|
||||
reader: frame.reader,
|
||||
entry,
|
||||
expected,
|
||||
selection,
|
||||
});
|
||||
context.files.push({
|
||||
path,
|
||||
|
|
@ -238,20 +209,14 @@ const expectedContainer = (
|
|||
return container;
|
||||
};
|
||||
|
||||
const readTextIfSelected = async (
|
||||
const readText = async (
|
||||
context: ReadContext,
|
||||
input: ReadTextInput,
|
||||
): Promise<JavaScriptArtifactFile["text"]> => {
|
||||
if (input.expected.kind === "native-addon")
|
||||
return { included: false, reason: "not-applicable" };
|
||||
if (input.selection?.selected !== true)
|
||||
return {
|
||||
included: false,
|
||||
reason: input.selection?.reason ?? "file-limit",
|
||||
};
|
||||
const bytes = await readBounded(
|
||||
const bytes = await readAll(
|
||||
await input.reader.open(input.entry, context.signal),
|
||||
JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxTextFileBytes,
|
||||
context.signal,
|
||||
);
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
|
|
@ -310,42 +275,8 @@ const expectedInventory = (
|
|||
return { files, containers };
|
||||
};
|
||||
|
||||
const selectTextFiles = (
|
||||
files: ReadonlyMap<string, ExpectedFile>,
|
||||
): ReadonlyMap<string, Selection> => {
|
||||
const selected = new Map<string, Selection>();
|
||||
let fileCount = 0;
|
||||
let bytes = 0;
|
||||
const ordered = [...files.values()].sort((left, right) => {
|
||||
const priority = filePriority(left.kind) - filePriority(right.kind);
|
||||
return priority === 0 ? compareCodePoints(left.path, right.path) : priority;
|
||||
});
|
||||
for (const file of ordered) {
|
||||
if (file.kind === "native-addon") continue;
|
||||
if (
|
||||
fileCount >= JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxTextFiles ||
|
||||
file.bytes > JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxTextFileBytes
|
||||
) {
|
||||
selected.set(file.path, { selected: false, reason: "file-limit" });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
bytes + file.bytes >
|
||||
JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxTotalTextBytes
|
||||
) {
|
||||
selected.set(file.path, { selected: false, reason: "byte-limit" });
|
||||
continue;
|
||||
}
|
||||
selected.set(file.path, { selected: true, reason: null });
|
||||
fileCount += 1;
|
||||
bytes += file.bytes;
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
const readBounded = async (
|
||||
const readAll = async (
|
||||
stream: Readable,
|
||||
maximum: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer> => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
|
@ -354,13 +285,6 @@ const readBounded = async (
|
|||
abortIfNeeded(signal);
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
bytes += chunk.length;
|
||||
if (bytes > maximum) {
|
||||
stream.destroy();
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Artifact entry grew after inventory and exceeded the selected text read size",
|
||||
);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, bytes);
|
||||
|
|
@ -391,23 +315,6 @@ const relevantKind = (path: string): JavaScriptArtifactFileKind | undefined => {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
const filePriority = (kind: JavaScriptArtifactFileKind): number => {
|
||||
switch (kind) {
|
||||
case "package-json":
|
||||
return 0;
|
||||
case "html":
|
||||
return 1;
|
||||
case "javascript":
|
||||
return 2;
|
||||
case "json":
|
||||
return 3;
|
||||
case "source-map":
|
||||
return 4;
|
||||
case "native-addon":
|
||||
return 5;
|
||||
}
|
||||
};
|
||||
|
||||
const isFilesystemAsar = (entry: ArtifactEntry, path: string): boolean =>
|
||||
entry.kind === "file" &&
|
||||
path.toLowerCase().endsWith(".asar") &&
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ import {
|
|||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFileSet } from "./JavaScriptArtifactFiles.js";
|
||||
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
|
||||
import {
|
||||
javaScriptAnalysisLimits,
|
||||
type JavaScriptArtifactGraphContext,
|
||||
} from "./JavaScriptArtifactGraphContext.js";
|
||||
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
|
||||
import {
|
||||
addJavaScriptHtmlRoles,
|
||||
addJavaScriptSourceMapOriginals,
|
||||
|
|
@ -22,7 +19,6 @@ import {
|
|||
import {
|
||||
completeApplicationCoverage,
|
||||
partialApplicationCoverage,
|
||||
truncatedApplicationCoverage,
|
||||
} from "../domain/javascriptApplicationEvidenceSchemas.js";
|
||||
import {
|
||||
addJavaScriptArtifactContainers,
|
||||
|
|
@ -31,14 +27,13 @@ import {
|
|||
addJavaScriptPackageNodes,
|
||||
createJavaScriptArtifactRootNode,
|
||||
} from "./JavaScriptArtifactGraphStructure.js";
|
||||
import { JAVASCRIPT_APPLICATION_RESOURCE_LIMITS } from "./JavaScriptArtifactReconstructionInput.js";
|
||||
import { addElectronBoundaries } from "./ElectronBoundaryGraph.js";
|
||||
import {
|
||||
classifyElectronIpcPairings,
|
||||
collectElectronIpcRecords,
|
||||
} from "./ElectronBoundaryAnalysis.js";
|
||||
|
||||
/** Project bounded artifact and AST facts into JavaScript Application Graph. */
|
||||
/** Project artifact and AST facts into JavaScript Application Graph. */
|
||||
export const buildJavaScriptArtifactGraph = (
|
||||
snapshot: ArtifactInventorySnapshot,
|
||||
fileSet: JavaScriptArtifactFileSet,
|
||||
|
|
@ -85,7 +80,6 @@ export const buildJavaScriptArtifactGraph = (
|
|||
};
|
||||
|
||||
const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
|
||||
const exactLimitOmissions = context.fileSet.limit_omitted_text_files;
|
||||
const sourceMapPolicyGap = context.analysis.source_maps.some(
|
||||
({ status }) => status === "invalid",
|
||||
);
|
||||
|
|
@ -104,40 +98,9 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
|
|||
malformedStructuredData ||
|
||||
partialJavaScript;
|
||||
if (context.analysis.truncated_scopes > 0)
|
||||
return truncatedApplicationCoverage(
|
||||
reconstructionLimits(),
|
||||
truncationOmittedCount(context),
|
||||
);
|
||||
if (exactLimitOmissions > 0)
|
||||
return truncatedApplicationCoverage(
|
||||
reconstructionLimits(),
|
||||
exactLimitOmissions,
|
||||
);
|
||||
if (unknownGap)
|
||||
return partialApplicationCoverage(reconstructionLimits(), null);
|
||||
return completeApplicationCoverage(reconstructionLimits());
|
||||
};
|
||||
|
||||
const truncationOmittedCount = (
|
||||
context: JavaScriptArtifactGraphContext,
|
||||
): number | null => {
|
||||
const staticTruncations = context.analysis.files.filter(
|
||||
({ javascript }) => javascript?.parse_status === "truncated",
|
||||
);
|
||||
if (staticTruncations.length > 0) return null;
|
||||
const semanticTruncations = context.analysis.files.flatMap(({ semantic }) =>
|
||||
semantic?.ir.coverage.status === "truncated" ? [semantic.ir.coverage] : [],
|
||||
);
|
||||
const sourceMapTruncations = context.analysis.source_maps.filter(
|
||||
({ status }) => status === "truncated",
|
||||
);
|
||||
if (sourceMapTruncations.length > 0) return null;
|
||||
if (semanticTruncations.length !== context.analysis.truncated_scopes)
|
||||
return null;
|
||||
const omissions = semanticTruncations.map(({ omittedCount }) => omittedCount);
|
||||
return omissions.some((omitted) => omitted === null)
|
||||
? null
|
||||
: omissions.reduce<number>((total, omitted) => total + (omitted ?? 0), 0);
|
||||
return partialApplicationCoverage([], null);
|
||||
if (unknownGap) return partialApplicationCoverage([], null);
|
||||
return completeApplicationCoverage();
|
||||
};
|
||||
|
||||
const graphLimitations = (
|
||||
|
|
@ -202,17 +165,3 @@ const graphLimitations = (
|
|||
]),
|
||||
];
|
||||
};
|
||||
|
||||
const reconstructionLimits = () => [
|
||||
{
|
||||
name: "max-text-files",
|
||||
value: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxTextFiles,
|
||||
unit: "items" as const,
|
||||
},
|
||||
{
|
||||
name: "max-total-text-bytes",
|
||||
value: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxTotalTextBytes,
|
||||
unit: "bytes" as const,
|
||||
},
|
||||
...javaScriptAnalysisLimits(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { ArtifactInventorySnapshot } from "./ArtifactInventory.js";
|
|||
import {
|
||||
completeApplicationCoverage,
|
||||
partialApplicationCoverage,
|
||||
truncatedApplicationCoverage,
|
||||
type ApplicationGraphEvidence,
|
||||
} from "../domain/javascriptApplicationEvidenceSchemas.js";
|
||||
import type { ApplicationNode } from "../domain/javascriptApplicationGraph.js";
|
||||
|
|
@ -214,10 +213,8 @@ export const javascriptAnalysisCoverage = (
|
|||
>,
|
||||
): JavaScriptArtifactGraphCoverage =>
|
||||
analysis.parse_status === "complete"
|
||||
? completeApplicationCoverage(javaScriptAnalysisLimits())
|
||||
: analysis.parse_status === "truncated"
|
||||
? truncatedApplicationCoverage(javaScriptAnalysisLimits(), null)
|
||||
: partialApplicationCoverage(javaScriptAnalysisLimits(), null);
|
||||
? completeApplicationCoverage()
|
||||
: partialApplicationCoverage([], null);
|
||||
|
||||
/** Stable identity for a fact scoped to one exact artifact. */
|
||||
export const artifactLocalIdentity = (
|
||||
|
|
@ -228,8 +225,8 @@ export const artifactLocalIdentity = (
|
|||
strategy: "artifact-local-key" as const,
|
||||
stability: "artifact-version" as const,
|
||||
artifact_sha256: sha256,
|
||||
namespace: namespace.slice(0, 512),
|
||||
key: key.slice(0, 4_096),
|
||||
namespace,
|
||||
key,
|
||||
});
|
||||
|
||||
/** Select the graph node kind for one inventoried relevant file. */
|
||||
|
|
@ -344,17 +341,3 @@ export const sourceNodeFor = (
|
|||
moduleKey === null
|
||||
? context.sourceModuleNodes.get(path)
|
||||
: context.moduleNodes.get(moduleLookupKey(path, moduleKey));
|
||||
|
||||
/** Actual parser safety bounds committed to graph evidence coverage. */
|
||||
export const javaScriptAnalysisLimits = () => [
|
||||
{
|
||||
name: "max-ast-nodes",
|
||||
value: 2_000_000,
|
||||
unit: "items" as const,
|
||||
},
|
||||
{
|
||||
name: "max-parse-milliseconds",
|
||||
value: 30_000,
|
||||
unit: "milliseconds" as const,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ import {
|
|||
import { artifactObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
|
||||
import {
|
||||
completeApplicationCoverage,
|
||||
truncatedApplicationCoverage,
|
||||
partialApplicationCoverage,
|
||||
} from "../domain/javascriptApplicationEvidenceSchemas.js";
|
||||
import { javaScriptAnalysisLimits } from "./JavaScriptArtifactGraphContext.js";
|
||||
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
|
||||
|
||||
/** Project HTML renderer entrypoints and their local script assets. */
|
||||
|
|
@ -94,10 +93,7 @@ export const addJavaScriptSourceMapOriginals = (
|
|||
if (mapFile === undefined || mapNode === undefined) continue;
|
||||
const coverage =
|
||||
sourceMap.status === "truncated"
|
||||
? truncatedApplicationCoverage(
|
||||
javaScriptAnalysisLimits(),
|
||||
sourceMap.omitted_sources,
|
||||
)
|
||||
? partialApplicationCoverage([], null)
|
||||
: completeApplicationCoverage();
|
||||
for (const original of sourceMap.sources) {
|
||||
const node = context.accumulator.addNode({
|
||||
|
|
|
|||
|
|
@ -5,8 +5,32 @@ import {
|
|||
type ResolveArtifactPathInput,
|
||||
} from "./JavaScriptArtifactPathResolution.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js";
|
||||
import { applicationNodeIdentitySchema } from "../domain/javascriptApplicationEvidenceSchemas.js";
|
||||
import { analyzeJavaScriptStaticSource } from "../domain/javascriptStaticAnalysis.js";
|
||||
|
||||
describe("artifact-local graph identity", () => {
|
||||
it("preserves complete long namespace and key values", () => {
|
||||
const digest = "a".repeat(64);
|
||||
const namespacePrefix = "n".repeat(512);
|
||||
const keyPrefix = "k".repeat(4_096);
|
||||
const first = artifactLocalIdentity(
|
||||
digest,
|
||||
`${namespacePrefix}a`,
|
||||
`${keyPrefix}a`,
|
||||
);
|
||||
const second = artifactLocalIdentity(
|
||||
digest,
|
||||
`${namespacePrefix}b`,
|
||||
`${keyPrefix}b`,
|
||||
);
|
||||
|
||||
expect(applicationNodeIdentitySchema.parse(first)).toEqual(first);
|
||||
expect(applicationNodeIdentitySchema.parse(second)).toEqual(second);
|
||||
expect(first).not.toEqual(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contextual JavaScript artifact path resolution", () => {
|
||||
it.each([
|
||||
["main.js", "app/main.js"],
|
||||
|
|
@ -249,7 +273,7 @@ describe("contextual JavaScript module identity", () => {
|
|||
window.loadURL("file:///renderer/index.html");
|
||||
`,
|
||||
{
|
||||
maxAstNodes: 10_000,
|
||||
maxAstNodes: Number.POSITIVE_INFINITY,
|
||||
deadline: Number.POSITIVE_INFINITY,
|
||||
now: () => 0,
|
||||
},
|
||||
|
|
@ -315,6 +339,6 @@ const file = (
|
|||
unpacked: false,
|
||||
text:
|
||||
text === null
|
||||
? { included: false, reason: "file-limit" }
|
||||
? { included: false, reason: "invalid-utf8" }
|
||||
: { included: true, value: text },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { lstat, realpath } from "node:fs/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
import { AsarArtifactReader } from "../artifacts/AsarArtifactReader.js";
|
||||
import {
|
||||
|
|
@ -34,10 +33,7 @@ export interface JavaScriptArtifactReconstructionResult {
|
|||
readonly statistics: {
|
||||
readonly relevant_files: number;
|
||||
readonly nested_asar_containers: number;
|
||||
readonly text_files_selected: number;
|
||||
readonly text_bytes_read: number;
|
||||
readonly omitted_text_files: number;
|
||||
readonly limit_omitted_text_files: number;
|
||||
readonly invalid_utf8_files: number;
|
||||
readonly parsed_javascript_files: number;
|
||||
readonly visited_ast_nodes: number;
|
||||
|
|
@ -67,9 +63,7 @@ export const reconstructJavaScriptArtifact = async (
|
|||
const reader = createReader(path, format);
|
||||
try {
|
||||
const files = await readJavaScriptArtifactFiles(reader, snapshot, signal);
|
||||
const analysis = analyzeJavaScriptArtifactFiles(files, () =>
|
||||
performance.now(),
|
||||
);
|
||||
const analysis = analyzeJavaScriptArtifactFiles(files);
|
||||
abortIfNeeded(signal);
|
||||
const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis);
|
||||
const semanticGraph = buildJavaScriptSemanticGraph({
|
||||
|
|
@ -89,10 +83,7 @@ export const reconstructJavaScriptArtifact = async (
|
|||
statistics: {
|
||||
relevant_files: files.files.length,
|
||||
nested_asar_containers: files.containers.length,
|
||||
text_files_selected: files.text_files_selected,
|
||||
text_bytes_read: files.text_bytes_read,
|
||||
omitted_text_files: files.omitted_text_files,
|
||||
limit_omitted_text_files: files.limit_omitted_text_files,
|
||||
invalid_utf8_files: files.invalid_utf8_files,
|
||||
parsed_javascript_files: analysis.files.filter(
|
||||
({ javascript }) => javascript !== null,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/** Resource safeguards for local artifact traversal and static parsing. */
|
||||
export const JAVASCRIPT_APPLICATION_RESOURCE_LIMITS = {
|
||||
maxTextFiles: 5_000,
|
||||
maxTotalTextBytes: 128 * 1_024 * 1_024,
|
||||
maxTextFileBytes: 8 * 1_024 * 1_024,
|
||||
maxAstNodes: 2_000_000,
|
||||
maxParseMilliseconds: 30_000,
|
||||
} as const;
|
||||
|
||||
/** Local ASAR/directory reconstruction request. */
|
||||
export const javascriptArtifactReconstructionInputSchema = z.strictObject({
|
||||
input_path: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -179,7 +179,6 @@ export const prepareReplayPlan = async (
|
|||
: {
|
||||
path: input.reproducer_export.path,
|
||||
include_sources: input.reproducer_export.include_sources,
|
||||
authority: "evidence_write" as const,
|
||||
},
|
||||
};
|
||||
const publicPlan: ReplayPlan = {
|
||||
|
|
|
|||
|
|
@ -105,11 +105,7 @@ export const runControlledReplayValidated = async (
|
|||
),
|
||||
);
|
||||
|
||||
const exportContext = await authorizeReproducerExport(
|
||||
dependencies.authority,
|
||||
input,
|
||||
prepared.value,
|
||||
);
|
||||
const exportContext = await authorizeReproducerExport(input);
|
||||
if (!exportContext.ok) return exportContext;
|
||||
|
||||
const executed = await executeValidatedReplay({
|
||||
|
|
@ -201,45 +197,12 @@ interface ReproducerExportContext {
|
|||
readonly includeSources: boolean;
|
||||
}
|
||||
|
||||
const authorizeReproducerExport = async (
|
||||
authority: PermissionAuthority | undefined,
|
||||
const authorizeReproducerExport = (
|
||||
input: ControlledReplayExecutionInput,
|
||||
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
|
||||
): Promise<Result<ReproducerExportContext | undefined, AnalysisError>> => {
|
||||
): Result<ReproducerExportContext | undefined, AnalysisError> => {
|
||||
if (input.reproducer_export === undefined) return ok(undefined);
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-javascript-replay",
|
||||
OPERATION,
|
||||
"JavaScript replay permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const exportAuthorized = await authority.authorize(
|
||||
{
|
||||
capability: "evidence_write",
|
||||
roots: [input.reproducer_export.path],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: `${OPERATION}:reproducer:${prepared.publicPlan.plan_digest}`,
|
||||
},
|
||||
"write",
|
||||
);
|
||||
if (!exportAuthorized.ok)
|
||||
return err(projectPermissionFailure(exportAuthorized.error));
|
||||
const canonicalExportPath = exportAuthorized.value.request.roots[0];
|
||||
if (canonicalExportPath !== input.reproducer_export.path)
|
||||
return err(
|
||||
new AnalysisInputError(`${OPERATION}:reproducer_export:path`, {
|
||||
cause: new TypeError(
|
||||
`Use the canonical reproducer path: ${canonicalExportPath ?? "unavailable"}`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
return ok({
|
||||
path: canonicalExportPath,
|
||||
path: input.reproducer_export.path,
|
||||
includeSources: input.reproducer_export.include_sources === true,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { access, realpath } from "node:fs/promises";
|
|||
import { delimiter, join } from "node:path";
|
||||
|
||||
const TOOL_NAMES = ["node", "npm", "npx"] as const;
|
||||
const MAX_CANDIDATES_PER_TOOL = 16;
|
||||
const MAX_OUTPUT_BYTES = 65_536;
|
||||
const MAX_DIAGNOSTIC_BYTES = 4_096;
|
||||
const PROBE_CONCURRENCY = 4;
|
||||
|
|
@ -142,7 +141,6 @@ const discoverToolCandidates = async (
|
|||
break;
|
||||
}
|
||||
}
|
||||
if (candidates.length >= MAX_CANDIDATES_PER_TOOL) break;
|
||||
}
|
||||
return candidates;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||
import { BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioValues.js";
|
||||
|
||||
const secret = (secret_id: string) => ({ source: "secret", secret_id });
|
||||
const literal = (value: string) => ({
|
||||
|
|
@ -120,7 +119,7 @@ const baseScenario = () => ({
|
|||
});
|
||||
|
||||
describe("browserScenarioSchema", () => {
|
||||
it("accepts and normalizes a bounded declared scenario", () => {
|
||||
it("accepts and normalizes a declared scenario", () => {
|
||||
const parsed = browserScenarioSchema.parse(baseScenario());
|
||||
expect(parsed.allowed_origins).toEqual([
|
||||
"https://api.example.test",
|
||||
|
|
@ -150,10 +149,23 @@ describe("browserScenarioSchema", () => {
|
|||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
]);
|
||||
expect(parsed.limits).toEqual(BROWSER_SCENARIO_CAPTURE_LIMITS);
|
||||
expect(parsed).not.toHaveProperty("limits");
|
||||
});
|
||||
|
||||
it("rejects caller supplied capture limits", () => {
|
||||
it("accepts caller selected action timeouts without a fixed ceiling", () => {
|
||||
const input = baseScenario();
|
||||
input.actions[0] = { ...input.actions[0], timeout_ms: 120_000 } as never;
|
||||
input.actions[1] = {
|
||||
step_id: "pause",
|
||||
action: "wait_for_timeout",
|
||||
duration_ms: 120_001,
|
||||
} as never;
|
||||
const actions = browserScenarioSchema.parse(input).actions;
|
||||
expect(actions[0]).toMatchObject({ timeout_ms: 120_000 });
|
||||
expect(actions[1]).toMatchObject({ duration_ms: 120_001 });
|
||||
});
|
||||
|
||||
it("does not accept the removed provider timeout configuration", () => {
|
||||
expect(
|
||||
browserScenarioSchema.safeParse({
|
||||
...baseScenario(),
|
||||
|
|
|
|||
|
|
@ -24,14 +24,9 @@ export interface BrowserScenarioSessionPort {
|
|||
readonly dropped: number;
|
||||
readonly items: readonly BrowserScenarioEvent[];
|
||||
};
|
||||
perform(
|
||||
action: BrowserScenarioAction,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void>;
|
||||
perform(action: BrowserScenarioAction, signal?: AbortSignal): Promise<void>;
|
||||
capture(
|
||||
requested: ReadonlySet<SnapshotKind>,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserStepArtifacts>;
|
||||
close(): Promise<"terminated-owned-process" | "disconnected-external">;
|
||||
|
|
@ -43,7 +38,6 @@ export interface BrowserScenarioSessionFactory {
|
|||
scenario: BrowserScenario,
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly deadlineAt?: number;
|
||||
},
|
||||
): Promise<BrowserScenarioSessionPort>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { delayWithCancellation } from "./CdpCaptureValues.js";
|
||||
|
||||
describe("cancellable browser observation delays", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("schedules durations beyond the native timer range in consecutive waits", async () => {
|
||||
vi.useFakeTimers();
|
||||
let completed = false;
|
||||
const waiting = delayWithCancellation(
|
||||
2_147_483_648,
|
||||
"observe_javascript_runtime",
|
||||
).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(2_147_483_647);
|
||||
expect(completed).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await waiting;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -78,19 +78,24 @@ export const delayWithCancellation = async (
|
|||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
if (durationMs === 0) return;
|
||||
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, durationMs);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(new AnalysisCancelledError(operation));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) onAbort();
|
||||
});
|
||||
const maximumTimerDelay = 2_147_483_647;
|
||||
let remaining = durationMs;
|
||||
while (remaining > 0) {
|
||||
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||
const delay = Math.min(remaining, maximumTimerDelay);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, delay);
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(new AnalysisCancelledError(operation));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) onAbort();
|
||||
});
|
||||
remaining -= delay;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import WebSocket, { type RawData } from "ws";
|
|||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisError,
|
||||
AnalysisTimeoutError,
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
|
|
@ -17,14 +16,10 @@ export interface CdpEvent {
|
|||
interface PendingCommand {
|
||||
readonly resolve: (value: unknown) => void;
|
||||
readonly reject: (error: AnalysisError) => void;
|
||||
readonly timer: ReturnType<typeof setTimeout>;
|
||||
readonly removeAbort: () => void;
|
||||
}
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 5_000;
|
||||
const COMMAND_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Correlated, bounded JSON-RPC transport for one CDP target operation. */
|
||||
/** Correlated JSON-RPC transport for one CDP target operation. */
|
||||
export class CdpConnection {
|
||||
readonly #pending = new Map<number, PendingCommand>();
|
||||
readonly #listeners = new Set<(event: CdpEvent) => void>();
|
||||
|
|
@ -50,7 +45,7 @@ export class CdpConnection {
|
|||
): Promise<CdpConnection> {
|
||||
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||
const socket = new WebSocket(url, {
|
||||
handshakeTimeout: CONNECT_TIMEOUT_MS,
|
||||
handshakeTimeout: 0,
|
||||
maxPayload: 0,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
|
|
@ -74,7 +69,7 @@ export class CdpConnection {
|
|||
return () => this.#disconnectListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Execute one bounded command, optionally within a flat target session. */
|
||||
/** Execute one command, optionally within a flat target session. */
|
||||
async send(
|
||||
method: string,
|
||||
params: Readonly<Record<string, unknown>> = {},
|
||||
|
|
@ -93,21 +88,14 @@ export class CdpConnection {
|
|||
const onAbort = (): void => {
|
||||
const pending = this.#pending.get(id);
|
||||
if (pending === undefined) return;
|
||||
clearTimeout(pending.timer);
|
||||
pending.removeAbort();
|
||||
this.#pending.delete(id);
|
||||
reject(new AnalysisCancelledError(this.operation));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
this.#pending.delete(id);
|
||||
reject(new AnalysisTimeoutError(this.operation, COMMAND_TIMEOUT_MS));
|
||||
}, COMMAND_TIMEOUT_MS);
|
||||
this.#pending.set(id, {
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
removeAbort: () => signal?.removeEventListener("abort", onAbort),
|
||||
});
|
||||
this.socket.send(
|
||||
|
|
@ -208,7 +196,6 @@ export class CdpConnection {
|
|||
}
|
||||
|
||||
#complete(id: number, pending: PendingCommand): void {
|
||||
clearTimeout(pending.timer);
|
||||
pending.removeAbort();
|
||||
this.#pending.delete(id);
|
||||
}
|
||||
|
|
@ -247,11 +234,6 @@ const waitForOpen = async (
|
|||
signal?: AbortSignal,
|
||||
): Promise<void> =>
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
terminateSocket(socket);
|
||||
reject(new AnalysisTimeoutError(operation, CONNECT_TIMEOUT_MS));
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
const onOpen = (): void => {
|
||||
cleanup();
|
||||
resolve();
|
||||
|
|
@ -266,18 +248,26 @@ const waitForOpen = async (
|
|||
}),
|
||||
);
|
||||
};
|
||||
const onClose = (code: number, reason: Buffer): void => {
|
||||
onFailure(
|
||||
new Error(
|
||||
`CDP WebSocket closed before opening (${code}: ${reason.toString("utf8")})`,
|
||||
),
|
||||
);
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
terminateSocket(socket);
|
||||
onFailure(new AnalysisCancelledError(operation));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer);
|
||||
socket.off("open", onOpen);
|
||||
socket.off("error", onFailure);
|
||||
socket.off("close", onClose);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
socket.once("open", onOpen);
|
||||
socket.once("error", onFailure);
|
||||
socket.once("close", onClose);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) onAbort();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@ import { z } from "zod";
|
|||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisError,
|
||||
AnalysisTimeoutError,
|
||||
BrowserObservationError,
|
||||
type BrowserObservationOperation,
|
||||
} from "../domain/errors.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
|
||||
const HTTP_TIMEOUT_MS = 5_000;
|
||||
|
||||
const endpointVersionSchema = z.object({
|
||||
Browser: z.string().min(1),
|
||||
"Protocol-Version": z.string().min(1),
|
||||
|
|
@ -323,9 +320,6 @@ export const readCdpJson = async (
|
|||
);
|
||||
},
|
||||
);
|
||||
request_.setTimeout(HTTP_TIMEOUT_MS, () =>
|
||||
request_.destroy(new AnalysisTimeoutError(operation, HTTP_TIMEOUT_MS)),
|
||||
);
|
||||
request_.on("error", (cause: unknown) =>
|
||||
reject(endpointFailure(cause, operation, signal)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -143,11 +143,7 @@ class FakeSession implements BrowserScenarioSessionPort {
|
|||
return { retained: 0, dropped: 0, items: [] };
|
||||
}
|
||||
|
||||
async perform(
|
||||
_action: BrowserScenarioAction,
|
||||
_timeout: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
async perform(_action: BrowserScenarioAction, signal?: AbortSignal) {
|
||||
this.performCalls += 1;
|
||||
this.onPerform?.();
|
||||
if (signal?.aborted === true) throw new Error("request cancelled");
|
||||
|
|
@ -156,7 +152,6 @@ class FakeSession implements BrowserScenarioSessionPort {
|
|||
|
||||
capture(
|
||||
requested: ReadonlySet<SnapshotKind>,
|
||||
_maximumTimeoutMs: number,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<BrowserStepArtifacts> {
|
||||
this.captureCalls += 1;
|
||||
|
|
|
|||
|
|
@ -134,13 +134,11 @@ const initialStep = async (input: {
|
|||
readonly session: BrowserScenarioSessionPort;
|
||||
readonly scenario: BrowserScenario;
|
||||
readonly elapsedMs: number;
|
||||
readonly maximumTimeoutMs: number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}): Promise<BrowserScenarioStep> => {
|
||||
const { session, scenario, elapsedMs, maximumTimeoutMs, signal } = input;
|
||||
const { session, scenario, elapsedMs, signal } = input;
|
||||
const artifacts = await session.capture(
|
||||
requestedForStep(scenario, 0),
|
||||
maximumTimeoutMs,
|
||||
signal,
|
||||
);
|
||||
return createStep({
|
||||
|
|
@ -163,19 +161,10 @@ const executeStep = async (input: {
|
|||
readonly scenario: BrowserScenario;
|
||||
readonly action: BrowserScenarioAction;
|
||||
readonly stepIndex: number;
|
||||
readonly startedAt: number;
|
||||
readonly priorFailure: boolean;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}): Promise<BrowserScenarioStep> => {
|
||||
const {
|
||||
session,
|
||||
scenario,
|
||||
action,
|
||||
stepIndex,
|
||||
startedAt,
|
||||
priorFailure,
|
||||
signal,
|
||||
} = input;
|
||||
const { session, scenario, action, stepIndex, priorFailure, signal } = input;
|
||||
const beforeUrl = session.currentUrl();
|
||||
const eventStart = session.nextEventSequence();
|
||||
const requested = requestedForStep(scenario, stepIndex);
|
||||
|
|
@ -208,10 +197,7 @@ const executeStep = async (input: {
|
|||
outcome = { status: "cancelled", error: "scenario request cancelled" };
|
||||
} else {
|
||||
try {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const remaining = scenario.limits.max_duration_ms - elapsed;
|
||||
if (remaining <= 0) throw new Error("Scenario duration limit reached");
|
||||
await session.perform(action, remaining, signal);
|
||||
await session.perform(action, signal);
|
||||
} catch (cause: unknown) {
|
||||
outcome = {
|
||||
status: requestCancelled() ? "cancelled" : "failed",
|
||||
|
|
@ -219,16 +205,10 @@ const executeStep = async (input: {
|
|||
};
|
||||
}
|
||||
}
|
||||
const remaining = scenario.limits.max_duration_ms - (Date.now() - startedAt);
|
||||
const artifacts =
|
||||
outcome.status === "cancelled" || remaining <= 0
|
||||
? unavailableArtifacts(
|
||||
requested,
|
||||
outcome.status === "cancelled"
|
||||
? "scenario request cancelled"
|
||||
: "scenario duration limit reached",
|
||||
)
|
||||
: await session.capture(requested, remaining, signal);
|
||||
outcome.status === "cancelled"
|
||||
? unavailableArtifacts(requested, "scenario request cancelled")
|
||||
: await session.capture(requested, signal);
|
||||
return createStep({
|
||||
stepIndex,
|
||||
stepId: action.step_id,
|
||||
|
|
@ -266,10 +246,10 @@ const runScenario = async (
|
|||
if (options.signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
const startedAt = Date.now();
|
||||
const session = await factory.open(scenario, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
deadlineAt: startedAt + scenario.limits.max_duration_ms,
|
||||
});
|
||||
const session = await factory.open(
|
||||
scenario,
|
||||
options.signal === undefined ? {} : { signal: options.signal },
|
||||
);
|
||||
const steps: BrowserScenarioStep[] = [];
|
||||
let cleanup: "terminated-owned-process" | "disconnected-external" | undefined;
|
||||
try {
|
||||
|
|
@ -278,8 +258,6 @@ const runScenario = async (
|
|||
session,
|
||||
scenario,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
maximumTimeoutMs:
|
||||
scenario.limits.max_duration_ms - (Date.now() - startedAt),
|
||||
signal: options.signal,
|
||||
}),
|
||||
);
|
||||
|
|
@ -290,7 +268,6 @@ const runScenario = async (
|
|||
scenario,
|
||||
action,
|
||||
stepIndex: offset + 1,
|
||||
startedAt,
|
||||
priorFailure: failed,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,23 +61,46 @@ it("rejects non-absolute deep-link values", () => {
|
|||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts any argument count while validating each argument", () => {
|
||||
it("keeps long Electron arguments and deep-link URLs intact", () => {
|
||||
const longArgument = "x".repeat(5_000);
|
||||
const longUrl = `rea-fixture://open/${"item".repeat(2_000)}`;
|
||||
const longStepId = "step".repeat(100);
|
||||
const input = electronActiveObservationInputSchema.parse({
|
||||
executable_path: "/opt/electron",
|
||||
application_path: "/opt/app/main.js",
|
||||
application_root: "/opt/app",
|
||||
args: Array.from({ length: 40 }, (_, index) => `--flag-${index}`),
|
||||
args: [longArgument],
|
||||
actions: [
|
||||
{
|
||||
step_id: longStepId,
|
||||
kind: "deep-link",
|
||||
delivery: "open-url",
|
||||
url: longUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(input.args).toHaveLength(40);
|
||||
expect(input.args).toEqual([longArgument]);
|
||||
expect(input.actions[0]).toMatchObject({
|
||||
step_id: longStepId,
|
||||
url: longUrl,
|
||||
});
|
||||
expect(
|
||||
electronActiveObservationInputSchema.safeParse({
|
||||
executable_path: "/opt/electron",
|
||||
application_path: "/opt/app/main.js",
|
||||
application_root: "/opt/app",
|
||||
args: ["x".repeat(4_097)],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
electronActiveObservationResultSchema.parse({
|
||||
...createElectronActiveObservationFixtureResult("/opt/app"),
|
||||
actions: [
|
||||
{
|
||||
step_id: longStepId,
|
||||
kind: "deep-link",
|
||||
window_index: null,
|
||||
target: null,
|
||||
status: "completed",
|
||||
elapsed_ms: 0,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}).actions[0]?.step_id,
|
||||
).toBe(longStepId);
|
||||
});
|
||||
|
||||
it("redacts action inputs from Playwright failures", async () => {
|
||||
|
|
@ -114,6 +137,131 @@ it("redacts action inputs from Playwright failures", async () => {
|
|||
expect(result[0]?.error).not.toContain("raw-secret");
|
||||
});
|
||||
|
||||
it("retains complete action errors and Electron window text", async () => {
|
||||
const longError = `locator failed ${"detail ".repeat(300)}`;
|
||||
const input = electronActiveObservationInputSchema.parse({
|
||||
executable_path: "/opt/electron",
|
||||
application_path: "/opt/app/main.js",
|
||||
application_root: "/opt/app",
|
||||
actions: [{ step_id: "click-step", kind: "click", selector: "#run" }],
|
||||
});
|
||||
const page = {
|
||||
locator: () => ({
|
||||
click: async () => Promise.reject(new Error(longError)),
|
||||
}),
|
||||
};
|
||||
const actions = await runElectronActions(
|
||||
{ windows: () => [page] } as never,
|
||||
input,
|
||||
{},
|
||||
Date.now() + 1_000,
|
||||
);
|
||||
const result = createElectronActiveObservationFixtureResult("/opt/app");
|
||||
const longUrl = `file://${"/segment".repeat(10_000)}`;
|
||||
const longTitle = "title".repeat(4_000);
|
||||
|
||||
expect(actions[0]?.error).toBe(longError);
|
||||
expect(
|
||||
electronActiveObservationResultSchema.parse({
|
||||
...result,
|
||||
windows: [{ ...result.windows[0], url: longUrl, title: longTitle }],
|
||||
actions: [
|
||||
{
|
||||
step_id: "click-step",
|
||||
kind: "click",
|
||||
window_index: 0,
|
||||
target: "window:0",
|
||||
status: "failed",
|
||||
elapsed_ms: 1,
|
||||
error: longError,
|
||||
},
|
||||
],
|
||||
}).windows[0],
|
||||
).toMatchObject({ url: longUrl, title: longTitle });
|
||||
});
|
||||
|
||||
it("accepts complete Electron input and observation strings", () => {
|
||||
const longText = "evidence-".repeat(3_000);
|
||||
const longPath = `/opt/${"directory/".repeat(2_000)}app`;
|
||||
const input = electronActiveObservationInputSchema.parse({
|
||||
executable_path: longPath,
|
||||
application_path: `${longPath}/main.js`,
|
||||
application_root: longPath,
|
||||
actions: [
|
||||
{ step_id: longText, kind: "click", selector: longText },
|
||||
{
|
||||
step_id: longText,
|
||||
kind: "deep-link",
|
||||
delivery: "open-url",
|
||||
url: `custom://${longText}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = createElectronActiveObservationFixtureResult("/opt/app");
|
||||
const event = {
|
||||
...fixture.ipc.events[0],
|
||||
correlation_id: longText,
|
||||
event: longText,
|
||||
channel: longText,
|
||||
sender: longText,
|
||||
receiver: longText,
|
||||
frame: longText,
|
||||
target: longText,
|
||||
result_shape: longText,
|
||||
process_type: longText,
|
||||
source: longText,
|
||||
artifact_path: longPath,
|
||||
};
|
||||
const result = electronActiveObservationResultSchema.parse({
|
||||
...fixture,
|
||||
application: {
|
||||
...fixture.application,
|
||||
application_path: longPath,
|
||||
electron_version: longText,
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
...fixture.actions[0],
|
||||
step_id: longText,
|
||||
target: longText,
|
||||
error: longText,
|
||||
status: "failed",
|
||||
},
|
||||
],
|
||||
windows: [
|
||||
{
|
||||
...fixture.windows[0],
|
||||
window_id: longText,
|
||||
web_contents_id: longText,
|
||||
url: longText,
|
||||
title: longText,
|
||||
},
|
||||
],
|
||||
ipc: { events: [event], observed: 1 },
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
...event,
|
||||
kind: "navigation",
|
||||
phase: "observed",
|
||||
direction: null,
|
||||
capture_method: "event-emitter",
|
||||
},
|
||||
],
|
||||
observed: 1,
|
||||
},
|
||||
limitations: [longText],
|
||||
});
|
||||
|
||||
expect(input.actions[0]?.step_id).toBe(longText);
|
||||
expect(input.actions[0]?.kind === "click" && input.actions[0].selector).toBe(
|
||||
longText,
|
||||
);
|
||||
expect(result.ipc.events[0]?.channel).toBe(longText);
|
||||
expect(result.windows[0]?.title).toBe(longText);
|
||||
expect(result.limitations[0]).toBe(longText);
|
||||
});
|
||||
|
||||
it("runs an untargeted deep-link without requiring a BrowserWindow", async () => {
|
||||
const input = electronActiveObservationInputSchema.parse({
|
||||
executable_path: "/opt/electron",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const ACTION_TIMEOUT_MS = 5_000;
|
|||
|
||||
const hookEventSchema = z.strictObject({
|
||||
sequence: z.number().int().min(1),
|
||||
correlation_id: z.string().max(256).nullable(),
|
||||
correlation_id: z.string().nullable(),
|
||||
kind: z.enum([
|
||||
"main-handler-invocation",
|
||||
"main-event-invocation",
|
||||
|
|
@ -40,10 +40,9 @@ const hookEventSchema = z.strictObject({
|
|||
"updater",
|
||||
"error",
|
||||
]),
|
||||
event: z.string().max(128).nullable(),
|
||||
event: z.string().nullable(),
|
||||
phase: z.enum(["attempted", "completed", "blocked", "failed", "observed"]),
|
||||
channel: z.string().max(1_024).nullable(),
|
||||
channel_truncated: z.boolean().default(false),
|
||||
channel: z.string().nullable(),
|
||||
direction: z
|
||||
.enum([
|
||||
"renderer-to-main",
|
||||
|
|
@ -52,17 +51,16 @@ const hookEventSchema = z.strictObject({
|
|||
"utility-to-main",
|
||||
])
|
||||
.nullable(),
|
||||
sender: z.string().max(256).nullable(),
|
||||
receiver: z.string().max(256).nullable(),
|
||||
frame: z.string().max(256).nullable(),
|
||||
target: z.string().max(256).nullable(),
|
||||
argument_shapes: z.array(z.string().max(64)).max(32),
|
||||
argument_shapes_truncated: z.boolean().default(false),
|
||||
result_shape: z.string().max(64).nullable(),
|
||||
process_type: z.string().max(128).nullable(),
|
||||
source: z.string().max(64),
|
||||
sender: z.string().nullable(),
|
||||
receiver: z.string().nullable(),
|
||||
frame: z.string().nullable(),
|
||||
target: z.string().nullable(),
|
||||
argument_shapes: z.array(z.string()),
|
||||
result_shape: z.string().nullable(),
|
||||
process_type: z.string().nullable(),
|
||||
source: z.string(),
|
||||
capture_method: z.enum(["api-wrapper", "event-emitter", "process-hook"]),
|
||||
artifact_path: z.string().max(16_384).nullable(),
|
||||
artifact_path: z.string().nullable(),
|
||||
artifact_sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
|
|
@ -80,14 +78,14 @@ const hookSnapshotSchema = z.strictObject({
|
|||
|
||||
const metricSchema = z.strictObject({
|
||||
pid: z.number().int().min(1),
|
||||
type: z.string().min(1).max(128),
|
||||
name: z.string().max(256).nullable(),
|
||||
service_name: z.string().max(256).nullable(),
|
||||
type: z.string().min(1),
|
||||
name: z.string().nullable(),
|
||||
service_name: z.string().nullable(),
|
||||
});
|
||||
|
||||
const windowMetadataSchema = z.strictObject({
|
||||
window_id: z.string().max(256),
|
||||
web_contents_id: z.string().max(256),
|
||||
window_id: z.string(),
|
||||
web_contents_id: z.string(),
|
||||
visible: z.boolean().nullable(),
|
||||
destroyed: z.boolean(),
|
||||
});
|
||||
|
|
@ -341,8 +339,8 @@ export const readApplicationState = async (
|
|||
pages.map(
|
||||
async (page: Page, index): Promise<ElectronPageSnapshot> => ({
|
||||
index,
|
||||
url: page.url().slice(0, 65_536),
|
||||
title: (await page.title()).slice(0, 16_384),
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
@ -481,5 +479,5 @@ const safeActionErrorMessage = (
|
|||
.sort((left, right) => right.length - left.length);
|
||||
for (const value of inputValues)
|
||||
message = message.split(value).join("<redacted-input>");
|
||||
return redactSensitiveText(message).slice(0, 1_024);
|
||||
return redactSensitiveText(message);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,19 +7,29 @@ const OPERATION = "capture_browser_scenario" as const;
|
|||
|
||||
export const withPlaywrightExecutionBoundary = <Value>(
|
||||
work: () => Promise<Value>,
|
||||
maximumTimeoutMs: number,
|
||||
timeoutMs: number | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Value> => {
|
||||
if (maximumTimeoutMs <= 0)
|
||||
return Promise.reject(
|
||||
new AnalysisTimeoutError(OPERATION, maximumTimeoutMs),
|
||||
);
|
||||
if (timeoutMs !== undefined && timeoutMs <= 0)
|
||||
return Promise.reject(new AnalysisTimeoutError(OPERATION, timeoutMs));
|
||||
if (signal?.aborted === true)
|
||||
return Promise.reject(new AnalysisCancelledError(OPERATION));
|
||||
|
||||
return new Promise<Value>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline =
|
||||
timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
|
||||
const scheduleTimeout = (): void => {
|
||||
if (deadline === undefined) return;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
fail(new AnalysisTimeoutError(OPERATION, timeoutMs ?? 0));
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(scheduleTimeout, Math.min(remaining, 2_147_483_647));
|
||||
timer.unref();
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
|
|
@ -39,11 +49,7 @@ export const withPlaywrightExecutionBoundary = <Value>(
|
|||
const onAbort = (): void => {
|
||||
fail(new AnalysisCancelledError(OPERATION));
|
||||
};
|
||||
timer = setTimeout(
|
||||
() => fail(new AnalysisTimeoutError(OPERATION, maximumTimeoutMs)),
|
||||
maximumTimeoutMs,
|
||||
);
|
||||
timer.unref();
|
||||
scheduleTimeout();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) {
|
||||
onAbort();
|
||||
|
|
|
|||
|
|
@ -34,23 +34,19 @@ interface PerformActionInput {
|
|||
readonly page: Page;
|
||||
readonly action: BrowserScenarioAction;
|
||||
readonly secrets: BrowserScenarioSecrets;
|
||||
readonly defaultTimeoutMs: number;
|
||||
readonly maximumTimeoutMs: number;
|
||||
}
|
||||
|
||||
/** Execute one admitted declarative action with no caller-supplied code. */
|
||||
export const performPlaywrightScenarioAction = async (
|
||||
input: PerformActionInput,
|
||||
): Promise<void> => {
|
||||
const { page, action, secrets, defaultTimeoutMs, maximumTimeoutMs } = input;
|
||||
const { page, action, secrets } = input;
|
||||
const timeout = "timeout_ms" in action ? action.timeout_ms : undefined;
|
||||
const options = {
|
||||
timeout: Math.min(timeout ?? defaultTimeoutMs, maximumTimeoutMs),
|
||||
};
|
||||
const options = timeout === undefined ? {} : { timeout };
|
||||
switch (action.action) {
|
||||
case "goto":
|
||||
await page.goto(secrets.url(action.destination), {
|
||||
timeout: options.timeout,
|
||||
...options,
|
||||
waitUntil: action.wait_until,
|
||||
});
|
||||
return;
|
||||
|
|
@ -89,8 +85,6 @@ export const performPlaywrightScenarioAction = async (
|
|||
});
|
||||
return;
|
||||
case "wait_for_timeout":
|
||||
if (action.duration_ms > maximumTimeoutMs)
|
||||
throw new Error("Scenario duration limit reached");
|
||||
await page.waitForTimeout(action.duration_ms);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -118,17 +118,11 @@ const configureAttachedEnvironment = async (
|
|||
export const openPlaywrightScenarioBrowser = async (
|
||||
scenario: BrowserScenario,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
maximumTimeoutMs: number,
|
||||
): Promise<OpenedScenarioBrowser> => {
|
||||
if (scenario.browser.mode === "connect") {
|
||||
const browser = await chromium.connectOverCDP(
|
||||
scenario.browser.cdp_endpoint,
|
||||
{
|
||||
timeout: Math.min(
|
||||
scenario.limits.navigation_timeout_ms,
|
||||
maximumTimeoutMs,
|
||||
),
|
||||
},
|
||||
{ timeout: 0 },
|
||||
);
|
||||
try {
|
||||
const target = await findConnectedPage(
|
||||
|
|
@ -165,7 +159,7 @@ export const openPlaywrightScenarioBrowser = async (
|
|||
handleSIGHUP: false,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
timeout: maximumTimeoutMs,
|
||||
timeout: 0,
|
||||
});
|
||||
const browser = context.browser();
|
||||
if (browser === null) {
|
||||
|
|
|
|||
|
|
@ -211,8 +211,8 @@ const initializePage = async (
|
|||
scenario: BrowserScenario,
|
||||
secrets: BrowserScenarioSecrets,
|
||||
): Promise<Set<Page>> => {
|
||||
context.setDefaultTimeout(scenario.limits.action_timeout_ms);
|
||||
context.setDefaultNavigationTimeout(scenario.limits.navigation_timeout_ms);
|
||||
context.setDefaultTimeout(0);
|
||||
context.setDefaultNavigationTimeout(0);
|
||||
const ownedPages = new Set([page]);
|
||||
if (scenario.browser.mode === "connect")
|
||||
await blockAttachedServiceWorkers(page);
|
||||
|
|
@ -272,25 +272,19 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||
environment: Readonly<Record<string, string | undefined>>,
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly deadlineAt: number;
|
||||
},
|
||||
): Promise<PlaywrightScenarioSession> {
|
||||
const remaining = (): number => options.deadlineAt - Date.now();
|
||||
if (options.signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
const secrets = BrowserScenarioSecrets.resolve(scenario, environment);
|
||||
if (secrets === undefined)
|
||||
throw new BrowserObservationError(OPERATION, "secret_unavailable");
|
||||
const opening = openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
environment,
|
||||
remaining(),
|
||||
);
|
||||
const opening = openPlaywrightScenarioBrowser(scenario, environment);
|
||||
let opened: OpenedScenarioBrowser;
|
||||
try {
|
||||
opened = await withPlaywrightExecutionBoundary(
|
||||
() => opening,
|
||||
remaining(),
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
|
|
@ -302,7 +296,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||
try {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() => initializePage(opened.context, opened.page, scenario, secrets),
|
||||
remaining(),
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
const events = new PlaywrightScenarioEvents({
|
||||
|
|
@ -321,12 +315,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||
() =>
|
||||
opened.page.goto(secrets.url(scenario.start_url), {
|
||||
waitUntil: "load",
|
||||
timeout: Math.min(
|
||||
scenario.limits.navigation_timeout_ms,
|
||||
remaining(),
|
||||
),
|
||||
timeout: 0,
|
||||
}),
|
||||
remaining(),
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
return session;
|
||||
|
|
@ -358,7 +349,6 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||
|
||||
async perform(
|
||||
action: BrowserScenarioAction,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
|
|
@ -367,10 +357,8 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||
page: this.opened.page,
|
||||
action,
|
||||
secrets: this.secrets,
|
||||
defaultTimeoutMs: this.scenario.limits.action_timeout_ms,
|
||||
maximumTimeoutMs,
|
||||
}),
|
||||
maximumTimeoutMs,
|
||||
"timeout_ms" in action ? action.timeout_ms : undefined,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
|
@ -379,7 +367,6 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||
requested: ReadonlySet<
|
||||
BrowserScenario["capture"]["after_each_step"][number]
|
||||
>,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return withPlaywrightExecutionBoundary(
|
||||
|
|
@ -391,7 +378,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||
secrets: this.secrets,
|
||||
requested,
|
||||
}),
|
||||
maximumTimeoutMs,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
|
@ -429,13 +416,8 @@ export class PlaywrightScenarioSessionFactory
|
|||
scenario: BrowserScenario,
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly deadlineAt?: number;
|
||||
} = {},
|
||||
): Promise<BrowserScenarioSessionPort> {
|
||||
return PlaywrightScenarioSession.open(scenario, this.environment, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
deadlineAt:
|
||||
options.deadlineAt ?? Date.now() + scenario.limits.max_duration_ms,
|
||||
});
|
||||
return PlaywrightScenarioSession.open(scenario, this.environment, options);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type {
|
|||
} from "../application/AnalysisProvider.js";
|
||||
import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js";
|
||||
import {
|
||||
MAX_JAVASCRIPT_RUNTIME_LOCATION_BYTES,
|
||||
javascriptRuntimeObservationSchema,
|
||||
javascriptRuntimeTargetListSchema,
|
||||
type JavaScriptRuntimeObservation,
|
||||
|
|
@ -20,7 +19,6 @@ import {
|
|||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
boundedText,
|
||||
numberValue,
|
||||
recordValue,
|
||||
stringValue,
|
||||
|
|
@ -273,11 +271,7 @@ const ingestScript = (
|
|||
state.scriptsObserved += 1;
|
||||
const value = recordValue(event.params);
|
||||
const rawUrl = stringValue(value?.url);
|
||||
if (
|
||||
rawUrl === undefined ||
|
||||
rawUrl === "" ||
|
||||
Buffer.byteLength(rawUrl) > MAX_JAVASCRIPT_RUNTIME_LOCATION_BYTES
|
||||
) {
|
||||
if (rawUrl === undefined || rawUrl === "") {
|
||||
state.invalidScripts += 1;
|
||||
retainEvent(state, 0);
|
||||
return;
|
||||
|
|
@ -285,7 +279,7 @@ const ingestScript = (
|
|||
const draft: ScriptDraft = {
|
||||
rawUrl,
|
||||
executionContextKey: contextKey(value?.executionContextId),
|
||||
cdpHash: boundedText(value?.hash, 512),
|
||||
cdpHash: stringValue(value?.hash) ?? null,
|
||||
length: nonnegativeInteger(value?.length),
|
||||
isModule: value?.isModule === true,
|
||||
};
|
||||
|
|
@ -320,7 +314,7 @@ const ingestContext = (
|
|||
}
|
||||
const origin =
|
||||
event.method === "Runtime.executionContextCreated"
|
||||
? boundedText(runtimeContext?.origin, 4_096)
|
||||
? (stringValue(runtimeContext?.origin) ?? null)
|
||||
: null;
|
||||
const draft: ContextDraft = {
|
||||
contextKey: key,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { electronPageInspectionOptions } from "./cliObservationOptions.js";
|
||||
|
||||
describe("Electron CLI observation duration", () => {
|
||||
it("accepts caller-selected windows longer than ten seconds", () => {
|
||||
expect(
|
||||
electronPageInspectionOptions.parse({ observationMs: 60_000 })
|
||||
.observationMs,
|
||||
).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
|
@ -77,12 +77,7 @@ export const electronScopeOptions = {
|
|||
|
||||
export const electronPageInspectionOptions = z.object({
|
||||
...electronScopeOptions,
|
||||
observationMs: boundedCount(
|
||||
"observation duration in milliseconds",
|
||||
10_000,
|
||||
100,
|
||||
0,
|
||||
),
|
||||
observationMs: observationDuration(100),
|
||||
includeScriptSources: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ describe("configuration parsing integration", () => {
|
|||
HOPPER_TARGET_KIND: "executable",
|
||||
HOPPER_LOADER_ARGS_JSON: '["-l","Mach-O","--aarch64"]',
|
||||
REA_ANALYSIS_PROVIDER: "hopper",
|
||||
REA_EVIDENCE_ROOTS_JSON: '["/tmp/evidence"]',
|
||||
REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: '["/tmp/analysis"]',
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("unreachable");
|
||||
|
|
@ -37,10 +35,6 @@ describe("configuration parsing integration", () => {
|
|||
"Mach-O",
|
||||
"--aarch64",
|
||||
]);
|
||||
expect(result.value.evidenceFilePolicy.roots).toEqual(["/tmp/evidence"]);
|
||||
expect(result.value.analysisSnapshotFilePolicy.roots).toEqual([
|
||||
"/tmp/analysis",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects target kind values outside the enum", () => {
|
||||
|
|
|
|||
|
|
@ -22,12 +22,7 @@ describe("runtime configuration", () => {
|
|||
expect(result.value.hopperLoaderArgs).toEqual([]);
|
||||
expect(result.value.logLevel).toBe("info");
|
||||
expect(result.value.referenceSourcePolicy).toEqual({
|
||||
roots: [],
|
||||
secretPatterns: [],
|
||||
maxBytes: 16 * 1024 * 1024,
|
||||
maxEntries: 10_000,
|
||||
maxDepth: 32,
|
||||
maxPathBytes: 4_096,
|
||||
});
|
||||
expect(result.value.browserObservationPolicy).toEqual({
|
||||
status: "disabled",
|
||||
|
|
@ -400,27 +395,8 @@ describe("runtime target configuration", () => {
|
|||
processExecutionPolicy: {
|
||||
status: "disabled",
|
||||
},
|
||||
evidenceFilePolicy: {
|
||||
roots: [],
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
},
|
||||
analysisSnapshotFilePolicy: {
|
||||
roots: [],
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
},
|
||||
referenceSourcePolicy: {
|
||||
roots: [],
|
||||
secretPatterns: [],
|
||||
maxBytes: 16 * 1024 * 1024,
|
||||
maxEntries: 10_000,
|
||||
maxDepth: 32,
|
||||
maxPathBytes: 4_096,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -446,41 +422,18 @@ describe("runtime collection configuration", () => {
|
|||
expect(parseConfig({ REA_LOG_LEVEL: "verbose" }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("parses reference source policy roots and secret patterns", () => {
|
||||
it("parses reference source secret patterns", () => {
|
||||
const result = parseConfig({
|
||||
REA_REFERENCE_ROOTS_JSON: '["/approved", "/srv/reference"]',
|
||||
REA_REFERENCE_SECRET_PATTERNS_JSON: '["*.env", "*.pem", "secrets/"]',
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.referenceSourcePolicy).toEqual({
|
||||
roots: ["/approved", "/srv/reference"],
|
||||
secretPatterns: ["*.env", "*.pem", "secrets/"],
|
||||
maxBytes: 16 * 1024 * 1024,
|
||||
maxEntries: 10_000,
|
||||
maxDepth: 32,
|
||||
maxPathBytes: 4_096,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parses evidence roots", () => {
|
||||
const result = parseConfig({
|
||||
REA_EVIDENCE_ROOTS_JSON: '["/evidence"]',
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.evidenceFilePolicy.roots).toEqual(["/evidence"]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["not-json", "{}", '["/ok", 1]'])(
|
||||
"rejects invalid reference source roots: %s",
|
||||
(encoded) => {
|
||||
expect(parseConfig({ REA_REFERENCE_ROOTS_JSON: encoded }).ok).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["not-json", "{}", '["*.ok", 1]'])(
|
||||
"rejects invalid reference source secret patterns: %s",
|
||||
(encoded) => {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
|
|||
name: "capture_browser_scenario",
|
||||
...toolContractMetadata("capture_browser_scenario"),
|
||||
description:
|
||||
"Run a controlled browser scenario when passive observation cannot exercise the application. Choose launch mode with an approved executable or connect mode with an approved loopback CDP target, declare the exact start origin scope, and provide explicit actions. Environment, empty storage, disabled request replay, redaction, and a URL-only final capture have safe defaults; declare secrets, initial storage, exact request replay, or additional retained artifacts only when needed. Provider-owned duration, action, and navigation timeouts remain fixed. There are no action, secret, storage, route, event, frame, DOM, network, artifact-size, or cumulative metadata count caps; every observation is returned inline.",
|
||||
"Run a controlled browser scenario when passive observation cannot exercise the application. Choose launch mode with an approved executable or connect mode with an approved loopback CDP target, declare the exact start origin scope, and provide explicit actions. Environment, empty storage, disabled request replay, redaction, and a URL-only final capture have safe defaults; declare secrets, initial storage, exact request replay, or additional retained artifacts only when needed. Scenarios have no provider-imposed duration or action timeout. Set timeout_ms on an action when it needs a deadline; omitted timeouts wait until completion or request cancellation. There are no action, secret, storage, route, event, frame, DOM, network, artifact-size, or cumulative metadata count caps; every observation is returned inline.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: browserScenarioInputSchema,
|
||||
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
|
||||
|
|
|
|||
|
|
@ -155,10 +155,7 @@ const staticEvidence = createEvidence(
|
|||
statistics: {
|
||||
relevant_files: 1,
|
||||
nested_asar_containers: 0,
|
||||
text_files_selected: 1,
|
||||
text_bytes_read: script.bytes,
|
||||
omitted_text_files: 0,
|
||||
limit_omitted_text_files: 0,
|
||||
invalid_utf8_files: 0,
|
||||
parsed_javascript_files: 1,
|
||||
visited_ast_nodes: 1,
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
|||
),
|
||||
session(
|
||||
"close_binary",
|
||||
"Optionally write a provider-neutral analysis snapshot atomically, then close the active target and every provider resource started for it. Snapshot files require an operator-approved root and explicit overwrite; a failed save leaves the session open so cached analysis is not lost.",
|
||||
"Optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active target and every provider resource started for it. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost.",
|
||||
closeBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
|
|
@ -378,7 +378,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
|||
),
|
||||
session(
|
||||
"import_evidence_bundle",
|
||||
"Read a bounded local JSON bundle beneath an operator-approved root, validate every Evidence ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed.",
|
||||
"Read the JSON bundle at the supplied local path, validate every Evidence ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed.",
|
||||
importEvidenceBundleInputSchema,
|
||||
),
|
||||
session(
|
||||
|
|
@ -418,7 +418,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
|||
),
|
||||
session(
|
||||
"correlate_static_and_runtime",
|
||||
"Evaluate explicit caller-declared hypotheses between exact static comparison findings and runtime comparison dimensions. Similar names or paths are never auto-matched, consistent cochange never proves causality, and unknown or truncated inputs remain unresolved.",
|
||||
"Evaluate every explicit caller-declared hypothesis between exact static comparison findings and runtime comparison dimensions. Similar names or paths are never auto-matched, consistent cochange never proves causality, and unknown or truncated inputs remain unresolved. Returns all correlations and their complete Evidence closure inline.",
|
||||
staticRuntimeCorrelationInputSchema,
|
||||
),
|
||||
session(
|
||||
|
|
|
|||
|
|
@ -78,8 +78,6 @@ export const analysisErrorCategory = (
|
|||
return "unavailable";
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return artifactErrorCategory(error.reason);
|
||||
if (error instanceof EvidenceFileError && error.reason === "disabled")
|
||||
return "unavailable";
|
||||
if (error instanceof WorkspaceStorageError)
|
||||
return workspaceStorageCategory(error.reason);
|
||||
return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure";
|
||||
|
|
@ -255,14 +253,8 @@ const artifactMessage = (reason: ArtifactOperationError["reason"]): string => {
|
|||
};
|
||||
|
||||
const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => {
|
||||
if (reason === "disabled")
|
||||
return "Evidence file access is disabled. Enable an evidence directory or use inline evidence.";
|
||||
if (reason === "outside-root")
|
||||
return "Evidence path is outside the allowed directory. Choose a path inside the configured evidence directory.";
|
||||
if (reason === "not-file")
|
||||
return "Evidence path does not point to a file. Choose an evidence file and try again.";
|
||||
if (reason === "too-large")
|
||||
return "Evidence file is too large. Reduce its size and try again.";
|
||||
return "Evidence path does not point to a regular file. Choose a file and try again.";
|
||||
if (reason === "exists")
|
||||
return "Evidence file already exists. Choose another path or allow overwrite.";
|
||||
if (reason === "invalid-json")
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ describe("analysis error projection: caller contract", () => {
|
|||
const projected = projectAnalysisError(
|
||||
new PermissionRequiredError({
|
||||
requested: {
|
||||
capability: "evidence_read",
|
||||
capability: "process_capture",
|
||||
roots: ["/workspace/evidence.json"],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
|
|
|
|||
|
|
@ -107,12 +107,10 @@ const artifactOperationCode = (
|
|||
|
||||
const evidenceFileCode = (
|
||||
reason: EvidenceFileError["reason"],
|
||||
): AnalysisErrorProjection["code"] => {
|
||||
if (reason === "outside-root") return "outside_approved_root";
|
||||
if (reason === "too-large") return "truncated";
|
||||
if (reason === "disabled") return "capability_unavailable";
|
||||
return "execution_failure";
|
||||
};
|
||||
): AnalysisErrorProjection["code"] =>
|
||||
reason === "invalid-json"
|
||||
? "evidence_integrity_mismatch"
|
||||
: "execution_failure";
|
||||
|
||||
const workspaceStorageCode = (
|
||||
reason: WorkspaceStorageError["reason"],
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ import { evidenceSchema } from "./evidence.js";
|
|||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
const pathSchema = z.string().min(1).max(4_096);
|
||||
const pathSchema = z.string().min(1);
|
||||
const componentSchema = z.strictObject({
|
||||
path: pathSchema,
|
||||
artifact_id: z.string().regex(/^art_[a-f0-9]{64}$/u),
|
||||
sha256: digestSchema,
|
||||
format: z.string().min(1).max(100),
|
||||
format: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Authenticated APK inventory pages projected as one Android application. */
|
||||
|
|
@ -64,7 +64,7 @@ export const androidApplicationProjectionResultSchema = z.strictObject({
|
|||
status: z.enum(["complete-within-inventory", "partial"]),
|
||||
inventory_complete: z.boolean(),
|
||||
}),
|
||||
limitations: z.array(z.string().min(1).max(4_096)).max(100),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
export type AndroidApplicationProjectionInput = z.infer<
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import canonicalize from "canonicalize";
|
|||
import { z } from "zod";
|
||||
|
||||
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
|
||||
import { projectBoundedCartesian } from "./boundedCartesianProjection.js";
|
||||
import { evidenceSchema } from "./evidence.js";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
|
|
@ -20,12 +19,7 @@ const componentSchema = z.strictObject({
|
|||
|
||||
/** Authenticated artifact inventory pages projected as one Apple application. */
|
||||
export const appleApplicationProjectionInputSchema = z.strictObject({
|
||||
inventory_evidence: z.array(evidenceSchema).min(1).max(100),
|
||||
limits: z
|
||||
.strictObject({
|
||||
max_components: z.number().int().min(1).max(10_000).default(1_000),
|
||||
})
|
||||
.default({ max_components: 1_000 }),
|
||||
inventory_evidence: z.array(evidenceSchema).min(1),
|
||||
});
|
||||
|
||||
/** Deterministic, execution-free Apple application inventory projection. */
|
||||
|
|
@ -33,7 +27,7 @@ export const appleApplicationProjectionResultSchema = z.strictObject({
|
|||
projection_id: z.string().regex(/^aap_[a-f0-9]{64}$/u),
|
||||
root_sha256: digestSchema,
|
||||
root_format: z.literal("ipa"),
|
||||
source_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
source_evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
application_roots: z.array(boundedPathSchema),
|
||||
components: z.strictObject({
|
||||
bundle_metadata: z.array(componentSchema),
|
||||
|
|
@ -66,12 +60,10 @@ export const appleApplicationProjectionResultSchema = z.strictObject({
|
|||
}),
|
||||
),
|
||||
coverage: z.strictObject({
|
||||
status: z.enum(["complete-within-inventory", "partial", "truncated"]),
|
||||
status: z.enum(["complete-within-inventory", "partial"]),
|
||||
inventory_complete: z.boolean(),
|
||||
omitted_components: z.number().int().min(0),
|
||||
omitted_bridge_candidates: z.number().int().min(0),
|
||||
}),
|
||||
limitations: z.array(z.string().min(1).max(4_096)).max(100),
|
||||
limitations: z.array(z.string().min(1).max(4_096)),
|
||||
});
|
||||
|
||||
export type AppleApplicationProjectionInput = z.infer<
|
||||
|
|
@ -123,23 +115,15 @@ export const projectAppleApplication = (
|
|||
),
|
||||
].sort(compare);
|
||||
const classified = classifyComponents(all, roots);
|
||||
const retained = retainComponents(classified, parsed.limits.max_components);
|
||||
const omitted = componentCount(classified) - componentCount(retained);
|
||||
const runtimeFamilies = identifyRuntimeFamilies(all);
|
||||
const bridgeProjection = identifyBridgeCandidates(
|
||||
retained.javascript,
|
||||
classified.javascript,
|
||||
deduplicateComponents([
|
||||
...retained.executables,
|
||||
...retained.native_libraries,
|
||||
...classified.executables,
|
||||
...classified.native_libraries,
|
||||
]),
|
||||
parsed.limits.max_components,
|
||||
);
|
||||
const limitations = projectionLimitations(
|
||||
inventory.complete,
|
||||
omitted,
|
||||
bridgeProjection.omitted,
|
||||
roots,
|
||||
);
|
||||
const limitations = projectionLimitations(inventory.complete, roots);
|
||||
const withoutId = {
|
||||
root_sha256: inventory.manifest.root_sha256,
|
||||
root_format: "ipa" as const,
|
||||
|
|
@ -147,19 +131,14 @@ export const projectAppleApplication = (
|
|||
.map(({ evidence_id: id }) => id)
|
||||
.sort(compare),
|
||||
application_roots: roots,
|
||||
components: retained,
|
||||
components: classified,
|
||||
runtime_families: runtimeFamilies,
|
||||
bridge_candidates: bridgeProjection.candidates,
|
||||
coverage: {
|
||||
status:
|
||||
omitted > 0 || bridgeProjection.omitted > 0
|
||||
? ("truncated" as const)
|
||||
: inventory.complete
|
||||
? ("complete-within-inventory" as const)
|
||||
: ("partial" as const),
|
||||
status: inventory.complete
|
||||
? ("complete-within-inventory" as const)
|
||||
: ("partial" as const),
|
||||
inventory_complete: inventory.complete,
|
||||
omitted_components: omitted,
|
||||
omitted_bridge_candidates: bridgeProjection.omitted,
|
||||
},
|
||||
limitations,
|
||||
};
|
||||
|
|
@ -194,27 +173,6 @@ const classifyComponents = (
|
|||
};
|
||||
};
|
||||
|
||||
const retainComponents = <Groups extends Record<string, readonly Component[]>>(
|
||||
groups: Groups,
|
||||
maximum: number,
|
||||
): { [Key in keyof Groups]: Component[] } => {
|
||||
let remaining = maximum;
|
||||
// SAFETY: the projection preserves every key from Groups and replaces only
|
||||
// each readonly Component array with a bounded mutable Component array.
|
||||
return Object.fromEntries(
|
||||
Object.entries(groups).map(([key, values]) => {
|
||||
const retained = [...values]
|
||||
.sort((left, right) => compare(left.path, right.path))
|
||||
.slice(0, remaining);
|
||||
remaining -= retained.length;
|
||||
return [key, retained];
|
||||
}),
|
||||
) as { [Key in keyof Groups]: Component[] };
|
||||
};
|
||||
|
||||
const componentCount = (groups: Record<string, readonly Component[]>): number =>
|
||||
Object.values(groups).reduce((total, values) => total + values.length, 0);
|
||||
|
||||
const deduplicateComponents = (values: readonly Component[]): Component[] =>
|
||||
[...new Map(values.map((value) => [value.path, value])).values()].sort(
|
||||
(left, right) => compare(left.path, right.path),
|
||||
|
|
@ -257,23 +215,15 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => {
|
|||
const identifyBridgeCandidates = (
|
||||
scripts: readonly Component[],
|
||||
native: readonly Component[],
|
||||
maximum: number,
|
||||
) => {
|
||||
const projection = projectBoundedCartesian(
|
||||
scripts,
|
||||
native,
|
||||
maximum,
|
||||
(script, item) => ({
|
||||
) => ({
|
||||
candidates: scripts.flatMap((script) =>
|
||||
native.map((item) => ({
|
||||
source_path: script.path,
|
||||
native_path: item.path,
|
||||
basis: bridgeBasis(item.path),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
candidates: projection.values,
|
||||
omitted: projection.omitted,
|
||||
};
|
||||
};
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
const bridgeBasis = (
|
||||
path: string,
|
||||
|
|
@ -292,23 +242,11 @@ const bridgeBasis = (
|
|||
|
||||
const projectionLimitations = (
|
||||
complete: boolean,
|
||||
omitted: number,
|
||||
omittedBridges: number,
|
||||
roots: readonly string[],
|
||||
): string[] => [
|
||||
...(!complete
|
||||
? ["Source inventory pages are incomplete; absence is unknown."]
|
||||
: []),
|
||||
...(omitted > 0
|
||||
? [
|
||||
`${String(omitted)} component observations were omitted by the projection limit.`,
|
||||
]
|
||||
: []),
|
||||
...(omittedBridges > 0
|
||||
? [
|
||||
`${String(omittedBridges)} bridge hypotheses were omitted by the projection limit.`,
|
||||
]
|
||||
: []),
|
||||
...(roots.length === 0
|
||||
? [
|
||||
"No Payload/*.app directory was present in the supplied inventory pages.",
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { projectBoundedCartesian } from "./boundedCartesianProjection.js";
|
||||
|
||||
describe("bounded Cartesian projection", () => {
|
||||
it("stops projection work at the output limit", () => {
|
||||
const values = Array.from({ length: 10_000 }, (_, index) => index);
|
||||
let projections = 0;
|
||||
|
||||
const result = projectBoundedCartesian(values, values, 3, (left, right) => {
|
||||
projections += 1;
|
||||
return [left, right] as const;
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
values: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
],
|
||||
omitted: 100_000_000 - 3,
|
||||
});
|
||||
expect(projections).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/** Project a Cartesian product without materializing values beyond the output limit. */
|
||||
export const projectBoundedCartesian = <Left, Right, Value>(
|
||||
left: readonly Left[],
|
||||
right: readonly Right[],
|
||||
maximum: number,
|
||||
project: (left: Left, right: Right) => Value,
|
||||
): { readonly values: Value[]; readonly omitted: number } => {
|
||||
const values: Value[] = [];
|
||||
for (const leftValue of left) {
|
||||
for (const rightValue of right) {
|
||||
if (values.length === maximum)
|
||||
return {
|
||||
values,
|
||||
omitted: left.length * right.length - values.length,
|
||||
};
|
||||
values.push(project(leftValue, rightValue));
|
||||
}
|
||||
}
|
||||
return { values, omitted: 0 };
|
||||
};
|
||||
|
|
@ -71,3 +71,97 @@ it("accepts scenarios beyond former action, secret, storage, and replay-route co
|
|||
result.request_replay.mode === "exact" && result.request_replay.routes,
|
||||
).toHaveLength(257);
|
||||
});
|
||||
|
||||
it("accepts complete browser inputs beyond former string length caps", () => {
|
||||
const longValue = "value".repeat(20_000);
|
||||
const longToken = "a".repeat(70_000);
|
||||
const longEnvironmentVariable = "SECRET_A".repeat(10_000);
|
||||
const longPath = `/app/${"segment".repeat(3_000)}`;
|
||||
const result = browserScenarioSchema.parse({
|
||||
browser: {
|
||||
mode: "launch",
|
||||
executable_path: longPath,
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
start_url: { url: `https://app.example.test${longPath}` },
|
||||
allowed_origins: ["https://app.example.test"],
|
||||
secrets: [
|
||||
{
|
||||
secret_id: longToken,
|
||||
environment_variable: longEnvironmentVariable,
|
||||
purpose: "input",
|
||||
redaction: "replace-with-secret-reference",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
step_id: longToken,
|
||||
action: "fill",
|
||||
locator: { kind: "css", selector: `#${longValue}` },
|
||||
value: { source: "secret", secret_id: longToken },
|
||||
},
|
||||
],
|
||||
storage: {
|
||||
cookies: [
|
||||
{
|
||||
name: longToken,
|
||||
value: {
|
||||
source: "literal",
|
||||
value: longValue,
|
||||
classification: "public",
|
||||
},
|
||||
destination: { url: "https://app.example.test/" },
|
||||
http_only: false,
|
||||
secure: true,
|
||||
same_site: "Lax",
|
||||
},
|
||||
],
|
||||
},
|
||||
request_replay: {
|
||||
mode: "exact",
|
||||
unmatched: "abort",
|
||||
routes: [
|
||||
{
|
||||
route_id: longToken,
|
||||
method: "GET",
|
||||
request: { url: "https://app.example.test/" },
|
||||
response: {
|
||||
kind: "response",
|
||||
status: 200,
|
||||
headers: [
|
||||
{
|
||||
name: longToken,
|
||||
value: {
|
||||
source: "literal",
|
||||
value: longValue,
|
||||
classification: "public",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.actions[0]?.step_id).toBe(longToken);
|
||||
expect(
|
||||
result.actions[0]?.action === "fill" &&
|
||||
result.actions[0].value.source === "secret"
|
||||
? result.actions[0].value.secret_id
|
||||
: undefined,
|
||||
).toBe(longToken);
|
||||
expect(result.storage.cookies[0]?.name).toBe(longToken);
|
||||
expect(result.secrets[0]?.environment_variable).toBe(longEnvironmentVariable);
|
||||
const replayRoute =
|
||||
result.request_replay.mode === "exact"
|
||||
? result.request_replay.routes[0]
|
||||
: undefined;
|
||||
expect(
|
||||
replayRoute?.response.kind === "response"
|
||||
? replayRoute.response.headers[0]?.name
|
||||
: undefined,
|
||||
).toBe(longToken);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||
browserScenarioActionSchema,
|
||||
browserScenarioAllowedOriginsSchema,
|
||||
browserScenarioBrowserSchema,
|
||||
|
|
@ -18,7 +17,6 @@ import {
|
|||
} from "./browserScenarioValues.js";
|
||||
|
||||
export {
|
||||
BROWSER_SCENARIO_LIMITS,
|
||||
browserScenarioActionSchema,
|
||||
browserScenarioUrlSchema,
|
||||
browserScenarioValueSchema,
|
||||
|
|
@ -54,14 +52,7 @@ export const browserScenarioInputSchema = z.strictObject({
|
|||
capture: browserScenarioCaptureSchema,
|
||||
});
|
||||
|
||||
const scenarioShapeSchema = browserScenarioInputSchema.transform(
|
||||
(scenario) => ({
|
||||
...scenario,
|
||||
limits: BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||
}),
|
||||
);
|
||||
|
||||
type ScenarioShape = z.infer<typeof scenarioShapeSchema>;
|
||||
type ScenarioShape = z.infer<typeof browserScenarioInputSchema>;
|
||||
|
||||
const addIssue = (
|
||||
context: z.RefinementCtx,
|
||||
|
|
@ -348,8 +339,8 @@ const validateBrowserScenario = (
|
|||
validateRedactionCoverage(scenario, context);
|
||||
};
|
||||
|
||||
/** Strict provider-neutral contract for one bounded browser capture scenario. */
|
||||
export const browserScenarioSchema = scenarioShapeSchema.superRefine(
|
||||
/** Strict provider-neutral contract for one controlled browser scenario. */
|
||||
export const browserScenarioSchema = browserScenarioInputSchema.superRefine(
|
||||
validateBrowserScenario,
|
||||
);
|
||||
export type BrowserScenario = z.infer<typeof browserScenarioSchema>;
|
||||
|
|
|
|||
|
|
@ -6,32 +6,13 @@ import {
|
|||
browserOriginSchema,
|
||||
} from "./browserObservation.js";
|
||||
|
||||
export const BROWSER_SCENARIO_LIMITS = {
|
||||
actionTimeoutMs: 60_000,
|
||||
} as const;
|
||||
|
||||
/** Fixed internal deadlines for provider-owned scenario capture. */
|
||||
export interface BrowserScenarioCaptureLimits {
|
||||
readonly max_duration_ms: number;
|
||||
readonly action_timeout_ms: number;
|
||||
readonly navigation_timeout_ms: number;
|
||||
}
|
||||
|
||||
export const BROWSER_SCENARIO_CAPTURE_LIMITS: BrowserScenarioCaptureLimits =
|
||||
Object.freeze({
|
||||
max_duration_ms: 60_000,
|
||||
action_timeout_ms: 5_000,
|
||||
navigation_timeout_ms: 10_000,
|
||||
});
|
||||
|
||||
export const scenarioIdentifierSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u);
|
||||
.regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
|
||||
|
||||
const absoluteExecutablePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.startsWith("/") ||
|
||||
|
|
@ -43,7 +24,6 @@ const absoluteExecutablePathSchema = z
|
|||
const browserScenarioBaseUrlSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(65_536)
|
||||
.transform((value, context) => {
|
||||
let url: URL;
|
||||
try {
|
||||
|
|
@ -72,7 +52,7 @@ const browserScenarioBaseUrlSchema = z
|
|||
export const browserScenarioValueSchema = z.discriminatedUnion("source", [
|
||||
z.strictObject({
|
||||
source: z.literal("literal"),
|
||||
value: z.string().max(65_536),
|
||||
value: z.string(),
|
||||
classification: z.literal("public"),
|
||||
}),
|
||||
z.strictObject({
|
||||
|
|
@ -83,7 +63,7 @@ export const browserScenarioValueSchema = z.discriminatedUnion("source", [
|
|||
export type BrowserScenarioValue = z.infer<typeof browserScenarioValueSchema>;
|
||||
|
||||
const queryEntrySchema = z.strictObject({
|
||||
name: z.string().min(1).max(256),
|
||||
name: z.string().min(1),
|
||||
value: browserScenarioValueSchema,
|
||||
});
|
||||
|
||||
|
|
@ -105,7 +85,7 @@ export const browserScenarioBrowserSchema = z.discriminatedUnion("mode", [
|
|||
z.strictObject({
|
||||
mode: z.literal("connect"),
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
target_id: z.string().trim().min(1),
|
||||
ownership: z.literal("external"),
|
||||
cleanup: z.literal("disconnect-only"),
|
||||
}),
|
||||
|
|
@ -123,13 +103,11 @@ export const browserScenarioEnvironmentSchema = z
|
|||
locale: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/u)
|
||||
.default("en-US"),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9_+-]+(?:\/[A-Za-z0-9_+-]+)*$/u)
|
||||
.default("UTC"),
|
||||
color_scheme: z.enum(["light", "dark", "no-preference"]).default("light"),
|
||||
|
|
@ -151,7 +129,7 @@ export const browserScenarioEnvironmentSchema = z
|
|||
const locatorSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("test_id"),
|
||||
value: z.string().min(1).max(512),
|
||||
value: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("role"),
|
||||
|
|
@ -171,12 +149,12 @@ const locatorSchema = z.discriminatedUnion("kind", [
|
|||
"tab",
|
||||
"textbox",
|
||||
]),
|
||||
name: z.string().min(1).max(1_024),
|
||||
name: z.string().min(1),
|
||||
exact: z.literal(true),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("css"),
|
||||
selector: z.string().min(1).max(4_096),
|
||||
selector: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
@ -186,8 +164,10 @@ const stepBase = {
|
|||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs)
|
||||
.optional(),
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional action deadline in milliseconds; omitted means wait until completion or request cancellation.",
|
||||
),
|
||||
};
|
||||
|
||||
export const browserScenarioActionSchema = z.discriminatedUnion("action", [
|
||||
|
|
@ -214,7 +194,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [
|
|||
...stepBase,
|
||||
action: z.literal("press"),
|
||||
locator: locatorSchema,
|
||||
key: z.string().min(1).max(64),
|
||||
key: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
|
|
@ -240,13 +220,15 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [
|
|||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs),
|
||||
.describe(
|
||||
"Wait this many milliseconds; the wait ends early on cancellation.",
|
||||
),
|
||||
}),
|
||||
]);
|
||||
export type BrowserScenarioAction = z.infer<typeof browserScenarioActionSchema>;
|
||||
|
||||
const storageEntrySchema = z.strictObject({
|
||||
name: z.string().min(1).max(1_024),
|
||||
name: z.string().min(1),
|
||||
value: browserScenarioValueSchema,
|
||||
});
|
||||
|
||||
|
|
@ -255,7 +237,7 @@ export const browserScenarioStorageSchema = z
|
|||
cookies: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: z.string().min(1).max(1_024),
|
||||
name: z.string().min(1),
|
||||
value: browserScenarioValueSchema,
|
||||
destination: browserScenarioUrlSchema,
|
||||
http_only: z.boolean(),
|
||||
|
|
@ -289,7 +271,6 @@ export const browserScenarioStorageSchema = z
|
|||
const headerNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u)
|
||||
.transform((value) => value.toLowerCase());
|
||||
|
||||
|
|
@ -341,7 +322,7 @@ export const browserScenarioRequestReplaySchema = z
|
|||
|
||||
export const browserScenarioSecretSchema = z.strictObject({
|
||||
secret_id: scenarioIdentifierSchema,
|
||||
environment_variable: z.string().regex(/^[A-Z_][A-Z0-9_]{0,127}$/u),
|
||||
environment_variable: z.string().regex(/^[A-Z_][A-Z0-9_]*$/u),
|
||||
purpose: z.enum(["input", "storage", "request-replay"]),
|
||||
redaction: z.literal("replace-with-secret-reference"),
|
||||
});
|
||||
|
|
@ -360,7 +341,6 @@ const normalizedNames = () =>
|
|||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.transform((value) => value.toLowerCase()),
|
||||
)
|
||||
.transform((values) => [...new Set(values)].sort());
|
||||
|
|
|
|||
|
|
@ -136,4 +136,25 @@ describe("bytecode provider", () => {
|
|||
const parsed = bytecodeAnalysisSchema.safeParse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("returns every artifact when the inventory exceeds ten thousand entries", () => {
|
||||
const artifacts = Array.from({ length: 10_001 }, (_, index) => ({
|
||||
path: `classes/${index}.class`,
|
||||
family: "jvm" as const,
|
||||
format: "class" as const,
|
||||
size: 1,
|
||||
digest: "a".repeat(64),
|
||||
symbols: [],
|
||||
is_standard_library: false,
|
||||
is_generated: false,
|
||||
is_vendored: false,
|
||||
}));
|
||||
|
||||
const result = analyzeBytecodeArtifacts(artifacts, "jvm");
|
||||
|
||||
expect(result.artifacts).toHaveLength(artifacts.length);
|
||||
expect(bytecodeAnalysisSchema.parse(result).artifacts).toHaveLength(
|
||||
artifacts.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export const bytecodeAnalysisSchema = z.strictObject({
|
|||
/** Bytecode family. */
|
||||
family: bytecodeFamilySchema,
|
||||
/** All discovered artifacts. */
|
||||
artifacts: z.array(bytecodeArtifactSchema).min(0).max(10_000),
|
||||
artifacts: z.array(bytecodeArtifactSchema),
|
||||
/** Total symbols across all artifacts. */
|
||||
total_symbols: z.number().int().nonnegative(),
|
||||
/** Symbols classified as application code. */
|
||||
|
|
@ -176,26 +176,29 @@ export function analyzeBytecodeArtifacts(
|
|||
artifacts: readonly BytecodeArtifact[],
|
||||
family: BytecodeFamily,
|
||||
): BytecodeAnalysis {
|
||||
const applicationSymbols = artifacts
|
||||
.flatMap((a) => a.symbols)
|
||||
.filter((s) => s.provenance === "application").length;
|
||||
const generatedSymbols = artifacts
|
||||
.flatMap((a) => a.symbols)
|
||||
.filter((s) => s.provenance === "generated").length;
|
||||
const vendoredSymbols = artifacts
|
||||
.flatMap((a) => a.symbols)
|
||||
.filter((s) => s.provenance === "vendored").length;
|
||||
const standardLibrarySymbols = artifacts
|
||||
.flatMap((a) => a.symbols)
|
||||
.filter((s) => s.provenance === "standard_library").length;
|
||||
const totals = artifacts.reduce(
|
||||
(counts, artifact) => {
|
||||
counts.total += artifact.symbols.length;
|
||||
for (const symbol of artifact.symbols) counts[symbol.provenance] += 1;
|
||||
return counts;
|
||||
},
|
||||
{
|
||||
total: 0,
|
||||
application: 0,
|
||||
generated: 0,
|
||||
vendored: 0,
|
||||
standard_library: 0,
|
||||
bundled: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
family,
|
||||
artifacts: artifacts.slice(0, 10_000),
|
||||
total_symbols: artifacts.reduce((sum, a) => sum + a.symbols.length, 0),
|
||||
application_symbols: applicationSymbols,
|
||||
generated_symbols: generatedSymbols,
|
||||
vendored_symbols: vendoredSymbols,
|
||||
standard_library_symbols: standardLibrarySymbols,
|
||||
artifacts: [...artifacts],
|
||||
total_symbols: totals.total,
|
||||
application_symbols: totals.application,
|
||||
generated_symbols: totals.generated,
|
||||
vendored_symbols: totals.vendored,
|
||||
standard_library_symbols: totals.standard_library,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const right = source("2");
|
|||
const comparison = (
|
||||
operation: "compare_process_captures" | "compare_artifacts",
|
||||
result: JsonValue,
|
||||
input: { readonly salt?: number; readonly evidenceLinks?: string[] } = {},
|
||||
): Evidence => {
|
||||
const process = operation === "compare_process_captures";
|
||||
return createEvidence(
|
||||
|
|
@ -41,11 +42,14 @@ const comparison = (
|
|||
? "rea.process-comparison"
|
||||
: "rea.artifact-comparison",
|
||||
operation,
|
||||
parameters: {},
|
||||
parameters: input.salt === undefined ? {} : { salt: input.salt },
|
||||
result,
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: [left.evidence_id, right.evidence_id],
|
||||
evidenceLinks: input.evidenceLinks ?? [
|
||||
left.evidence_id,
|
||||
right.evidence_id,
|
||||
],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -262,3 +266,30 @@ describe("changed behavior", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changed behavior aggregation", () => {
|
||||
it("keeps all comparisons and their complete evidence closure", () => {
|
||||
const comparisons = Array.from({ length: 101 }, (_, comparisonIndex) =>
|
||||
comparison(
|
||||
"compare_process_captures",
|
||||
processResult({ status: "changed", terminal: "changed" }),
|
||||
{
|
||||
salt: comparisonIndex,
|
||||
evidenceLinks: Array.from(
|
||||
{ length: 200 },
|
||||
(_, linkIndex) =>
|
||||
`ev_${(comparisonIndex * 200 + linkIndex + 1)
|
||||
.toString(16)
|
||||
.padStart(64, "0")}`,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import {
|
|||
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
|
||||
/** Strict bounded input for aggregating existing comparison Evidence. */
|
||||
/** Input for aggregating existing comparison Evidence. */
|
||||
export const changedBehaviorInputSchema = z.object({
|
||||
comparisons: z.array(evidenceSchema).max(100).default([]),
|
||||
comparisons: z.array(evidenceSchema).default([]),
|
||||
});
|
||||
|
||||
const findingSchema = z.object({
|
||||
|
|
@ -28,7 +28,7 @@ const findingSchema = z.object({
|
|||
]),
|
||||
status: comparisonStatusSchema,
|
||||
source_comparison_id: evidenceIdSchema,
|
||||
evidence_links: z.array(evidenceIdSchema).min(3).max(201),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export const changedBehaviorResultSchema = z
|
|||
findings: z.object({
|
||||
items: z.array(findingSchema),
|
||||
}),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3).max(20_100),
|
||||
evidence_links: z.array(evidenceIdSchema).min(3),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((result, context) => {
|
||||
|
|
@ -125,8 +125,6 @@ export const findChangedBehavior = (
|
|||
const links = uniqueSorted(
|
||||
evidence.flatMap((item) => [item.evidence_id, ...item.evidence_links]),
|
||||
);
|
||||
if (links.length > 20_100)
|
||||
throw new TypeError("Changed-behavior Evidence closure exceeds limit");
|
||||
return changedBehaviorResultSchema.parse({
|
||||
behavior_status: behaviorStatus(runtimeStatuses),
|
||||
summary: {
|
||||
|
|
|
|||
|
|
@ -9,17 +9,17 @@ import {
|
|||
type EvidenceCompletionLedger,
|
||||
} from "./evidenceCompletionLedger.js";
|
||||
|
||||
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,99}$/u);
|
||||
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const identitySchema = z.strictObject({
|
||||
id: identifierSchema,
|
||||
version: z.string().min(1).max(100),
|
||||
version: z.string().min(1),
|
||||
});
|
||||
const environmentSchema = z.strictObject({
|
||||
platform: identifierSchema,
|
||||
architecture: identifierSchema,
|
||||
runtime: identifierSchema,
|
||||
runtime_version: z.string().min(1).max(100),
|
||||
runtime_version: z.string().min(1),
|
||||
});
|
||||
const scenarioSchema = z.strictObject({
|
||||
id: identifierSchema,
|
||||
|
|
@ -55,7 +55,7 @@ const verifierRunSchema = z
|
|||
launcher_pid: z.number().int().positive(),
|
||||
launcher_parent_pid: z.number().int().nonnegative(),
|
||||
process_group_id: z.number().int().positive().nullable(),
|
||||
reason: z.string().min(1).max(500),
|
||||
reason: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
|
@ -76,7 +76,7 @@ const verifierRunSchema = z
|
|||
const reportClaimSchema = z.strictObject({
|
||||
claim_id: identifierSchema,
|
||||
scenario: scenarioSchema,
|
||||
artifact_sha256s: z.array(digestSchema).max(100),
|
||||
artifact_sha256s: z.array(digestSchema),
|
||||
provider: identitySchema,
|
||||
status: evidenceCompletionRecordSchema.shape.status,
|
||||
evidence_ids: evidenceCompletionRecordSchema.shape.evidence_ids,
|
||||
|
|
@ -86,7 +86,7 @@ const completionVerifierReportObjectSchema = z.strictObject({
|
|||
verifier: identitySchema,
|
||||
verifier_run: verifierRunSchema,
|
||||
environment: environmentSchema,
|
||||
claims: z.array(reportClaimSchema).min(1).max(10_000),
|
||||
claims: z.array(reportClaimSchema).min(1),
|
||||
});
|
||||
|
||||
/** Strict output contract implemented by completion-aware verifiers. */
|
||||
|
|
@ -113,8 +113,8 @@ const completionManifestObjectSchema = z.strictObject({
|
|||
manifest_id: z.string().regex(/^ecm_[a-f0-9]{64}$/u),
|
||||
verifier: identitySchema,
|
||||
environment: environmentSchema,
|
||||
skill_digests: z.array(skillDigestSchema).min(1).max(100),
|
||||
claims: z.array(manifestClaimSchema).min(1).max(10_000),
|
||||
skill_digests: z.array(skillDigestSchema).min(1),
|
||||
claims: z.array(manifestClaimSchema).min(1),
|
||||
});
|
||||
type CompletionManifestValue = z.infer<typeof completionManifestObjectSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { err, ok, type Result } from "./result.js";
|
|||
|
||||
const conformancePackageIdSchema = z.string().regex(/^cp_[a-f0-9]{64}$/u);
|
||||
|
||||
const scenarioIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,99}$/u);
|
||||
const scenarioIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
|
||||
|
||||
const scenarioManifestSchema = z.strictObject({
|
||||
scenario_id: scenarioIdSchema,
|
||||
|
|
@ -35,8 +35,7 @@ const replayPlanSchema = z.strictObject({
|
|||
timeout_ms: z.number().int().positive(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
.min(1),
|
||||
/** Environment variables to set (never inherit host paths). */
|
||||
environment: z.record(z.string(), z.string()).default({}),
|
||||
});
|
||||
|
|
@ -44,22 +43,20 @@ const replayPlanSchema = z.strictObject({
|
|||
const shimPlanSchema = z.strictObject({
|
||||
scenario_id: scenarioIdSchema,
|
||||
/** Shims to install, each intercepting a named effect. */
|
||||
shims: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
shim_id: z.string().min(1),
|
||||
kind: z.enum(["filesystem", "network", "process", "signal"]),
|
||||
target: z.string().min(1),
|
||||
policy: z.enum(["observe", "allow", "block", "emulate"]),
|
||||
}),
|
||||
)
|
||||
.max(50),
|
||||
shims: z.array(
|
||||
z.strictObject({
|
||||
shim_id: z.string().min(1),
|
||||
kind: z.enum(["filesystem", "network", "process", "signal"]),
|
||||
target: z.string().min(1),
|
||||
policy: z.enum(["observe", "allow", "block", "emulate"]),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const expectedEvidenceSchema = z.strictObject({
|
||||
scenario_id: scenarioIdSchema,
|
||||
/** Expected evidence envelopes. */
|
||||
envelopes: z.array(evidenceEnvelopeSchema).max(100),
|
||||
envelopes: z.array(evidenceEnvelopeSchema),
|
||||
/** Expected evidence bundle. */
|
||||
bundle: evidenceBundleSchema.nullable(),
|
||||
/** Required dimensions that must be present in the evidence. */
|
||||
|
|
@ -77,8 +74,7 @@ const verifierContractSchema = z.strictObject({
|
|||
comparison: z.enum(["exact", "semantic", "fuzzy"]),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
.min(1),
|
||||
/** Tolerance for timing differences in milliseconds. */
|
||||
timing_tolerance_ms: z.number().int().nonnegative().default(0),
|
||||
});
|
||||
|
|
@ -88,15 +84,15 @@ const conformancePackageContentsSchema = z.strictObject({
|
|||
description: z.string().min(1),
|
||||
created_at: z.string().datetime(),
|
||||
/** Scenario manifests. */
|
||||
scenarios: z.array(scenarioManifestSchema).min(1).max(100),
|
||||
scenarios: z.array(scenarioManifestSchema).min(1),
|
||||
/** Exactly one replay plan for each scenario. */
|
||||
replay_plans: z.array(replayPlanSchema).min(1).max(100),
|
||||
replay_plans: z.array(replayPlanSchema).min(1),
|
||||
/** At most one optional shim plan for each scenario. */
|
||||
shim_plans: z.array(shimPlanSchema).default([]),
|
||||
/** Exactly one expected-evidence record for each scenario. */
|
||||
expected_evidence: z.array(expectedEvidenceSchema).min(1).max(100),
|
||||
expected_evidence: z.array(expectedEvidenceSchema).min(1),
|
||||
/** Exactly one verifier contract for each scenario. */
|
||||
verifier_contracts: z.array(verifierContractSchema).min(1).max(100),
|
||||
verifier_contracts: z.array(verifierContractSchema).min(1),
|
||||
});
|
||||
|
||||
const conformancePackageRecordSchema = conformancePackageContentsSchema.extend({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { z } from "zod";
|
|||
const absolutePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16_384)
|
||||
.refine(isAbsolute, "path must be absolute");
|
||||
|
||||
const windowIndexSchema = z.number().int().min(0);
|
||||
|
|
@ -13,7 +12,6 @@ const windowIndexSchema = z.number().int().min(0);
|
|||
const deepLinkUrlSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.refine((value) => {
|
||||
try {
|
||||
return new URL(value).protocol.length > 0;
|
||||
|
|
@ -24,29 +22,29 @@ const deepLinkUrlSchema = z
|
|||
|
||||
const actionSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
step_id: z.string().min(1).max(128),
|
||||
step_id: z.string().min(1),
|
||||
kind: z.literal("click"),
|
||||
selector: z.string().min(1).max(512),
|
||||
selector: z.string().min(1),
|
||||
window_index: windowIndexSchema.default(0),
|
||||
}),
|
||||
z.strictObject({
|
||||
step_id: z.string().min(1).max(128),
|
||||
step_id: z.string().min(1),
|
||||
kind: z.literal("wait"),
|
||||
duration_ms: z.number().int().min(0),
|
||||
window_index: windowIndexSchema.optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
step_id: z.string().min(1).max(128),
|
||||
step_id: z.string().min(1),
|
||||
kind: z.literal("renderer-reload"),
|
||||
window_index: windowIndexSchema.default(0),
|
||||
}),
|
||||
z.strictObject({
|
||||
step_id: z.string().min(1).max(128),
|
||||
step_id: z.string().min(1),
|
||||
kind: z.literal("renderer-crash"),
|
||||
window_index: windowIndexSchema.default(0),
|
||||
}),
|
||||
z.strictObject({
|
||||
step_id: z.string().min(1).max(128),
|
||||
step_id: z.string().min(1),
|
||||
kind: z.literal("deep-link"),
|
||||
delivery: z.enum(["open-url", "second-instance"]),
|
||||
url: deepLinkUrlSchema,
|
||||
|
|
@ -58,7 +56,7 @@ export const electronActiveObservationInputSchema = z.strictObject({
|
|||
executable_path: absolutePathSchema,
|
||||
application_path: absolutePathSchema,
|
||||
application_root: absolutePathSchema,
|
||||
args: z.array(z.string().max(4_096)).default([]),
|
||||
args: z.array(z.string()).default([]),
|
||||
actions: z.array(actionSchema).default([]),
|
||||
});
|
||||
export type ElectronActiveObservationInput = z.infer<
|
||||
|
|
@ -67,7 +65,7 @@ export type ElectronActiveObservationInput = z.infer<
|
|||
|
||||
const ipcEventSchema = z.strictObject({
|
||||
sequence: z.number().int().min(1),
|
||||
correlation_id: z.string().max(256).nullable().default(null),
|
||||
correlation_id: z.string().nullable().default(null),
|
||||
kind: z.enum([
|
||||
"main-handler-invocation",
|
||||
"main-event-invocation",
|
||||
|
|
@ -79,13 +77,12 @@ const ipcEventSchema = z.strictObject({
|
|||
"ipc-renderer-invoke",
|
||||
"ipc-renderer-post-message",
|
||||
]),
|
||||
event: z.string().max(128).nullable().optional(),
|
||||
event: z.string().nullable().optional(),
|
||||
phase: z
|
||||
.enum(["attempted", "completed", "blocked", "failed", "observed"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
channel: z.string().max(1_024).nullable(),
|
||||
channel_truncated: z.boolean().default(false),
|
||||
channel: z.string().nullable(),
|
||||
direction: z
|
||||
.enum([
|
||||
"renderer-to-main",
|
||||
|
|
@ -95,19 +92,18 @@ const ipcEventSchema = z.strictObject({
|
|||
])
|
||||
.nullable()
|
||||
.optional(),
|
||||
sender: z.string().max(256).nullable().optional(),
|
||||
receiver: z.string().max(256).nullable().optional(),
|
||||
frame: z.string().max(256).nullable().default(null),
|
||||
target: z.string().max(256).nullable().optional(),
|
||||
argument_shapes: z.array(z.string().max(64)).max(32),
|
||||
argument_shapes_truncated: z.boolean().default(false),
|
||||
result_shape: z.string().max(64).nullable(),
|
||||
process_type: z.string().max(128).nullable(),
|
||||
source: z.string().max(64).default("electron-active-hook"),
|
||||
sender: z.string().nullable().optional(),
|
||||
receiver: z.string().nullable().optional(),
|
||||
frame: z.string().nullable().default(null),
|
||||
target: z.string().nullable().optional(),
|
||||
argument_shapes: z.array(z.string()),
|
||||
result_shape: z.string().nullable(),
|
||||
process_type: z.string().nullable(),
|
||||
source: z.string().default("electron-active-hook"),
|
||||
capture_method: z
|
||||
.enum(["api-wrapper", "event-emitter", "process-hook"])
|
||||
.default("api-wrapper"),
|
||||
artifact_path: z.string().max(16_384).nullable().default(null),
|
||||
artifact_path: z.string().nullable().default(null),
|
||||
artifact_sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
|
|
@ -118,7 +114,7 @@ const ipcEventSchema = z.strictObject({
|
|||
|
||||
const timelineEventSchema = z.strictObject({
|
||||
sequence: z.number().int().min(1),
|
||||
correlation_id: z.string().max(256).nullable().default(null),
|
||||
correlation_id: z.string().nullable().default(null),
|
||||
kind: z.enum([
|
||||
"main-handler-invocation",
|
||||
"main-event-invocation",
|
||||
|
|
@ -144,10 +140,9 @@ const timelineEventSchema = z.strictObject({
|
|||
"updater",
|
||||
"error",
|
||||
]),
|
||||
event: z.string().max(128).nullable(),
|
||||
event: z.string().nullable(),
|
||||
phase: z.enum(["attempted", "completed", "blocked", "failed", "observed"]),
|
||||
channel: z.string().max(1_024).nullable(),
|
||||
channel_truncated: z.boolean(),
|
||||
channel: z.string().nullable(),
|
||||
direction: z
|
||||
.enum([
|
||||
"renderer-to-main",
|
||||
|
|
@ -156,17 +151,16 @@ const timelineEventSchema = z.strictObject({
|
|||
"utility-to-main",
|
||||
])
|
||||
.nullable(),
|
||||
sender: z.string().max(256).nullable(),
|
||||
receiver: z.string().max(256).nullable(),
|
||||
frame: z.string().max(256).nullable(),
|
||||
target: z.string().max(256).nullable(),
|
||||
argument_shapes: z.array(z.string().max(64)).max(32),
|
||||
argument_shapes_truncated: z.boolean(),
|
||||
result_shape: z.string().max(64).nullable(),
|
||||
process_type: z.string().max(128).nullable(),
|
||||
source: z.string().max(64),
|
||||
sender: z.string().nullable(),
|
||||
receiver: z.string().nullable(),
|
||||
frame: z.string().nullable(),
|
||||
target: z.string().nullable(),
|
||||
argument_shapes: z.array(z.string()),
|
||||
result_shape: z.string().nullable(),
|
||||
process_type: z.string().nullable(),
|
||||
source: z.string(),
|
||||
capture_method: z.enum(["api-wrapper", "event-emitter", "process-hook"]),
|
||||
artifact_path: z.string().max(16_384).nullable(),
|
||||
artifact_path: z.string().nullable(),
|
||||
artifact_sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
|
|
@ -176,16 +170,16 @@ const timelineEventSchema = z.strictObject({
|
|||
|
||||
const processMetricSchema = z.strictObject({
|
||||
pid: z.number().int().min(1),
|
||||
type: z.string().min(1).max(128),
|
||||
name: z.string().max(256).nullable(),
|
||||
service_name: z.string().max(256).nullable(),
|
||||
type: z.string().min(1),
|
||||
name: z.string().nullable(),
|
||||
service_name: z.string().nullable(),
|
||||
});
|
||||
|
||||
const windowResultSchema = z.strictObject({
|
||||
window_id: z.string().max(256),
|
||||
web_contents_id: z.string().max(256),
|
||||
url: z.string().max(65_536),
|
||||
title: z.string().max(16_384),
|
||||
window_id: z.string(),
|
||||
web_contents_id: z.string(),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
visible: z.boolean().nullable(),
|
||||
destroyed: z.boolean(),
|
||||
});
|
||||
|
|
@ -198,14 +192,14 @@ const coverageSchema = z.strictObject({
|
|||
"target_exited",
|
||||
"cleanup_failed",
|
||||
]),
|
||||
observed_event_families: z.array(z.string().max(128)).max(32),
|
||||
unavailable_event_families: z.array(z.string().max(128)).max(32),
|
||||
observed_roles: z.array(z.string().max(128)).max(16),
|
||||
observed_event_families: z.array(z.string()),
|
||||
unavailable_event_families: z.array(z.string()),
|
||||
observed_roles: z.array(z.string()),
|
||||
pre_capture_activity: z.literal("unavailable"),
|
||||
});
|
||||
|
||||
const actionResultContextShape = {
|
||||
step_id: z.string().min(1).max(128),
|
||||
step_id: z.string().min(1),
|
||||
kind: z.enum([
|
||||
"click",
|
||||
"wait",
|
||||
|
|
@ -214,7 +208,7 @@ const actionResultContextShape = {
|
|||
"deep-link",
|
||||
]),
|
||||
window_index: windowIndexSchema.nullable(),
|
||||
target: z.string().max(256).nullable(),
|
||||
target: z.string().nullable(),
|
||||
elapsed_ms: z.number().int().min(0),
|
||||
};
|
||||
|
||||
|
|
@ -236,7 +230,7 @@ export const electronActiveObservationResultSchema = z.strictObject({
|
|||
application: z.strictObject({
|
||||
executable_path: absolutePathSchema,
|
||||
application_path: absolutePathSchema,
|
||||
electron_version: z.string().min(1).max(256),
|
||||
electron_version: z.string().min(1),
|
||||
process_ownership: z.literal("provider-owned"),
|
||||
cleanup: z.literal("terminated-owned-process"),
|
||||
}),
|
||||
|
|
@ -262,7 +256,7 @@ export const electronActiveObservationResultSchema = z.strictObject({
|
|||
observed_roles: [],
|
||||
pre_capture_activity: "unavailable",
|
||||
}),
|
||||
limitations: z.array(z.string().max(4_096)).max(100),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type ElectronActiveObservationResult = z.infer<
|
||||
typeof electronActiveObservationResultSchema
|
||||
|
|
|
|||
|
|
@ -324,14 +324,7 @@ export class EvidenceFileError extends AnalysisError {
|
|||
|
||||
constructor(
|
||||
readonly operation: "read" | "write",
|
||||
readonly reason:
|
||||
| "disabled"
|
||||
| "outside-root"
|
||||
| "not-file"
|
||||
| "too-large"
|
||||
| "exists"
|
||||
| "invalid-json"
|
||||
| "io",
|
||||
readonly reason: "not-file" | "exists" | "invalid-json" | "io",
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Evidence bundle ${operation} failed: ${reason}`, options);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,42 @@ import {
|
|||
getDescendants,
|
||||
maxTreeDepth,
|
||||
reconstructProcessTree,
|
||||
type ProcessNode,
|
||||
type ProcessTreeEvent,
|
||||
} from "./eventProcessTree.js";
|
||||
|
||||
describe("event-backed process tree reconstruction", () => {
|
||||
it("walks trees larger than ten thousand nodes", () => {
|
||||
const childPids = Array.from({ length: 10_001 }, (_, index) => index + 2);
|
||||
const root: ProcessNode = {
|
||||
pid: 1,
|
||||
ppid: null,
|
||||
process_name: "root",
|
||||
executable: null,
|
||||
arguments: [],
|
||||
children: childPids,
|
||||
spawn_time_ms: null,
|
||||
exit_time_ms: null,
|
||||
exit_code: null,
|
||||
is_re_exec: false,
|
||||
is_reparented: false,
|
||||
previous_ppid: null,
|
||||
};
|
||||
const children = childPids.map(
|
||||
(pid): ProcessNode => ({
|
||||
...root,
|
||||
pid,
|
||||
ppid: 1,
|
||||
children: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(maxTreeDepth([root, ...children], 1)).toBe(1);
|
||||
expect(getDescendants([root, ...children], 1)).toHaveLength(
|
||||
childPids.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("reconstructs a simple process tree", () => {
|
||||
const events: ProcessTreeEvent[] = [
|
||||
{
|
||||
|
|
@ -78,7 +110,9 @@ describe("event-backed process tree reconstruction", () => {
|
|||
expect(result.events_unmatched).toBe(0);
|
||||
expect(result.short_lived_descendants).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("short-lived process descendants", () => {
|
||||
it("detects short-lived descendants", () => {
|
||||
const events: ProcessTreeEvent[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export const processTreeReconstructionSchema = z.strictObject({
|
|||
/** Root PID of the process tree. */
|
||||
root_pid: z.number().int().positive(),
|
||||
/** All reconstructed process nodes. */
|
||||
processes: z.array(processNodeSchema).min(0).max(10_000),
|
||||
processes: z.array(processNodeSchema).min(0),
|
||||
/** Events that were consumed. */
|
||||
events_consumed: z.number().int().nonnegative(),
|
||||
/** Events that could not be matched. */
|
||||
|
|
@ -207,9 +207,9 @@ export function getDescendants(
|
|||
const visited = new Set<number>();
|
||||
const result: number[] = [];
|
||||
const queue: number[] = [rootPid];
|
||||
while (queue.length > 0) {
|
||||
const pid = queue.shift();
|
||||
if (pid === undefined) break;
|
||||
for (let head = 0; head < queue.length; head += 1) {
|
||||
const pid = queue[head];
|
||||
if (pid === undefined) continue;
|
||||
if (visited.has(pid)) continue;
|
||||
visited.add(pid);
|
||||
const node = byPid.get(pid);
|
||||
|
|
@ -239,12 +239,24 @@ export function maxTreeDepth(
|
|||
rootPid: number,
|
||||
): number {
|
||||
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
||||
function depth(pid: number, visited: Set<number>): number {
|
||||
if (visited.has(pid)) return 0;
|
||||
visited.add(pid);
|
||||
const node = byPid.get(pid);
|
||||
if (!node || node.children.length === 0) return 0;
|
||||
return 1 + Math.max(...node.children.map((c) => depth(c, visited)));
|
||||
const pending = [{ pid: rootPid, depth: 0 }];
|
||||
const visited = new Set<number>();
|
||||
let maximumDepth = 0;
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (!current || visited.has(current.pid)) continue;
|
||||
visited.add(current.pid);
|
||||
|
||||
const node = byPid.get(current.pid);
|
||||
if (!node || node.children.length === 0) continue;
|
||||
|
||||
const childDepth = current.depth + 1;
|
||||
maximumDepth = Math.max(maximumDepth, childDepth);
|
||||
for (const childPid of node.children)
|
||||
if (!visited.has(childPid))
|
||||
pending.push({ pid: childPid, depth: childDepth });
|
||||
}
|
||||
return depth(rootPid, new Set());
|
||||
|
||||
return maximumDepth;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,19 @@ describe("Evidence completion ledger", () => {
|
|||
expect(left.ledger_id).toMatch(/^ecl_[a-f0-9]{64}$/u);
|
||||
});
|
||||
|
||||
it("accepts every Evidence ID supplied for one claim", () => {
|
||||
const evidenceIds = Array.from(
|
||||
{ length: 101 },
|
||||
(_, index) => `ev_${index.toString(16).padStart(64, "0")}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
createEvidenceCompletionLedger([
|
||||
record("scenario.many-evidence-items", "pass", evidenceIds),
|
||||
]).records[0]?.evidence_ids,
|
||||
).toHaveLength(evidenceIds.length);
|
||||
});
|
||||
|
||||
it("rejects missing, malformed, and duplicate Evidence IDs", () => {
|
||||
expect(() =>
|
||||
createEvidenceCompletionLedger([record("scenario.empty", "pass", [])]),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import canonicalize from "canonicalize";
|
|||
import { z } from "zod";
|
||||
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
const claimIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,99}$/u);
|
||||
const claimIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
|
||||
|
||||
/** Terminal outcome recorded for one verifier claim. */
|
||||
export const evidenceCompletionStatusSchema = z.enum([
|
||||
|
|
@ -19,11 +19,11 @@ export const evidenceCompletionStatusSchema = z.enum([
|
|||
export const evidenceCompletionRecordSchema = z.strictObject({
|
||||
claim_id: claimIdSchema,
|
||||
status: evidenceCompletionStatusSchema,
|
||||
evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
evidence_ids: z.array(evidenceIdSchema).min(1),
|
||||
});
|
||||
|
||||
const completionSummarySchema = z.strictObject({
|
||||
total: z.number().int().min(1).max(10_000),
|
||||
total: z.number().int().min(1),
|
||||
pass: z.number().int().nonnegative(),
|
||||
fail: z.number().int().nonnegative(),
|
||||
unsupported: z.number().int().nonnegative(),
|
||||
|
|
@ -34,7 +34,7 @@ const completionSummarySchema = z.strictObject({
|
|||
|
||||
const completionLedgerObjectSchema = z.strictObject({
|
||||
ledger_id: z.string().regex(/^ecl_[a-f0-9]{64}$/u),
|
||||
records: z.array(evidenceCompletionRecordSchema).min(1).max(10_000),
|
||||
records: z.array(evidenceCompletionRecordSchema).min(1),
|
||||
summary: completionSummarySchema,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -122,11 +122,7 @@ describe("function comparison", () => {
|
|||
}),
|
||||
]),
|
||||
);
|
||||
for (const dimension of result.dimensions)
|
||||
expect(dimension.evidence_links).toEqual([
|
||||
FUNCTION_COMPARISON_EXAMPLE.left.evidence_id,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right.evidence_id,
|
||||
]);
|
||||
expectInlineComparisonEvidence(result);
|
||||
expectFunctionDimensionAlgebra(result);
|
||||
});
|
||||
|
||||
|
|
@ -252,6 +248,35 @@ describe("function comparison", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const expectInlineComparisonEvidence = (
|
||||
result: ReturnType<typeof compareFunctions>,
|
||||
): void => {
|
||||
for (const dimension of result.dimensions)
|
||||
expect(dimension.evidence_links).toEqual([
|
||||
FUNCTION_COMPARISON_EXAMPLE.left.evidence_id,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right.evidence_id,
|
||||
]);
|
||||
const firstDimension = result.dimensions[0];
|
||||
expect(firstDimension).toBeDefined();
|
||||
if (firstDimension === undefined) return;
|
||||
expect(
|
||||
functionComparisonResultSchema.safeParse({
|
||||
...result,
|
||||
dimensions: result.dimensions.map((dimension, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...dimension,
|
||||
evidence_links: Array.from(
|
||||
{ length: 201 },
|
||||
() => FUNCTION_COMPARISON_EXAMPLE.left.evidence_id,
|
||||
),
|
||||
}
|
||||
: dimension,
|
||||
),
|
||||
}).success,
|
||||
).toBe(true);
|
||||
};
|
||||
|
||||
const expectFunctionDimensionAlgebra = (
|
||||
result: ReturnType<typeof compareFunctions>,
|
||||
): void => {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const functionDimensionContextShape = {
|
|||
dimension: dimensionNameSchema,
|
||||
left_count: z.number().int().min(0).nullable(),
|
||||
right_count: z.number().int().min(0).nullable(),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2).max(200),
|
||||
evidence_links: z.array(evidenceIdSchema).min(2),
|
||||
limitations: z.array(z.string()),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -45,10 +45,7 @@ const electronBoundarySummarySchema = z.strictObject({
|
|||
const reconstructionStatisticsSchema = z.strictObject({
|
||||
relevant_files: countSchema,
|
||||
nested_asar_containers: countSchema,
|
||||
text_files_selected: countSchema,
|
||||
text_bytes_read: countSchema,
|
||||
omitted_text_files: countSchema,
|
||||
limit_omitted_text_files: countSchema,
|
||||
invalid_utf8_files: countSchema,
|
||||
parsed_javascript_files: countSchema,
|
||||
visited_ast_nodes: countSchema,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const boundedTextSchema = z.string().min(1).max(4_096);
|
||||
const boundedKeySchema = z.string().min(1).max(512);
|
||||
const boundedTextSchema = z.string().min(1);
|
||||
const boundedKeySchema = z.string().min(1);
|
||||
const unsafePathSegments = new Set(["", ".", ".."]);
|
||||
const isSafeRelativePath = (path: string): boolean => {
|
||||
if (path.startsWith("/") || path.includes("\\")) return false;
|
||||
|
|
@ -11,7 +11,6 @@ const isSafeRelativePath = (path: string): boolean => {
|
|||
const relativePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.refine(
|
||||
isSafeRelativePath,
|
||||
"Expected a normalized relative POSIX path without traversal",
|
||||
|
|
@ -138,7 +137,7 @@ const applicationLocationSchema = z.discriminatedUnion("kind", [
|
|||
fileOffsetRangeSchema,
|
||||
z.strictObject({
|
||||
kind: z.literal("url"),
|
||||
url: z.string().min(1).max(16_384),
|
||||
url: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("runtime"),
|
||||
|
|
@ -170,14 +169,14 @@ const applicationLocationReferenceSchema = z.discriminatedUnion("available", [
|
|||
|
||||
/** Versioned extractor coordinates attached to every graph fact. */
|
||||
const applicationExtractorSchema = z.strictObject({
|
||||
name: z.string().min(1).max(128),
|
||||
version: z.string().min(1).max(128),
|
||||
operation: z.string().min(1).max(256),
|
||||
name: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
operation: z.string().min(1),
|
||||
executable_sha256: digestSchema.nullable(),
|
||||
});
|
||||
|
||||
const applicationLimitSchema = z.strictObject({
|
||||
name: z.string().min(1).max(128),
|
||||
name: z.string().min(1),
|
||||
value: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
unit: z.enum([
|
||||
"items",
|
||||
|
|
@ -265,8 +264,8 @@ const applicationGraphEvidenceBaseSchema = z.strictObject({
|
|||
location: applicationLocationReferenceSchema,
|
||||
extractor: applicationExtractorSchema,
|
||||
coverage: applicationCoverageSchema,
|
||||
limitations: z.array(boundedTextSchema).max(100),
|
||||
evidence_ids: z.array(z.string().regex(/^ev_[a-f0-9]{64}$/u)).max(100),
|
||||
limitations: z.array(boundedTextSchema),
|
||||
evidence_ids: z.array(z.string().regex(/^ev_[a-f0-9]{64}$/u)),
|
||||
});
|
||||
|
||||
type EvidenceValue = z.infer<typeof applicationGraphEvidenceBaseSchema>;
|
||||
|
|
@ -431,7 +430,7 @@ export const applicationNodeIdentitySchema = z.discriminatedUnion("strategy", [
|
|||
z.strictObject({
|
||||
strategy: z.literal("structural-fingerprint"),
|
||||
stability: z.literal("cross-version-inference"),
|
||||
algorithm: z.string().min(1).max(128),
|
||||
algorithm: z.string().min(1),
|
||||
fingerprint_sha256: digestSchema,
|
||||
basis: z
|
||||
.array(
|
||||
|
|
@ -444,8 +443,7 @@ export const applicationNodeIdentitySchema = z.discriminatedUnion("strategy", [
|
|||
"api-usage",
|
||||
]),
|
||||
)
|
||||
.min(1)
|
||||
.max(6),
|
||||
.min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
strategy: z.literal("artifact-local-key"),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
computeJavaScriptApplicationGraphSha256,
|
||||
createJavaScriptApplicationGraph,
|
||||
createJavaScriptApplicationNode,
|
||||
javascriptApplicationGraphSchema,
|
||||
parseJavaScriptApplicationGraph,
|
||||
serializeJavaScriptApplicationGraph,
|
||||
|
|
@ -13,7 +14,11 @@ import {
|
|||
JAVASCRIPT_APPLICATION_NODE_KINDS,
|
||||
JAVASCRIPT_APPLICATION_RELATIONS,
|
||||
} from "./javascriptApplicationGraphSchemas.js";
|
||||
import { buildSyntheticJavaScriptApplicationGraph } from "./javascriptApplicationGraph.fixture.js";
|
||||
import {
|
||||
APPLICATION_GRAPH_DIGESTS,
|
||||
artifactEvidence,
|
||||
buildSyntheticJavaScriptApplicationGraph,
|
||||
} from "./javascriptApplicationGraph.fixture.js";
|
||||
|
||||
const nodeByLabel = (
|
||||
graph: JavaScriptApplicationGraph,
|
||||
|
|
@ -26,6 +31,54 @@ const nodeByLabel = (
|
|||
return node;
|
||||
};
|
||||
|
||||
const createLargeNativeExportGraph = (): JavaScriptApplicationGraph => {
|
||||
const properties = Object.fromEntries(
|
||||
Array.from({ length: 1_001 }, (_, index) => [
|
||||
`${"export-property-"}${"k".repeat(120)}-${index}`,
|
||||
"v".repeat(4_097),
|
||||
]),
|
||||
);
|
||||
const nodes = Array.from({ length: 1_001 }, (_, index) => {
|
||||
const path = `native/exports/${index}.symbol`;
|
||||
return createJavaScriptApplicationNode({
|
||||
kind: "native-export",
|
||||
identity: {
|
||||
strategy: "canonical-path",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: APPLICATION_GRAPH_DIGESTS.nativeAddon,
|
||||
path,
|
||||
},
|
||||
observations: [
|
||||
{
|
||||
label: index === 0 ? "large export graph label ".repeat(300) : path,
|
||||
properties: index === 0 ? properties : {},
|
||||
evidence: artifactEvidence(
|
||||
APPLICATION_GRAPH_DIGESTS.nativeAddon,
|
||||
path,
|
||||
"native-analysis-provider",
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
const first = nodes[0];
|
||||
if (first === undefined) throw new TypeError("Missing native export");
|
||||
|
||||
return createJavaScriptApplicationGraph({
|
||||
schema: "JavaScriptApplicationGraph",
|
||||
root_node_ids: [first.node_id],
|
||||
nodes,
|
||||
edges: [],
|
||||
coverage: {
|
||||
status: "complete",
|
||||
truncated: false,
|
||||
omitted_count: 0,
|
||||
limits: [],
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
};
|
||||
|
||||
describe("JavaScript Application Graph", () => {
|
||||
it("defines the complete provider-neutral node and relation vocabulary", () => {
|
||||
expect(JAVASCRIPT_APPLICATION_NODE_KINDS).toEqual([
|
||||
|
|
@ -102,6 +155,20 @@ describe("JavaScript Application Graph", () => {
|
|||
).toEqual(graph);
|
||||
});
|
||||
|
||||
it("keeps large native-export graphs and their complete properties", () => {
|
||||
const graph = createLargeNativeExportGraph();
|
||||
const largeExport = graph.nodes.find(({ observations }) =>
|
||||
observations.some(({ label }) =>
|
||||
label?.startsWith("large export graph label "),
|
||||
),
|
||||
);
|
||||
|
||||
expect(graph.nodes).toHaveLength(1_001);
|
||||
expect(
|
||||
Object.keys(largeExport?.observations[0]?.properties ?? {}),
|
||||
).toHaveLength(1_001);
|
||||
});
|
||||
|
||||
it("represents the synthetic ASAR to preload to IPC to native chain", () => {
|
||||
const graph = buildSyntheticJavaScriptApplicationGraph();
|
||||
const labels = [
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const contentNode = (
|
|||
});
|
||||
|
||||
describe("JavaScript Application Graph", () => {
|
||||
it("rejects false truncation claims and unbounded properties", () => {
|
||||
it("rejects false truncation claims and accepts large properties", () => {
|
||||
const truncated = artifactEvidence(
|
||||
APPLICATION_GRAPH_DIGESTS.asar,
|
||||
"dist/module.js",
|
||||
|
|
@ -75,11 +75,11 @@ describe("JavaScript Application Graph", () => {
|
|||
const properties = Object.fromEntries(
|
||||
Array.from({ length: 65 }, (_, index) => [`key-${String(index)}`, index]),
|
||||
);
|
||||
expect(() =>
|
||||
expect(
|
||||
contentNode(
|
||||
artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "dist/module.js"),
|
||||
properties,
|
||||
),
|
||||
).toThrow(/exceed 64 keys/u);
|
||||
).observations[0]?.properties,
|
||||
).toEqual(properties);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import {
|
|||
applicationGraphEvidenceSchema,
|
||||
applicationNodeIdentitySchema,
|
||||
} from "./javascriptApplicationEvidenceSchemas.js";
|
||||
import { isJsonWithinLimits } from "./jsonLimits.js";
|
||||
import { isJsonWithinDepth } from "./jsonLimits.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const boundedTextSchema = z.string().min(1).max(4_096);
|
||||
const boundedTextSchema = z.string().min(1);
|
||||
|
||||
/** Node kinds admitted by JavaScript Application Graph. */
|
||||
export const JAVASCRIPT_APPLICATION_NODE_KINDS = [
|
||||
|
|
@ -71,29 +71,18 @@ export const JAVASCRIPT_APPLICATION_RELATIONS = [
|
|||
const applicationRelationSchema = z.enum(JAVASCRIPT_APPLICATION_RELATIONS);
|
||||
|
||||
const applicationPropertiesSchema = z
|
||||
.record(z.string().min(1).max(128), jsonValueSchema)
|
||||
.record(z.string().min(1), jsonValueSchema)
|
||||
.superRefine((properties, context) => {
|
||||
if (Object.keys(properties).length > 64)
|
||||
if (!isJsonWithinDepth(properties, 6))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Application graph properties exceed 64 keys",
|
||||
});
|
||||
if (
|
||||
!isJsonWithinLimits(properties, {
|
||||
maxDepth: 6,
|
||||
maxNodes: 512,
|
||||
maxStringLength: 4_096,
|
||||
})
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Application graph properties exceed structural limits",
|
||||
message: "Application graph properties exceed the supported depth",
|
||||
});
|
||||
});
|
||||
|
||||
/** One node observation before its semantic identifier is derived. */
|
||||
const applicationNodeObservationInputSchema = z.strictObject({
|
||||
label: z.string().min(1).max(1_024).nullable(),
|
||||
label: z.string().min(1).nullable(),
|
||||
properties: applicationPropertiesSchema,
|
||||
evidence: applicationGraphEvidenceSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { parse } from "@babel/parser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fingerprintJavaScriptAst } from "../../src/domain/javascriptAstFingerprint.js";
|
||||
|
||||
describe("JavaScript AST fingerprints", () => {
|
||||
it("includes complete literal values in the fingerprint", () => {
|
||||
const prefix = "x".repeat(1_024);
|
||||
const left = parse(`const value = ${JSON.stringify(`${prefix}left`)};`);
|
||||
const right = parse(`const value = ${JSON.stringify(`${prefix}right`)};`);
|
||||
|
||||
expect(fingerprintJavaScriptAst(left, 100).sha256).not.toBe(
|
||||
fingerprintJavaScriptAst(right, 100).sha256,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -55,24 +55,24 @@ export const collectJavaScriptExports = (node: t.Node): StaticExports => {
|
|||
|
||||
const semanticTokens = (node: t.Node): string[] => {
|
||||
const tokens = [`node:${node.type}`];
|
||||
if (t.isStringLiteral(node)) tokens.push(`string:${bounded(node.value)}`);
|
||||
if (t.isStringLiteral(node)) tokens.push(`string:${node.value}`);
|
||||
else if (t.isNumericLiteral(node))
|
||||
tokens.push(`number:${String(node.value)}`);
|
||||
else if (t.isBooleanLiteral(node))
|
||||
tokens.push(`boolean:${String(node.value)}`);
|
||||
else if (t.isRegExpLiteral(node))
|
||||
tokens.push(`regexp:${bounded(node.pattern)}/${node.flags}`);
|
||||
tokens.push(`regexp:${node.pattern}/${node.flags}`);
|
||||
else if (t.isBinaryExpression(node) || t.isLogicalExpression(node))
|
||||
tokens.push(`operator:${node.operator}`);
|
||||
else if (t.isUnaryExpression(node) || t.isUpdateExpression(node))
|
||||
tokens.push(`operator:${node.operator}`);
|
||||
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||
const property = propertyName(node.property);
|
||||
if (property !== "") tokens.push(`property:${bounded(property)}`);
|
||||
if (property !== "") tokens.push(`property:${property}`);
|
||||
}
|
||||
if ((t.isObjectProperty(node) || t.isObjectMethod(node)) && !node.computed) {
|
||||
const key = propertyName(node.key);
|
||||
if (key !== "") tokens.push(`key:${bounded(key)}`);
|
||||
if (key !== "") tokens.push(`key:${key}`);
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
|
|
@ -156,7 +156,5 @@ const propertyName = (node: t.Node): string => {
|
|||
const stringValue = (node: t.Node | null | undefined): string | undefined =>
|
||||
t.isStringLiteral(node) ? node.value : undefined;
|
||||
|
||||
const bounded = (value: string): string => value.slice(0, 1_024);
|
||||
|
||||
const compareCodePoints = (left: string, right: string): number =>
|
||||
left < right ? -1 : left > right ? 1 : 0;
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ const requestedExports = (nodes: readonly ApplicationNode[]): string[] => {
|
|||
...stringArray(observation.properties.members),
|
||||
]),
|
||||
);
|
||||
return [...new Set(values)].sort(compareCodePoints).slice(0, 1_000);
|
||||
return [...new Set(values)].sort(compareCodePoints);
|
||||
};
|
||||
|
||||
const stringArray = (value: unknown): string[] =>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ import { z } from "zod";
|
|||
import { evidenceEnvelopeSchema } from "./evidence.js";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const absolutePathSchema = z.string().startsWith("/").max(4096);
|
||||
const absolutePathSchema = z.string().startsWith("/");
|
||||
const aliasSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(/^[A-Za-z0-9_./:@-]+$/u);
|
||||
|
||||
const replayModuleSchema = z
|
||||
|
|
@ -16,7 +15,7 @@ const replayModuleSchema = z
|
|||
path: absolutePathSchema,
|
||||
format: z.enum(["esm", "commonjs-factory"]),
|
||||
role: z.enum(["module", "stub"]).default("module"),
|
||||
dependencies: z.record(z.string().min(1).max(256), aliasSchema).default({}),
|
||||
dependencies: z.record(z.string().min(1), aliasSchema).default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -24,13 +23,13 @@ const replaySideSchema = z
|
|||
.object({
|
||||
modules: z.array(replayModuleSchema).min(1),
|
||||
entry_alias: aliasSchema,
|
||||
entry_export: z.string().min(1).max(256).default("default"),
|
||||
entry_export: z.string().min(1).default("default"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const explicitCaseSchema = z
|
||||
.object({
|
||||
case_id: z.string().min(1).max(128),
|
||||
case_id: z.string().min(1),
|
||||
arguments: z.array(z.json()),
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -43,59 +42,59 @@ const generatorSchema = z
|
|||
"clipboard-boundaries",
|
||||
]),
|
||||
seed: z.number().int().min(0).max(0xffff_ffff),
|
||||
count: z.number().int().min(1).max(64),
|
||||
count: z.number().int().safe().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const replayLimitsSchema = z
|
||||
.object({
|
||||
wall_time_ms: z.number().int().min(100).max(10_000).default(3_000),
|
||||
wall_time_ms: z.number().int().safe().min(1).default(3_000),
|
||||
memory_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(16 * 1024 * 1024)
|
||||
.max(512 * 1024 * 1024)
|
||||
.safe()
|
||||
.positive()
|
||||
.default(128 * 1024 * 1024),
|
||||
tasks: z.number().int().min(1).max(32).default(8),
|
||||
cpu_quota_percent: z.number().int().min(1).max(100).default(50),
|
||||
tasks: z.number().int().safe().min(1).default(8),
|
||||
cpu_quota_percent: z.number().int().safe().min(1).default(50),
|
||||
tmpfs_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1024 * 1024)
|
||||
.max(64 * 1024 * 1024)
|
||||
.safe()
|
||||
.min(1)
|
||||
.default(16 * 1024 * 1024),
|
||||
module_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.safe()
|
||||
.min(1)
|
||||
.max(16 * 1024 * 1024)
|
||||
.default(4 * 1024 * 1024),
|
||||
input_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.safe()
|
||||
.min(1)
|
||||
.max(1024 * 1024)
|
||||
.default(256 * 1024),
|
||||
protocol_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1024 * 1024)
|
||||
.max(64 * 1024 * 1024)
|
||||
.safe()
|
||||
.min(1)
|
||||
.default(16 * 1024 * 1024),
|
||||
output_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.safe()
|
||||
.min(1)
|
||||
.max(4 * 1024 * 1024)
|
||||
.default(512 * 1024),
|
||||
stderr_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.safe()
|
||||
.min(0)
|
||||
.max(256 * 1024)
|
||||
.default(32 * 1024),
|
||||
result_depth: z.number().int().min(1).max(64).default(16),
|
||||
result_nodes: z.number().int().min(1).max(100_000).default(10_000),
|
||||
result_depth: z.number().int().safe().min(1).default(16),
|
||||
result_nodes: z.number().int().safe().min(1).default(10_000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -258,7 +257,6 @@ const replayPlanSchema = z
|
|||
.object({
|
||||
path: z.string().min(1),
|
||||
include_sources: z.boolean(),
|
||||
authority: z.literal("evidence_write"),
|
||||
})
|
||||
.strict()
|
||||
.nullable(),
|
||||
|
|
|
|||
|
|
@ -7,24 +7,21 @@ import {
|
|||
browserOriginSchema,
|
||||
} from "./browserObservation.js";
|
||||
|
||||
const boundedTextSchema = z.string().min(1).max(4_096);
|
||||
const observationTextSchema = z.string().min(1);
|
||||
|
||||
const runtimeFileRootsSchema = z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16_384)
|
||||
.refine(isAbsolute, "Runtime file roots must be absolute paths")
|
||||
.overwrite(resolve),
|
||||
)
|
||||
.max(32)
|
||||
.overwrite((roots) => [...new Set(roots)].sort())
|
||||
.default([]);
|
||||
|
||||
const runtimeOriginsSchema = z
|
||||
.array(browserOriginSchema)
|
||||
.max(32)
|
||||
.overwrite((origins) => [...new Set(origins)].sort())
|
||||
.default([]);
|
||||
|
||||
|
|
@ -72,24 +69,21 @@ const observedJavaScriptRuntimeKindSchema = javascriptRuntimeKindSchema.or(
|
|||
z.literal("unknown"),
|
||||
);
|
||||
|
||||
/** Maximum accepted source location byte length in one inspector event. */
|
||||
export const MAX_JAVASCRIPT_RUNTIME_LOCATION_BYTES = 16_384;
|
||||
|
||||
/** Input for one bounded, attach-only V8 Inspector observation. */
|
||||
export const observeJavaScriptRuntimeToolInputSchema = z.strictObject({
|
||||
discovery_evidence_id: z.string().min(1),
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
target_id: z.string().trim().min(1),
|
||||
runtime_kind: javascriptRuntimeKindSchema.optional(),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(100),
|
||||
observation_ms: z.number().int().min(0).default(100),
|
||||
});
|
||||
|
||||
/** Input after discovery Evidence supplies the authorized target scope. */
|
||||
export const observeJavaScriptRuntimeInputSchema = z
|
||||
.strictObject({
|
||||
...runtimeScope,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
target_id: z.string().trim().min(1),
|
||||
runtime_kind: javascriptRuntimeKindSchema.optional(),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(100),
|
||||
observation_ms: z.number().int().min(0).default(100),
|
||||
})
|
||||
.superRefine(requireRuntimeScope);
|
||||
export type ObserveJavaScriptRuntimeInput = z.infer<
|
||||
|
|
@ -97,25 +91,25 @@ export type ObserveJavaScriptRuntimeInput = z.infer<
|
|||
>;
|
||||
|
||||
export const javascriptRuntimeVersionSchema = z.strictObject({
|
||||
product: z.string().min(1).max(1_024),
|
||||
protocol_version: z.string().min(1).max(100),
|
||||
v8_version: z.string().max(1_024).nullable(),
|
||||
product: z.string().min(1),
|
||||
protocol_version: z.string().min(1),
|
||||
v8_version: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const javascriptRuntimeLocationSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("file"),
|
||||
file_path: z.string().min(1).max(16_384),
|
||||
file_path: z.string().min(1),
|
||||
authority: z.literal("scope-fallback").optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("url"),
|
||||
origin: z.string().min(1).max(2_048),
|
||||
sanitized_url: z.string().min(1).max(65_536),
|
||||
origin: z.string().min(1),
|
||||
sanitized_url: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("builtin"),
|
||||
specifier: z.string().min(1).max(4_096),
|
||||
specifier: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
export type JavaScriptRuntimeLocation = z.infer<
|
||||
|
|
@ -123,8 +117,8 @@ export type JavaScriptRuntimeLocation = z.infer<
|
|||
>;
|
||||
|
||||
const javascriptRuntimeTargetSchema = z.strictObject({
|
||||
target_id: z.string().min(1).max(256),
|
||||
protocol_type: z.string().min(1).max(100),
|
||||
target_id: z.string().min(1),
|
||||
protocol_type: z.string().min(1),
|
||||
attached: z.boolean(),
|
||||
location: javascriptRuntimeLocationSchema,
|
||||
});
|
||||
|
|
@ -139,7 +133,7 @@ export const javascriptRuntimeTargetListSchema = z.strictObject({
|
|||
unsupported_location: z.number().int().min(0),
|
||||
unconnectable: z.number().int().min(0),
|
||||
}),
|
||||
limitations: z.array(boundedTextSchema).max(100),
|
||||
limitations: z.array(observationTextSchema),
|
||||
});
|
||||
export type JavaScriptRuntimeTargetList = z.infer<
|
||||
typeof javascriptRuntimeTargetListSchema
|
||||
|
|
@ -148,18 +142,18 @@ export type JavaScriptRuntimeTargetList = z.infer<
|
|||
const javascriptRuntimeScriptSchema = z.strictObject({
|
||||
script_key: z.string().regex(/^v8_script_[a-f0-9]{64}$/u),
|
||||
location: javascriptRuntimeLocationSchema,
|
||||
execution_context_key: z.string().max(64).nullable(),
|
||||
cdp_hash: z.string().max(512).nullable(),
|
||||
execution_context_key: z.string().nullable(),
|
||||
cdp_hash: z.string().nullable(),
|
||||
length: z.number().int().min(0),
|
||||
is_module: z.boolean(),
|
||||
status: z.literal("observed-loaded"),
|
||||
});
|
||||
|
||||
const javascriptRuntimeContextSchema = z.strictObject({
|
||||
context_key: z.string().min(1).max(64),
|
||||
context_key: z.string().min(1),
|
||||
state: z.enum(["created", "destroyed", "cleared"]),
|
||||
name: z.string().max(1_024).nullable(),
|
||||
origin: z.string().max(4_096).nullable(),
|
||||
name: z.string().nullable(),
|
||||
origin: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Deterministic passive script/context snapshot from one bounded window. */
|
||||
|
|
@ -179,7 +173,7 @@ export const javascriptRuntimeObservationSchema = z.strictObject({
|
|||
events_dropped: z.number().int().min(0),
|
||||
metadata_bytes_retained: z.number().int().min(0),
|
||||
truncated: z.boolean(),
|
||||
truncation_reasons: z.array(boundedTextSchema).max(20),
|
||||
truncation_reasons: z.array(observationTextSchema),
|
||||
}),
|
||||
scripts: z.strictObject({
|
||||
items: z.array(javascriptRuntimeScriptSchema),
|
||||
|
|
@ -192,10 +186,10 @@ export const javascriptRuntimeObservationSchema = z.strictObject({
|
|||
}),
|
||||
}),
|
||||
execution_contexts: z.array(javascriptRuntimeContextSchema),
|
||||
directly_observed: z.array(boundedTextSchema).max(20),
|
||||
unavailable_without_instrumentation: z.array(boundedTextSchema).max(20),
|
||||
unknowns: z.array(boundedTextSchema).max(100),
|
||||
limitations: z.array(boundedTextSchema).max(100),
|
||||
directly_observed: z.array(observationTextSchema),
|
||||
unavailable_without_instrumentation: z.array(observationTextSchema),
|
||||
unknowns: z.array(observationTextSchema),
|
||||
limitations: z.array(observationTextSchema),
|
||||
});
|
||||
export type JavaScriptRuntimeObservation = z.infer<
|
||||
typeof javascriptRuntimeObservationSchema
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const parseActiveElectronCapture = (
|
|||
};
|
||||
};
|
||||
|
||||
const absolutePathSchema = z.string().min(1).max(16_384).refine(isAbsolute);
|
||||
const absolutePathSchema = z.string().min(1).refine(isAbsolute);
|
||||
|
||||
const assertIdentity = (evidence: Evidence): void => {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -279,11 +279,9 @@ const assertV8RuntimeParameters = (
|
|||
const parameters = z
|
||||
.object({
|
||||
inspector_endpoint: browserEndpointSchema,
|
||||
allowed_file_roots: z
|
||||
.array(z.string().min(1).max(16_384).refine(isAbsolute))
|
||||
.max(32),
|
||||
allowed_origins: z.array(browserOriginSchema).max(32),
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
allowed_file_roots: z.array(z.string().min(1).refine(isAbsolute)),
|
||||
allowed_origins: z.array(browserOriginSchema),
|
||||
target_id: z.string().trim().min(1),
|
||||
runtime_kind: javascriptRuntimeKindSchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
|
@ -405,7 +403,7 @@ const assertRuntimeParameters = (
|
|||
},
|
||||
): void => {
|
||||
const common = {
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
target_id: z.string().trim().min(1),
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
include_script_sources: z.boolean(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ const targetEntity = (
|
|||
label: target.title,
|
||||
properties: {
|
||||
runtime_type: target.type,
|
||||
location: location.value.slice(0, 4_096),
|
||||
location: location.value,
|
||||
attached: target.attached,
|
||||
},
|
||||
section: "target",
|
||||
|
|
@ -117,7 +117,7 @@ const frameEntities = (
|
|||
label: location.value,
|
||||
properties: {
|
||||
parent_frame_id: frame.parent_frame_id,
|
||||
location: location.value.slice(0, 4_096),
|
||||
location: location.value,
|
||||
},
|
||||
section: "frames",
|
||||
});
|
||||
|
|
@ -145,7 +145,7 @@ const scriptEntities = (
|
|||
: null,
|
||||
label: location.value,
|
||||
properties: {
|
||||
location: location.value.slice(0, 4_096),
|
||||
location: location.value,
|
||||
cdp_hash: script.cdp_hash,
|
||||
length: script.length,
|
||||
is_module: script.is_module,
|
||||
|
|
@ -186,7 +186,7 @@ const workerEntities = (
|
|||
label: location.value,
|
||||
properties: {
|
||||
runtime_type: worker.type,
|
||||
location: location.value.slice(0, 4_096),
|
||||
location: location.value,
|
||||
attached: worker.attached,
|
||||
opener_target_id: worker.opener_target_id,
|
||||
parent_frame_id: worker.parent_frame_id,
|
||||
|
|
@ -233,7 +233,7 @@ const runtimeEntity = (
|
|||
},
|
||||
observations: [
|
||||
{
|
||||
label: input.label.slice(0, 1_024) || null,
|
||||
label: input.label || null,
|
||||
properties: input.properties,
|
||||
evidence: runtimeEvidence(capture, input),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ import {
|
|||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
const nodeIdSchema = z.string().regex(/^jag_node_[a-f0-9]{64}$/u);
|
||||
const boundedTextSchema = z.string().min(1).max(4_096);
|
||||
const boundedTextSchema = z.string().min(1);
|
||||
const safePrefixSchema = z
|
||||
.string()
|
||||
.max(4_096)
|
||||
.refine(
|
||||
(value) =>
|
||||
value === "" ||
|
||||
|
|
@ -29,7 +28,6 @@ const runtimeFileMappingSchema = z.strictObject({
|
|||
root: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16_384)
|
||||
.refine(isAbsolute, "Runtime file mapping root must be absolute")
|
||||
.overwrite((value) => resolve(value)),
|
||||
artifact_prefix: safePrefixSchema.default(""),
|
||||
|
|
@ -40,7 +38,6 @@ const runtimeUrlMappingSchema = z.strictObject({
|
|||
prefix: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16_384)
|
||||
.refine(
|
||||
(value) => normalizeRuntimeUrlPrefix(value) !== null,
|
||||
"Runtime URL prefix must be HTTP(S) without credentials, query, or fragment",
|
||||
|
|
@ -144,7 +141,7 @@ const layerSummarySchema = z.strictObject({
|
|||
evidence_id: evidenceIdSchema,
|
||||
graph_id: z.string().regex(/^jag_[a-f0-9]{64}$/u),
|
||||
root_artifact_sha256: digestSchema,
|
||||
input_path: z.string().min(1).max(16_384),
|
||||
input_path: z.string().min(1),
|
||||
format: z.enum(["asar", "directory"]),
|
||||
runtime_mappings: z.array(runtimeLocationMappingSchema),
|
||||
});
|
||||
|
|
@ -155,7 +152,7 @@ const captureSummarySchema = z.strictObject({
|
|||
kind: z.enum(["browser", "electron", "v8-inspector", "electron-active"]),
|
||||
target_node_id: nodeIdSchema,
|
||||
target_key: boundedTextSchema,
|
||||
target_location: z.string().min(1).max(131_072),
|
||||
target_location: z.string().min(1),
|
||||
frames: z.number().int().min(0),
|
||||
scripts: z.number().int().min(0),
|
||||
workers: z.number().int().min(0),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { applicationGraphEvidenceSchema } from "./javascriptApplicationEvidenceSchemas.js";
|
||||
import { isJsonWithinLimits } from "./jsonLimits.js";
|
||||
import { isJsonWithinDepth } from "./jsonLimits.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const boundedTextSchema = z.string().min(1).max(4_096);
|
||||
const boundedTextSchema = z.string().min(1);
|
||||
const semanticNodeIdSchema = z.string().regex(/^jsrg_node_[a-f0-9]{64}$/u);
|
||||
const semanticRelationIdSchema = z
|
||||
.string()
|
||||
|
|
@ -192,18 +192,12 @@ const sourceRangeSchema = z
|
|||
});
|
||||
|
||||
const semanticPropertiesSchema = z
|
||||
.record(z.string().min(1).max(128), jsonValueSchema)
|
||||
.record(z.string().min(1), jsonValueSchema)
|
||||
.superRefine((properties, context) => {
|
||||
if (
|
||||
!isJsonWithinLimits(properties, {
|
||||
maxDepth: 6,
|
||||
maxNodes: 512,
|
||||
maxStringLength: 4_096,
|
||||
})
|
||||
)
|
||||
if (!isJsonWithinDepth(properties, 6))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Semantic properties exceed structural limits",
|
||||
message: "Semantic properties exceed the supported depth",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -211,7 +205,7 @@ const semanticNodeIdentitySchema = z.strictObject({
|
|||
artifact_sha256: digestSchema,
|
||||
module_path: relativePathSchema,
|
||||
source_range: sourceRangeSchema.nullable(),
|
||||
role_key: z.string().min(1).max(1_024),
|
||||
role_key: z.string().min(1),
|
||||
});
|
||||
|
||||
/** One semantic entity before its canonical identifier is derived. */
|
||||
|
|
@ -220,7 +214,7 @@ export const javaScriptSemanticNodeInputSchema = z.strictObject({
|
|||
identity: semanticNodeIdentitySchema,
|
||||
function_node_id: semanticNodeIdSchema.nullable(),
|
||||
application_node_ids: z.array(z.string().regex(/^jag_node_[a-f0-9]{64}$/u)),
|
||||
label: z.string().min(1).max(1_024).nullable(),
|
||||
label: z.string().min(1).nullable(),
|
||||
properties: semanticPropertiesSchema,
|
||||
evidence: applicationGraphEvidenceSchema,
|
||||
});
|
||||
|
|
@ -271,7 +265,7 @@ export const JAVASCRIPT_SEMANTIC_UNKNOWN_REASONS = [
|
|||
export const javaScriptSemanticUnknownInputSchema = z.strictObject({
|
||||
node_id: semanticNodeIdSchema.nullable(),
|
||||
family: z.enum(JAVASCRIPT_SEMANTIC_RELATION_FAMILIES),
|
||||
relation_kinds: z.array(z.enum(JAVASCRIPT_SEMANTIC_RELATIONS)).min(1).max(45),
|
||||
relation_kinds: z.array(z.enum(JAVASCRIPT_SEMANTIC_RELATIONS)).min(1),
|
||||
reason: z.enum(JAVASCRIPT_SEMANTIC_UNKNOWN_REASONS),
|
||||
detail: boundedTextSchema,
|
||||
candidate_node_ids: z.array(semanticNodeIdSchema),
|
||||
|
|
@ -285,24 +279,22 @@ export const javaScriptSemanticUnknownSchema =
|
|||
});
|
||||
|
||||
const fingerprintComponentsSchema = z.strictObject({
|
||||
parameter_arity: z.number().int().min(0).max(10_000),
|
||||
parameter_arity: z.number().int().min(0),
|
||||
normalized_ast_sha256: digestSchema,
|
||||
control_flow_sha256: digestSchema,
|
||||
relation_shape_sha256: digestSchema,
|
||||
literal_set_sha256: digestSchema,
|
||||
effects: z
|
||||
.array(
|
||||
z.enum([
|
||||
"async",
|
||||
"child-process",
|
||||
"event",
|
||||
"network",
|
||||
"promise",
|
||||
"resource",
|
||||
"timer",
|
||||
]),
|
||||
)
|
||||
.max(7),
|
||||
effects: z.array(
|
||||
z.enum([
|
||||
"async",
|
||||
"child-process",
|
||||
"event",
|
||||
"network",
|
||||
"promise",
|
||||
"resource",
|
||||
"timer",
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
/** One bounded rename-resistant function fingerprint. */
|
||||
|
|
@ -311,7 +303,7 @@ export const javaScriptSemanticFingerprintInputSchema = z.strictObject({
|
|||
algorithm: z.literal("rea.javascript-semantic-function"),
|
||||
status: z.enum(["complete", "partial", "unavailable"]),
|
||||
components: fingerprintComponentsSchema,
|
||||
limitations: z.array(boundedTextSchema).max(100),
|
||||
limitations: z.array(boundedTextSchema),
|
||||
evidence: applicationGraphEvidenceSchema,
|
||||
});
|
||||
|
||||
|
|
@ -337,7 +329,7 @@ const graphCoverageSchema = z.strictObject({
|
|||
omitted_relations: z.number().int().min(0).nullable(),
|
||||
limits: z.array(
|
||||
z.strictObject({
|
||||
name: z.string().min(1).max(128),
|
||||
name: z.string().min(1),
|
||||
value: z.number().int().min(0),
|
||||
unit: z.enum(["items", "bytes", "milliseconds", "depth", "other"]),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isJsonWithinLimits } from "./jsonLimits.js";
|
||||
import { isJsonWithinDepth, isJsonWithinLimits } from "./jsonLimits.js";
|
||||
|
||||
const limits = {
|
||||
maxDepth: 3,
|
||||
|
|
@ -9,6 +9,18 @@ const limits = {
|
|||
};
|
||||
|
||||
describe("JSON limits", () => {
|
||||
it("checks depth without rejecting large keys, collections, or strings", () => {
|
||||
const properties = Object.fromEntries(
|
||||
Array.from({ length: 1_001 }, (_, index) => [
|
||||
`${"k".repeat(128)}${index}`,
|
||||
"v".repeat(4_097),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(isJsonWithinDepth(properties, 6)).toBe(true);
|
||||
expect(isJsonWithinDepth({ nested: { value: true } }, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("traverses nested object and array values", () => {
|
||||
expect(isJsonWithinLimits({ one: ["two", { three: 3 }] }, limits)).toBe(
|
||||
true,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,22 @@ export interface JsonLimits {
|
|||
readonly maxNodes: number;
|
||||
}
|
||||
|
||||
/** Return whether a JSON value fits a depth constraint without size quotas. */
|
||||
export const isJsonWithinDepth = (root: unknown, maxDepth: number): boolean => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
if (current.depth > maxDepth) return false;
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const value of Object.values(current.value))
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Return whether every node, key, and string fits within the supplied limits. */
|
||||
export const isJsonWithinLimits = (
|
||||
root: unknown,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ describe("managed member comparison", () => {
|
|||
expect(result.limitations).toContain(
|
||||
"Names are reported as observations but are not used as a matching basis.",
|
||||
);
|
||||
expect(
|
||||
managedMemberComparisonResultSchema.safeParse({
|
||||
...result,
|
||||
limitations: Array.from({ length: 101 }, () => "x".repeat(4_097)),
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses structural method shape when build-local token operands drift", () => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { keyMembers, sha256 } from "./managedMemberComparisonMatch.js";
|
|||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
|
||||
const boundedTextSchema = z.string().min(1).max(4_096);
|
||||
const boundedTextSchema = z.string().min(1);
|
||||
|
||||
/** Two authenticated managed member observations. */
|
||||
export const compareManagedMembersInputSchema = z
|
||||
|
|
@ -113,26 +113,24 @@ const comparisonItemSchema = <
|
|||
|
||||
const comparisonItemContextShape = {
|
||||
evidence_links: z.array(evidenceIdSchema).length(2),
|
||||
limitations: z.array(boundedTextSchema).max(100),
|
||||
limitations: z.array(boundedTextSchema),
|
||||
};
|
||||
|
||||
const methodComparisonItemSchema = comparisonItemSchema(
|
||||
{
|
||||
...comparisonItemContextShape,
|
||||
item_id: z.string().regex(/^mmc_method_[a-f0-9]{64}$/u),
|
||||
dimensions: z
|
||||
.array(
|
||||
z.enum([
|
||||
"signature",
|
||||
"cil",
|
||||
"opcode-shape",
|
||||
"call-shape",
|
||||
"field-shape",
|
||||
"exception-shape",
|
||||
"availability",
|
||||
]),
|
||||
)
|
||||
.max(7),
|
||||
dimensions: z.array(
|
||||
z.enum([
|
||||
"signature",
|
||||
"cil",
|
||||
"opcode-shape",
|
||||
"call-shape",
|
||||
"field-shape",
|
||||
"exception-shape",
|
||||
"availability",
|
||||
]),
|
||||
),
|
||||
},
|
||||
memberIdentitySchema,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEvidence } from "./evidence.js";
|
||||
import { managedReconstructionImportInputSchema } from "./managedReconstruction.js";
|
||||
|
||||
describe("managed reconstruction import input", () => {
|
||||
it("accepts more than the former 50-method and 100-note ceilings", () => {
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "inspect_managed_members",
|
||||
parameters: {},
|
||||
result: {},
|
||||
},
|
||||
);
|
||||
const method = {
|
||||
token: "0x06000001",
|
||||
signature_sha256: "a".repeat(64),
|
||||
normalized_il_sha256: null,
|
||||
reconstruction: {
|
||||
kind: "semantic-pseudocode",
|
||||
language: "pseudocode",
|
||||
text: "return;",
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = managedReconstructionImportInputSchema.parse({
|
||||
static_members: evidence,
|
||||
decompiler: {
|
||||
name: "Fixture decompiler",
|
||||
version: null,
|
||||
family: "other",
|
||||
executable_sha256: null,
|
||||
options: Array.from({ length: 51 }, (_, index) => `option-${index}`),
|
||||
},
|
||||
methods: Array.from({ length: 51 }, (_, index) => ({
|
||||
...method,
|
||||
token: `0x${(0x06000001 + index).toString(16).padStart(8, "0")}`,
|
||||
signature_sha256: index.toString(16).padStart(64, "0"),
|
||||
})),
|
||||
notes: Array.from({ length: 101 }, (_, index) => `note-${index}`),
|
||||
});
|
||||
|
||||
expect(parsed.methods).toHaveLength(51);
|
||||
expect(parsed.decompiler.options).toHaveLength(51);
|
||||
expect(parsed.notes).toHaveLength(101);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,7 +22,7 @@ const decompilerSchema = z.strictObject({
|
|||
version: z.string().min(1).max(128).nullable(),
|
||||
family: z.enum(["ilspy", "dnspy", "dnlib", "mono-cecil", "other"]),
|
||||
executable_sha256: digestSchema.nullable(),
|
||||
options: z.array(z.string().max(512)).max(50).default([]),
|
||||
options: z.array(z.string().max(512)).default([]),
|
||||
});
|
||||
|
||||
const managedReconstructionMethodInputSchema = z.strictObject({
|
||||
|
|
@ -47,8 +47,8 @@ type ManagedReconstructionMethodInput = z.infer<
|
|||
export const managedReconstructionImportInputSchema = z.strictObject({
|
||||
static_members: evidenceSchema,
|
||||
decompiler: decompilerSchema,
|
||||
methods: z.array(managedReconstructionMethodInputSchema).min(1).max(50),
|
||||
notes: z.array(z.string().min(1).max(4_096)).max(100).default([]),
|
||||
methods: z.array(managedReconstructionMethodInputSchema).min(1),
|
||||
notes: z.array(z.string().min(1).max(4_096)).default([]),
|
||||
});
|
||||
|
||||
const importedMethodSchema = z.strictObject({
|
||||
|
|
@ -95,16 +95,16 @@ export const managedReconstructionImportResultSchema = z.strictObject({
|
|||
options_sha256: digestSchema,
|
||||
}),
|
||||
summary: z.strictObject({
|
||||
imported_methods: z.number().int().min(1).max(50),
|
||||
decompiled_csharp_methods: z.number().int().min(0).max(50),
|
||||
decompiled_il_methods: z.number().int().min(0).max(50),
|
||||
pseudocode_methods: z.number().int().min(0).max(50),
|
||||
imported_methods: z.number().int().min(1),
|
||||
decompiled_csharp_methods: z.number().int().min(0),
|
||||
decompiled_il_methods: z.number().int().min(0),
|
||||
pseudocode_methods: z.number().int().min(0),
|
||||
total_text_bytes: z.number().int().min(1),
|
||||
}),
|
||||
methods: z.array(importedMethodSchema).min(1).max(50),
|
||||
notes: z.array(z.string().min(1).max(4_096)).max(100),
|
||||
methods: z.array(importedMethodSchema).min(1),
|
||||
notes: z.array(z.string().min(1).max(4_096)),
|
||||
evidence_links: z.array(evidenceIdSchema).length(1),
|
||||
limitations: z.array(z.string().min(1).max(4_096)).max(1_000),
|
||||
limitations: z.array(z.string().min(1).max(4_096)),
|
||||
});
|
||||
|
||||
export type ManagedReconstructionImportInput = z.infer<
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { JsonValue } from "./jsonValue.js";
|
|||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
|
||||
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
|
||||
const boundedTextSchema = z.string().min(1).max(4_096);
|
||||
const boundedTextSchema = z.string().min(1);
|
||||
|
||||
const managedRuntimeEffectSchema = z.enum([
|
||||
"attach",
|
||||
|
|
@ -30,9 +30,9 @@ const managedRuntimeHostSchema = z.strictObject({
|
|||
});
|
||||
|
||||
const managedRuntimeBoundsSchema = z.strictObject({
|
||||
timeout_ms: z.number().int().min(1).max(60_000).default(5_000),
|
||||
max_threads: z.number().int().min(1).max(256).default(32),
|
||||
max_output_bytes: z.number().int().min(0).max(1_048_576).default(65_536),
|
||||
timeout_ms: z.number().int().min(1).default(5_000),
|
||||
max_threads: z.number().int().min(1).default(32),
|
||||
max_output_bytes: z.number().int().min(0).default(65_536),
|
||||
allow_network: z.literal(false).default(false),
|
||||
allow_ui: z.literal(false).default(false),
|
||||
});
|
||||
|
|
@ -109,7 +109,7 @@ export const managedRuntimeCorrelationResultSchema = z.strictObject({
|
|||
bounds: managedRuntimeBoundsSchema,
|
||||
unsupported_until_executor_exists: z.literal(true),
|
||||
evidence_links: z.array(evidenceIdSchema).length(1),
|
||||
limitations: z.array(boundedTextSchema).max(1_000),
|
||||
limitations: z.array(boundedTextSchema),
|
||||
});
|
||||
|
||||
export type ManagedRuntimeCorrelationInput = z.infer<
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const nodeRuntimeCharacterizationPlanSchema =
|
|||
|
||||
export const nodeCharacterizationPreparationInputSchema = z
|
||||
.strictObject({
|
||||
selected_alias: z.string().min(1).max(200),
|
||||
selected_alias: z.string().min(1),
|
||||
expected_effect: nodeCharacterizationExpectedEffectSchema,
|
||||
instrumentation: javascriptExportInstrumentationInputSchema,
|
||||
replay: controlledReplayPlanInputSchema,
|
||||
|
|
|
|||
|
|
@ -453,10 +453,10 @@ const processCaptureShapeSchema: z.ZodType<UnverifiedProcessCapture> = z.object(
|
|||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
transition_id: z.string().min(1).max(64),
|
||||
state_before: z.string().min(1).max(64),
|
||||
state_after: z.string().min(1).max(64),
|
||||
sensitive_aliases: z.array(z.string().min(1).max(64)).max(32),
|
||||
transition_id: z.string().min(1),
|
||||
state_before: z.string().min(1),
|
||||
state_after: z.string().min(1),
|
||||
sensitive_aliases: z.array(z.string().min(1)),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ const processReactiveOutcomeSchema = z.enum([
|
|||
|
||||
const processReactiveTransitionShape = {
|
||||
sequence: z.number().int().nonnegative(),
|
||||
transition_id: z.string().min(1).max(64),
|
||||
state_before: z.string().min(1).max(64),
|
||||
transition_id: z.string().min(1),
|
||||
state_before: z.string().min(1),
|
||||
trigger_event_ids: z.array(z.string().min(1)),
|
||||
action_event_ids: z.array(z.string().min(1)),
|
||||
action_types: z.array(
|
||||
|
|
@ -32,7 +32,7 @@ const processReactiveTransitionShape = {
|
|||
const processReactiveTransitionSchema = z.union([
|
||||
z.object({
|
||||
...processReactiveTransitionShape,
|
||||
state_after: z.string().min(1).max(64),
|
||||
state_after: z.string().min(1),
|
||||
outcome: z.null(),
|
||||
}),
|
||||
z.object({
|
||||
|
|
@ -43,7 +43,7 @@ const processReactiveTransitionSchema = z.union([
|
|||
]);
|
||||
|
||||
const processReactiveRunShape = {
|
||||
active_state: z.string().min(1).max(64),
|
||||
active_state: z.string().min(1),
|
||||
controls: z
|
||||
.array(
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -173,6 +173,22 @@ describe("process reactive runtime predicates", () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
it("matches caller supplied terminal literals longer than the former schema cap", () => {
|
||||
const literal = "Ready".repeat(2_001);
|
||||
const scenario = scenarioWith([
|
||||
finish("long_literal", terminalTrigger(literal)),
|
||||
]);
|
||||
const decision = offer(
|
||||
scenario,
|
||||
createProcessReactiveSnapshot(scenario),
|
||||
observation("terminal_raw", 0, { data: `prefix${literal}suffix` }),
|
||||
);
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
kind: "transition",
|
||||
record: { transition_id: "long_literal" },
|
||||
});
|
||||
});
|
||||
it("evaluates ordered and repeated predicates without array-order arbitration", () => {
|
||||
const event = (name: string) => ({
|
||||
kind: "event" as const,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,56 @@ const baseScenario = () => ({
|
|||
],
|
||||
});
|
||||
|
||||
const buildLargeCallerScenario = () => {
|
||||
const states = Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `state_${String(index)}`,
|
||||
max_visits: 300,
|
||||
deadline_ms: 30_000,
|
||||
on: [] as unknown[],
|
||||
}));
|
||||
const manyPredicates = {
|
||||
kind: "repeat",
|
||||
trigger: {
|
||||
kind: "all",
|
||||
triggers: Array.from({ length: 20 }, () => terminalTrigger()),
|
||||
},
|
||||
min: 1,
|
||||
max: 1_000,
|
||||
};
|
||||
const sendInputs = Array.from({ length: 300 }, () => ({
|
||||
type: "send_input",
|
||||
data: "x",
|
||||
sensitive: false,
|
||||
}));
|
||||
states[0]!.on = Array.from({ length: 130 }, (_, index) => ({
|
||||
id: `transition_${String(index)}`,
|
||||
priority: index,
|
||||
max_uses: 300,
|
||||
when: index === 0 ? manyPredicates : terminalTrigger(),
|
||||
actions: index === 0 ? sendInputs : [],
|
||||
target:
|
||||
index > 0 && index <= 39
|
||||
? { kind: "goto", state: `state_${String(index)}` }
|
||||
: { kind: "finish", outcome: "passed" },
|
||||
}));
|
||||
for (let index = 1; index < states.length; index += 1) {
|
||||
const state = states[index]!;
|
||||
state.on = [
|
||||
{
|
||||
...finishTransition(),
|
||||
id: `finish_${String(index)}`,
|
||||
max_uses: 300,
|
||||
actions: [{ type: "checkpoint", name: `ready_${String(index)}` }],
|
||||
},
|
||||
];
|
||||
}
|
||||
return processReactiveScenarioSchema.safeParse({
|
||||
initial_state: "state_0",
|
||||
deadline_ms: 30_000,
|
||||
states,
|
||||
});
|
||||
};
|
||||
|
||||
describe("process reactive scenario schema", () => {
|
||||
it("parses a terminal scenario", () => {
|
||||
expect(processReactiveScenarioSchema.parse(baseScenario())).toMatchObject({
|
||||
|
|
@ -48,60 +98,49 @@ describe("process reactive scenario schema", () => {
|
|||
});
|
||||
|
||||
it("accepts caller-defined scenario sizes without count ceilings", () => {
|
||||
const states = Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `state_${String(index)}`,
|
||||
max_visits: 300,
|
||||
deadline_ms: 30_000,
|
||||
on: [] as unknown[],
|
||||
}));
|
||||
const manyPredicates = {
|
||||
kind: "repeat",
|
||||
trigger: {
|
||||
kind: "all",
|
||||
triggers: Array.from({ length: 20 }, () => terminalTrigger()),
|
||||
},
|
||||
min: 1,
|
||||
max: 1_000,
|
||||
};
|
||||
const sendInputs = Array.from({ length: 300 }, () => ({
|
||||
type: "send_input",
|
||||
data: "x",
|
||||
sensitive: false,
|
||||
}));
|
||||
const transitions = Array.from({ length: 130 }, (_, index) => ({
|
||||
id: `transition_${String(index)}`,
|
||||
priority: index,
|
||||
max_uses: 300,
|
||||
when: index === 0 ? manyPredicates : terminalTrigger(),
|
||||
actions: index === 0 ? sendInputs : [],
|
||||
target:
|
||||
index > 0 && index <= 39
|
||||
? { kind: "goto", state: `state_${String(index)}` }
|
||||
: { kind: "finish", outcome: "passed" },
|
||||
}));
|
||||
states[0]!.on = transitions;
|
||||
for (let index = 1; index < states.length; index += 1) {
|
||||
const state = states[index]!;
|
||||
state.on = [
|
||||
{
|
||||
...finishTransition(),
|
||||
id: `finish_${String(index)}`,
|
||||
max_uses: 300,
|
||||
actions: [{ type: "checkpoint", name: `ready_${String(index)}` }],
|
||||
},
|
||||
];
|
||||
}
|
||||
const parsed = processReactiveScenarioSchema.safeParse({
|
||||
initial_state: "state_0",
|
||||
deadline_ms: 30_000,
|
||||
states,
|
||||
});
|
||||
const parsed = buildLargeCallerScenario();
|
||||
expect(
|
||||
parsed.success,
|
||||
parsed.success ? "" : JSON.stringify(parsed.error.issues),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts long caller supplied identifiers, terminal matchers, and input", () => {
|
||||
const identifier = `transition_${"x".repeat(128)}`;
|
||||
const checkpoint = `checkpoint_${"x".repeat(128)}`;
|
||||
const literal = "Ready".repeat(2_001);
|
||||
const data = "input".repeat(200_001);
|
||||
const parsed = processReactiveScenarioSchema.safeParse({
|
||||
initial_state: "starting",
|
||||
deadline_ms: 30_000,
|
||||
states: [
|
||||
{
|
||||
id: "starting",
|
||||
max_visits: 1,
|
||||
deadline_ms: 5_000,
|
||||
on: [
|
||||
{
|
||||
...finishTransition(),
|
||||
id: identifier,
|
||||
when: { ...terminalTrigger(), literal },
|
||||
actions: [
|
||||
{ type: "checkpoint", name: checkpoint },
|
||||
{ type: "send_input", data, sensitive: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.states[0]?.on[0]?.id).toBe(identifier);
|
||||
expect(parsed.data.states[0]?.on[0]?.when).toMatchObject({ literal });
|
||||
expect(parsed.data.states[0]?.on[0]?.actions[1]).toMatchObject({ data });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["unknown major version", { ...baseScenario(), version: 2 }],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -12,10 +12,8 @@ export const PROCESS_REACTIVE_LIMITS = {
|
|||
jsonDepth: 256,
|
||||
} as const;
|
||||
|
||||
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u);
|
||||
const checkpointNameSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u);
|
||||
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
|
||||
const checkpointNameSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/u);
|
||||
const positiveRuntime = z.number().int().safe().positive();
|
||||
|
||||
const frontierSchema = z.discriminatedUnion("kind", [
|
||||
|
|
@ -93,7 +91,6 @@ const processReactiveTriggerSchema: z.ZodType<ProcessReactiveTrigger> = z.lazy(
|
|||
exact: jsonValueSchema,
|
||||
ignore_fields: z
|
||||
.array(ignoredFieldSchema)
|
||||
.max(5)
|
||||
.refine((values) => new Set(values).size === values.length, {
|
||||
message: "ignored event fields must be unique",
|
||||
}),
|
||||
|
|
@ -112,7 +109,7 @@ const processReactiveTriggerSchema: z.ZodType<ProcessReactiveTrigger> = z.lazy(
|
|||
kind: z.literal("terminal_text"),
|
||||
view: z.literal("decoded"),
|
||||
encoding: z.literal("utf8"),
|
||||
literal: z.string().min(1).max(10_000),
|
||||
literal: z.string().min(1),
|
||||
case_sensitive: z.literal(true),
|
||||
control_sequences: z.literal("include"),
|
||||
occurrence: z.number().int().safe().positive(),
|
||||
|
|
@ -156,13 +153,13 @@ const signalTargetSchema = z.discriminatedUnion("kind", [
|
|||
const processReactiveActionSchema = z.discriminatedUnion("type", [
|
||||
z.strictObject({
|
||||
type: z.literal("send_input"),
|
||||
data: z.string().max(1_000_000),
|
||||
data: z.string(),
|
||||
sensitive: z.boolean(),
|
||||
}),
|
||||
z.strictObject({
|
||||
type: z.literal("resize"),
|
||||
columns: z.number().int().min(1).max(1_000),
|
||||
rows: z.number().int().min(1).max(1_000),
|
||||
columns: z.number().int().positive(),
|
||||
rows: z.number().int().positive(),
|
||||
}),
|
||||
z.strictObject({ type: z.literal("close_stdin") }),
|
||||
z.strictObject({
|
||||
|
|
|
|||
|
|
@ -16,21 +16,52 @@ describe("process scenario collection inputs", () => {
|
|||
expect(scenario.checkpoints).toHaveLength(65);
|
||||
});
|
||||
|
||||
it("lets callers raise the output byte budget without a tool-side ceiling", () => {
|
||||
it("allows a zero-depth filesystem observation", () => {
|
||||
expect(
|
||||
processScenarioSchema.parse({
|
||||
executable: "/bin/echo",
|
||||
working_directory: "/tmp",
|
||||
limits: { filesystem_depth: 0 },
|
||||
}).limits.filesystem_depth,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts caller budgets above the former tool-side ceilings", () => {
|
||||
const scenario = processScenarioSchema.parse({
|
||||
executable: "/bin/echo",
|
||||
working_directory: "/tmp",
|
||||
limits: { output_bytes: 10_000_001 },
|
||||
timeout_ms: 600_000,
|
||||
idle_timeout_ms: 900_000,
|
||||
settle_ms: 20_000,
|
||||
limits: {
|
||||
output_bytes: 10_000_001,
|
||||
files: 100_001,
|
||||
file_bytes: 100_000_001,
|
||||
processes: 10_001,
|
||||
protocol_events: 100_001,
|
||||
protocol_body_bytes: 10_000_001,
|
||||
connections: 1_001,
|
||||
filesystem_depth: 100,
|
||||
},
|
||||
checkpoints: [
|
||||
{
|
||||
name: "later",
|
||||
trigger: { type: "time", at_ms: 600_000 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(scenario.limits.output_bytes).toBe(10_000_001);
|
||||
expect(
|
||||
processScenarioSchema.safeParse({
|
||||
executable: "/bin/echo",
|
||||
working_directory: "/tmp",
|
||||
limits: { frames: 100_001 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(scenario.limits.files).toBe(100_001);
|
||||
expect(scenario.limits.file_bytes).toBe(100_000_001);
|
||||
expect(scenario.limits.processes).toBe(10_001);
|
||||
expect(scenario.limits.protocol_events).toBe(100_001);
|
||||
expect(scenario.limits.protocol_body_bytes).toBe(10_000_001);
|
||||
expect(scenario.limits.connections).toBe(1_001);
|
||||
expect(scenario.limits.filesystem_depth).toBe(100);
|
||||
expect(scenario.timeout_ms).toBe(600_000);
|
||||
expect(scenario.idle_timeout_ms).toBe(900_000);
|
||||
expect(scenario.settle_ms).toBe(20_000);
|
||||
});
|
||||
|
||||
it("accepts large interaction and replay descriptions under the run budgets", () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import {
|
|||
} from "./processReactiveScenario.js";
|
||||
import { replayMachineSchema } from "./replayMachine.js";
|
||||
|
||||
const positiveBudget = z.number().int().positive();
|
||||
const positiveBudget = z.number().int().safe().positive();
|
||||
const maximumTimerDelay = 2_147_483_647;
|
||||
const timedEventBase = { at_ms: z.number().int().nonnegative() };
|
||||
const environmentName = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u);
|
||||
const reservedEnvironment = new Set([
|
||||
|
|
@ -23,11 +24,11 @@ export const normalizationSchema = z.object({
|
|||
paths: z.boolean(),
|
||||
pids: z.boolean(),
|
||||
ports: z.boolean(),
|
||||
time_bucket_ms: positiveBudget.max(60_000),
|
||||
time_bucket_ms: positiveBudget,
|
||||
patterns: z.array(
|
||||
z.object({
|
||||
pattern: z.string().max(500),
|
||||
replacement: z.string().max(100),
|
||||
pattern: z.string(),
|
||||
replacement: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
|
@ -38,9 +39,9 @@ const commandNameSchema = z
|
|||
.string()
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u);
|
||||
const outputChunkSchema = z.object({
|
||||
at_ms: z.number().int().nonnegative().max(300_000),
|
||||
at_ms: z.number().int().safe().nonnegative().max(maximumTimerDelay),
|
||||
stream: z.enum(["stdout", "stderr"]),
|
||||
data: z.string().max(1_000_000),
|
||||
data: z.string(),
|
||||
});
|
||||
|
||||
/** Compute a canonical SHA-256 commitment independent of object key order. */
|
||||
|
|
@ -128,9 +129,9 @@ export const processScenarioSchema = z
|
|||
filesystem_roots: z.array(z.string().startsWith("/")).default([]),
|
||||
terminal: z
|
||||
.object({
|
||||
columns: z.number().int().min(1).max(1_000).default(80),
|
||||
rows: z.number().int().min(1).max(1_000).default(24),
|
||||
scrollback: z.number().int().min(0).max(10_000).default(1_000),
|
||||
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),
|
||||
})
|
||||
.default({ columns: 80, rows: 24, scrollback: 1_000 }),
|
||||
checkpoints: z
|
||||
|
|
@ -140,11 +141,16 @@ export const processScenarioSchema = z
|
|||
trigger: z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("time"),
|
||||
at_ms: z.number().int().nonnegative().max(300_000),
|
||||
at_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.safe()
|
||||
.nonnegative()
|
||||
.max(maximumTimerDelay),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("terminal_literal"),
|
||||
value: z.string().min(1).max(10_000),
|
||||
value: z.string().min(1),
|
||||
occurrence: positiveBudget.default(1),
|
||||
}),
|
||||
z.object({ type: z.literal("root_exit") }),
|
||||
|
|
@ -191,8 +197,8 @@ export const processScenarioSchema = z
|
|||
z.object({
|
||||
...timedEventBase,
|
||||
type: z.literal("resize"),
|
||||
columns: z.number().int().min(1).max(1_000),
|
||||
rows: z.number().int().min(1).max(1_000),
|
||||
columns: z.number().int().min(1).max(65_535),
|
||||
rows: z.number().int().min(1).max(65_535),
|
||||
}),
|
||||
z.object({
|
||||
...timedEventBase,
|
||||
|
|
@ -202,19 +208,19 @@ export const processScenarioSchema = z
|
|||
]),
|
||||
)
|
||||
.default([]),
|
||||
timeout_ms: positiveBudget.max(300_000).default(30_000),
|
||||
idle_timeout_ms: positiveBudget.max(300_000).default(30_000),
|
||||
settle_ms: z.number().int().nonnegative().max(10_000).default(100),
|
||||
timeout_ms: positiveBudget.default(30_000),
|
||||
idle_timeout_ms: positiveBudget.default(30_000),
|
||||
settle_ms: z.number().int().safe().nonnegative().default(100),
|
||||
limits: z
|
||||
.object({
|
||||
output_bytes: positiveBudget.safe().default(1_000_000),
|
||||
files: positiveBudget.max(100_000).default(10_000),
|
||||
file_bytes: positiveBudget.max(100_000_000).default(10_000_000),
|
||||
processes: positiveBudget.max(10_000).default(1_000),
|
||||
protocol_events: positiveBudget.max(100_000).default(10_000),
|
||||
protocol_body_bytes: positiveBudget.max(10_000_000).default(1_000_000),
|
||||
connections: positiveBudget.max(1_000).default(100),
|
||||
filesystem_depth: z.number().int().min(1).max(64).default(16),
|
||||
output_bytes: positiveBudget.default(1_000_000),
|
||||
files: positiveBudget.default(10_000),
|
||||
file_bytes: positiveBudget.default(10_000_000),
|
||||
processes: positiveBudget.default(1_000),
|
||||
protocol_events: positiveBudget.default(10_000),
|
||||
protocol_body_bytes: positiveBudget.default(1_000_000),
|
||||
connections: positiveBudget.default(100),
|
||||
filesystem_depth: z.number().int().safe().nonnegative().default(16),
|
||||
})
|
||||
.strict()
|
||||
.default({
|
||||
|
|
@ -232,15 +238,14 @@ export const processScenarioSchema = z
|
|||
paths: z.boolean().default(true),
|
||||
pids: z.boolean().default(true),
|
||||
ports: z.boolean().default(true),
|
||||
time_bucket_ms: positiveBudget.max(60_000).default(10),
|
||||
time_bucket_ms: positiveBudget.default(10),
|
||||
patterns: z
|
||||
.array(
|
||||
z.object({
|
||||
pattern: z.string().max(500),
|
||||
replacement: z.string().max(100),
|
||||
pattern: z.string(),
|
||||
replacement: z.string(),
|
||||
}),
|
||||
)
|
||||
.max(32)
|
||||
.default([]),
|
||||
})
|
||||
.default({
|
||||
|
|
@ -256,31 +261,38 @@ export const processScenarioSchema = z
|
|||
http: z
|
||||
.array(
|
||||
z.object({
|
||||
method: z.string().max(16),
|
||||
method: z.string(),
|
||||
path: z.string().startsWith("/"),
|
||||
status: z.number().int().min(100).max(599),
|
||||
body: z.string().max(1_000_000),
|
||||
body: z.string(),
|
||||
request_headers: z.record(z.string(), z.string()).default({}),
|
||||
request_body: z.string().max(1_000_000).optional(),
|
||||
request_body: z.string().optional(),
|
||||
response_headers: z.record(z.string(), z.string()).default({}),
|
||||
delay_ms: z.number().int().nonnegative().max(30_000).default(0),
|
||||
delay_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.safe()
|
||||
.nonnegative()
|
||||
.max(maximumTimerDelay)
|
||||
.default(0),
|
||||
disconnect: z.boolean().default(false),
|
||||
max_calls: positiveBudget.default(1),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
websocket_messages: z.array(z.string().max(1_000_000)).default([]),
|
||||
websocket_messages: z.array(z.string()).default([]),
|
||||
websocket_connections: z
|
||||
.array(
|
||||
z.object({
|
||||
messages: z.array(
|
||||
z.object({
|
||||
data: z.string().max(1_000_000),
|
||||
data: z.string(),
|
||||
delay_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.safe()
|
||||
.nonnegative()
|
||||
.max(30_000)
|
||||
.max(maximumTimerDelay)
|
||||
.default(0),
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,49 @@ const partialSpecification = (): ProcessTraceSpecification => ({
|
|||
},
|
||||
});
|
||||
describe("process trace specification", () => {
|
||||
it("accepts event sets and cardinalities beyond the former fixed ceilings", () => {
|
||||
const ids = Array.from({ length: 257 }, (_, index) => `event-${index}`);
|
||||
const events = ids.map((id) => ({
|
||||
id,
|
||||
source: "terminal_raw" as const,
|
||||
exact: { id },
|
||||
cardinality: { kind: "required" as const },
|
||||
}));
|
||||
const trace = processTraceSpecificationSchema.safeParse({
|
||||
events: [
|
||||
{
|
||||
id: "repeated",
|
||||
source: "terminal_raw",
|
||||
exact: "event",
|
||||
cardinality: { kind: "exact", count: 10_001 },
|
||||
},
|
||||
],
|
||||
language: {
|
||||
kind: "finite_traces",
|
||||
variants: [
|
||||
{
|
||||
id: "long-trace",
|
||||
trace: Array.from({ length: 10_001 }, () => "repeated"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(trace.success).toBe(true);
|
||||
|
||||
const eventSet = processTraceSpecificationSchema.safeParse({
|
||||
events,
|
||||
language: {
|
||||
kind: "partial_order",
|
||||
happens_before: ids.slice(1).map((after, index) => ({
|
||||
before: ids[index] ?? "",
|
||||
after,
|
||||
})),
|
||||
prefix: Array.from({ length: 4_097 }, () => ids[0] ?? ""),
|
||||
},
|
||||
});
|
||||
expect(eventSet.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects cycles, implicit concurrency, overlap, and unsatisfiable variants", () => {
|
||||
const base = partialSpecification();
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
type ProcessObservationSource,
|
||||
} from "./processObservation.js";
|
||||
|
||||
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u);
|
||||
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]*$/u);
|
||||
|
||||
/** Process observation families admitted by a declared trace specification. */
|
||||
export const processTraceSourceSchema = processObservationSourceSchema;
|
||||
|
|
@ -19,13 +19,13 @@ const cardinalitySchema = z.discriminatedUnion("kind", [
|
|||
z.strictObject({ kind: z.literal("optional") }),
|
||||
z.strictObject({
|
||||
kind: z.literal("exact"),
|
||||
count: z.number().int().min(0).max(10_000),
|
||||
count: z.number().int().min(0),
|
||||
}),
|
||||
z
|
||||
.strictObject({
|
||||
kind: z.literal("range"),
|
||||
min: z.number().int().min(0).max(10_000),
|
||||
max: z.number().int().min(0).max(10_000),
|
||||
min: z.number().int().min(0),
|
||||
max: z.number().int().min(0),
|
||||
})
|
||||
.refine(({ min, max }) => min <= max, {
|
||||
message: "cardinality min must not exceed max",
|
||||
|
|
@ -47,7 +47,6 @@ const eventDeclarationSchema = z.strictObject({
|
|||
"elapsed_ms",
|
||||
]),
|
||||
)
|
||||
.max(5)
|
||||
.refine((values) => new Set(values).size === values.length, {
|
||||
message: "ignored event fields must be unique",
|
||||
})
|
||||
|
|
@ -64,7 +63,6 @@ const partialOrderLanguageSchema = z.strictObject({
|
|||
after: identifierSchema,
|
||||
}),
|
||||
)
|
||||
.max(4_096)
|
||||
.default([]),
|
||||
not_before: z
|
||||
.array(
|
||||
|
|
@ -73,21 +71,17 @@ const partialOrderLanguageSchema = z.strictObject({
|
|||
anchor: identifierSchema,
|
||||
}),
|
||||
)
|
||||
.max(4_096)
|
||||
.default([]),
|
||||
unordered_groups: z
|
||||
.array(
|
||||
z.strictObject({ events: z.array(identifierSchema).min(2).max(256) }),
|
||||
)
|
||||
.max(256)
|
||||
.array(z.strictObject({ events: z.array(identifierSchema).min(2) }))
|
||||
.default([]),
|
||||
prefix: z.array(identifierSchema).max(4_096).default([]),
|
||||
suffix: z.array(identifierSchema).max(4_096).default([]),
|
||||
prefix: z.array(identifierSchema).default([]),
|
||||
suffix: z.array(identifierSchema).default([]),
|
||||
});
|
||||
|
||||
const finiteTraceTokenSchema = z.union([
|
||||
identifierSchema,
|
||||
z.strictObject({ unordered: z.array(identifierSchema).min(2).max(256) }),
|
||||
z.strictObject({ unordered: z.array(identifierSchema).min(2) }),
|
||||
]);
|
||||
|
||||
const finiteTraceLanguageSchema = z.strictObject({
|
||||
|
|
@ -96,15 +90,14 @@ const finiteTraceLanguageSchema = z.strictObject({
|
|||
.array(
|
||||
z.strictObject({
|
||||
id: identifierSchema,
|
||||
trace: z.array(finiteTraceTokenSchema).max(4_096),
|
||||
trace: z.array(finiteTraceTokenSchema),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(64),
|
||||
.min(1),
|
||||
});
|
||||
|
||||
const specificationShapeSchema = z.strictObject({
|
||||
events: z.array(eventDeclarationSchema).min(1).max(256),
|
||||
events: z.array(eventDeclarationSchema).min(1),
|
||||
language: z.discriminatedUnion("kind", [
|
||||
partialOrderLanguageSchema,
|
||||
finiteTraceLanguageSchema,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,32 @@ import {
|
|||
} from "./protocolCapture.js";
|
||||
|
||||
describe("protocol capture", () => {
|
||||
it("accepts every captured message", () => {
|
||||
const message: ProtocolMessage = {
|
||||
sequence: 0,
|
||||
at_ms: 0,
|
||||
family: "json-rpc",
|
||||
direction: "request",
|
||||
endpoint: null,
|
||||
content_type: null,
|
||||
raw_payload: null,
|
||||
decoded_fields: [],
|
||||
schema_hypotheses: [],
|
||||
truncated: false,
|
||||
credentials_redacted: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
protocolCaptureSchema.parse({
|
||||
family: "json-rpc",
|
||||
messages: Array.from({ length: 10_001 }, (_, sequence) => ({
|
||||
...message,
|
||||
sequence,
|
||||
})),
|
||||
}).messages,
|
||||
).toHaveLength(10_001);
|
||||
});
|
||||
|
||||
it("classifies gRPC from content type", () => {
|
||||
expect(classifyProtocolFamily("application/grpc+proto", null)).toBe("grpc");
|
||||
});
|
||||
|
|
@ -87,6 +113,12 @@ describe("JSON-RPC decoding", () => {
|
|||
});
|
||||
|
||||
describe("MessagePack decoding", () => {
|
||||
it("does not report unobserved payload bytes as truncation", () => {
|
||||
const result = decodeMessagePack(new Uint8Array(10_001).fill(0x82));
|
||||
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("detects map type", () => {
|
||||
const payload = new Uint8Array([0x82]);
|
||||
const result = decodeMessagePack(payload);
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export const protocolCaptureSchema = z.strictObject({
|
|||
/** Protocol family for this capture. */
|
||||
family: protocolFamilySchema,
|
||||
/** Captured messages in order. */
|
||||
messages: z.array(protocolMessageSchema).min(0).max(10_000),
|
||||
messages: z.array(protocolMessageSchema).min(0),
|
||||
/** Aggregated schema hypotheses across all messages. */
|
||||
inferred_schema: z.array(schemaHypothesisSchema).default([]),
|
||||
/** Whether any message was truncated. */
|
||||
|
|
@ -192,7 +192,7 @@ export function decodeMessagePack(rawPayload: Uint8Array): {
|
|||
}
|
||||
return {
|
||||
decoded_fields: fields,
|
||||
truncated: rawPayload.length > 10_000,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ export function decodeGrpcFrame(rawPayload: Uint8Array): {
|
|||
inferred: false,
|
||||
},
|
||||
],
|
||||
truncated: length > 10_000,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue