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:)
This commit is contained in:
Jeremy McSpadden 2026-03-17 22:57:53 -05:00 committed by GitHub
parent ce1ad35706
commit 45bff3456c
6 changed files with 449 additions and 1 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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<void> {
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);

View file

@ -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 });

View file

@ -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");
});

View file

@ -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;
}