fix(sandbox): centralize async git worktree resolution and enforce read-only security (#25040)

This commit is contained in:
Emily Hedlund
2026-04-09 15:04:16 -07:00
committed by GitHub
parent 0f7f7be4ef
commit 451edb3ea6
11 changed files with 459 additions and 208 deletions

View File

@@ -507,6 +507,102 @@ describe('SandboxManager Integration', () => {
});
});
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);
});
});
describe('Network Access', () => {
let server: http.Server;
let url: string;

View File

@@ -13,7 +13,6 @@ import {
sanitizePaths,
findSecretFiles,
isSecretFile,
tryRealpath,
resolveSandboxPaths,
getPathIdentity,
type SandboxRequest,
@@ -36,10 +35,25 @@ vi.mock('node:fs/promises', async () => {
readdir: vi.fn(),
realpath: vi.fn(),
stat: vi.fn(),
lstat: vi.fn(),
readFile: vi.fn(),
},
readdir: vi.fn(),
realpath: vi.fn(),
stat: vi.fn(),
lstat: vi.fn(),
readFile: vi.fn(),
};
});
vi.mock('../utils/paths.js', async () => {
const actual =
await vi.importActual<typeof import('../utils/paths.js')>(
'../utils/paths.js',
);
return {
...actual,
resolveToRealPath: vi.fn((p) => p),
};
});
@@ -279,104 +293,6 @@ describe('SandboxManager', () => {
});
});
describe('tryRealpath', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return the realpath if the file exists', async () => {
const realPath = path.resolve('/real/path/to/file.txt');
const symlinkPath = path.resolve('/some/symlink/to/file.txt');
vi.mocked(fsPromises.realpath).mockResolvedValue(realPath as never);
const result = await tryRealpath(symlinkPath);
expect(result).toBe(realPath);
expect(fsPromises.realpath).toHaveBeenCalledWith(symlinkPath);
});
it('should fallback to parent directory if file does not exist (ENOENT)', async () => {
const nonexistent = path.resolve('/workspace/nonexistent.txt');
const workspace = path.resolve('/workspace');
const realWorkspace = path.resolve('/real/workspace');
vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => {
if (p === nonexistent) {
return Promise.reject(
Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
}),
);
}
if (p === workspace) {
return Promise.resolve(realWorkspace);
}
return Promise.reject(new Error(`Unexpected path: ${p}`));
}) as never);
const result = await tryRealpath(nonexistent);
// It should combine the real path of the parent with the original basename
expect(result).toBe(path.join(realWorkspace, 'nonexistent.txt'));
});
it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => {
const missingFile = path.resolve(
'/workspace/missing_dir/missing_file.txt',
);
const missingDir = path.resolve('/workspace/missing_dir');
const workspace = path.resolve('/workspace');
const realWorkspace = path.resolve('/real/workspace');
vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => {
if (p === missingFile) {
return Promise.reject(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
}
if (p === missingDir) {
return Promise.reject(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
}
if (p === workspace) {
return Promise.resolve(realWorkspace);
}
return Promise.reject(new Error(`Unexpected path: ${p}`));
}) as never);
const result = await tryRealpath(missingFile);
// It should resolve '/workspace' to '/real/workspace' and append the missing parts
expect(result).toBe(
path.join(realWorkspace, 'missing_dir', 'missing_file.txt'),
);
});
it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => {
const rootPath = path.resolve('/');
vi.mocked(fsPromises.realpath).mockImplementation(() =>
Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })),
);
const result = await tryRealpath(rootPath);
expect(result).toBe(rootPath);
});
it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => {
const secretFile = path.resolve('/secret/file.txt');
vi.mocked(fsPromises.realpath).mockImplementation(() =>
Promise.reject(
Object.assign(new Error('EACCES: permission denied'), {
code: 'EACCES',
}),
),
);
await expect(tryRealpath(secretFile)).rejects.toThrow(
'EACCES: permission denied',
);
});
});
describe('NoopSandboxManager', () => {
const sandboxManager = new NoopSandboxManager();

View File

@@ -15,7 +15,6 @@ import {
isKnownSafeCommand as isWindowsSafeCommand,
isDangerousCommand as isWindowsDangerousCommand,
} from '../sandbox/windows/commandSafety.js';
import { isNodeError } from '../utils/errors.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
@@ -24,6 +23,7 @@ import {
import type { ShellExecutionResult } from './shellExecutionService.js';
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { resolveToRealPath } from '../utils/paths.js';
import { resolveGitWorktreePaths } from '../sandbox/utils/fsUtils.js';
/**
* A structured result of fully resolved sandbox paths.
@@ -48,6 +48,13 @@ export interface ResolvedSandboxPaths {
policyRead: string[];
/** Paths granted temporary write access by the current command's dynamic permissions. */
policyWrite: string[];
/** Auto-detected paths for git worktrees/submodules. */
gitWorktree?: {
/** The actual .git directory for this worktree. */
worktreeGitDir: string;
/** The main repository's .git directory (if applicable). */
mainGitDir?: string;
};
}
export interface SandboxPermissions {
@@ -392,6 +399,12 @@ export async function resolveSandboxPaths(
);
const forbiddenIdentities = new Set(forbidden.map(getPathIdentity));
const { worktreeGitDir, mainGitDir } =
await resolveGitWorktreePaths(resolvedWorkspace);
const gitWorktree = worktreeGitDir
? { gitWorktree: { worktreeGitDir, mainGitDir } }
: undefined;
/**
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
*/
@@ -413,8 +426,10 @@ export async function resolveSandboxPaths(
policyAllowed: filter(policyAllowed),
policyRead: filter(policyRead),
policyWrite: filter(policyWrite),
...gitWorktree,
};
}
/**
* Sanitizes an array of paths by deduplicating them and ensuring they are absolute.
* Always returns an array (empty if input is null/undefined).
@@ -451,24 +466,4 @@ export function getPathIdentity(p: string): string {
return isCaseInsensitive ? norm.toLowerCase() : norm;
}
/**
* Resolves symlinks for a given path to prevent sandbox escapes.
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
* Other errors (e.g. EACCES) are re-thrown.
*/
export async function tryRealpath(p: string): Promise<string> {
try {
return await fs.realpath(p);
} catch (e) {
if (isNodeError(e) && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(await tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
export { createSandboxManager } from './sandboxManagerFactory.js';