From 45bff3456cae93263cb13e1c5f44a3b2bb8c103b Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 17 Mar 2026 22:57:53 -0500 Subject: [PATCH] feat(gsd): add directory safeguards for system/home paths (#1053) * feat(gsd): add directory safeguards to prevent running in system/home paths GSD previously had no protection against being launched from dangerous directories like $HOME, /, /usr, or /etc. This adds layered validation: - Blocked system paths (hard stop): /, /usr, /etc, /var, $HOME, tmpdir, etc. - High entry count heuristic (>200 entries triggers confirmation dialog) - Symlink resolution via realpathSync to prevent bypass - Integrated at three chokepoints: projectRoot(), showSmartEntry(), bootstrapGsdDirectory() Includes 19 tests covering all blocked categories, boundary conditions, and the assertSafeDirectory throw/return behavior. * fix: make directory safeguard tests cross-platform (Windows CI) - Skip Unix-specific blocked path tests on Windows (/, /usr, /etc, etc.) - Add Windows-specific blocked path tests (C:\, C:\Windows) - Use platform-appropriate path separator in trailing slash test - Fix root path normalization for Windows drive letters (C:\ not C:) --- .plans/directory-safeguards.md | 38 +++ src/resources/extensions/gsd/commands.ts | 5 +- src/resources/extensions/gsd/guided-flow.ts | 17 ++ src/resources/extensions/gsd/init-wizard.ts | 4 + .../gsd/tests/validate-directory.test.ts | 222 ++++++++++++++++++ .../extensions/gsd/validate-directory.ts | 164 +++++++++++++ 6 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 .plans/directory-safeguards.md create mode 100644 src/resources/extensions/gsd/tests/validate-directory.test.ts create mode 100644 src/resources/extensions/gsd/validate-directory.ts diff --git a/.plans/directory-safeguards.md b/.plans/directory-safeguards.md new file mode 100644 index 000000000..bfad22ea0 --- /dev/null +++ b/.plans/directory-safeguards.md @@ -0,0 +1,38 @@ +# Directory Safeguards Plan + +## Problem +GSD had zero protection against being launched from dangerous directories like `$HOME`, `/`, `/usr`, `/etc`, etc. Running `gsd init` from these locations would create `.gsd/` and write planning files into system directories. + +## Solution +Added a `validate-directory.ts` module with layered safeguards: + +### Layer 1: Blocked system paths (hard stop) +- Filesystem roots: `/`, `/usr`, `/bin`, `/sbin`, `/etc`, `/var`, `/dev`, `/proc`, `/sys`, `/boot`, `/lib`, `/lib64` +- macOS: `/System`, `/Library`, `/Applications`, `/Volumes`, `/private` +- Windows: `C:\`, `C:\Windows`, `C:\Program Files` +- User's `$HOME` directory itself (subdirs are fine) +- System temp directory root (`os.tmpdir()`) + +### Layer 2: High entry count heuristic (warning) +- Directories with >200 top-level entries trigger a confirmation dialog +- User can override if they really want to proceed + +### Layer 3: Symlink resolution +- All paths are resolved through `realpathSync()` before checking +- Prevents bypassing via symlinks (e.g., `ln -s / ~/myproject`) + +## Integration Points +1. `projectRoot()` in `commands.ts` — gateway for all `/gsd` subcommands (throws on blocked) +2. `showSmartEntry()` in `guided-flow.ts` — smart entry wizard (shows error/confirmation UI) +3. `bootstrapGsdDirectory()` in `init-wizard.ts` — final safety check before writing files (throws on blocked) + +## Test Coverage +19 tests covering: +- All blocked path categories (/, /usr, /etc, /var, /usr/local/bin) +- Home directory (with and without trailing slash) +- Temp directory root +- Normal project directories (pass) +- Empty directories (pass) +- 200-entry boundary (pass) vs 210-entry (warning) +- assertSafeDirectory throw behavior +- Trailing slash normalization diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 6ebd77367..1a1d17f27 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -16,6 +16,7 @@ import { GSDVisualizerOverlay } from "./visualizer-overlay.js"; import { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.js"; import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, dispatchDirectPhase } from "./auto.js"; import { resolveProjectRoot } from "./worktree.js"; +import { assertSafeDirectory, validateDirectory } from "./validate-directory.js"; import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js"; import { getGlobalGSDPreferencesPath, @@ -72,7 +73,9 @@ export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, /** Resolve the effective project root, accounting for worktree paths. */ function projectRoot(): string { - return resolveProjectRoot(process.cwd()); + const root = resolveProjectRoot(process.cwd()); + assertSafeDirectory(root); + return root; } export function registerGSDCommand(pi: ExtensionAPI): void { diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 2e5cd261f..068f2ef20 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -29,6 +29,7 @@ import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitig import { loadEffectiveGSDPreferences } from "./preferences.js"; import { detectProjectState } from "./detection.js"; import { showProjectInit, offerMigration } from "./init-wizard.js"; +import { assertSafeDirectory, validateDirectory } from "./validate-directory.js"; import { showConfirm } from "../shared/mod.js"; import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js"; import { debugLog } from "./debug-logger.js"; @@ -1071,6 +1072,22 @@ export async function showSmartEntry( ): Promise { const stepMode = options?.step; + // ── Directory safety check — refuse to operate in system/home dirs ─── + const dirCheck = validateDirectory(basePath); + if (dirCheck.severity === "blocked") { + ctx.ui.notify(dirCheck.reason!, "error"); + return; + } + if (dirCheck.severity === "warning") { + const proceed = await showConfirm(ctx, { + title: "GSD — Unusual Directory", + message: dirCheck.reason!, + confirmLabel: "Continue anyway", + declineLabel: "Cancel", + }); + if (!proceed) return; + } + // ── Detection preamble — run before any bootstrap ──────────────────── if (!existsSync(join(basePath, ".gsd"))) { const detection = detectProjectState(basePath); diff --git a/src/resources/extensions/gsd/init-wizard.ts b/src/resources/extensions/gsd/init-wizard.ts index f559b8dee..bd8fb53f6 100644 --- a/src/resources/extensions/gsd/init-wizard.ts +++ b/src/resources/extensions/gsd/init-wizard.ts @@ -13,6 +13,7 @@ import { showNextAction } from "../shared/mod.js"; import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js"; import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js"; import { gsdRoot } from "./paths.js"; +import { assertSafeDirectory } from "./validate-directory.js"; import type { ProjectDetection, ProjectSignals } from "./detection.js"; // ─── Types ────────────────────────────────────────────────────────────────────── @@ -434,6 +435,9 @@ function bootstrapGsdDirectory( prefs: ProjectPreferences, signals: ProjectSignals, ): void { + // Final safety check before writing any files + assertSafeDirectory(basePath); + const gsd = gsdRoot(basePath); mkdirSync(join(gsd, "milestones"), { recursive: true }); diff --git a/src/resources/extensions/gsd/tests/validate-directory.test.ts b/src/resources/extensions/gsd/tests/validate-directory.test.ts new file mode 100644 index 000000000..fcce388f5 --- /dev/null +++ b/src/resources/extensions/gsd/tests/validate-directory.test.ts @@ -0,0 +1,222 @@ +/** + * Unit tests for GSD Directory Validation — safeguards against dangerous directories. + * + * Exercises validateDirectory() and assertSafeDirectory() with: + * - Blocked system paths (/, /usr, /etc, $HOME, C:\Windows) + * - Temp directory root + * - Normal project directories (should pass) + * - Directories with many entries (warning heuristic) + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir, platform } from "node:os"; +import { validateDirectory, assertSafeDirectory } from "../validate-directory.ts"; + +const isWindows = platform() === "win32"; + +function makeTempDir(prefix: string): string { + const dir = join( + tmpdir(), + `gsd-validate-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +// ─── Blocked system paths (Unix) ───────────────────────────────────────────────── + +test("validateDirectory: root filesystem is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => { + const result = validateDirectory("/"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); + assert.ok(result.reason?.includes("system directory")); +}); + +test("validateDirectory: /usr is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => { + const result = validateDirectory("/usr"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); +}); + +test("validateDirectory: /etc is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => { + const result = validateDirectory("/etc"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); +}); + +test("validateDirectory: /var is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => { + const result = validateDirectory("/var"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); +}); + +test("validateDirectory: /usr/local/bin is blocked", { skip: isWindows ? "Unix-only test" : undefined }, () => { + const result = validateDirectory("/usr/local/bin"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); +}); + +// ─── Blocked system paths (Windows) ────────────────────────────────────────────── + +test("validateDirectory: C:\\ is blocked", { skip: !isWindows ? "Windows-only test" : undefined }, () => { + const result = validateDirectory("C:\\"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); + assert.ok(result.reason?.includes("system directory")); +}); + +test("validateDirectory: C:\\Windows is blocked", { skip: !isWindows ? "Windows-only test" : undefined }, () => { + const result = validateDirectory("C:\\Windows"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); +}); + +// ─── Home directory (cross-platform) ───────────────────────────────────────────── + +test("validateDirectory: home directory itself is blocked", () => { + const result = validateDirectory(homedir()); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); + assert.ok(result.reason?.includes("home directory")); +}); + +test("validateDirectory: home directory with trailing slash is blocked", () => { + const sep = isWindows ? "\\" : "/"; + const result = validateDirectory(homedir() + sep); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); +}); + +test("validateDirectory: subdirectory of home is NOT blocked", () => { + const dir = makeTempDir("home-subdir"); + try { + const result = validateDirectory(dir); + assert.equal(result.severity, "ok"); + assert.equal(result.safe, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ─── Temp directory root ───────────────────────────────────────────────────────── + +test("validateDirectory: temp directory root is blocked", () => { + const result = validateDirectory(tmpdir()); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); + assert.ok(result.reason?.includes("temp directory")); +}); + +// ─── Normal project directories ────────────────────────────────────────────────── + +test("validateDirectory: normal project directory is safe", () => { + const dir = makeTempDir("normal-project"); + try { + writeFileSync(join(dir, "package.json"), "{}"); + mkdirSync(join(dir, "src")); + const result = validateDirectory(dir); + assert.equal(result.safe, true); + assert.equal(result.severity, "ok"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("validateDirectory: empty directory is safe", () => { + const dir = makeTempDir("empty"); + try { + const result = validateDirectory(dir); + assert.equal(result.safe, true); + assert.equal(result.severity, "ok"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ─── High entry count heuristic ────────────────────────────────────────────────── + +test("validateDirectory: directory with >200 entries triggers warning", () => { + const dir = makeTempDir("many-entries"); + try { + for (let i = 0; i < 210; i++) { + writeFileSync(join(dir, `file-${i.toString().padStart(4, "0")}.txt`), ""); + } + const result = validateDirectory(dir); + assert.equal(result.safe, false); + assert.equal(result.severity, "warning"); + assert.ok(result.reason?.includes("210 entries")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("validateDirectory: directory with exactly 200 entries is safe", () => { + const dir = makeTempDir("boundary-entries"); + try { + for (let i = 0; i < 200; i++) { + writeFileSync(join(dir, `file-${i.toString().padStart(4, "0")}.txt`), ""); + } + const result = validateDirectory(dir); + assert.equal(result.safe, true); + assert.equal(result.severity, "ok"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ─── assertSafeDirectory ───────────────────────────────────────────────────────── + +test("assertSafeDirectory: throws for blocked directories", { skip: isWindows ? "Unix-only test" : undefined }, () => { + assert.throws( + () => assertSafeDirectory("/"), + (err: Error) => err.message.includes("system directory"), + ); +}); + +test("assertSafeDirectory: throws for home directory", () => { + assert.throws( + () => assertSafeDirectory(homedir()), + (err: Error) => err.message.includes("home directory"), + ); +}); + +test("assertSafeDirectory: returns result for warnings (does not throw)", () => { + const dir = makeTempDir("assert-warning"); + try { + for (let i = 0; i < 210; i++) { + writeFileSync(join(dir, `file-${i.toString().padStart(4, "0")}.txt`), ""); + } + const result = assertSafeDirectory(dir); + assert.equal(result.severity, "warning"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("assertSafeDirectory: returns ok for safe directories", () => { + const dir = makeTempDir("assert-safe"); + try { + const result = assertSafeDirectory(dir); + assert.equal(result.severity, "ok"); + assert.equal(result.safe, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ─── Trailing slash normalization ──────────────────────────────────────────────── + +test("validateDirectory: handles paths with trailing slashes", { skip: isWindows ? "Unix-only test" : undefined }, () => { + const result = validateDirectory("/usr/"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); +}); + +test("validateDirectory: handles paths with multiple trailing slashes", { skip: isWindows ? "Unix-only test" : undefined }, () => { + const result = validateDirectory("/etc///"); + assert.equal(result.safe, false); + assert.equal(result.severity, "blocked"); +}); diff --git a/src/resources/extensions/gsd/validate-directory.ts b/src/resources/extensions/gsd/validate-directory.ts new file mode 100644 index 000000000..4341826c2 --- /dev/null +++ b/src/resources/extensions/gsd/validate-directory.ts @@ -0,0 +1,164 @@ +/** + * GSD Directory Validation — Safeguards against running in dangerous directories. + * + * Prevents GSD from creating .gsd/ structures in system paths, home directories, + * or other locations where writing project scaffolding would be harmful. + */ + +import { realpathSync, readdirSync } from "node:fs"; +import { homedir, platform, tmpdir } from "node:os"; +import { resolve } from "node:path"; + +// ─── Types ────────────────────────────────────────────────────────────────────── + +export interface DirectoryValidationResult { + /** Whether the directory is safe for GSD operations */ + safe: boolean; + /** Severity: "blocked" = hard stop, "warning" = user can override */ + severity: "ok" | "blocked" | "warning"; + /** Human-readable reason if not safe */ + reason?: string; +} + +// ─── Blocked Paths ────────────────────────────────────────────────────────────── + +/** Paths where GSD must never create .gsd/ — no override possible. */ +const UNIX_BLOCKED_PATHS = new Set([ + "/", + "/bin", + "/sbin", + "/usr", + "/usr/bin", + "/usr/sbin", + "/usr/lib", + "/usr/local", + "/usr/local/bin", + "/etc", + "/var", + "/var/tmp", + "/dev", + "/proc", + "/sys", + "/boot", + "/lib", + "/lib64", + // macOS-specific + "/System", + "/Library", + "/Applications", + "/Volumes", + "/private", + "/private/var", + "/private/etc", + "/private/tmp", +]); + +const WINDOWS_BLOCKED_PATHS = new Set([ + "C:\\", + "C:\\Windows", + "C:\\Windows\\System32", + "C:\\Program Files", + "C:\\Program Files (x86)", +]); + +// ─── Core Validation ──────────────────────────────────────────────────────────── + +/** + * Validate whether a directory is safe for GSD to operate in. + * + * Checks in order: + * 1. Blocked system paths (hard stop) + * 2. Home directory itself (hard stop) + * 3. Temp directory root (hard stop) + * 4. High entry count heuristic (warning) + */ +export function validateDirectory(dirPath: string): DirectoryValidationResult { + // Resolve to absolute + follow symlinks so aliases can't bypass checks + let resolved: string; + try { + resolved = realpathSync(resolve(dirPath)); + } catch { + // If we can't resolve, use the raw resolved path + resolved = resolve(dirPath); + } + + // Normalize trailing slashes for consistent comparison. + // Special cases: "/" → "/" (not ""), "C:\" → "C:\" (not "C:") + let normalized = resolved.replace(/[/\\]+$/, ""); + if (normalized === "") { + normalized = "/"; + } else if (/^[A-Za-z]:$/.test(normalized)) { + normalized = normalized + "\\"; + } + + // ── Check 1: Blocked system paths ────────────────────────────────────── + const blockedPaths = platform() === "win32" ? WINDOWS_BLOCKED_PATHS : UNIX_BLOCKED_PATHS; + if (blockedPaths.has(normalized)) { + return { + safe: false, + severity: "blocked", + reason: `Refusing to run in system directory: ${normalized}. GSD must be run inside a project directory.`, + }; + } + + // ── Check 2: Home directory itself (not subdirs) ─────────────────────── + let resolvedHome: string; + try { + resolvedHome = realpathSync(resolve(homedir())).replace(/[/\\]+$/, ""); + } catch { + resolvedHome = resolve(homedir()).replace(/[/\\]+$/, ""); + } + + if (normalized === resolvedHome) { + return { + safe: false, + severity: "blocked", + reason: `Refusing to run in your home directory (${normalized}). GSD must be run inside a project directory, not $HOME.`, + }; + } + + // ── Check 3: Temp directory root ─────────────────────────────────────── + let resolvedTmp: string; + try { + resolvedTmp = realpathSync(resolve(tmpdir())).replace(/[/\\]+$/, ""); + } catch { + resolvedTmp = resolve(tmpdir()).replace(/[/\\]+$/, ""); + } + + if (normalized === resolvedTmp) { + return { + safe: false, + severity: "blocked", + reason: `Refusing to run in the system temp directory (${normalized}). Use a project subdirectory instead.`, + }; + } + + // ── Check 4: Suspiciously large directory (heuristic warning) ────────── + try { + const entries = readdirSync(normalized); + if (entries.length > 200) { + return { + safe: false, + severity: "warning", + reason: `This directory has ${entries.length} entries, which suggests it may not be a project directory. Are you sure you want to initialize GSD here?`, + }; + } + } catch { + // Can't read directory — let downstream handle the error + } + + return { safe: true, severity: "ok" }; +} + +/** + * Assert that a directory is safe for GSD operations. + * Throws with a descriptive message if the directory is blocked. + * Returns the validation result for warnings (caller decides how to handle). + */ +export function assertSafeDirectory(dirPath: string): DirectoryValidationResult { + const result = validateDirectory(dirPath); + if (result.severity === "blocked") { + throw new Error(result.reason); + } + return result; +}