/** * Security — Input validation, path traversal prevention, and prompt injection guards * * This module centralizes security checks for GSD tooling. Because GSD generates * markdown files that become LLM system prompts (agent instructions, workflow state, * phase plans), any user-controlled text that flows into these files is a potential * indirect prompt injection vector. * * Threat model: * 1. Path traversal: user-supplied file paths escape the project directory * 2. Prompt injection: malicious text in arguments/PRDs embeds LLM instructions * 3. Shell metacharacter injection: user text interpreted by shell * 4. JSON injection: malformed JSON crashes or corrupts state * 5. Regex DoS: crafted input causes catastrophic backtracking * * ADR-457 build-at-publish: the hand-written bin/lib/security.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only types are added. */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; // ─── Path Traversal Prevention ────────────────────────────────────────────── /** * THE containment comparison — the single place this repo decides whether an * already-resolved path lies inside an already-resolved root (ADR-4650). * * Separator-aware on purpose: comparing the bare strings would accept a * sibling that merely shares a prefix (`-evil` against ``), so both * sides get a trailing separator before the prefix test. `target === root` is * contained. * * `pathImpl` lets a caller supply `path.win32` / `path.posix` instead of the * ambient module, so win32 separator semantics are testable off Windows. * * Exported for callers that have ALREADY resolved both operands themselves * and need only this comparison step (e.g. a caller that owns its own * `fs.realpathSync` calls to preserve an exists-vs-escaped tri-state). A * caller that has NOT resolved its operands must NOT reach for this function * directly — the comparison alone is not a containment check — and should use * `assertWithinRoot` / `tryWithinRoot` (or the `assertWithinRootLexical` / * `tryWithinRootLexical` pair) instead. */ export function isContainedIn( resolvedTarget: string, resolvedRoot: string, pathImpl: { sep: string } = path, ): boolean { if (resolvedTarget === resolvedRoot) return true; return (resolvedTarget + pathImpl.sep).startsWith(resolvedRoot + pathImpl.sep); // allow-handrolled-containment: this IS the canonical comparison every other site routes through } /** * Validate that a file path resolves within an allowed base directory. * Prevents path traversal attacks via ../ sequences, symlinks, or absolute paths. */ function validatePath(filePath: unknown, baseDir: unknown, opts: { allowAbsolute?: boolean } = {}): { safe: boolean; resolved: string; error?: string } { if (!filePath || typeof filePath !== 'string') { return { safe: false, resolved: '', error: 'Empty or invalid file path' }; } if (!baseDir || typeof baseDir !== 'string') { return { safe: false, resolved: '', error: 'Empty or invalid base directory' }; } if (filePath.includes('\0')) { return { safe: false, resolved: '', error: 'Path contains null bytes' }; } let resolvedBase: string; try { resolvedBase = fs.realpathSync(path.resolve(baseDir)); } catch { resolvedBase = path.resolve(baseDir); } let resolvedPath: string; if (path.isAbsolute(filePath)) { if (!opts.allowAbsolute) { return { safe: false, resolved: '', error: 'Absolute paths not allowed' }; } resolvedPath = path.resolve(filePath); } else { resolvedPath = path.resolve(baseDir, filePath); } try { resolvedPath = fs.realpathSync(resolvedPath); } catch { // realpathSync failed — either resolvedPath doesn't exist at all, or it's // a dangling symlink (the link itself exists but its target doesn't). // lstat (unlike stat/realpath) stats the link itself and does NOT follow // it, so it succeeds for a dangling symlink and throws ENOENT for a // genuinely absent path. That's the discriminator: without it, a dangling // symlink to a non-existent OUTSIDE path would fall through to the // parent-resolution fallback below and be re-accepted as an in-project // path, while a symlink to an EXISTING outside path is correctly // rejected via the realpathSync success branch above — a state // difference an attacker can use as an existence oracle for arbitrary // absolute paths. try { if (fs.lstatSync(resolvedPath).isSymbolicLink()) { return { safe: false, resolved: '', error: 'Path is an unresolvable symbolic link' }; } } catch { // lstat also threw — resolvedPath (and its would-be link) genuinely // doesn't exist. Fall through to ancestor resolution below. } // Walk up to the nearest ancestor that exists and realpath THAT, then // re-append the remaining (not-yet-created) segments. This canonicalizes // resolvedPath the same way resolvedBase was canonicalized above, // regardless of how many leading directories are missing — a single // parent-only check would leave resolvedPath un-canonicalized whenever // the parent is also missing, which breaks the startsWith comparison // below on any non-canonical cwd (e.g. macOS /var/... vs // /private/var/...). let ancestor = path.dirname(resolvedPath); const remainder: string[] = [path.basename(resolvedPath)]; for (;;) { try { const realAncestor = fs.realpathSync(ancestor); resolvedPath = path.join(realAncestor, ...remainder); break; } catch { const parent = path.dirname(ancestor); if (parent === ancestor) { // Reached filesystem root without finding an existing ancestor — // keep resolvedPath as-is. break; } remainder.unshift(path.basename(ancestor)); ancestor = parent; } } } if (!isContainedIn(resolvedPath, resolvedBase)) { return { safe: false, resolved: resolvedPath, error: `Path escapes allowed directory: ${resolvedPath} is outside ${resolvedBase}`, }; } return { safe: true, resolved: resolvedPath }; } /** * Load the opt-in trusted global roots allowlist from config. * * Reads `config.agent_skills_security.trusted_global_roots` (an array of * path strings). Each entry is canonicalized via realpathSync: non-strings * are dropped, leading `~/` is expanded to `os.homedir()`, entries that are * not absolute after expansion are dropped (project-relative paths are * rejected as a security boundary), and entries that do not exist on disk are * dropped (a non-existent root is not trustworthy). The canonical realpath is * used for all subsequent checks and as the stored value — this closes the * case-insensitive bypass on macOS APFS (`/users/alice` vs `/Users/alice`) * and ensures trust doesn't drift across re-invocations if a root is * re-created at a different target. Results are de-duplicated by canonical path. */ export function loadTrustedGlobalRoots(config: unknown): string[] { const roots = (config as Record | null | undefined) ?.['agent_skills_security'] as Record | undefined; const raw = roots?.['trusted_global_roots']; if (!Array.isArray(raw)) return []; // Compute canonical homedir once for case-insensitive-safe comparison. let realHome: string; try { realHome = fs.realpathSync(os.homedir()); } catch { realHome = os.homedir(); } const seen = new Set(); const result: string[] = []; for (const entry of raw) { if (typeof entry !== 'string') continue; let expanded: string; if (entry === '~') { expanded = os.homedir(); } else if (entry.startsWith('~/')) { expanded = path.join(os.homedir(), entry.slice(2)); } else { expanded = entry; } if (!path.isAbsolute(expanded)) continue; // reject project-relative // Canonicalize: resolve symlinks and normalise case. If the path doesn't // exist or can't be read, skip it — a non-existent root is not trustworthy. let real: string; try { real = fs.realpathSync(expanded); } catch { continue; // non-existent or unreadable — skip } // Reject dangerously broad roots: filesystem root (e.g. '/' or 'C:\' or UNC '\\server\share'). // Normalize both sides by stripping trailing path separators before comparing so that // Windows UNC shares (where path.parse().root includes a trailing separator) are caught. const stripTrailingSep = (p: string): string => p.replace(/[\\/]+$/, ''); if (stripTrailingSep(path.parse(real).root) === stripTrailingSep(real)) continue; // Reject homedir itself (canonical compare closes case-insensitive bypass). // Apply stripTrailingSep for robustness on platforms where realpathSync may // or may not include a trailing separator on the homedir path. if (stripTrailingSep(real) === stripTrailingSep(realHome)) continue; if (seen.has(real)) continue; seen.add(real); result.push(real); } return result; } /** * A path proven to resolve inside a declared root. * * A plain `string` is NOT assignable to `ContainedPath` — that asymmetry is * the entire point. The shape being replaced (`validatePath`'s * `{ resolved: string }`) returns a usable-looking path even when the answer * is unsafe (the traversal branch still populates `resolved` with the * escaping path), so a plain string in hand proves nothing. A * `ContainedPath` can only be produced by `assertWithinRoot` / * `tryWithinRoot` on their success paths, so possessing one is proof the * containment check already passed. */ export type ContainedPath = string & { readonly __containedIn: unique symbol }; /** * Named acceptance policy for what kind of candidate path is even considered. * * This replaces the old per-call-site `{ allowAbsolute: true }` boolean flag. * At a call site, `{ allowAbsolute: true }` reads as "containment is relaxed * here" — which is FALSE. An absolute path that resolves OUTSIDE the root is * still rejected; the flag only ever controlled whether an absolute candidate * was considered at all. `AbsoluteInsideRoot` states the real contract: an * absolute candidate is accepted for consideration, but containment is * enforced exactly as it is for a relative one. */ export const PathAcceptance = { /** Relative candidates only; an absolute candidate is rejected outright. */ RelativeOnly: 'relative-only', /** * An absolute candidate is accepted — but ONLY if it still resolves inside the * root. Containment is NOT relaxed by this policy; an absolute path outside the * root is rejected exactly as a traversal is. This is the distinction the old * `{ allowAbsolute: true }` flag failed to make at its call sites. */ AbsoluteInsideRoot: 'absolute-inside-root', } as const; export type PathAcceptancePolicy = (typeof PathAcceptance)[keyof typeof PathAcceptance]; /** * Validate a file path and throw on traversal attempt. * Convenience wrapper around validatePath for use in CLI commands. */ export function assertWithinRoot(candidate: unknown, root: unknown, label?: string | null, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath { const result = validatePath(candidate, root, { allowAbsolute: policy === PathAcceptance.AbsoluteInsideRoot }); if (!result.safe) { throw new Error(`${label || 'Path'} validation failed: ${result.error}`); } return result.resolved as ContainedPath; } /** * Validate a file path and return null on traversal attempt (no throw). * * Returns exactly `null` when unsafe — never `''`, never `result.resolved`. * `validatePath` populates `resolved` with the ESCAPING path on the * traversal branch, so returning it here would reproduce the defect this * narrowing exists to remove. */ export function tryWithinRoot(candidate: unknown, root: unknown, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath | null { const result = validatePath(candidate, root, { allowAbsolute: policy === PathAcceptance.AbsoluteInsideRoot }); if (!result.safe) { return null; } return result.resolved as ContainedPath; } /** * Validate a file path and throw on traversal attempt. * Convenience wrapper around validatePath for use in CLI commands. * * Delegates to assertWithinRoot so there is one implementation beneath both * names; its declared return type is ContainedPath (a branded string, still * assignable to string) so existing callers keep compiling untouched. */ export function requireSafePath(filePath: unknown, baseDir: unknown, label: string | null | undefined, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath { return assertWithinRoot(filePath, baseDir, label, policy); } /** * LEXICAL containment — `path.resolve` only, never any filesystem access. * * Shares `isContainedIn` with the realpath-based predicate, so there is ONE * containment decision in this repo; these differ only in how a path is * RESOLVED before that decision, never in the decision itself (ADR-4650 * decisions 1 and 6). * * Use this — and say why at the call site — only where a symlink must be * PRESERVED rather than resolved, or where the target legitimately does not * exist yet. Three such cases exist: a destination validated before the * `mkdirSync` that creates it, a migration that snapshots and restores a * symlinked path AS A LINK, and a restore gate that refuses links outright. * Everywhere else the realpath-based `assertWithinRoot` / `tryWithinRoot` is * the correct predicate, because a lexical check CANNOT SEE A SYMLINK: a * caller relying on one for a write-confinement guarantee must pair it with * its own symlink refusal. * * `candidate` is resolved RELATIVE TO `root` (so an absolute candidate is * taken as-is, matching `path.resolve` semantics). `target === root` is * contained. * * DELIBERATELY ABSENT: no NUL-byte rejection here. The existing lexical * callers do not reject NUL at this layer (one of them checks NUL itself, * separately), and adding it here would change their behavior. Callers that * need it keep their own check. */ export function tryWithinRootLexical( candidate: unknown, root: unknown, opts: { pathImpl?: { resolve(...segments: string[]): string; sep: string } } = {}, ): ContainedPath | null { const p = opts.pathImpl || path; if (typeof candidate !== 'string' || candidate === '') return null; if (typeof root !== 'string' || root === '') return null; const rootResolved = p.resolve(root); const targetResolved = p.resolve(root, candidate); return isContainedIn(targetResolved, rootResolved, p) ? (targetResolved as ContainedPath) : null; } export function assertWithinRootLexical( candidate: unknown, root: unknown, label?: string | null, opts: { pathImpl?: { resolve(...segments: string[]): string; sep: string } } = {}, ): ContainedPath { const contained = tryWithinRootLexical(candidate, root, opts); if (contained === null) { throw new Error(`${label || 'Path'} validation failed: lexical containment check failed`); } return contained; } // ─── Prompt Injection Detection ──────────────────────────────────────────────────── /** * Patterns that indicate prompt injection attempts in user-supplied text. * These patterns catch common indirect prompt injection techniques where * an attacker embeds LLM instructions in text that will be read by an agent. * * Note: This is defense-in-depth — not a complete solution. The primary defense * is proper input/output boundaries in agent prompts. */ export const INJECTION_PATTERNS: RegExp[] = [ // Direct instruction override attempts /ignore\s+(all\s+)?previous\s+instructions/i, /ignore\s+(all\s+)?above\s+instructions/i, /disregard\s+(all\s+)?previous/i, /forget\s+(all\s+)?(your\s+)?instructions/i, /override\s+(system|previous)\s+(prompt|instructions)/i, // Role/identity manipulation /you\s+are\s+now\s+(?:a|an|the)\s+/i, /\bact\s+as\s+(?:a|an|the)\s+(?!plan|phase|wave)/i, /pretend\s+(?:you(?:'re| are)\s+|to\s+be\s+)/i, /from\s+now\s+on,?\s+you\s+(?:are|will|should|must)/i, // System prompt extraction /(?:print|output|reveal|show|display|repeat)\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions)/i, /what\s+(?:are|is)\s+your\s+(?:system\s+)?(?:prompt|instructions)/i, // Hidden instruction markers (XML/HTML tags that mimic system messages) // Note: is excluded — GSD uses it as legitimate prompt structure // Requires > to close the tag (not just whitespace) to avoid matching generic types like Promise /<\/?(?:system|assistant|human)>/i, /\[SYSTEM\]/i, /\[\/?(INST)\]/i, /<<\s*SYS\s*>>/i, // Exfiltration attempts /(?:send|post|fetch|curl|wget)\s+(?:to|from)\s+https?:\/\//i, /(?:base64|btoa|encode)\s+(?:and\s+)?(?:send|exfiltrate|output)/i, // Tool manipulation /(?:run|execute|call|invoke)\s+(?:the\s+)?(?:bash|shell|exec|spawn)\s+(?:tool|command)/i, ]; // Explicit safe-list for data: MIME types that are benign in link targets. // Note: image/svg+xml is intentionally NOT in this list (SVG can host