Files
rea/src/domain/process/processScenario.ts
T
morluto f9ea9a9268 fix(process)!: bound terminal buffers before capture
Validate columns × (rows + scrollback) against a 1,000,000-cell xterm working-buffer budget, including every resize with the selected scrollback. Document the budget in the advertised MCP input and process-capture guide; never clamp caller dimensions.

Validation: default/exact-boundary schema cases, prelaunch MCP rejection, compiled CLI capture, terminal rendering, normalization, cancellation, and lifecycle checks pass on macOS. This is resource admission, not a measured serializer throughput improvement; Linux-only and real deep-analysis providers were not exercised.

BREAKING CHANGE: process scenarios whose initial or resized terminal exceeds 1,000,000 combined visible-plus-scrollback cells are now rejected before launch.
2026-10-11 03:19:29 +08:00

283 lines
9.8 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>> => ({
...scenario,
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,
limits: scenario.limits,
normalization: scenario.normalization,
...(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),
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) => {
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);