fix(core): allow explicit write permissions to override governance file protections in sandboxes (#25338)

This commit is contained in:
Gal Zahavi
2026-04-16 14:18:09 -07:00
committed by GitHub
parent 655165cde4
commit fe890429a4
8 changed files with 593 additions and 95 deletions
@@ -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,
@@ -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`,
]);
});
@@ -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<string[]> {
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;
}
@@ -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,
@@ -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++) {
@@ -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
@@ -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;
@@ -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).
*/