diff --git a/src/application/AuthorizedArtifactInventory.ts b/src/application/AuthorizedArtifactInventory.ts index f58641625..7800ed48c 100644 --- a/src/application/AuthorizedArtifactInventory.ts +++ b/src/application/AuthorizedArtifactInventory.ts @@ -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( diff --git a/src/application/BoundedJsonFiles.ts b/src/application/BoundedJsonFiles.ts index c62c708d9..21e936460 100644 --- a/src/application/BoundedJsonFiles.ts +++ b/src/application/BoundedJsonFiles.ts @@ -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 => { - 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 === "" || diff --git a/src/application/ConfiguredRoots.ts b/src/application/ConfiguredRoots.ts new file mode 100644 index 000000000..b04ede63c --- /dev/null +++ b/src/application/ConfiguredRoots.ts @@ -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 => { + 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"; diff --git a/src/application/ProcessCaptureAuthority.ts b/src/application/ProcessCaptureAuthority.ts index 9b4041232..74bf6dc4d 100644 --- a/src/application/ProcessCaptureAuthority.ts +++ b/src/application/ProcessCaptureAuthority.ts @@ -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 => { 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", diff --git a/src/application/ProcessCaptureLifecycle.ts b/src/application/ProcessCaptureLifecycle.ts index bf1d89bf6..afe98cc5b 100644 --- a/src/application/ProcessCaptureLifecycle.ts +++ b/src/application/ProcessCaptureLifecycle.ts @@ -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 ( diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index 539e6ab08..7cc48b33e 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -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, diff --git a/src/application/ProjectPermissionStore.ts b/src/application/ProjectPermissionStore.ts index c9876765f..2c52c77dd 100644 --- a/src/application/ProjectPermissionStore.ts +++ b/src/application/ProjectPermissionStore.ts @@ -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 || diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index d10a5282d..ca7194420 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -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); } diff --git a/tests/authorizedArtifactInventory.test.ts b/tests/authorizedArtifactInventory.test.ts new file mode 100644 index 000000000..2cf170ffe --- /dev/null +++ b/tests/authorizedArtifactInventory.test.ts @@ -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 }); + } + }); +}); diff --git a/tests/evidenceBundleFiles.test.ts b/tests/evidenceBundleFiles.test.ts index 76ff41694..c98661a3f 100644 --- a/tests/evidenceBundleFiles.test.ts +++ b/tests/evidenceBundleFiles.test.ts @@ -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"); diff --git a/tests/fixtures/processFidelity.mjs b/tests/fixtures/processFidelity.mjs index 0d4864845..27227f602 100644 --- a/tests/fixtures/processFidelity.mjs +++ b/tests/fixtures/processFidelity.mjs @@ -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(() => { diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index db914a9ac..d1b5b3047 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -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; diff --git a/tests/projectPermissionStore.test.ts b/tests/projectPermissionStore.test.ts index 823731934..fbea54278 100644 --- a/tests/projectPermissionStore.test.ts +++ b/tests/projectPermissionStore.test.ts @@ -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"); diff --git a/tests/referenceSourceImport.test.ts b/tests/referenceSourceImport.test.ts index b49fa78dc..957387450 100644 --- a/tests/referenceSourceImport.test.ts +++ b/tests/referenceSourceImport.test.ts @@ -34,12 +34,16 @@ const fixture = async (parent: string, name: string): Promise => { 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 {