diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index a431085c33..27c3e42746 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -25,7 +25,6 @@ import { import { isStrictlyApproved, verifySandboxOverrides, - getCommandName, } from '../utils/commandUtils.js'; import { assertValidPathString } from '../../utils/paths.js'; import { @@ -40,6 +39,11 @@ import { import { isErrnoException } from '../utils/fsUtils.js'; import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js'; import { buildBwrapArgs } from './bwrapArgsBuilder.js'; +import { + getCommandRoots, + initializeShellParsers, + stripShellWrapper, +} from '../../utils/shell-utils.js'; let cachedBpfPath: string | undefined; @@ -218,7 +222,15 @@ export class LinuxSandboxManager implements SandboxManager { args = ['-c', 'cat > "$1"', '_', ...args]; } - const commandName = await getCommandName({ ...req, command, args }); + await initializeShellParsers(); + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + const commandName = roots.length > 0 ? roots[0] : join(command); + const isGitCommand = roots.includes('git'); + const isApproved = allowOverrides ? await isStrictlyApproved( { ...req, command, args }, @@ -253,6 +265,15 @@ export class LinuxSandboxManager implements SandboxManager { false, }; + // If the workspace is writable and we're running a git command, + // automatically allow write access to the .git directory. + if (workspaceWrite && isGitCommand) { + const gitDir = join(this.options.workspace, '.git'); + if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) { + mergedAdditional.fileSystem!.write!.push(gitDir); + } + } + const { command: finalCommand, args: finalArgs } = handleReadWriteCommands( req, mergedAdditional, diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts index 6cff168d21..45571f066f 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.test.ts @@ -115,14 +115,14 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => { workspace, workspace, '--ro-bind', + `${workspace}/.git`, + `${workspace}/.git`, + '--ro-bind', `${workspace}/.gitignore`, `${workspace}/.gitignore`, '--ro-bind', `${workspace}/.geminiignore`, `${workspace}/.geminiignore`, - '--ro-bind', - `${workspace}/.git`, - `${workspace}/.git`, ]); }); diff --git a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts index 591bba8a0e..14301bb888 100644 --- a/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts +++ b/packages/core/src/sandbox/linux/bwrapArgsBuilder.ts @@ -14,6 +14,7 @@ import { import { isErrnoException } from '../utils/fsUtils.js'; import { spawnAsync } from '../../utils/shell-utils.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import { toPathKey } from '../../utils/paths.js'; /** * Options for building bubblewrap (bwrap) arguments. @@ -63,58 +64,101 @@ export async function buildBwrapArgs( '/tmp', ); - const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try'; + type MountType = + | '--bind' + | '--ro-bind' + | '--bind-try' + | '--ro-bind-try' + | '--symlink'; - bwrapArgs.push(bindFlag, workspace.original, workspace.original); + type Mount = + | { + type: MountType; + src: string; + dest: string; + } + | { type: '--tmpfs-ro'; dest: string }; + + const mounts: Mount[] = []; + + const bindFlag: MountType = workspaceWrite ? '--bind-try' : '--ro-bind-try'; + mounts.push({ + type: bindFlag, + src: workspace.original, + dest: workspace.original, + }); if (workspace.resolved !== workspace.original) { - bwrapArgs.push(bindFlag, workspace.resolved, workspace.resolved); + mounts.push({ + type: bindFlag, + src: workspace.resolved, + dest: workspace.resolved, + }); } for (const includeDir of resolvedPaths.globalIncludes) { - bwrapArgs.push('--ro-bind-try', includeDir, includeDir); + mounts.push({ type: '--ro-bind-try', src: includeDir, dest: includeDir }); } for (const allowedPath of resolvedPaths.policyAllowed) { if (fs.existsSync(allowedPath)) { - bwrapArgs.push('--bind-try', allowedPath, allowedPath); + mounts.push({ type: '--bind-try', src: allowedPath, dest: allowedPath }); } else { - // If the path doesn't exist, we still want to allow access to its parent - // to enable creating it. const parent = dirname(allowedPath); - bwrapArgs.push( - isReadOnlyCommand ? '--ro-bind-try' : '--bind-try', - parent, - parent, - ); + mounts.push({ + type: isReadOnlyCommand ? '--ro-bind-try' : '--bind-try', + src: parent, + dest: parent, + }); } } for (const p of resolvedPaths.policyRead) { - bwrapArgs.push('--ro-bind-try', p, p); + mounts.push({ type: '--ro-bind-try', src: p, dest: p }); } + // Collect explicit additional write permissions. for (const p of resolvedPaths.policyWrite) { - bwrapArgs.push('--bind-try', p, p); + mounts.push({ type: '--bind-try', src: p, dest: p }); } + const policyWriteKeys = new Set(resolvedPaths.policyWrite.map(toPathKey)); + for (const file of GOVERNANCE_FILES) { const filePath = join(workspace.original, file.path); const realPath = join(workspace.resolved, file.path); - bwrapArgs.push('--ro-bind', filePath, filePath); - if (realPath !== filePath) { - bwrapArgs.push('--ro-bind', realPath, realPath); + + const isExplicitlyWritable = + policyWriteKeys.has(toPathKey(filePath)) || + policyWriteKeys.has(toPathKey(realPath)); + + // If the workspace is writable, we allow editing .gitignore and .geminiignore by default. + // .git remains protected unless explicitly requested (e.g. for git commands). + const isImplicitlyWritable = workspaceWrite && file.path !== '.git'; + + if (!isExplicitlyWritable && !isImplicitlyWritable) { + mounts.push({ type: '--ro-bind', src: filePath, dest: filePath }); + if (realPath !== filePath) { + mounts.push({ type: '--ro-bind', src: realPath, dest: realPath }); + } } } - // Grant read-only access to git worktrees/submodules. We do this last in order to - // ensure that these rules aren't overwritten by broader write policies. + // Grant read-only access to git worktrees/submodules. if (resolvedPaths.gitWorktree) { const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree; - if (worktreeGitDir) { - bwrapArgs.push('--ro-bind-try', worktreeGitDir, worktreeGitDir); + if (worktreeGitDir && !policyWriteKeys.has(toPathKey(worktreeGitDir))) { + mounts.push({ + type: '--ro-bind-try', + src: worktreeGitDir, + dest: worktreeGitDir, + }); } - if (mainGitDir) { - bwrapArgs.push('--ro-bind-try', mainGitDir, mainGitDir); + if (mainGitDir && !policyWriteKeys.has(toPathKey(mainGitDir))) { + mounts.push({ + type: '--ro-bind-try', + src: mainGitDir, + dest: mainGitDir, + }); } } @@ -123,37 +167,23 @@ export async function buildBwrapArgs( try { const stat = fs.statSync(p); if (stat.isDirectory()) { - bwrapArgs.push('--tmpfs', p, '--remount-ro', p); + mounts.push({ type: '--tmpfs-ro', dest: p }); } else { - bwrapArgs.push('--ro-bind', '/dev/null', p); + mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p }); } } catch (e: unknown) { if (isErrnoException(e) && e.code === 'ENOENT') { - bwrapArgs.push('--symlink', '/dev/null', p); + mounts.push({ type: '--symlink', src: '/dev/null', dest: p }); } else { debugLogger.warn( `Failed to secure forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`, ); - bwrapArgs.push('--ro-bind', '/dev/null', p); + mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p }); } } } // Mask secret files (.env, .env.*) - const secretArgs = await getSecretFilesArgs(resolvedPaths, maskFilePath); - bwrapArgs.push(...secretArgs); - - return bwrapArgs; -} - -/** - * Generates bubblewrap arguments to mask secret files. - */ -async function getSecretFilesArgs( - resolvedPaths: ResolvedSandboxPaths, - maskPath: string, -): Promise { - const args: string[] = []; const searchDirs = new Set([ resolvedPaths.workspace.original, resolvedPaths.workspace.resolved, @@ -164,9 +194,6 @@ async function getSecretFilesArgs( for (const dir of searchDirs) { try { - // Use the native 'find' command for performance and to catch nested secrets. - // We limit depth to 3 to keep it fast while covering common nested structures. - // We use -prune to skip heavy directories efficiently while matching dotfiles. const findResult = await spawnAsync('find', [ dir, '-maxdepth', @@ -203,7 +230,7 @@ async function getSecretFilesArgs( const files = findResult.stdout.toString().split('\0'); for (const file of files) { if (file.trim()) { - args.push('--bind', maskPath, file.trim()); + mounts.push({ type: '--bind', src: maskFilePath, dest: file.trim() }); } } } catch (e) { @@ -213,5 +240,19 @@ async function getSecretFilesArgs( ); } } - return args; + + // Sort mounts by destination path length to ensure parents are bound before children. + // This prevents hierarchical masking where a parent mount would hide a child mount. + mounts.sort((a, b) => a.dest.length - b.dest.length); + + // Emit final bwrap arguments + for (const m of mounts) { + if (m.type === '--tmpfs-ro') { + bwrapArgs.push('--tmpfs', m.dest, '--remount-ro', m.dest); + } else { + bwrapArgs.push(m.type, m.src, m.dest); + } + } + + return bwrapArgs; } diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index f87dc0289c..90c80078f0 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -22,7 +22,11 @@ import { getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js'; -import { initializeShellParsers } from '../../utils/shell-utils.js'; +import { + initializeShellParsers, + getCommandRoots, + stripShellWrapper, +} from '../../utils/shell-utils.js'; import { isKnownSafeCommand, isDangerousCommand, @@ -133,6 +137,22 @@ export class MacOsSandboxManager implements SandboxManager { false, }; + // If the workspace is writable and we're running a git command, + // automatically allow write access to the .git directory. + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + const isGitCommand = roots.includes('git'); + + if (workspaceWrite && isGitCommand) { + const gitDir = path.join(this.options.workspace, '.git'); + if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) { + mergedAdditional.fileSystem!.write!.push(gitDir); + } + } + const { command: finalCommand, args: finalArgs } = handleReadWriteCommands( req, mergedAdditional, diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index abbf1a6d92..39d5cbe6fd 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -16,7 +16,7 @@ import { SECRET_FILES, type ResolvedSandboxPaths, } from '../../services/sandboxManager.js'; -import { resolveToRealPath } from '../../utils/paths.js'; +import { isSubpath, resolveToRealPath } from '../../utils/paths.js'; /** * Options for building macOS Seatbelt profile. @@ -37,6 +37,47 @@ export function escapeSchemeString(str: string): string { return str.replace(/[\\"]/g, '\\$&'); } +/** + * Checks if a path is explicitly allowed by additional write permissions. + */ +function isPathExplicitlyAllowed( + filePath: string, + realFilePath: string, + policyWrite: string[], +): boolean { + return policyWrite.some( + (p) => + p === filePath || + p === realFilePath || + isSubpath(p, filePath) || + isSubpath(p, realFilePath), + ); +} + +function denyUnlessExplicitlyAllowed( + targetPath: string, + ruleType: 'literal' | 'subpath', + policyWrite: string[], + implicitlyAllowed: boolean = false, +): string { + if (implicitlyAllowed) { + return ''; + } + + const realPath = resolveToRealPath(targetPath); + + if (isPathExplicitlyAllowed(targetPath, realPath, policyWrite)) { + return ''; // Skip if explicitly allowed + } + + let rules = `(deny file-write* (${ruleType} "${escapeSchemeString(targetPath)}"))\n`; + if (realPath !== targetPath) { + rules += `(deny file-write* (${ruleType} "${escapeSchemeString(realPath)}"))\n`; + } + + return rules; +} + /** * Builds a complete macOS Seatbelt profile string using a strict allowlist. * It embeds paths directly into the profile, properly escaped for Scheme. @@ -108,38 +149,6 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(allowedPath)}"))\n`; } - // Handle granular additional read permissions - for (let i = 0; i < resolvedPaths.policyRead.length; i++) { - const resolved = resolvedPaths.policyRead[i]; - let isFile = false; - try { - isFile = fs.statSync(resolved).isFile(); - } catch { - // Ignore error - } - if (isFile) { - profile += `(allow file-read* (literal "${escapeSchemeString(resolved)}"))\n`; - } else { - profile += `(allow file-read* (subpath "${escapeSchemeString(resolved)}"))\n`; - } - } - - // Handle granular additional write permissions - for (let i = 0; i < resolvedPaths.policyWrite.length; i++) { - const resolved = resolvedPaths.policyWrite[i]; - let isFile = false; - try { - isFile = fs.statSync(resolved).isFile(); - } catch { - // Ignore error - } - if (isFile) { - profile += `(allow file-read* file-write* (literal "${escapeSchemeString(resolved)}"))\n`; - } else { - profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(resolved)}"))\n`; - } - } - // Add explicit deny rules for governance files in the workspace. // These are added after the workspace allow rule to ensure they take precedence // (Seatbelt evaluates rules in order, later rules win for same path). @@ -161,13 +170,12 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { // Ignore errors, use default guess } - const ruleType = isDirectory ? 'subpath' : 'literal'; - - profile += `(deny file-write* (${ruleType} "${escapeSchemeString(governanceFile)}"))\n`; - - if (realGovernanceFile !== governanceFile) { - profile += `(deny file-write* (${ruleType} "${escapeSchemeString(realGovernanceFile)}"))\n`; - } + profile += denyUnlessExplicitlyAllowed( + governanceFile, + isDirectory ? 'subpath' : 'literal', + resolvedPaths.policyWrite, + workspaceWrite && GOVERNANCE_FILES[i].path !== '.git', + ); } // Grant read-only access to git worktrees/submodules. We do this last in order to @@ -175,10 +183,18 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { if (resolvedPaths.gitWorktree) { const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree; if (worktreeGitDir) { - profile += `(deny file-write* (subpath "${escapeSchemeString(worktreeGitDir)}"))\n`; + profile += denyUnlessExplicitlyAllowed( + worktreeGitDir, + 'subpath', + resolvedPaths.policyWrite, + ); } if (mainGitDir) { - profile += `(deny file-write* (subpath "${escapeSchemeString(mainGitDir)}"))\n`; + profile += denyUnlessExplicitlyAllowed( + mainGitDir, + 'subpath', + resolvedPaths.policyWrite, + ); } } @@ -211,6 +227,38 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string { } } + // Handle granular additional read permissions + for (let i = 0; i < resolvedPaths.policyRead.length; i++) { + const resolved = resolvedPaths.policyRead[i]; + let isFile = false; + try { + isFile = fs.statSync(resolved).isFile(); + } catch { + // Ignore error + } + if (isFile) { + profile += `(allow file-read* (literal "${escapeSchemeString(resolved)}"))\n`; + } else { + profile += `(allow file-read* (subpath "${escapeSchemeString(resolved)}"))\n`; + } + } + + // Handle granular additional write permissions + for (let i = 0; i < resolvedPaths.policyWrite.length; i++) { + const resolved = resolvedPaths.policyWrite[i]; + let isFile = false; + try { + isFile = fs.statSync(resolved).isFile(); + } catch { + // Ignore error + } + if (isFile) { + profile += `(allow file-read* file-write* (literal "${escapeSchemeString(resolved)}"))\n`; + } else { + profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(resolved)}"))\n`; + } + } + // Handle forbiddenPaths const forbiddenPaths = resolvedPaths.forbidden; for (let i = 0; i < forbiddenPaths.length; i++) { diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 42fa196749..bf36c2e221 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -25,7 +25,13 @@ import { getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; import { debugLogger } from '../../utils/debugLogger.js'; -import { spawnAsync, getCommandName } from '../../utils/shell-utils.js'; +import { + spawnAsync, + getCommandName, + initializeShellParsers, + getCommandRoots, + stripShellWrapper, +} from '../../utils/shell-utils.js'; import { isKnownSafeCommand, isDangerousCommand, @@ -261,6 +267,14 @@ export class WindowsSandboxManager implements SandboxManager { this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; const networkAccess = defaultNetwork || mergedAdditional.network; + await initializeShellParsers(); + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + const isGitCommand = roots.includes('git'); + const resolvedPaths = await resolveSandboxPaths( this.options, req, @@ -348,6 +362,13 @@ export class WindowsSandboxManager implements SandboxManager { if (workspaceWrite) { addWritableRoot(resolvedPaths.workspace.resolved); + + // If the workspace is writable and we're running a git command, + // automatically allow write access to the .git directory. + if (isGitCommand) { + const gitDir = path.join(resolvedPaths.workspace.resolved, '.git'); + addWritableRoot(gitDir); + } } // B. Globally included directories diff --git a/packages/core/src/services/sandboxManager.integration.test.ts b/packages/core/src/services/sandboxManager.integration.test.ts index 3481c53ca7..6fd21f1005 100644 --- a/packages/core/src/services/sandboxManager.integration.test.ts +++ b/packages/core/src/services/sandboxManager.integration.test.ts @@ -865,6 +865,336 @@ describe('SandboxManager Integration', () => { }); }); + describe('Governance Files', () => { + it('blocks write access to governance files in the workspace', async () => { + const tempWorkspace = createTempDir('workspace-'); + const gitDir = path.join(tempWorkspace, '.git'); + fs.mkdirSync(gitDir); + const testFile = path.join(gitDir, 'config'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: tempWorkspace }, + ); + + const { command, args } = Platform.touch(testFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: tempWorkspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + expect(fs.existsSync(testFile)).toBe(false); + }); + + it('allows write access to governance files when explicitly requested via additionalPermissions', async () => { + const tempWorkspace = createTempDir('workspace-'); + const gitDir = path.join(tempWorkspace, '.git'); + fs.mkdirSync(gitDir); + const testFile = path.join(gitDir, 'config'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: tempWorkspace }, + ); + + const { command, args } = Platform.touch(testFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: tempWorkspace, + env: process.env, + policy: { + additionalPermissions: { fileSystem: { write: [gitDir] } }, + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(testFile)).toBe(true); + }); + }); + + describe('Git Worktree Support', () => { + it('allows access to git common directory in a worktree', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + fs.writeFileSync( + path.join(mainGitDir, 'config'), + '[core]\n\trepositoryformatversion = 0\n', + ); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + // Create the .git file in the worktree directory pointing to the worktree git dir + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + + // Create the backlink from worktree git dir to the worktree's .git file + const backlinkPath = path.join(worktreeGitDir, 'gitdir'); + fs.writeFileSync(backlinkPath, path.join(worktreeDir, '.git')); + + // Create a file in the worktree git dir that we want to access + const secretFile = path.join(worktreeGitDir, 'secret.txt'); + fs.writeFileSync(secretFile, 'git-secret'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: worktreeDir }, + ); + + const { command, args } = Platform.cat(secretFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(result.stdout.trim()).toBe('git-secret'); + }); + + it('blocks write access to git common directory in a worktree', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + fs.writeFileSync( + path.join(worktreeGitDir, 'gitdir'), + path.join(worktreeDir, '.git'), + ); + + const targetFile = path.join(worktreeGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + // Use YOLO mode to ensure the workspace is fully writable, but git worktrees should still be read-only + { workspace: worktreeDir, modeConfig: { yolo: true } }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + expect(fs.existsSync(targetFile)).toBe(false); + }); + + it('blocks write access to git common directory in a worktree when not explicitly requested via additionalPermissions', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + fs.writeFileSync( + path.join(worktreeGitDir, 'gitdir'), + path.join(worktreeDir, '.git'), + ); + + const targetFile = path.join(worktreeGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: worktreeDir }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'failure'); + expect(fs.existsSync(targetFile)).toBe(false); + }); + + it('allows write access to git common directory in a worktree when explicitly requested via additionalPermissions', async () => { + const mainRepo = createTempDir('main-repo-'); + const worktreeDir = createTempDir('worktree-'); + + const mainGitDir = path.join(mainRepo, '.git'); + fs.mkdirSync(mainGitDir, { recursive: true }); + + const worktreeGitDir = path.join( + mainGitDir, + 'worktrees', + 'test-worktree', + ); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, '.git'), + `gitdir: ${worktreeGitDir}\n`, + ); + fs.writeFileSync( + path.join(worktreeGitDir, 'gitdir'), + path.join(worktreeDir, '.git'), + ); + + const targetFile = path.join(worktreeGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: worktreeDir }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: worktreeDir, + env: process.env, + policy: { + additionalPermissions: { fileSystem: { write: [worktreeGitDir] } }, + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(targetFile)).toBe(true); + }); + + it('allows write access to external git directory in a non-worktree environment when explicitly requested via additionalPermissions', async () => { + const externalGitDir = createTempDir('external-git-'); + const workspaceDir = createTempDir('workspace-'); + + fs.mkdirSync(externalGitDir, { recursive: true }); + + fs.writeFileSync( + path.join(workspaceDir, '.git'), + `gitdir: ${externalGitDir}\n`, + ); + + const targetFile = path.join(externalGitDir, 'secret.txt'); + + const osManager = createSandboxManager( + { enabled: true }, + { workspace: workspaceDir }, + ); + + const { command, args } = Platform.touch(targetFile); + const sandboxed = await osManager.prepareCommand({ + command, + args, + cwd: workspaceDir, + env: process.env, + policy: { + additionalPermissions: { fileSystem: { write: [externalGitDir] } }, + }, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(targetFile)).toBe(true); + }); + }); + + describe('Git and Governance Write Access', () => { + it('allows write access to .gitignore when workspace is writable', async () => { + const testFile = path.join(workspace, '.gitignore'); + fs.writeFileSync(testFile, 'initial'); + + const editManager = createSandboxManager( + { enabled: true }, + { workspace, modeConfig: { readonly: false, allowOverrides: true } }, + ); + + const { command, args } = Platform.touch(testFile); + const sandboxed = await editManager.prepareCommand({ + command, + args, + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(testFile)).toBe(true); + }); + + it('automatically allows write access to .git when running git command and workspace is writable', async () => { + const gitDir = path.join(workspace, '.git'); + if (!fs.existsSync(gitDir)) fs.mkdirSync(gitDir); + const lockFile = path.join(gitDir, 'index.lock'); + + const editManager = createSandboxManager( + { enabled: true }, + { workspace, modeConfig: { readonly: false, allowOverrides: true } }, + ); + + // We use a command that looks like git to trigger the special handling. + // LinuxSandboxManager identifies the command root from the shell wrapper. + const { command: nodePath, args: nodeArgs } = Platform.touch(lockFile); + + const commandString = Platform.isWindows + ? `git --version > NUL && "${nodePath.replace(/\\/g, '/')}" ${nodeArgs + .map((a) => `'${a.replace(/\\/g, '/')}'`) + .join(' ')}` + : `git --version > /dev/null; "${nodePath}" ${nodeArgs + .map((a) => (a.includes(' ') || a.includes('(') ? `'${a}'` : a)) + .join(' ')}`; + + const sandboxed = await editManager.prepareCommand({ + command: 'sh', + args: ['-c', commandString], + cwd: workspace, + env: process.env, + }); + + const result = await runCommand(sandboxed); + assertResult(result, sandboxed, 'success'); + expect(fs.existsSync(lockFile)).toBe(true); + }); + }); + describe('Network Security', () => { describe('Network Access', () => { let server: http.Server; diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index a4dc356984..4fa3d61097 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -409,6 +409,23 @@ export async function resolveSandboxPaths( ? { gitWorktree: { worktreeGitDir, mainGitDir } } : undefined; + if (worktreeGitDir) { + const gitIdentities = new Set( + [ + path.join(options.workspace, '.git'), + path.join(resolvedWorkspace, '.git'), + ].map(toPathKey), + ); + if (policyRead.some((p) => gitIdentities.has(toPathKey(p)))) { + policyRead.push(worktreeGitDir); + if (mainGitDir) policyRead.push(mainGitDir); + } + if (policyWrite.some((p) => gitIdentities.has(toPathKey(p)))) { + policyWrite.push(worktreeGitDir); + if (mainGitDir) policyWrite.push(mainGitDir); + } + } + /** * Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved). */