fix: harden PTY events and configured roots
This commit is contained in:
parent
2575b30dfc
commit
31b40f39e4
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 === "" ||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue