Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions __tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
44 changes: 42 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import * as fs from 'fs';
import * as path from 'path';
import { gitCommonDir, gitWorktreeRoot } from './sync/worktree';

// ============================================================
// SECURITY UTILITIES
Expand All @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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}`;
}
}
Expand Down