// User-defined reference directories (#455). // // Loaded from `config/reference-dirs.json`. Users can specify external // directories that the agent can read (but not write to). // // Docker mode: mounted as `:ro` — filesystem-enforced read-only. // Non-Docker mode: prompt-based restriction only. import { createHash } from "crypto"; import path from "path"; import { homedir } from "os"; import { log } from "../system/logger/index.js"; import { readReferenceDirsJson, writeReferenceDirsJson, isExistingDirectory } from "../utils/files/reference-dirs-io.js"; import { hasStringProp, isRecord } from "../utils/types.js"; import { validateEntryList, type EntryListResult } from "../utils/validateEntryList.js"; // ── Types ─────────────────────────────────────────────────────── export interface ReferenceDirEntry { /** Absolute host path to the directory. */ hostPath: string; /** Short label shown in prompt and UI. */ label: string; } // ── Constants ─────────────────────────────────────────────────── const MAX_ENTRIES = 20; const MAX_LABEL_LENGTH = 100; const CONTAINER_MOUNT_ROOT = "/mnt/readonly"; /** Home-relative directories that must never be mounted. */ const HOME_RELATIVE_BLOCKED = [".ssh", ".aws", ".gnupg", ".config/gh", ".kube", ".docker"]; const IS_WINDOWS = process.platform === "win32"; /** Absolute system paths that must never be mounted, POSIX spelling. */ const POSIX_SYSTEM_BLOCKED = ["/etc", "/root", "/var", "/proc", "/sys", "/boot", "/private/etc", "/private/var", "/System", "/Library"]; /** The Windows equivalents, read from the environment rather than hardcoded: * the system drive is not always `C:`, and a hardcoded letter would silently * block nothing on a machine that boots from another one. Entries the OS * doesn't set are simply absent. */ function windowsSystemBlocked(): string[] { const candidates = [process.env.SystemRoot, process.env.windir, process.env.ProgramFiles, process.env["ProgramFiles(x86)"], process.env.ProgramData]; return candidates.filter((value): value is string => typeof value === "string" && value.length > 0).map((value) => path.resolve(value)); } // The POSIX list is dead weight on Windows — `path.resolve("/etc")` yields // `:\etc`, which matches no entry — so each platform carries only its // own vocabulary. const SYSTEM_BLOCKED_PREFIXES = IS_WINDOWS ? windowsSystemBlocked() : POSIX_SYSTEM_BLOCKED; /** Comparison key for a path. Windows filesystems are case-insensitive, so * `c:\windows` and `C:\Windows` name the same directory — comparing them * verbatim would let the lowercase spelling walk straight past the blocklist. */ function pathKey(absPath: string): string { return IS_WINDOWS ? absPath.toLowerCase() : absPath; } // eslint-disable-next-line no-control-regex const CONTROL_CHAR_RE_G = /[\x00-\x1f]/g; // ── Validation ────────────────────────────────────────────────── function expandHome(inputPath: string): string { if (inputPath.startsWith("~/")) { return path.join(homedir(), inputPath.slice(2)); } return inputPath; } function isSensitivePath(absPath: string): boolean { const normalized = path.resolve(absPath); const key = pathKey(normalized); // Reject filesystem root if (normalized === path.parse(normalized).root) return true; const home = homedir(); // Block $HOME itself (transitively exposes .ssh etc.) if (key === pathKey(home)) return true; // Block home-relative sensitive dirs if (HOME_RELATIVE_BLOCKED.some((blockedPath) => isAtOrUnder(key, path.join(home, blockedPath)))) { return true; } // Block system directories return SYSTEM_BLOCKED_PREFIXES.some((blockedPrefix) => isAtOrUnder(key, blockedPrefix)); } /** True when `key` (already a `pathKey`) is the blocked dir itself or sits * underneath it. The `+ path.sep` guard is what keeps `/etc-backup` out of * `/etc`'s subtree. */ function isAtOrUnder(key: string, blockedDir: string): boolean { const blocked = pathKey(blockedDir); return key === blocked || key.startsWith(blocked + path.sep); } function sanitizeLabel(raw: string): string { if (typeof raw !== "string") return ""; return raw.replace(CONTROL_CHAR_RE_G, " ").trim().slice(0, MAX_LABEL_LENGTH); } function hasTraversalSegment(inputPath: string): boolean { return inputPath.split(path.sep).some((segment) => segment === ".."); } function validateEntry(raw: unknown): ReferenceDirEntry | null { if (!isRecord(raw)) return null; const rawPath = typeof raw.hostPath === "string" ? raw.hostPath : ""; if (!rawPath) return null; const expanded = expandHome(rawPath); // Must be absolute if (!path.isAbsolute(expanded)) return null; // Normalize to collapse . and // segments const absPath = path.resolve(expanded); // Reject actual ".." traversal segments (not substrings in filenames) if (hasTraversalSegment(expanded)) return null; // Block sensitive directories if (isSensitivePath(absPath)) { log.warn("reference-dirs", "blocked sensitive path", { path: absPath }); return null; } // Type-check like `hostPath` above rather than `String(...)`-ing: a // non-string `label` in the hand-edited config falls back to the basename // instead of labelling the directory "[object Object]". const label = sanitizeLabel(hasStringProp(raw, "label") ? raw.label : path.basename(absPath)); return { hostPath: absPath, label }; } // ── Load ──────────────────────────────────────────────────────── export function loadReferenceDirs(root?: string): ReferenceDirEntry[] { const parsed = readReferenceDirsJson(root); const seenLabels = new Set(); const entries = parsed .slice(0, MAX_ENTRIES) .map(validateEntry) .filter((entry): entry is ReferenceDirEntry => { if (!entry) return false; // Deduplicate labels — first entry wins if (seenLabels.has(entry.label)) return false; seenLabels.add(entry.label); return true; }); const skipped = parsed.length - entries.length; if (skipped > 0) { log.warn("reference-dirs", "skipped invalid entries", { skipped }); } return entries; } // ── Save ──────────────────────────────────────────────────────── export function saveReferenceDirs(entries: readonly ReferenceDirEntry[], root?: string): void { writeReferenceDirsJson(entries, root); invalidateCache(); } // ── Validate input array (for API) ───────────────────────────── export function validateReferenceDirs(raw: unknown): EntryListResult { const result = validateEntryList(raw, { maxEntries: MAX_ENTRIES, validateEntry, echoProp: "hostPath", describeInvalid: (hostPath) => `invalid or blocked path "${hostPath}"`, }); if ("error" in result) return result; // Reject duplicate labels — @ref/