mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
fix(core): allow explicit write permissions to override governance file protections in sandboxes (#25338)
This commit is contained in:
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user