From e47bd7363da9455ce4b3af3b6afcea0b0f11bfe2 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 27 Oct 2026 11:20:34 -0500 Subject: [PATCH] fix(security): the sensitive-folder guard refuses links, case variants and \\?\ paths The guard that keeps ~/.ssh, ~/.gnupg, ~/.aws, ~/.config and system folders such as /etc and C:\Windows from being opened as a project compared the path as typed. A junction or symlink to one of them, a different capitalization on Windows or macOS (~/.SSH), or a \\?\ or \\.\ spelling on Windows passed, so an MCP projectPath, `codegraph ui` and the viewer's per-file reads all served what was inside. The MCP gate also skipped the guard for a path that doesn't exist and then walked up from it, so ~/.config/no-such-dir reached an index of ~/.config. validateProjectPath now checks the path as spelled (with a Win32 namespace prefix stripped) and its fs.realpathSync.native form. Both are compared with the system set and the system folders' own real paths (macOS /private/etc), and with the home folders as spelled, under the home's real path and by their own real paths (a linked ~/.ssh). Case is folded on win32 and darwin. A path that can't be resolved keeps the lexical check. It stays at one realpath per call: the system folders' real paths are read once and the home folders' are cached for two seconds. The MCP handler also checks the root its up-walk lands on. Validation on Windows 11, with a private home holding a fake token: - Probe: on main 11 of 16 sensitive spellings were allowed, and the token was served through 6 MCP projectPath shapes and 2 viewer roots. With the fix all are refused. Controls (an ordinary folder, its upper-case and \\?\ spellings, the home itself) are still allowed. - codegraph ui end to end: main served the token for 4 of 6 spellings and started the viewer for a fifth; the fix refuses all 6 at startup. - Tests: the 9 new tests that run on Windows fail on main's source and pass with the fix. The POSIX and macOS cases are gated and were not run here. - Cost: validateProjectPath 48 -> 130 us per call, the viewer's per-file resolveProjectFile 700 -> 780 us (medians of 5 rounds, 3 runs). Reported in PR #1930 by @mixxer; this reimplements the fix independently. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + __tests__/cli-ui-command.test.ts | 16 +++ __tests__/security.test.ts | 188 ++++++++++++++++++++++++++++++- __tests__/ui-server.test.ts | 22 ++++ docs/viewer-launch-changelog.md | 2 + src/mcp/tools.ts | 8 ++ src/utils.ts | 139 +++++++++++++++++++++-- 7 files changed, 370 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e422877584..6b4dfbb8af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Security + +- CodeGraph's refusal to use a sensitive folder such as `~/.ssh`, `~/.config`, `/etc` or `C:\Windows` as a project can no longer be sidestepped: a link or junction that leads to one, a differently capitalized name on Windows and macOS, a `\\?\` spelling on Windows, and a path to a missing folder inside one are now refused like the folder itself. Before, any of these let an AI agent's query read the files inside. Thanks @mixxer for finding it. + ### Fixes - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) diff --git a/__tests__/cli-ui-command.test.ts b/__tests__/cli-ui-command.test.ts index d2bd848982..7ca6d210a1 100644 --- a/__tests__/cli-ui-command.test.ts +++ b/__tests__/cli-ui-command.test.ts @@ -185,6 +185,22 @@ describe('codegraph ui — refusals', () => { expect(code).toBe(1); expect(output).toContain('Refusing to operate on sensitive'); }); + + // #1930: a link (a junction on Windows) to a sensitive folder is refused like + // the folder itself, not taken for an ordinary unindexed project. + it('refuses a link to a sensitive home folder', () => { + const home = path.join(unindexed, 'home'); + fs.mkdirSync(path.join(home, '.config'), { recursive: true }); + const link = path.join(unindexed, 'config-link'); + fs.symlinkSync(path.join(home, '.config'), link, process.platform === 'win32' ? 'junction' : 'dir'); + try { + const { code, output } = runCli(['ui', link], { HOME: home, USERPROFILE: home }); + expect(code).toBe(1); + expect(output).toContain('Refusing to operate on sensitive'); + } finally { + fs.unlinkSync(link); + } + }); }); describe('codegraph ui — serving', () => { diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index fd075828a6..4051fbda87 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -14,7 +14,7 @@ import * as path from 'path'; import * as os from 'os'; import { FileLock, validateProjectPath, validatePathWithinRoot } from '../src/utils'; import CodeGraph from '../src/index'; -import { ToolHandler, tools } from '../src/mcp/tools'; +import { ToolHandler, tools, __setLoadCodeGraphForTests } from '../src/mcp/tools'; import { scanDirectory, isSourceFile } from '../src/extraction'; import { DatabaseConnection, getDatabasePath } from '../src/db'; import { QueryBuilder } from '../src/db/queries'; @@ -395,6 +395,192 @@ describe('validateProjectPath — sensitive directory blocking', () => { ); }); +/** + * A directory link any user can make: a junction on Windows (no privilege or + * Developer Mode needed), a symlink elsewhere. + */ +function linkDir(target: string, link: string): void { + fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir'); +} + +/** Point `os.homedir()` at `home`: it reads USERPROFILE on Windows, HOME elsewhere. */ +function setHome(home: string | undefined, profile: string | undefined = home): void { + if (home === undefined) delete process.env.HOME; + else process.env.HOME = home; + if (profile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = profile; +} + +/** + * A temp folder holding a private home with its own `.ssh` and `.config`, and + * the links a case makes. Links are removed before the tree, so the cleanup + * can never walk into a link's target. + */ +function sensitiveFixture() { + const state = { + tmp: '', + home: '', + links: [] as string[], + savedHome: undefined as string | undefined, + savedProfile: undefined as string | undefined, + }; + beforeEach(() => { + state.tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sensitive-'))); + state.home = path.join(state.tmp, 'home'); + fs.mkdirSync(path.join(state.home, '.ssh'), { recursive: true }); + fs.mkdirSync(path.join(state.home, '.config', 'nvim'), { recursive: true }); + fs.mkdirSync(path.join(state.tmp, 'project')); + fs.mkdirSync(path.join(state.tmp, 'elsewhere')); + state.links = []; + state.savedHome = process.env.HOME; + state.savedProfile = process.env.USERPROFILE; + setHome(state.home); + }); + afterEach(() => { + setHome(state.savedHome, state.savedProfile); + for (const link of state.links.reverse()) fs.unlinkSync(link); + fs.rmSync(state.tmp, { recursive: true, force: true }); + }); + const link = (target: string, at: string): string => { + linkDir(target, at); + state.links.push(at); + return at; + }; + return { state, link }; +} + +// #1930: the guard compared the path as typed, so a link to a sensitive folder, +// a case variant of one (Windows, macOS) or a `\\?\` spelling (Windows) passed. +describe('validateProjectPath — the folder a path really names (#1930)', () => { + const { state, link } = sensitiveFixture(); + + it('refuses a link to a sensitive home folder, and the folders under it', () => { + const elsewhere = path.join(state.tmp, 'elsewhere'); + const ssh = link(path.join(state.home, '.ssh'), path.join(elsewhere, 'ssh')); + const config = link(path.join(state.home, '.config'), path.join(elsewhere, 'config')); + + expect(validateProjectPath(ssh)).toMatch(/sensitive directory/i); + expect(validateProjectPath(config)).toMatch(/sensitive directory/i); + expect(validateProjectPath(path.join(config, 'nvim'))).toMatch(/sensitive directory/i); + // The refusal names the folder the link leads to. + expect(validateProjectPath(ssh)).toContain(path.join(state.home, '.ssh')); + // A link to an ordinary folder is still a fine project root. + const project = link(path.join(state.tmp, 'project'), path.join(elsewhere, 'project')); + expect(validateProjectPath(project)).toBeNull(); + }); + + it('refuses a sensitive home folder that is itself a link, and links to it', () => { + // ~/.ssh kept in a dotfiles checkout and linked into the home. + const dotfiles = path.join(state.tmp, 'dotfiles', 'ssh'); + fs.mkdirSync(dotfiles, { recursive: true }); + fs.rmdirSync(path.join(state.home, '.ssh')); + link(dotfiles, path.join(state.home, '.ssh')); + + expect(validateProjectPath(dotfiles)).toMatch(/sensitive directory/i); + const viaLink = link(path.join(state.home, '.ssh'), path.join(state.tmp, 'elsewhere', 'ssh')); + expect(validateProjectPath(viaLink)).toMatch(/sensitive directory/i); + }); + + it("refuses a linked home's sensitive folders by their real path", () => { + const realHome = path.join(state.tmp, 'real-home'); + fs.mkdirSync(path.join(realHome, '.aws'), { recursive: true }); + const linkedHome = link(realHome, path.join(state.tmp, 'linked-home')); + setHome(linkedHome); + + expect(validateProjectPath(path.join(realHome, '.aws'))).toMatch(/sensitive directory/i); + expect(validateProjectPath(path.join(linkedHome, '.aws'))).toMatch(/sensitive directory/i); + }); + + it.runIf(process.platform === 'win32' || process.platform === 'darwin')( + 'refuses a case variant of a sensitive home folder (case-insensitive file systems)', + () => { + expect(validateProjectPath(path.join(state.home, '.SSH'))).toMatch(/sensitive directory/i); + expect(validateProjectPath(path.join(state.home, '.Config', 'nvim'))).toMatch(/sensitive directory/i); + expect(validateProjectPath(path.join(state.home.toUpperCase(), '.config'))).toMatch( + /sensitive directory/i + ); + } + ); + + it.runIf(process.platform === 'win32')('refuses \\\\?\\ and \\\\.\\ spellings of a sensitive folder', () => { + for (const spelled of [ + `\\\\?\\${path.join(state.home, '.ssh')}`, + `\\\\.\\${path.join(state.home, '.config')}`, + `//?/${path.join(state.home, '.ssh').replace(/\\/g, '/')}`, + ]) { + expect(validateProjectPath(spelled), spelled).toMatch(/sensitive directory/i); + } + for (const spelled of ['\\\\?\\C:\\Windows', '\\\\.\\C:\\Windows', '\\\\?\\c:\\windows\\system32', '\\\\?\\C:\\']) { + expect(validateProjectPath(spelled), spelled).toMatch(/sensitive system directory/i); + } + // The prefix on an ordinary folder changes nothing. + expect(validateProjectPath(`\\\\?\\${path.join(state.tmp, 'project')}`)).toBeNull(); + }); + + it.runIf(process.platform === 'win32' && fs.existsSync('C:\\Windows'))( + 'refuses a junction to a Windows system folder', + () => { + const windows = link('C:\\Windows', path.join(state.tmp, 'elsewhere', 'windows')); + expect(validateProjectPath(windows)).toMatch(/sensitive system directory/i); + } + ); + + // On macOS `/etc` is itself a link to `/private/etc`, so this also checks that + // the system folders are compared by their own real paths. + it.runIf(process.platform !== 'win32')('refuses a symlink to a system folder', () => { + const etc = link('/etc', path.join(state.tmp, 'elsewhere', 'etc')); + expect(validateProjectPath(etc)).toMatch(/sensitive system directory/i); + }); + + it.runIf(process.platform === 'darwin')('refuses /private/etc, the folder /etc names on macOS', () => { + expect(validateProjectPath('/private/etc')).toMatch(/sensitive system directory/i); + }); +}); + +// #1930 through the MCP entry point, against a real index of the folder. +describe('MCP projectPath into a sensitive folder (#1930)', () => { + const { state, link } = sensitiveFixture(); + let handler: ToolHandler; + + beforeEach(async () => { + const config = path.join(state.home, '.config'); + fs.mkdirSync(path.join(config, 'tool')); + fs.writeFileSync( + path.join(config, 'tool', 'secrets.ts'), + "export function readToken(): string { return 'FAKE-TOKEN-1930'; }\n" + ); + const indexed = await CodeGraph.init(config, { index: true }); + indexed.close(); + // Without the seam an unrefused path fails to load the engine here instead + // of opening the folder's index, which would hide a leak. + __setLoadCodeGraphForTests(CodeGraph); + handler = new ToolHandler(null); + }); + + afterEach(async () => { + await handler.closeAll(); + __setLoadCodeGraphForTests(null); + }); + + it('refuses a link to the folder, and a missing folder under it', async () => { + const config = path.join(state.home, '.config'); + const viaLink = link(config, path.join(state.tmp, 'elsewhere', 'config')); + // A path that doesn't exist skips the up-front check and walks up to the + // folder's own index, so the root the walk lands on is checked too. + for (const projectPath of [viaLink, path.join(config, 'no-such-dir'), path.join(viaLink, 'no-such-dir')]) { + const result = await handler.execute('codegraph_node', { + symbol: 'readToken', + includeCode: true, + projectPath, + }); + const text = result.content.map((c) => c.text).join('\n'); + expect(result.isError, projectPath).toBe(true); + expect(text, projectPath).toMatch(/sensitive directory/i); + expect(text, projectPath).not.toContain('FAKE-TOKEN-1930'); + } + }); +}); + describe('MCP Input Validation', () => { let testDir: string; let cg: CodeGraph; diff --git a/__tests__/ui-server.test.ts b/__tests__/ui-server.test.ts index 4b4e63fc8c..928e945ff5 100644 --- a/__tests__/ui-server.test.ts +++ b/__tests__/ui-server.test.ts @@ -521,6 +521,28 @@ describe('resolveProjectFile — the source read chokepoint', () => { expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError); }); + // #1930: the root is checked as the folder it really names, so a root that + // is a link (a junction on Windows) to a sensitive folder is refused too. + it('refuses a root that is a link to a sensitive home folder', () => { + const home = path.join(tempDir, 'home'); + fs.mkdirSync(path.join(home, '.ssh'), { recursive: true }); + fs.writeFileSync(path.join(home, '.ssh', 'id_ed25519'), 'FAKE-KEY\n'); + const root = path.join(tempDir, 'ssh-link'); + fs.symlinkSync(path.join(home, '.ssh'), root, process.platform === 'win32' ? 'junction' : 'dir'); + const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = home; + process.env.USERPROFILE = home; + try { + expect(() => resolveProjectFile(root, 'id_ed25519')).toThrow(/sensitive directory/i); + } finally { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + fs.unlinkSync(root); + } + }); + it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => { const link = path.join(projectRoot, 'src', 'escape.ts'); fs.symlinkSync(path.join(tempDir, 'secret.txt'), link); diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index 70b1727148..4a123114f3 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -217,6 +217,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel - **Saved trails stay inside your project.** When `.codegraph/ui` was a link to a folder somewhere else, saving a trail wrote it into that folder, even though the save then reported an error, and a trail file that was itself a link to a file outside the project was listed and opened like one of yours. Both are now refused, and links that stay inside the project keep working. Thanks @danusha2345. +- **`codegraph ui` won't open a sensitive folder by another name.** A link or junction to a folder like `~/.ssh` or `~/.config`, a differently capitalized name on Windows and macOS, or a `\\?\` spelling on Windows got past the check that keeps such a folder from being opened, and the viewer then showed the files inside. They are now refused like the folder itself. Thanks @mixxer for finding it. + - **Saved trails record the right author when one process serves several projects.** An app embedding the viewer for more than one project wrote the first project's git user name on every trail it saved; each project now uses its own. Thanks @danusha2345. - **`codegraph ui` on Windows moves on from a port another program holds.** Windows refuses a port that another program keeps for itself, or one inside a range it reserves, with a different error than a port that is simply in use, so the viewer gave up at the first such port instead of trying the next one, and blamed ports below 1024 needing elevated privileges — a rule Windows doesn't have. It now moves on to the next free port, and when none is left, or the port you pinned with `--port` is refused, it says what Windows did and how to list the reserved ranges. Thanks @ijbranch for the report and @sx4im. (#2299) diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 5adf1cfb22..89fa53101f 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2382,6 +2382,14 @@ export class ToolHandler { ); } + // The root the walk landed on gets the same refusal. A path that doesn't + // exist skips the check above, so `~/.config/no-such-dir` would otherwise + // walk up into an index of `~/.config` (#1930). + const rootError = validateProjectPath(resolvedRoot); + if (rootError) { + throw new PathRefusalError(rootError); + } + const cg = this.openProjectRoot(resolvedRoot, canonicalRoot); // The walk above crosses git boundaries. A nested repository the ancestor // index leaves out (typically gitignored) would otherwise be answered from diff --git a/src/utils.ts b/src/utils.ts index c07e8c7dde..a8f09ae4d6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,6 +30,7 @@ */ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; // ============================================================ @@ -46,6 +47,22 @@ const SENSITIVE_PATHS = new Set([ 'c:\\', 'c:\\windows', 'c:\\windows\\system32', ]); +/** + * Credential and config folders in the home directory. Unlike the system set, + * everything under them is refused too. + */ +const SENSITIVE_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.config']; + +/** + * How long the real paths of the sensitive home folders are reused. The viewer + * re-checks its root on every file it reads, so they are not re-read per check, + * but a folder linked somewhere new while the process runs is noticed soon. + */ +const SENSITIVE_HOME_TTL_MS = 2_000; + +let sensitiveHomeCache: { home: string; expires: number; trees: string[] } | undefined; +let sensitiveSystemRealPaths: Set | undefined; + /** * Config "languages" whose nodes are pure key/value DATA lifted from a config * file (e.g. Spring `application.{yml,properties}`), not source code. @@ -183,6 +200,94 @@ export function validatePathWithinRoot( return null; } +/** + * A path in the form the sensitive-folder check compares: lower-cased where the + * file system ignores case by default (NTFS, APFS), so `~/.SSH` is `~/.ssh`. + */ +function foldPathCase(p: string): string { + return process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p; +} + +/** Whether `child` is `parent` or lies under it. Both are already folded. */ +function isSameOrUnder(child: string, parent: string): boolean { + if (child === parent) return true; + // A root (`C:\`, `/`) already ends with its separator. + return child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep); +} + +/** + * The plain spelling of a Win32 namespace path: `\\?\C:\x` and `\\.\C:\x` are + * `C:\x`, and `\\?\UNC\host\share` is `\\host\share`. `path.resolve` keeps the + * prefix, so a string comparison would never see the folder behind it. Other + * namespace paths (a `\\?\Volume{…}\` name, a device) are returned unchanged; + * the real path the caller also checks names their folder. + */ +function stripWin32NamespacePrefix(p: string): string { + if (process.platform !== 'win32') return p; + const prefix = /^[\\/]{2}[?.][\\/](UNC[\\/])?/i.exec(p); + if (!prefix) return p; + const rest = p.slice(prefix[0].length); + if (prefix[1]) return `\\\\${rest}`; + return /^[a-z]:[\\/]/i.test(rest) ? rest : p; +} + +/** + * The folder a path really names: links (symlinks, junctions, a `subst` drive) + * followed, and the case and long names stored on disk. Null when it can't be + * resolved, most often because it doesn't exist; the lexical check still runs. + */ +function nativeRealPath(p: string): string | null { + try { + return fs.realpathSync.native(p); + } catch { + return null; + } +} + +/** + * The system set's own real paths, folded. macOS keeps `/etc`, `/tmp` and + * `/var` under `/private`, so a project whose real path is `/private/etc` is + * `/etc`. Read once: these folders don't move. + */ +function sensitiveSystemPaths(): Set { + if (!sensitiveSystemRealPaths) { + sensitiveSystemRealPaths = new Set(); + for (const entry of SENSITIVE_PATHS) { + // Only this platform's entries: on Windows `/` would resolve to the root + // of the current drive and refuse every project at a drive root. + const native = process.platform === 'win32' ? /^[a-z]:\\/.test(entry) : entry.startsWith('/'); + if (!native) continue; + const real = nativeRealPath(entry); + if (real) sensitiveSystemRealPaths.add(foldPathCase(real)); + } + } + return sensitiveSystemRealPaths; +} + +/** + * The sensitive home folders, folded: as spelled under the home directory, + * under the home's real path (a linked home), and each folder's own real path + * (a `~/.ssh` that is itself a link into a dotfiles checkout). + */ +function sensitiveHomeTrees(): string[] { + const home = os.homedir(); + const now = Date.now(); + if (sensitiveHomeCache && sensitiveHomeCache.home === home && now < sensitiveHomeCache.expires) { + return sensitiveHomeCache.trees; + } + const trees = new Set(); + const realHome = nativeRealPath(home); + for (const dir of SENSITIVE_HOME_DIRS) { + const spelled = path.join(home, dir); + trees.add(foldPathCase(spelled)); + if (realHome) trees.add(foldPathCase(path.join(realHome, dir))); + const real = nativeRealPath(spelled); + if (real) trees.add(foldPathCase(real)); + } + sensitiveHomeCache = { home, expires: now + SENSITIVE_HOME_TTL_MS, trees: [...trees] }; + return sensitiveHomeCache.trees; +} + /** * Validate that a path is a safe project root directory. * @@ -190,24 +295,42 @@ export function validatePathWithinRoot( * a real, existing directory. Used at MCP and API entry points * to prevent arbitrary directory access. * + * The path is checked both as spelled and as the folder it really names, so a + * link to `~/.ssh`, a case variant (`~/.SSH`) or a `\\?\` spelling is refused + * like the folder itself (#1930). It is kept to one realpath per call, since the + * viewer calls this for every file it reads: the sensitive folders' own real + * paths are cached and re-read every couple of seconds. + * * @param dirPath - The path to validate * @returns An error message if invalid, or null if valid */ export function validateProjectPath(dirPath: string): string | null { const resolved = path.resolve(dirPath); + const lexical = path.resolve(stripWin32NamespacePrefix(dirPath)); + const real = nativeRealPath(lexical); + const candidates = [lexical]; + if (real && real !== lexical) candidates.push(real); + const named = (candidate: string): string => + candidate === resolved ? resolved : `${resolved} (resolves to ${candidate})`; // Block sensitive system directories - if (SENSITIVE_PATHS.has(resolved) || SENSITIVE_PATHS.has(resolved.toLowerCase())) { - return `Refusing to operate on sensitive system directory: ${resolved}`; + const systemPaths = sensitiveSystemPaths(); + for (const candidate of candidates) { + if ( + SENSITIVE_PATHS.has(candidate) || + SENSITIVE_PATHS.has(candidate.toLowerCase()) || + systemPaths.has(foldPathCase(candidate)) + ) { + return `Refusing to operate on sensitive system directory: ${named(candidate)}`; + } } // Also block common sensitive home subdirectories - const homeDir = require('os').homedir(); - const sensitiveHomeDirs = ['.ssh', '.gnupg', '.aws', '.config']; - for (const dir of sensitiveHomeDirs) { - const sensitivePath = path.join(homeDir, dir); - if (resolved === sensitivePath || resolved.startsWith(sensitivePath + path.sep)) { - return `Refusing to operate on sensitive directory: ${resolved}`; + const homeTrees = sensitiveHomeTrees(); + for (const candidate of candidates) { + const folded = foldPathCase(candidate); + if (homeTrees.some((tree) => isSameOrUnder(folded, tree))) { + return `Refusing to operate on sensitive directory: ${named(candidate)}`; } }