rea/src/domain/process/processScenario.ts

316 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
const positiveBudget = z.number().int().safe().positive();
/**
* Maximum xterm backing cells for one visible-plus-scrollback buffer. Each
* xterm BufferLine stores three Uint32 words per cell (12 MB at this cap),
* before row objects, serialization strings, and retained frame copies.
*/
export const PROCESS_TERMINAL_CELL_BUDGET = 1_000_000;
const timedEventBase = { at_ms: z.number().int().safe().nonnegative() };
const reservedRunIdEnvironmentName = "REA_PROCESS_RUN_ID";
const ENVIRONMENT_NAME_CHARACTER = "[^=\\x00]";
/**
* Nonempty environment names other than the reserved run identifier. The
* exclusion is spelled as a complement instead of a negative look-ahead,
* because RE2 and Rust schema validators reject look-around and would refuse
* every tool in the advertised catalog.
*/
const environmentNamePattern = (reserved: string): RegExp => {
// After the complete reserved name, at least one more character is needed.
let rest = `${ENVIRONMENT_NAME_CHARACTER}+`;
for (let index = reserved.length - 1; index >= 0; index -= 1) {
const character = reserved[index] ?? "";
// Diverge here, match the next reserved character, or (after a nonempty
// prefix) end the name early.
const choice = `[^=\\x00${character}]${ENVIRONMENT_NAME_CHARACTER}*|${character}${rest}`;
rest = index === 0 ? choice : `(?:${choice})?`;
}
return new RegExp(`^(?:${rest})$`, "u");
};
const environmentName = z
.string()
.min(1, "Environment names must not be empty")
.regex(environmentNamePattern(reservedRunIdEnvironmentName), {
error: (issue) =>
issue.input === reservedRunIdEnvironmentName
? `${reservedRunIdEnvironmentName} is reserved by the process adapter`
: "Environment names cannot contain '=' or NUL",
});
const childProcessString = z
.string()
.regex(
/^[^\x00]*$/u,
"Values passed to operating-system APIs cannot contain NUL",
);
const fitsProcessTerminalCellBudget = (
columns: number,
rows: number,
scrollback: number,
): boolean =>
rows + scrollback <= Math.floor(PROCESS_TERMINAL_CELL_BUDGET / columns);
export const normalizationSchema = z.object({
paths: z.boolean(),
pids: z.boolean(),
ports: z.boolean(),
time_bucket_ms: positiveBudget,
patterns: z.array(
z.object({
pattern: z.string(),
replacement: z.string(),
}),
),
});
/** Compute a canonical SHA-256 commitment independent of object key order. */
export const digestProcessCommitment = (value: unknown): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Process capture commitment is not canonical JSON");
return createHash("sha256").update(serialized).digest("hex");
};
const committedScenarioEvents = (
events: ProcessScenario["events"],
includeSensitiveInputLength: boolean,
): ProcessScenario["events"] =>
events.map((event) =>
event.type === "input" && event.sensitive
? {
...event,
data: includeSensitiveInputLength
? `<redacted-input:${String(Buffer.byteLength(event.data))}-bytes>`
: "<redacted-input>",
}
: event,
);
/** Build the caller-selected scenario projection committed by process-capture Evidence. */
export const processScenarioCommitment = (
scenario: ProcessScenario,
executableSha256?: string,
): Readonly<Record<string, unknown>> => {
// A zero interval is today's immediate kill; leaving it out keeps the
// committed identity of every scenario that predates the field unchanged.
const { finalization_ms: finalizationMs, ...committed } = scenario;
return {
...committed,
...(finalizationMs > 0 ? { finalization_ms: finalizationMs } : {}),
events: committedScenarioEvents(scenario.events, true),
executable_sha256: executableSha256 ?? null,
};
};
/** Project observation settings shared by capture and reconstruction. */
export const processComparisonContract = (
scenario: ProcessScenario,
): Readonly<Record<string, unknown>> => ({
working_directory: scenario.working_directory,
environment: scenario.environment,
ambient_environment: "unknown",
filesystem_observation_paths: scenario.filesystem_observation_paths,
terminal: scenario.terminal,
// Input bytes are intentionally absent from compatibility identity. Their
// redacted observations remain unknown, so equal commitments never imply
// that two secret values were the same.
events: committedScenarioEvents(scenario.events, false),
timeout_ms: scenario.timeout_ms,
idle_timeout_ms: scenario.idle_timeout_ms,
settle_ms: scenario.settle_ms,
...(scenario.finalization_ms > 0
? { finalization_ms: scenario.finalization_ms }
: {}),
limits: scenario.limits,
normalization: scenario.normalization,
...(scenario.normalization.paths
? { path_normalization_version: "longest-original-paths-v1" }
: {}),
...(scenario.normalization.ports
? { port_normalization_version: "contextual-endpoints-v2" }
: {}),
...(scenario.normalization.patterns.length > 0
? { pattern_normalization_version: "literal-replacements-v1" }
: {}),
rendered_line_format: "trailing-spaces-trimmed-v1",
});
/**
* Boundary schema for one bounded process experiment.
* Defaults are part of the evidence contract and must remain deterministic.
*/
export const processScenarioSchema = z
.strictObject({
executable: childProcessString.min(1),
arguments: z.array(childProcessString).default([]),
working_directory: childProcessString.default("."),
environment: z.record(environmentName, childProcessString).default({}),
filesystem_observation_paths: z
.array(childProcessString.min(1))
.default([]),
terminal: z
.strictObject({
columns: z.number().int().min(1).max(65_535).default(80),
rows: z.number().int().min(1).max(65_535).default(24),
scrollback: z.number().int().safe().nonnegative().default(1_000),
})
.describe(
"The visible terminal and scrollback together must use at most 1,000,000 xterm renderer cells: columns × (rows + scrollback). Resize events use the same scrollback and limit.",
)
.default({ columns: 80, rows: 24, scrollback: 1_000 }),
events: z
.array(
z.discriminatedUnion("type", [
z.strictObject({
...timedEventBase,
type: z.literal("input"),
data: z.string(),
sensitive: z.boolean().default(false),
}),
z.strictObject({
...timedEventBase,
type: z.literal("resize"),
columns: z.number().int().min(1).max(65_535),
rows: z.number().int().min(1).max(65_535),
}),
z.strictObject({
...timedEventBase,
type: z.literal("signal"),
signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]),
}),
]),
)
.default([]),
timeout_ms: positiveBudget.default(30_000),
idle_timeout_ms: positiveBudget.default(30_000),
settle_ms: z.number().int().safe().nonnegative().default(100),
finalization_ms: z
.number()
.int()
.safe()
.nonnegative()
.default(0)
.describe(
"Milliseconds a target may keep running after SIGTERM when timeout_ms or idle_timeout_ms fires, before SIGKILL; 0 sends SIGKILL immediately. Both signals reach the captured root only while its launch-time start identity still matches; cancellation ends the run at once.",
),
limits: z
.strictObject({
output_bytes: positiveBudget.default(1_000_000),
files: positiveBudget.default(10_000),
file_bytes: positiveBudget.default(10_000_000),
processes: positiveBudget.default(1_000),
filesystem_depth: z.number().int().safe().nonnegative().default(16),
})
.strict()
.default({
output_bytes: 1_000_000,
files: 10_000,
file_bytes: 10_000_000,
processes: 1_000,
filesystem_depth: 16,
}),
normalization: z
.strictObject({
paths: z.boolean().default(false),
pids: z.boolean().default(true),
ports: z.boolean().default(true),
time_bucket_ms: positiveBudget.default(10),
patterns: z
.array(
z.strictObject({
pattern: z
.string()
.describe(
"Literal text to replace in captured output; not a regular expression.",
),
replacement: z.string(),
}),
)
.default([]),
})
.default({
paths: false,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
}),
})
.superRefine((scenario, context) => {
// Each budget is a safe integer, but their sum is not checked anywhere
// else. BigInt keeps the comparison exact beyond the representable range.
const lifecycleBudget =
BigInt(scenario.timeout_ms) +
BigInt(scenario.finalization_ms) +
BigInt(scenario.settle_ms);
if (lifecycleBudget > BigInt(Number.MAX_SAFE_INTEGER))
context.addIssue({
code: "custom",
message: `timeout_ms + finalization_ms + settle_ms must stay at or below ${String(Number.MAX_SAFE_INTEGER)} ms, the largest total exactly representable as a JavaScript number`,
path: ["timeout_ms"],
});
if (
!fitsProcessTerminalCellBudget(
scenario.terminal.columns,
scenario.terminal.rows,
scenario.terminal.scrollback,
)
)
context.addIssue({
code: "custom",
path: ["terminal"],
message: `terminal columns × (rows + scrollback) must not exceed ${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells`,
});
for (let index = 1; index < scenario.events.length; index += 1) {
const event = scenario.events[index];
const previous = scenario.events[index - 1];
if (eventsOutOfOrder(event, previous)) {
context.addIssue({
code: "custom",
message: "events must be ordered by at_ms",
path: ["events", index],
});
}
}
for (const [index, event] of scenario.events.entries()) {
if (
event.type === "resize" &&
!fitsProcessTerminalCellBudget(
event.columns,
event.rows,
scenario.terminal.scrollback,
)
)
context.addIssue({
code: "custom",
path: ["events", index],
message: `resize event at ${String(event.at_ms)} ms exceeds the ${String(PROCESS_TERMINAL_CELL_BUDGET)}-cell renderer budget with the selected scrollback`,
});
if (event.at_ms > scenario.timeout_ms) {
context.addIssue({
code: "custom",
message: "event occurs after the scenario timeout",
path: ["events"],
});
}
}
});
const eventsOutOfOrder = (
event: { readonly at_ms: number } | undefined,
previous: { readonly at_ms: number } | undefined,
): boolean =>
event !== undefined && previous !== undefined && event.at_ms < previous.at_ms;
/** Parsed instructions and resource bounds for one process experiment. */
export type ProcessScenario = z.infer<typeof processScenarioSchema>;
/** Parse untrusted process scenario input and apply safe default budgets. */
export const parseProcessScenario = (input: unknown): ProcessScenario =>
processScenarioSchema.parse(input);