fix: harden PTY events and configured roots

This commit is contained in:
N0zoM1z0 2026-07-14 15:21:39 +08:00
parent 2575b30dfc
commit 31b40f39e4
14 changed files with 180 additions and 40 deletions

View File

@ -8,6 +8,7 @@ import {
type ArtifactIntegrityPolicy,
type ArtifactInventorySnapshot,
} from "./ArtifactInventory.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
/** Resolve, authorize, and scan one artifact without a second path resolution. */
export const scanAuthorizedArtifactInventory = async (
@ -24,7 +25,7 @@ export const scanAuthorizedArtifactInventory = async (
);
const [path, approved] = await Promise.all([
realpath(inputPath),
Promise.all(roots.map(async (root) => realpath(root))),
canonicalizeConfiguredRoots(roots),
]);
if (!approved.some((root) => withinRoot(root, path)))
throw new ArtifactReaderFailure(

View File

@ -6,6 +6,7 @@ import writeFileAtomic from "write-file-atomic";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { EvidenceFileError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
/** Read bounded JSON data from a regular file beneath an approved root. */
export const readBoundedJson = async (
@ -82,8 +83,9 @@ const isApproved = async (
candidate: string,
roots: readonly string[],
): Promise<boolean> => {
for (const configuredRoot of roots) {
const root = await realpath(resolve(configuredRoot));
for (const root of await canonicalizeConfiguredRoots(
roots.map((configuredRoot) => resolve(configuredRoot)),
)) {
const relation = relative(root, candidate);
if (
relation === "" ||

View File

@ -0,0 +1,22 @@
import { realpath } from "node:fs/promises";
/** Canonicalize configured policy roots while ignoring stale missing entries. */
export const canonicalizeConfiguredRoots = async (
roots: readonly string[],
): Promise<string[]> => {
const canonical: string[] = [];
for (const root of roots) {
try {
canonical.push(await realpath(root));
} catch (cause: unknown) {
if (!isMissingPath(cause)) throw cause;
}
}
return canonical;
};
const isMissingPath = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";

View File

@ -8,6 +8,7 @@ import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
const isWithin = (candidate: string, root: string): boolean =>
candidate === root ||
@ -18,17 +19,15 @@ export const assertRealPathAuthority = async (
policy: ProcessExecutionPolicy,
): Promise<void> => {
const executable = await realpath(scenario.executable);
const executableRoots = await Promise.all(
policy.executableRoots.map((root) => realpath(root)),
const executableRoots = await canonicalizeConfiguredRoots(
policy.executableRoots,
);
if (!executableRoots.some((root) => isWithin(executable, root)))
throw new ProcessCaptureError(
"resolved executable is outside approved roots",
);
const workingDirectory = await realpath(scenario.working_directory);
const workingRoots = await Promise.all(
policy.workingRoots.map((root) => realpath(root)),
);
const workingRoots = await canonicalizeConfiguredRoots(policy.workingRoots);
if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
throw new ProcessCaptureError(
"resolved working directory is outside approved roots",

View File

@ -338,14 +338,12 @@ export const captureTerminalFrames = (options: {
readonly started: number;
readonly temporaryRoot: string;
readonly onOutput: () => void;
readonly onFirstOutput: () => void;
readonly renderer: TerminalRenderer;
readonly checkpoints: ProcessCheckpoints;
}): (() => boolean) => {
let outputBytes = 0;
let truncated = false;
options.terminal.onData((data) => {
options.onFirstOutput();
options.onOutput();
const bytes = Buffer.byteLength(data);
if (

View File

@ -130,20 +130,6 @@ const scheduleScenarioEvents = (options: ScenarioEventOptions): void => {
}
};
const startScenarioEvents = (options: ScenarioEventOptions): (() => void) => {
let eventsStarted = false;
return () => {
if (eventsStarted) return;
eventsStarted = true;
scheduleScenarioEvents(options);
};
};
const eventsRequireReadiness = (scenario: ProcessScenario): boolean =>
scenario.events.some(
(event) => event.type === "input" || event.type === "resize",
);
interface StartedCaptureRuntime {
readonly replay: LoopbackReplay;
readonly renderer: TerminalRenderer;
@ -243,22 +229,20 @@ const startCaptureRuntime = async (options: {
rows: scenario.terminal.rows,
name: "xterm-256color",
});
const startEvents = startScenarioEvents({
...options,
getTerminal: () => terminal,
renderer,
started,
});
if (!eventsRequireReadiness(scenario)) startEvents();
const framesTruncated = captureTerminalFrames({
...options,
terminal,
started,
onOutput: () => (lastOutput = Date.now()),
onFirstOutput: startEvents,
renderer,
checkpoints,
});
scheduleScenarioEvents({
...options,
getTerminal: () => terminal,
renderer,
started,
});
const stopSampler = startProcessSampler(
terminal.pid,
started,

View File

@ -104,9 +104,14 @@ export const readProjectPermissionStore = async (
const metadata = await stat(path);
if ((metadata.mode & 0o077) !== 0)
return err(new ProjectPermissionStoreError("not_owner_only"));
const parsed = storeSchema.safeParse(
JSON.parse(await readFile(path, "utf8")),
);
const encoded = await readFile(path, "utf8");
let decoded: unknown;
try {
decoded = JSON.parse(encoded);
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("invalid", { cause }));
}
const parsed = storeSchema.safeParse(decoded);
if (
!parsed.success ||
parsed.data.project_id !== project.value.id ||

View File

@ -12,6 +12,7 @@ import {
type ReferenceSourceImportError,
type ReferenceSourceImportOptions,
} from "./ReferenceSourceImportTypes.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
/** Validated, authorized inputs ready for filesystem traversal. */
export interface PreparedReferenceSourceImport {
@ -66,8 +67,10 @@ const authorizeRoot = async (
failure("invalid-root", "Reference source root is not a directory"),
);
const canonicalRoot = await realpath(resolve(requestedRoot));
for (const approvedRoot of approvedRoots) {
const canonicalApproved = await realpath(resolve(approvedRoot));
const canonicalApprovedRoots = await canonicalizeConfiguredRoots(
approvedRoots.map((approvedRoot) => resolve(approvedRoot)),
);
for (const canonicalApproved of canonicalApprovedRoots) {
if (withinRoot(canonicalApproved, canonicalRoot))
return ok(canonicalRoot);
}

View File

@ -0,0 +1,34 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { scanAuthorizedArtifactInventory } from "../src/application/AuthorizedArtifactInventory.js";
const LIMITS = {
maxEntries: 100,
maxTotalBytes: 1024 * 1024,
maxEntryBytes: 1024 * 1024,
maxCompressionRatio: 100,
maxDepth: 10,
maxPathBytes: 1024,
} as const;
describe("authorized artifact inventory", () => {
it("uses a valid input root when another configured root is missing", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-artifact-roots-"));
try {
const path = join(root, "artifact.js");
await writeFile(path, "export const value = 1;\n");
const inventory = await scanAuthorizedArtifactInventory(
path,
[join(root, "missing"), root],
LIMITS,
);
expect(inventory.manifest.root_format).toBe("javascript-bundle");
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

View File

@ -93,6 +93,21 @@ describe("evidence bundle filesystem adapter", () => {
).toMatchObject({ ok: true });
});
it("uses valid evidence roots when another configured root is missing", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-evidence-roots-"));
const path = join(directory, "bundle.json");
const configured = {
...policy(directory),
roots: [join(directory, "missing"), directory],
};
expect(
await writeEvidenceBundle(bundle(), path, false, configured),
).toMatchObject({ ok: true });
expect(await readEvidenceBundle(path, configured)).toMatchObject({
ok: true,
});
});
it("rejects traversal and symlink escape from approved roots", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-evidence-"));
const root = join(directory, "approved");

View File

@ -18,6 +18,13 @@ if (mode === "interactive") {
process.stdin.on("data", (value) => {
process.stdout.write(`input:${value.toString()} unicode:雪\n`);
});
} else if (mode === "silent-interactive") {
process.stdin.setRawMode?.(true);
process.stdin.resume();
process.stdin.on("data", (value) => {
process.stdout.write(`input:${value.toString()}\n`);
process.exit(0);
});
} else if (mode === "partial") {
process.stdout.write("partial-");
setTimeout(() => {

View File

@ -1067,6 +1067,47 @@ describe("process capture adapter", () => {
expect(result.value.exit.code).toBe(0);
});
it("dispatches scheduled events before a silent PTY produces output", async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "silent-interactive"],
working_directory: dirname(processFixture),
events: [
{ type: "resize", at_ms: 25, columns: 100, rows: 40 },
{ type: "input", at_ms: 50, data: "answer" },
],
timeout_ms: 2_000,
idle_timeout_ms: 2_000,
}),
{
enabled: true,
executableRoots: [
join(dirname(process.execPath), "missing"),
dirname(process.execPath),
],
workingRoots: [
join(dirname(processFixture), "missing"),
dirname(processFixture),
],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
"input:answer",
);
expect(result.value.interaction_events).toMatchObject([
{ type: "resize", outcome: "dispatched" },
{ type: "input", outcome: "dispatched" },
]);
});
it("samples and cleans a source-owned child and grandchild process tree", async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;

View File

@ -1,4 +1,4 @@
import { chmod, mkdtemp, stat } from "node:fs/promises";
import { chmod, mkdtemp, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -62,6 +62,17 @@ describe("project permission store", () => {
});
});
it("classifies malformed JSON as an invalid store", async () => {
const project = await mkdtemp(join(tmpdir(), "rea-policy-invalid-"));
const path = join(project, "permissions.json");
await writeFile(path, "{", { mode: 0o600 });
expect(await readProjectPermissionStore(path, project)).toMatchObject({
ok: false,
error: { reason: "invalid" },
});
});
it("serializes concurrent grant revocations without losing an update", async () => {
const project = await mkdtemp(join(tmpdir(), "rea-policy-revoke-"));
const path = join(project, ".rea", "permissions.json");

View File

@ -34,12 +34,16 @@ const fixture = async (parent: string, name: string): Promise<string> => {
return root;
};
const importTree = (root: string, approvedRoot: string, signal?: AbortSignal) =>
const importTree = (
root: string,
approvedRoot: string | readonly string[],
signal?: AbortSignal,
) =>
importReferenceSource({
root,
caller: "reference-import-test",
policy: {
roots: [approvedRoot],
roots: typeof approvedRoot === "string" ? [approvedRoot] : approvedRoot,
secretPatterns: [".env", ".env.*"],
...limits,
},
@ -153,6 +157,20 @@ describe("reference source import", () => {
}
});
it("uses a valid reference root when another configured root is missing", async () => {
const parent = await mkdtemp(join(tmpdir(), "rea-reference-roots-"));
try {
const root = await fixture(parent, "tree");
const imported = await importTree(root, [
join(parent, "missing"),
parent,
]);
expect(imported).toMatchObject({ ok: true });
} finally {
await rm(parent, { recursive: true, force: true });
}
});
it("records bounded local Git state without invoking Git", async () => {
const parent = await mkdtemp(join(tmpdir(), "rea-reference-git-"));
try {