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.
This commit is contained in:
parent
78778d91a4
commit
f9ea9a9268
|
|
@ -114,6 +114,12 @@ from launch, must be ordered, and must fall within `timeout_ms`. Inputs marked
|
|||
Environment overrides are recorded in the scenario commitment; inherited
|
||||
values are not copied into Evidence.
|
||||
|
||||
Terminal dimensions must satisfy `columns × (rows + scrollback) ≤ 1,000,000`.
|
||||
Every scheduled resize uses the same selected scrollback and must fit this
|
||||
working-buffer budget. REA rejects oversized scenarios before launching the
|
||||
command; it never clamps the requested dimensions. This limit is independent
|
||||
of `limits.output_bytes`, which bounds retained terminal observations.
|
||||
|
||||
The capture contains raw PTY output chunks and rendered terminal states,
|
||||
interaction dispatch outcomes, exit reason, sampled process-tree observations,
|
||||
and settlement status. When filesystem paths are selected, REA records
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
|||
),
|
||||
session(
|
||||
"capture_process_scenario",
|
||||
"Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. The target runs with the current user's permissions; this is not a security sandbox.",
|
||||
"Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. The renderer admits at most 1,000,000 combined terminal cells, computed as columns × (rows + scrollback); every resize uses the selected scrollback and must fit the same budget. Supply a smaller terminal shape when that product is too large; dimensions are never clamped. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. The target runs with the current user's permissions; this is not a security sandbox.",
|
||||
processScenarioSchema,
|
||||
),
|
||||
session(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseProcessScenario,
|
||||
PROCESS_TERMINAL_CELL_BUDGET,
|
||||
processScenarioSchema,
|
||||
} from "./processScenario.js";
|
||||
|
||||
it("admits the default and the exact combined terminal-cell budget", () => {
|
||||
expect(parseProcessScenario({ executable: "/bin/true" }).terminal).toEqual({
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
scrollback: 1_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
processScenarioSchema.safeParse({
|
||||
executable: "/bin/true",
|
||||
terminal: {
|
||||
columns: 1,
|
||||
rows: 1,
|
||||
scrollback: PROCESS_TERMINAL_CELL_BUDGET - 1,
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an oversized initial buffer without clamping caller dimensions", () => {
|
||||
const input = {
|
||||
executable: "/bin/true",
|
||||
terminal: { columns: 1_000, rows: 1, scrollback: 1_000 },
|
||||
};
|
||||
const parsed = processScenarioSchema.safeParse(input);
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) throw new Error("expected terminal capacity rejection");
|
||||
expect(parsed.error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "custom",
|
||||
path: ["terminal"],
|
||||
message: expect.stringContaining(
|
||||
`${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the selected scrollback budget to every scheduled resize", () => {
|
||||
const parsed = processScenarioSchema.safeParse({
|
||||
executable: "/bin/true",
|
||||
events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) throw new Error("expected resize capacity rejection");
|
||||
expect(parsed.error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "custom",
|
||||
path: ["events", 0],
|
||||
message: expect.stringContaining("resize event at 10 ms"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
processScenarioSchema.safeParse({
|
||||
executable: "/bin/true",
|
||||
terminal: { columns: 1, rows: 1, scrollback: 0 },
|
||||
events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
|
@ -3,6 +3,12 @@ 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]";
|
||||
|
|
@ -41,6 +47,14 @@ const childProcessString = z
|
|||
/^[^\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(),
|
||||
|
|
@ -131,8 +145,11 @@ export const processScenarioSchema = 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().min(0).default(1_000),
|
||||
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(
|
||||
|
|
@ -204,6 +221,18 @@ export const processScenarioSchema = z
|
|||
}),
|
||||
})
|
||||
.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];
|
||||
|
|
@ -215,7 +244,20 @@ export const processScenarioSchema = z
|
|||
});
|
||||
}
|
||||
}
|
||||
for (const event of scenario.events) {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { access } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { afterEach, expect } from "vitest";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
|
|
@ -13,6 +15,7 @@ import {
|
|||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { itWithCaptureCapability } from "./processCaptureCapability.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const processFixture = fileURLToPath(
|
||||
new URL("../../fixtures/processFidelity.mjs", import.meta.url),
|
||||
|
|
@ -23,6 +26,50 @@ afterEach(async () => {
|
|||
await Promise.all(resources.splice(0).map((resource) => resource.close()));
|
||||
});
|
||||
|
||||
it("rejects oversized initial and resize terminal buffers through MCP before launching", async () => {
|
||||
const root = await createTestTempDirectory("rea-terminal-admission-");
|
||||
const marker = join(root, "launched");
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Terminal admission must not launch a binary provider");
|
||||
});
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ logger: silentLogger },
|
||||
);
|
||||
const client = new Client({ name: "terminal-admission", version: "1" });
|
||||
resources.push(client, server, session);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
for (const input of [
|
||||
{ terminal: { columns: 1_000, rows: 1, scrollback: 1_000 } },
|
||||
{ events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }] },
|
||||
] as const) {
|
||||
const result = await client.callTool({
|
||||
name: "capture_process_scenario",
|
||||
arguments: {
|
||||
executable: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
"require('node:fs').writeFileSync(process.argv[1], 'launched')",
|
||||
marker,
|
||||
],
|
||||
...input,
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringContaining("1000000"),
|
||||
}),
|
||||
);
|
||||
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
await client.ping();
|
||||
});
|
||||
|
||||
itWithCaptureCapability(
|
||||
"records process residuals linked to capture Evidence",
|
||||
async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue