From 6ae2a43cda6f1c32450c2c8673a1fd0f24826e0a Mon Sep 17 00:00:00 2001 From: luvs01 Date: Tue, 11 Aug 2026 09:32:47 +0900 Subject: [PATCH] fix(lab): reject symlinked purge directories --- src/lab/ledger/purge.ts | 8 +++++++ src/lab/paths.ts | 10 +++++++-- tests/lab-evidence-ledger.test.ts | 35 ++++++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/lab/ledger/purge.ts b/src/lab/ledger/purge.ts index dd6b95853..d86d439b4 100644 --- a/src/lab/ledger/purge.ts +++ b/src/lab/ledger/purge.ts @@ -26,6 +26,7 @@ import { closeSync, existsSync, fsyncSync, + lstatSync, openSync, readdirSync, renameSync, @@ -128,6 +129,13 @@ function deleteArtifactsFailClosed(dir: TrustedArtifactDir, digests: string[]): function purgeBoundedDirectory(dirPath: string): void { if (!existsSync(dirPath)) return; + const metadata = lstatSync(dirPath); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new PurgeError( + "scratch_export_unsafe_directory", + `refusing to purge non-directory or symbolic-link path: ${dirPath}`, + ); + } const entries = readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const full = join(dirPath, entry.name); diff --git a/src/lab/paths.ts b/src/lab/paths.ts index 068db3203..5d37797e0 100644 --- a/src/lab/paths.ts +++ b/src/lab/paths.ts @@ -1,11 +1,15 @@ -import { chmodSync, mkdirSync, statSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config"; function ensureRestrictedDir(dir: string): void { mkdirSync(dir, { recursive: true, mode: 0o700 }); + const metadata = lstatSync(dir); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error(`lab state path must be a real directory: ${dir}`); + } if (process.platform === "win32") return; - const mode = statSync(dir).mode & 0o777; + const mode = metadata.mode & 0o777; if (mode !== 0o700) chmodSync(dir, 0o700); } @@ -54,6 +58,8 @@ export function ensureLabDirs(configDir = getConfigDir()): { const exportDir = labExportDir(configDir); ensureRestrictedDir(root); ensureRestrictedDir(artifactsDir); + ensureRestrictedDir(scratchDir); + ensureRestrictedDir(exportDir); return { root, ledgerPath: labLedgerPath(configDir), diff --git a/tests/lab-evidence-ledger.test.ts b/tests/lab-evidence-ledger.test.ts index 9678c9dd4..31f8fddb9 100644 --- a/tests/lab-evidence-ledger.test.ts +++ b/tests/lab-evidence-ledger.test.ts @@ -1138,6 +1138,39 @@ describe("CL-02 phase-2 review regressions", () => { }); }); + test.each(["scratch", "export"] as const)( + "%s purge refuses a symbolic-link directory root", + (action) => { + withHome((home) => { + const authority = loadCaseAuthority(); + const caseRecord = discoverScenarios(authority, ["responses-core"])[0]!; + const { event } = persistConformanceResult( + syntheticPassResult(caseRecord), + caseRecord, + authority, + { configDir: home, recordedAt: 1000 }, + ); + const outside = join(home, `outside-${action}`); + const labDir = join(home, "lab", action); + mkdirSync(outside); + writeFileSync(join(outside, "keep.txt"), "keep"); + rmSync(labDir, { recursive: true }); + symlinkSync(outside, labDir, process.platform === "win32" ? "junction" : "dir"); + + expect(() => + purgeSensitiveEvidence({ + configDir: home, + targetEventIds: [event.eventId], + targetArtifactDigests: [], + purgeActions: [action], + recordedAt: 5000, + }), + ).toThrow(/must be a real directory/); + expect(existsSync(join(outside, "keep.txt"))).toBe(true); + }); + }, + ); + test("behavior fingerprint includes frozen runtime keys deterministically", () => { const authority = loadCaseAuthority(); const caseRecord = discoverScenarios(authority, ["responses-core"])[0]!; @@ -1158,4 +1191,4 @@ describe("CL-02 phase-2 review regressions", () => { closeTrustedArtifactDir(dir); }); }); -}); \ No newline at end of file +});