feat(core): populate sandbox forbidden paths with project ignore file contents (#24038)

This commit is contained in:
Emily Hedlund
2026-04-01 12:27:55 -04:00
committed by GitHub
parent 066da2a1d1
commit 6a8a0d4faa
13 changed files with 309 additions and 79 deletions
@@ -420,7 +420,7 @@ describe('LinuxSandboxManager', () => {
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'],
forbiddenPaths: async () => ['/tmp/cache', '/opt/secret.txt'],
});
const bwrapArgs = await getBwrapArgs(
@@ -452,7 +452,7 @@ describe('LinuxSandboxManager', () => {
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/forbidden-symlink'],
forbiddenPaths: async () => ['/tmp/forbidden-symlink'],
});
const bwrapArgs = await getBwrapArgs(
@@ -480,7 +480,7 @@ describe('LinuxSandboxManager', () => {
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/not-here.txt'],
forbiddenPaths: async () => ['/tmp/not-here.txt'],
});
const bwrapArgs = await getBwrapArgs(
@@ -509,7 +509,7 @@ describe('LinuxSandboxManager', () => {
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/dir-link'],
forbiddenPaths: async () => ['/tmp/dir-link'],
});
const bwrapArgs = await getBwrapArgs(
@@ -534,7 +534,7 @@ describe('LinuxSandboxManager', () => {
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/conflict'],
forbiddenPaths: async () => ['/tmp/conflict'],
});
const bwrapArgs = await getBwrapArgs(
@@ -550,12 +550,14 @@ describe('LinuxSandboxManager', () => {
customManager,
);
const bindTryIdx = bwrapArgs.indexOf('--bind-try');
const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs');
// Conflict should have been filtered out of allow list (--bind-try)
expect(bwrapArgs).not.toContain('--bind-try');
expect(bwrapArgs).not.toContain('--bind-try-ro');
expect(bwrapArgs[bindTryIdx + 1]).toBe('/tmp/conflict');
expect(bwrapArgs[tmpfsIdx + 1]).toBe('/tmp/conflict');
expect(tmpfsIdx).toBeGreaterThan(bindTryIdx);
// It should only appear as a forbidden path (via --tmpfs)
const conflictIdx = bwrapArgs.indexOf('/tmp/conflict');
expect(conflictIdx).toBeGreaterThan(0);
expect(bwrapArgs[conflictIdx - 1]).toBe('--tmpfs');
});
});
});
@@ -17,6 +17,7 @@ import {
getSecretFileFindArgs,
sanitizePaths,
type ParsedSandboxDenial,
resolveSandboxPaths,
} from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
import {
@@ -294,7 +295,7 @@ export class LinuxSandboxManager implements SandboxManager {
bwrapArgs.push(bindFlag, mainGitDir, mainGitDir);
}
const includeDirs = sanitizePaths(this.options.includeDirectories) || [];
const includeDirs = sanitizePaths(this.options.includeDirectories);
for (const includeDir of includeDirs) {
try {
const resolved = tryRealpath(includeDir);
@@ -304,7 +305,8 @@ export class LinuxSandboxManager implements SandboxManager {
}
}
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
const { allowed: allowedPaths, forbidden: forbiddenPaths } =
await resolveSandboxPaths(this.options, req);
const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, '');
for (const allowedPath of allowedPaths) {
@@ -330,8 +332,7 @@ export class LinuxSandboxManager implements SandboxManager {
}
}
const additionalReads =
sanitizePaths(mergedAdditional.fileSystem?.read) || [];
const additionalReads = sanitizePaths(mergedAdditional.fileSystem?.read);
for (const p of additionalReads) {
try {
const safeResolvedPath = tryRealpath(p);
@@ -341,8 +342,7 @@ export class LinuxSandboxManager implements SandboxManager {
}
}
const additionalWrites =
sanitizePaths(mergedAdditional.fileSystem?.write) || [];
const additionalWrites = sanitizePaths(mergedAdditional.fileSystem?.write);
for (const p of additionalWrites) {
try {
const safeResolvedPath = tryRealpath(p);
@@ -362,7 +362,6 @@ export class LinuxSandboxManager implements SandboxManager {
}
}
const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || [];
for (const p of forbiddenPaths) {
let resolved: string;
try {
@@ -67,7 +67,7 @@ describe('MacOsSandboxManager', () => {
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith({
workspace: mockWorkspace,
allowedPaths: mockAllowedPaths,
forbiddenPaths: undefined,
forbiddenPaths: [],
networkAccess: mockNetworkAccess,
workspaceWrite: false,
additionalPermissions: {
@@ -218,7 +218,7 @@ describe('MacOsSandboxManager', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/forbidden1'],
forbiddenPaths: async () => ['/tmp/forbidden1'],
});
await customManager.prepareCommand({
command: 'echo',
@@ -238,7 +238,7 @@ describe('MacOsSandboxManager', () => {
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/does-not-exist'],
forbiddenPaths: async () => ['/tmp/does-not-exist'],
});
await customManager.prepareCommand({
command: 'echo',
@@ -258,7 +258,7 @@ describe('MacOsSandboxManager', () => {
it('should override allowed paths if a path is also in forbidden paths', async () => {
const customManager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/conflict'],
forbiddenPaths: async () => ['/tmp/conflict'],
});
await customManager.prepareCommand({
command: 'echo',
@@ -273,7 +273,7 @@ describe('MacOsSandboxManager', () => {
expect(seatbeltArgsBuilder.buildSeatbeltProfile).toHaveBeenCalledWith(
expect.objectContaining({
allowedPaths: ['/tmp/conflict'],
allowedPaths: [],
forbiddenPaths: ['/tmp/conflict'],
}),
);
@@ -14,6 +14,7 @@ import {
type SandboxPermissions,
type GlobalSandboxOptions,
type ParsedSandboxDenial,
resolveSandboxPaths,
} from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
import {
@@ -93,6 +94,9 @@ export class MacOsSandboxManager implements SandboxManager {
const defaultNetwork =
this.options.modeConfig?.network || req.policy?.networkAccess || false;
const { allowed: allowedPaths, forbidden: forbiddenPaths } =
await resolveSandboxPaths(this.options, req);
// Fetch persistent approvals for this command
const commandName = await getFullCommandName(currentReq);
const persistentPermissions = allowOverrides
@@ -128,10 +132,10 @@ export class MacOsSandboxManager implements SandboxManager {
const sandboxArgs = buildSeatbeltProfile({
workspace: this.options.workspace,
allowedPaths: [
...(req.policy?.allowedPaths || []),
...allowedPaths,
...(this.options.includeDirectories || []),
],
forbiddenPaths: this.options.forbiddenPaths,
forbiddenPaths,
networkAccess: mergedAdditional.network,
workspaceWrite,
additionalPermissions: mergedAdditional,
@@ -38,6 +38,8 @@ describe('seatbeltArgsBuilder', () => {
const profile = buildSeatbeltProfile({
workspace: '/Users/test/workspace',
allowedPaths: [],
forbiddenPaths: [],
});
expect(profile).toContain('(version 1)');
@@ -51,6 +53,8 @@ describe('seatbeltArgsBuilder', () => {
vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p);
const profile = buildSeatbeltProfile({
workspace: '/test',
allowedPaths: [],
forbiddenPaths: [],
networkAccess: true,
});
expect(profile).toContain('(allow network-outbound)');
@@ -70,6 +74,8 @@ describe('seatbeltArgsBuilder', () => {
const profile = buildSeatbeltProfile({
workspace: '/test/workspace',
allowedPaths: [],
forbiddenPaths: [],
});
expect(profile).toContain(
@@ -96,7 +102,11 @@ describe('seatbeltArgsBuilder', () => {
}) as unknown as fs.Stats,
);
const profile = buildSeatbeltProfile({ workspace: '/test/workspace' });
const profile = buildSeatbeltProfile({
workspace: '/test/workspace',
allowedPaths: [],
forbiddenPaths: [],
});
expect(profile).toContain(
`(deny file-write* (literal "/test/workspace/.gitignore"))`,
@@ -117,6 +127,7 @@ describe('seatbeltArgsBuilder', () => {
const profile = buildSeatbeltProfile({
workspace: '/test',
allowedPaths: ['/custom/path1', '/test/symlink'],
forbiddenPaths: [],
});
expect(profile).toContain(`(subpath "/custom/path1")`);
@@ -130,6 +141,7 @@ describe('seatbeltArgsBuilder', () => {
const profile = buildSeatbeltProfile({
workspace: '/test',
allowedPaths: [],
forbiddenPaths: ['/secret/path'],
});
@@ -148,6 +160,7 @@ describe('seatbeltArgsBuilder', () => {
const profile = buildSeatbeltProfile({
workspace: '/test',
allowedPaths: [],
forbiddenPaths: ['/test/symlink'],
});
@@ -161,6 +174,7 @@ describe('seatbeltArgsBuilder', () => {
const profile = buildSeatbeltProfile({
workspace: '/test',
allowedPaths: [],
forbiddenPaths: ['/test/missing-dir/missing-file.txt'],
});
@@ -13,7 +13,6 @@ import {
} from './baseProfile.js';
import {
type SandboxPermissions,
sanitizePaths,
GOVERNANCE_FILES,
SECRET_FILES,
} from '../../services/sandboxManager.js';
@@ -26,9 +25,9 @@ export interface SeatbeltArgsOptions {
/** The primary workspace path to allow access to. */
workspace: string;
/** Additional paths to allow access to. */
allowedPaths?: string[];
allowedPaths: string[];
/** Absolute paths to explicitly deny read/write access to (overrides allowlists). */
forbiddenPaths?: string[];
forbiddenPaths: string[];
/** Whether to allow network access. */
networkAccess?: boolean;
/** Granular additional permissions. */
@@ -92,10 +91,7 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
// Add explicit deny rules for secret files (.env, .env.*) in the workspace and allowed paths.
// We use regex rules to avoid expensive file discovery scans.
// Anchoring to workspace/allowed paths to avoid over-blocking.
const searchPaths = sanitizePaths([
options.workspace,
...(options.allowedPaths || []),
]) || [options.workspace];
const searchPaths = [options.workspace, ...options.allowedPaths];
for (const basePath of searchPaths) {
const resolvedBase = tryRealpath(basePath);
@@ -159,7 +155,7 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
}
// Handle allowedPaths
const allowedPaths = sanitizePaths(options.allowedPaths) || [];
const allowedPaths = options.allowedPaths;
for (let i = 0; i < allowedPaths.length; i++) {
const allowedPath = tryRealpath(allowedPaths[i]);
profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(allowedPath)}"))\n`;
@@ -203,7 +199,7 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
}
// Handle forbiddenPaths
const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || [];
const forbiddenPaths = options.forbiddenPaths;
for (let i = 0; i < forbiddenPaths.length; i++) {
const forbiddenPath = tryRealpath(forbiddenPaths[i]);
profile += `(deny file-read* file-write* (subpath "${escapeSchemeString(forbiddenPath)}"))\n`;
@@ -38,7 +38,7 @@ describe('WindowsSandboxManager', () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { readonly: false, allowOverrides: true },
forbiddenPaths: [],
forbiddenPaths: async () => [],
});
});
@@ -107,7 +107,7 @@ describe('WindowsSandboxManager', () => {
const planManager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { readonly: true, allowOverrides: false },
forbiddenPaths: [],
forbiddenPaths: async () => [],
});
const req: SandboxRequest = {
command: 'curl',
@@ -139,7 +139,7 @@ describe('WindowsSandboxManager', () => {
workspace: testCwd,
modeConfig: { allowOverrides: true, network: false },
policyManager: mockPolicyManager,
forbiddenPaths: [],
forbiddenPaths: async () => [],
});
const req: SandboxRequest = {
@@ -369,7 +369,7 @@ describe('WindowsSandboxManager', () => {
const managerWithForbidden = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: [missingPath],
forbiddenPaths: async () => [missingPath],
});
const req: SandboxRequest = {
@@ -397,7 +397,7 @@ describe('WindowsSandboxManager', () => {
try {
const managerWithForbidden = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: [forbiddenPath],
forbiddenPaths: async () => [forbiddenPath],
});
const req: SandboxRequest = {
@@ -427,7 +427,7 @@ describe('WindowsSandboxManager', () => {
try {
const managerWithForbidden = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: [conflictPath],
forbiddenPaths: async () => [conflictPath],
});
const req: SandboxRequest = {
@@ -458,12 +458,9 @@ describe('WindowsSandboxManager', () => {
call[1][0] === path.resolve(conflictPath),
);
// Both should have been called
expect(allowCallIndex).toBeGreaterThan(-1);
// Conflict should have been filtered out of allow calls
expect(allowCallIndex).toBe(-1);
expect(denyCallIndex).toBeGreaterThan(-1);
// Verify order: explicitly denying must happen after the explicit allow
expect(allowCallIndex).toBeLessThan(denyCallIndex);
} finally {
fs.rmSync(conflictPath, { recursive: true, force: true });
}
@@ -19,6 +19,7 @@ import {
tryRealpath,
type SandboxPermissions,
type ParsedSandboxDenial,
resolveSandboxPaths,
} from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
import {
@@ -288,14 +289,16 @@ export class WindowsSandboxManager implements SandboxManager {
await this.grantLowIntegrityAccess(this.options.workspace);
}
const { allowed: allowedPaths, forbidden: forbiddenPaths } =
await resolveSandboxPaths(this.options, req);
// Grant "Low Mandatory Level" access to includeDirectories.
const includeDirs = sanitizePaths(this.options.includeDirectories) || [];
const includeDirs = sanitizePaths(this.options.includeDirectories);
for (const includeDir of includeDirs) {
await this.grantLowIntegrityAccess(includeDir);
}
// Grant "Low Mandatory Level" read/write access to allowedPaths.
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
for (const allowedPath of allowedPaths) {
const resolved = await tryRealpath(allowedPath);
if (!fs.existsSync(resolved)) {
@@ -308,8 +311,9 @@ export class WindowsSandboxManager implements SandboxManager {
}
// Grant "Low Mandatory Level" write access to additional permissions write paths.
const additionalWritePaths =
sanitizePaths(mergedAdditional.fileSystem?.write) || [];
const additionalWritePaths = sanitizePaths(
mergedAdditional.fileSystem?.write,
);
for (const writePath of additionalWritePaths) {
const resolved = await tryRealpath(writePath);
if (!fs.existsSync(resolved)) {
@@ -358,7 +362,6 @@ export class WindowsSandboxManager implements SandboxManager {
// is restricted to avoid host corruption. External commands rely on
// Low Integrity read/write restrictions, while internal commands
// use the manifest for enforcement.
const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || [];
for (const forbiddenPath of forbiddenPaths) {
try {
await this.denyLowIntegrityAccess(forbiddenPath);