diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 3b3171782..4a1cc4c05 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -9,6 +9,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -315,6 +316,47 @@ describe('validateProjectPath — sensitive directory blocking', () => { } }); + it('allows a verified linked worktree under .config without allowing ordinary config repositories', () => { + const configRoot = path.join(os.homedir(), '.config'); + fs.mkdirSync(configRoot, { recursive: true }); + const configSandbox = fs.mkdtempSync(path.join(configRoot, 'codegraph-worktree-test-')); + const mainRepo = path.join(configSandbox, 'main-repo'); + const worktree = path.join(configSandbox, 'superpowers', 'worktrees', 'project'); + const ordinaryConfigRepo = path.join(configSandbox, 'ordinary-project'); + fs.mkdirSync(mainRepo, { recursive: true }); + fs.mkdirSync(ordinaryConfigRepo, { recursive: true }); + + try { + execFileSync('git', ['init', '-q'], { cwd: mainRepo, stdio: 'ignore' }); + fs.writeFileSync(path.join(mainRepo, 'README.md'), '# main\n'); + execFileSync('git', ['add', '.'], { cwd: mainRepo, stdio: 'ignore' }); + execFileSync( + 'git', + ['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'init'], + { cwd: mainRepo, stdio: 'ignore' }, + ); + fs.mkdirSync(path.dirname(worktree), { recursive: true }); + execFileSync('git', ['worktree', 'add', '-q', '-b', 'feature', worktree], { + cwd: mainRepo, + stdio: 'ignore', + }); + execFileSync('git', ['init', '-q'], { cwd: ordinaryConfigRepo, stdio: 'ignore' }); + + expect(validateProjectPath(worktree)).toBeNull(); + expect(validateProjectPath(ordinaryConfigRepo)).toMatch(/sensitive directory/i); + } finally { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktree], { + cwd: mainRepo, + stdio: 'ignore', + }); + } catch { + // best-effort cleanup + } + cleanupTempDir(configSandbox); + } + }); + // SENSITIVE_PATHS stores the Windows entries lowercase and validateProjectPath // matches via resolved.toLowerCase(), so 'C:\\Windows' and 'c:\\windows' are // both blocked. path.resolve is platform-specific, so this only runs on Windows. diff --git a/src/utils.ts b/src/utils.ts index ba5c13261..7b4cfb69b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -31,6 +31,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { gitCommonDir, gitWorktreeRoot } from './sync/worktree'; // ============================================================ // SECURITY UTILITIES @@ -46,6 +47,8 @@ const SENSITIVE_PATHS = new Set([ 'c:\\', 'c:\\windows', 'c:\\windows\\system32', ]); +const SENSITIVE_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.config']; + /** * Config "languages" whose nodes are pure key/value DATA lifted from a config * file (e.g. Spring `application.{yml,properties}`), not source code. @@ -80,6 +83,32 @@ function isWithinDir(child: string, parent: string): boolean { return c === p || c.startsWith(p + path.sep); } +/** + * Allow the narrow linked-worktree case used by agent harnesses that place + * task worktrees below ~/.config while keeping ordinary config directories + * blocked. The linked tree must point to a different checkout through Git's + * shared .git directory. + */ +function isVerifiedLinkedWorktreeUnderConfig( + resolved: string, + configPath: string, +): boolean { + const worktreeRoot = gitWorktreeRoot(resolved); + const commonDir = gitCommonDir(resolved); + if (!worktreeRoot || !commonDir) return false; + if (!isWithinDir(resolved, worktreeRoot)) return false; + if (!isWithinDir(worktreeRoot, configPath)) return false; + if (path.basename(commonDir) !== '.git') return false; + + try { + if (!fs.statSync(path.join(worktreeRoot, '.git')).isFile()) return false; + } catch { + return false; + } + + return path.dirname(commonDir) !== worktreeRoot; +} + /** * Validate that a file path stays within the project root, resolving symlinks. * @@ -165,10 +194,15 @@ export function validateProjectPath(dirPath: string): string | null { // Also block common sensitive home subdirectories const homeDir = require('os').homedir(); - const sensitiveHomeDirs = ['.ssh', '.gnupg', '.aws', '.config']; - for (const dir of sensitiveHomeDirs) { + for (const dir of SENSITIVE_HOME_DIRS) { const sensitivePath = path.join(homeDir, dir); if (resolved === sensitivePath || resolved.startsWith(sensitivePath + path.sep)) { + if ( + dir === '.config' + && isVerifiedLinkedWorktreeUnderConfig(resolved, sensitivePath) + ) { + continue; + } return `Refusing to operate on sensitive directory: ${resolved}`; } }