From ec83ea295344392086ad8a11cea4de66f93e737a Mon Sep 17 00:00:00 2001 From: visible-agent-orchestrator bootstrap Date: Thu, 16 Jul 2026 00:49:28 +0800 Subject: [PATCH] Allow verified linked worktrees below config roots Agent harnesses place legitimate task worktrees below ~/.config, which the sensitive-path guard previously rejected wholesale. Permit only Git-linked worktrees whose standard main checkout is outside every protected home directory, while preserving the block for ordinary config paths and all other sensitive roots. Constraint: ~/.ssh, ~/.gnupg, and ~/.aws must remain unconditional denials Rejected: Allow every Git repository under ~/.config | ordinary repositories there are still configuration data and should remain protected Confidence: high Scope-risk: narrow Directive: Do not widen this exception beyond verified linked worktrees without equivalent boundary tests Tested: security and worktree suites; full build; full suite 2373 passed with 2 host EMFILE watcher failures unrelated to this change Not-tested: Windows linked-worktree layout --- __tests__/security.test.ts | 41 +++++++++++++++++++++++++++++++++++ src/utils.ts | 44 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 3b3171782..fa3109367 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,46 @@ describe('validateProjectPath — sensitive directory blocking', () => { } }); + it('allows a verified linked worktree under .config without allowing ordinary config directories', () => { + const mainRepo = createTempDir(); + const configRoot = path.join(os.homedir(), '.config'); + fs.mkdirSync(configRoot, { recursive: true }); + const configSandbox = fs.mkdtempSync(path.join(configRoot, 'codegraph-worktree-test-')); + const worktree = path.join(configSandbox, 'superpowers', 'worktrees', 'project'); + const ordinaryConfigDir = path.join(configSandbox, 'ordinary-project'); + fs.mkdirSync(ordinaryConfigDir, { 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', + }); + + expect(validateProjectPath(worktree)).toBeNull(); + expect(validateProjectPath(ordinaryConfigDir)).toMatch(/sensitive directory/i); + } finally { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktree], { + cwd: mainRepo, + stdio: 'ignore', + }); + } catch { + // best-effort cleanup + } + cleanupTempDir(mainRepo); + 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..0eae05643 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,38 @@ 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 standard non-sensitive main + * checkout through Git's shared .git directory. + */ +function isVerifiedLinkedWorktreeUnderConfig( + resolved: string, + configPath: string, + homeDir: 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; + } + + const mainCheckout = path.dirname(commonDir); + if (mainCheckout === worktreeRoot) return false; + for (const dir of SENSITIVE_HOME_DIRS) { + if (isWithinDir(mainCheckout, path.join(homeDir, dir))) return false; + } + return true; +} + /** * Validate that a file path stays within the project root, resolving symlinks. * @@ -165,10 +200,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, homeDir) + ) { + continue; + } return `Refusing to operate on sensitive directory: ${resolved}`; } }