feat(sandbox): dynamic macOS sandbox expansion and worktree support (#23301)

This commit is contained in:
Gal Zahavi
2026-03-23 21:48:13 -07:00
committed by GitHub
parent 7fdf708e1a
commit fe7baee18b
40 changed files with 2201 additions and 183 deletions
@@ -63,7 +63,7 @@ describe('MacOsSandboxManager', () => {
expect(profile).toContain('(deny default)');
expect(profile).toContain('(allow process-exec)');
expect(profile).toContain('(subpath (param "WORKSPACE"))');
expect(profile).not.toContain('(allow network*)');
expect(profile).not.toContain('(allow network-outbound)');
expect(result.args).toContain('-D');
expect(result.args).toContain(`WORKSPACE=${mockWorkspace}`);
@@ -91,7 +91,7 @@ describe('MacOsSandboxManager', () => {
});
const profile = result.args[1];
expect(profile).toContain('(allow network*)');
expect(profile).toContain('(allow network-outbound)');
});
it('should parameterize allowed paths and normalize them', async () => {
@@ -4,41 +4,164 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
type SandboxManager,
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
type ExecutionPolicy,
sanitizePaths,
GOVERNANCE_FILES,
type SandboxPermissions,
type GlobalSandboxOptions,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import {
BASE_SEATBELT_PROFILE,
NETWORK_SEATBELT_PROFILE,
} from './baseProfile.js';
getCommandRoots,
initializeShellParsers,
splitCommands,
stripShellWrapper,
} from '../../utils/shell-utils.js';
import { isKnownSafeCommand } from './commandSafety.js';
import { parse as shellParse } from 'shell-quote';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import path from 'node:path';
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
/** Optional base sanitization config. */
sanitizationConfig?: EnvironmentSanitizationConfig;
/** The current sandbox mode behavior from config. */
modeConfig?: {
readonly?: boolean;
network?: boolean;
approvedTools?: string[];
allowOverrides?: boolean;
};
/** The policy manager for persistent approvals. */
policyManager?: SandboxPolicyManager;
}
/**
* A SandboxManager implementation for macOS that uses Seatbelt.
*/
export class MacOsSandboxManager implements SandboxManager {
constructor(private readonly options: GlobalSandboxOptions) {}
constructor(private readonly options: MacOsSandboxOptions) {}
private async isStrictlyApproved(req: SandboxRequest): Promise<boolean> {
const approvedTools = this.options.modeConfig?.approvedTools;
if (!approvedTools || approvedTools.length === 0) {
return false;
}
await initializeShellParsers();
const fullCmd = [req.command, ...req.args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped);
if (roots.length === 0) return false;
const allRootsApproved = roots.every((root) =>
approvedTools.includes(root),
);
if (allRootsApproved) {
return true;
}
const pipelineCommands = splitCommands(stripped);
if (pipelineCommands.length === 0) return false;
// For safety, every command in the pipeline must be considered safe.
for (const cmdString of pipelineCommands) {
const parsedArgs = shellParse(cmdString).map(String);
if (!isKnownSafeCommand(parsedArgs)) {
return false;
}
}
return true;
}
private async getCommandName(req: SandboxRequest): Promise<string> {
await initializeShellParsers();
const fullCmd = [req.command, ...req.args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped).filter(
(r) => r !== 'shopt' && r !== 'set',
);
if (roots.length > 0) {
return roots[0];
}
return path.basename(req.command);
}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
await initializeShellParsers();
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const sandboxArgs = this.buildSeatbeltArgs(this.options, req.policy);
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
// Reject override attempts in plan mode
if (!allowOverrides && req.policy?.additionalPermissions) {
const perms = req.policy.additionalPermissions;
if (
perms.network ||
(perms.fileSystem?.write && perms.fileSystem.write.length > 0)
) {
throw new Error(
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
);
}
}
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
const isApproved = allowOverrides
? await this.isStrictlyApproved(req)
: false;
const workspaceWrite = !isReadonlyMode || isApproved;
const networkAccess =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
// Fetch persistent approvals for this command
const commandName = await this.getCommandName(req);
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
// Merge all permissions
const mergedAdditional: SandboxPermissions = {
fileSystem: {
read: [
...(persistentPermissions?.fileSystem?.read ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
],
write: [
...(persistentPermissions?.fileSystem?.write ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
],
},
network:
networkAccess ||
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
};
const sandboxArgs = buildSeatbeltArgs({
workspace: this.options.workspace,
allowedPaths: [...(req.policy?.allowedPaths || [])],
forbiddenPaths: req.policy?.forbiddenPaths,
networkAccess: mergedAdditional.network,
workspaceWrite,
additionalPermissions: mergedAdditional,
});
return {
program: '/usr/bin/sandbox-exec',
@@ -47,124 +170,4 @@ export class MacOsSandboxManager implements SandboxManager {
cwd: req.cwd,
};
}
/**
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
*
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
* Does not include the final '--' separator or the command to run.
*/
private buildSeatbeltArgs(
options: GlobalSandboxOptions,
policy?: ExecutionPolicy,
): string[] {
const profileLines = [BASE_SEATBELT_PROFILE];
const args: string[] = [];
const workspacePath = this.tryRealpath(options.workspace);
args.push('-D', `WORKSPACE=${workspacePath}`);
// Add explicit deny rules for governance files in the workspace.
// These are added after the workspace allow rule (which is in BASE_SEATBELT_PROFILE)
// to ensure they take precedence (Seatbelt evaluates rules in order, later rules win for same path).
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path);
// Ensure the file/directory exists so Seatbelt rules are reliably applied.
this.touch(governanceFile, GOVERNANCE_FILES[i].isDirectory);
const realGovernanceFile = this.tryRealpath(governanceFile);
// Determine if it should be treated as a directory (subpath) or a file (literal).
// .git is generally a directory, while ignore files are literals.
let isActuallyDirectory = GOVERNANCE_FILES[i].isDirectory;
try {
if (fs.existsSync(realGovernanceFile)) {
isActuallyDirectory = fs.lstatSync(realGovernanceFile).isDirectory();
}
} catch {
// Ignore errors, use default guess
}
const ruleType = isActuallyDirectory ? 'subpath' : 'literal';
args.push('-D', `GOVERNANCE_FILE_${i}=${governanceFile}`);
profileLines.push(
`(deny file-write* (${ruleType} (param "GOVERNANCE_FILE_${i}")))`,
);
if (realGovernanceFile !== governanceFile) {
args.push('-D', `REAL_GOVERNANCE_FILE_${i}=${realGovernanceFile}`);
profileLines.push(
`(deny file-write* (${ruleType} (param "REAL_GOVERNANCE_FILE_${i}")))`,
);
}
}
const tmpPath = this.tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
const allowedPaths = sanitizePaths(policy?.allowedPaths) || [];
for (let i = 0; i < allowedPaths.length; i++) {
const allowedPath = this.tryRealpath(allowedPaths[i]);
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
profileLines.push(
`(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))`,
);
}
// TODO: handle forbidden paths
if (policy?.networkAccess) {
profileLines.push(NETWORK_SEATBELT_PROFILE);
}
args.unshift('-p', profileLines.join('\n'));
return args;
}
/**
* Ensures a file or directory exists.
*/
private touch(filePath: string, isDirectory: boolean) {
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
/**
* 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.
*/
private tryRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(this.tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
}
+96 -8
View File
@@ -16,11 +16,101 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
(import "system.sb")
; Core execution requirements
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))
(allow process-info*)
(allow file-write-data
(require-all
(path "/dev/null")
(vnode-type CHARACTER-DEVICE)))
; sysctls permitted.
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.model")
(sysctl-name "hw.memsize")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name-prefix "hw.optional.arm.")
(sysctl-name-prefix "hw.optional.armv8_")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.logicalcpu")
(sysctl-name "hw.cpufrequency")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "machdep.cpu.brand_string")
(sysctl-name "kern.argmax")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.maxproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name "vm.loadavg")
(sysctl-name-prefix "hw.perflevel")
(sysctl-name-prefix "kern.proc.pgrp.")
(sysctl-name-prefix "kern.proc.pid.")
(sysctl-name-prefix "net.routetable.")
)
(allow sysctl-write
(sysctl-name "kern.grade_cputype"))
(allow mach-lookup
(global-name "com.apple.sysmond")
)
\n; IOKit
(allow iokit-open
(iokit-registry-entry-class "RootDomainUserClient")
)
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo")
)
; Needed for python multiprocessing on MacOS for the SemLock
(allow ipc-posix-sem)
(allow mach-lookup
(global-name "com.apple.PowerManagement.control")
)
; PTY and Terminal support
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write*
(require-all
(regex #"^/dev/ttys[0-9]+")
(extension "com.apple.sandbox.pty")))
(allow file-ioctl (regex #"^/dev/ttys[0-9]+"))
; Allow basic read access to system frameworks and libraries required to run
(allow file-read*
@@ -38,11 +128,6 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
(subpath "/private/etc")
)
; PTY and Terminal support
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+"))
; Allow read/write access to temporary directories and common device nodes
(allow file-read* file-write*
(literal "/dev/null")
@@ -53,9 +138,10 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
)
; Workspace access using parameterized paths
(allow file-read* file-write*
(allow file-read*
(subpath (param "WORKSPACE"))
)
`;
/**
@@ -66,7 +152,9 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
*/
export const NETWORK_SEATBELT_PROFILE = `
; Network Access
(allow network*)
(allow network-outbound)
(allow network-inbound)
(allow network-bind)
(allow system-socket
(require-all
@@ -0,0 +1,469 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { parse as shellParse } from 'shell-quote';
/**
* Checks if a command with its arguments is known to be safe to execute
* without requiring user confirmation. This is primarily used to allow
* harmless, read-only commands to run silently in the macOS sandbox.
*
* It handles raw command execution as well as wrapped commands like `bash -c "..."` or `bash -lc "..."`.
* For wrapped commands, it parses the script and ensures all individual
* sub-commands are in the known-safe list and no dangerous shell operators
* (like subshells or redirection) are used.
*
* @param args - The command and its arguments (e.g., ['ls', '-la'])
* @returns true if the command is considered safe, false otherwise.
*/
export function isKnownSafeCommand(args: string[]): boolean {
if (!args || args.length === 0) {
return false;
}
// Normalize zsh to bash
const normalizedArgs = args.map((a) => (a === 'zsh' ? 'bash' : a));
if (isSafeToCallWithExec(normalizedArgs)) {
return true;
}
// Support `bash -lc "..."`
if (
normalizedArgs.length === 3 &&
normalizedArgs[0] === 'bash' &&
(normalizedArgs[1] === '-lc' || normalizedArgs[1] === '-c')
) {
try {
const script = normalizedArgs[2];
// Basic check for dangerous operators that could spawn subshells or redirect output
// We allow &&, ||, |, ; but explicitly block subshells () and redirection >, >>, <
if (/[()<>]/g.test(script)) {
return false;
}
const commands = script.split(/&&|\|\||\||;/);
let allSafe = true;
for (const cmd of commands) {
const trimmed = cmd.trim();
if (!trimmed) continue;
const parsed = shellParse(trimmed).map(String);
if (parsed.length === 0) continue;
if (!isSafeToCallWithExec(parsed)) {
allSafe = false;
break;
}
}
if (allSafe && commands.length > 0) {
return true;
}
} catch {
return false;
}
}
return false;
}
/**
* Core validation logic that checks a single command and its arguments
* against an allowlist of known safe operations. It performs deep validation
* for specific tools like `base64`, `find`, `rg`, `git`, and `sed` to ensure
* unsafe flags (like `--output`, `-exec`, or mutating options) are not used.
*
* @param args - The command and its arguments.
* @returns true if the command is strictly read-only and safe.
*/
function isSafeToCallWithExec(args: string[]): boolean {
if (!args || args.length === 0) return false;
const cmd = args[0];
const safeCommands = new Set([
'cat',
'cd',
'cut',
'echo',
'expr',
'false',
'grep',
'head',
'id',
'ls',
'nl',
'paste',
'pwd',
'rev',
'seq',
'stat',
'tail',
'tr',
'true',
'uname',
'uniq',
'wc',
'which',
'whoami',
'numfmt',
'tac',
]);
if (safeCommands.has(cmd)) {
return true;
}
if (cmd === 'base64') {
const unsafeOptions = new Set(['-o', '--output']);
return !args
.slice(1)
.some(
(arg) =>
unsafeOptions.has(arg) ||
arg.startsWith('--output=') ||
(arg.startsWith('-o') && arg !== '-o'),
);
}
if (cmd === 'find') {
const unsafeOptions = new Set([
'-exec',
'-execdir',
'-ok',
'-okdir',
'-delete',
'-fls',
'-fprint',
'-fprint0',
'-fprintf',
]);
return !args.some((arg) => unsafeOptions.has(arg));
}
if (cmd === 'rg') {
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
return !args.some((arg) => {
if (unsafeWithoutArgs.has(arg)) return true;
for (const opt of unsafeWithArgs) {
if (arg === opt || arg.startsWith(opt + '=')) return true;
}
return false;
});
}
if (cmd === 'git') {
if (gitHasConfigOverrideGlobalOption(args)) {
return false;
}
const { idx, subcommand } = findGitSubcommand(args, [
'status',
'log',
'diff',
'show',
'branch',
]);
if (!subcommand) {
return false;
}
const subcommandArgs = args.slice(idx + 1);
if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
return gitSubcommandArgsAreReadOnly(subcommandArgs);
}
if (subcommand === 'branch') {
return (
gitSubcommandArgsAreReadOnly(subcommandArgs) &&
gitBranchIsReadOnly(subcommandArgs)
);
}
return false;
}
if (cmd === 'sed') {
// Special-case sed -n {N|M,N}p
if (args.length <= 4 && args[1] === '-n' && isValidSedNArg(args[2])) {
return true;
}
return false;
}
return false;
}
/**
* Helper to identify which git subcommand is being executed, skipping over
* global git options like `-c` or `--git-dir`.
*
* @param args - The full git command arguments.
* @param subcommands - A list of subcommands to look for.
* @returns An object containing the index of the subcommand and its name.
*/
function findGitSubcommand(
args: string[],
subcommands: string[],
): { idx: number; subcommand: string | null } {
let skipNext = false;
for (let idx = 1; idx < args.length; idx++) {
if (skipNext) {
skipNext = false;
continue;
}
const arg = args[idx];
if (
arg.startsWith('--config-env=') ||
arg.startsWith('--exec-path=') ||
arg.startsWith('--git-dir=') ||
arg.startsWith('--namespace=') ||
arg.startsWith('--super-prefix=') ||
arg.startsWith('--work-tree=') ||
((arg.startsWith('-C') || arg.startsWith('-c')) && arg.length > 2)
) {
continue;
}
if (
arg === '-C' ||
arg === '-c' ||
arg === '--config-env' ||
arg === '--exec-path' ||
arg === '--git-dir' ||
arg === '--namespace' ||
arg === '--super-prefix' ||
arg === '--work-tree'
) {
skipNext = true;
continue;
}
if (arg === '--' || arg.startsWith('-')) {
continue;
}
if (subcommands.includes(arg)) {
return { idx, subcommand: arg };
}
return { idx: -1, subcommand: null };
}
return { idx: -1, subcommand: null };
}
/**
* Checks if a git command contains global configuration override flags
* (e.g., `-c` or `--config-env`) which could be used maliciously to
* execute arbitrary code via git config.
*
* @param args - The git command arguments.
* @returns true if config overrides are present.
*/
function gitHasConfigOverrideGlobalOption(args: string[]): boolean {
return args.some(
(arg) =>
arg === '-c' ||
arg === '--config-env' ||
(arg.startsWith('-c') && arg.length > 2) ||
arg.startsWith('--config-env='),
);
}
/**
* Validates that the arguments for safe git subcommands (like `status`, `log`,
* `diff`, `show`) do not contain flags that could cause mutations or execute
* arbitrary commands (e.g., `--output`, `--exec`).
*
* @param args - Arguments passed to the git subcommand.
* @returns true if the arguments only represent read-only operations.
*/
function gitSubcommandArgsAreReadOnly(args: string[]): boolean {
const unsafeFlags = new Set([
'--output',
'--ext-diff',
'--textconv',
'--exec',
'--paginate',
]);
return !args.some(
(arg) =>
unsafeFlags.has(arg) ||
arg.startsWith('--output=') ||
arg.startsWith('--exec='),
);
}
/**
* Validates that `git branch` is only used for read operations
* (e.g., listing branches) rather than creating, deleting, or renaming branches.
*
* @param args - Arguments passed to `git branch`.
* @returns true if it's purely a listing/read-only branch command.
*/
function gitBranchIsReadOnly(args: string[]): boolean {
if (args.length === 0) return true;
let sawReadOnlyFlag = false;
for (const arg of args) {
if (
[
'--list',
'-l',
'--show-current',
'-a',
'--all',
'-r',
'--remotes',
'-v',
'-vv',
'--verbose',
].includes(arg)
) {
sawReadOnlyFlag = true;
} else if (arg.startsWith('--format=')) {
sawReadOnlyFlag = true;
} else {
return false;
}
}
return sawReadOnlyFlag;
}
/**
* Ensures that a `sed` command argument is a valid line-printing instruction
* (e.g., `10p` or `5,10p`), preventing unsafe script execution in `sed`.
*
* @param arg - The script argument passed to `sed -n`.
* @returns true if it's a valid, safe print command.
*/
function isValidSedNArg(arg: string | undefined): boolean {
if (!arg) return false;
if (!arg.endsWith('p')) return false;
const core = arg.slice(0, -1);
const parts = core.split(',');
if (parts.length === 1) {
const num = parts[0];
return num.length > 0 && /^\d+$/.test(num);
} else if (parts.length === 2) {
const a = parts[0];
const b = parts[1];
return a.length > 0 && b.length > 0 && /^\d+$/.test(a) && /^\d+$/.test(b);
}
return false;
}
/**
* Checks if a command with its arguments is explicitly known to be dangerous
* and should be blocked or require strict user confirmation. This catches
* destructive commands like `rm -rf`, `sudo`, and commands with execution
* flags like `find -exec`.
*
* @param args - The command and its arguments.
* @returns true if the command is identified as dangerous, false otherwise.
*/
export function isDangerousCommand(args: string[]): boolean {
if (!args || args.length === 0) {
return false;
}
const cmd = args[0];
if (cmd === 'rm') {
return args[1] === '-f' || args[1] === '-rf' || args[1] === '-fr';
}
if (cmd === 'sudo') {
return isDangerousCommand(args.slice(1));
}
if (cmd === 'find') {
const unsafeOptions = new Set([
'-exec',
'-execdir',
'-ok',
'-okdir',
'-delete',
'-fls',
'-fprint',
'-fprint0',
'-fprintf',
]);
return args.some((arg) => unsafeOptions.has(arg));
}
if (cmd === 'rg') {
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
return args.some((arg) => {
if (unsafeWithoutArgs.has(arg)) return true;
for (const opt of unsafeWithArgs) {
if (arg === opt || arg.startsWith(opt + '=')) return true;
}
return false;
});
}
if (cmd === 'git') {
if (gitHasConfigOverrideGlobalOption(args)) {
return true;
}
const { idx, subcommand } = findGitSubcommand(args, [
'status',
'log',
'diff',
'show',
'branch',
]);
if (!subcommand) {
// It's a git command we don't recognize as explicitly safe.
return false;
}
const subcommandArgs = args.slice(idx + 1);
if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
return !gitSubcommandArgsAreReadOnly(subcommandArgs);
}
if (subcommand === 'branch') {
return !(
gitSubcommandArgsAreReadOnly(subcommandArgs) &&
gitBranchIsReadOnly(subcommandArgs)
);
}
return false;
}
if (cmd === 'base64') {
const unsafeOptions = new Set(['-o', '--output']);
return args
.slice(1)
.some(
(arg) =>
unsafeOptions.has(arg) ||
arg.startsWith('--output=') ||
(arg.startsWith('-o') && arg !== '-o'),
);
}
return false;
}
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import fs from 'node:fs';
import os from 'node:os';
describe('seatbeltArgsBuilder', () => {
it('should build a strict allowlist profile allowing the workspace via param', () => {
// Mock realpathSync to just return the path for testing
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' });
expect(args[0]).toBe('-p');
const profile = args[1];
expect(profile).toContain('(version 1)');
expect(profile).toContain('(deny default)');
expect(profile).toContain('(allow process-exec)');
expect(profile).toContain('(subpath (param "WORKSPACE"))');
expect(profile).not.toContain('(allow network*)');
expect(args).toContain('-D');
expect(args).toContain('WORKSPACE=/Users/test/workspace');
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
vi.restoreAllMocks();
});
it('should allow network when networkAccess is true', () => {
const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true });
const profile = args[1];
expect(profile).toContain('(allow network-outbound)');
});
it('should parameterize allowed paths and normalize them', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink') return '/test/real_path';
return p as string;
});
const args = buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1', '/test/symlink'],
});
const profile = args[1];
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
expect(args).toContain('-D');
expect(args).toContain('ALLOWED_PATH_0=/custom/path1');
expect(args).toContain('ALLOWED_PATH_1=/test/real_path');
vi.restoreAllMocks();
});
it('should resolve parent directories if a file does not exist', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink/nonexistent.txt') {
const error = new Error('ENOENT');
Object.assign(error, { code: 'ENOENT' });
throw error;
}
if (p === '/test/symlink') {
return '/test/real_path';
}
return p as string;
});
const args = buildSeatbeltArgs({
workspace: '/test/symlink/nonexistent.txt',
});
expect(args).toContain('WORKSPACE=/test/real_path/nonexistent.txt');
vi.restoreAllMocks();
});
it('should throw if realpathSync throws a non-ENOENT error', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
const error = new Error('Permission denied');
Object.assign(error, { code: 'EACCES' });
throw error;
});
expect(() =>
buildSeatbeltArgs({
workspace: '/test/workspace',
}),
).toThrow('Permission denied');
vi.restoreAllMocks();
});
describe('governance files', () => {
it('should inject explicit deny rules for governance files', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p.toString());
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'lstatSync').mockImplementation(
(p) =>
({
isDirectory: () => p.toString().endsWith('.git'),
isFile: () => !p.toString().endsWith('.git'),
}) as unknown as fs.Stats,
);
const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' });
const profile = args[1];
// .gitignore should be a literal deny
expect(args).toContain('-D');
expect(args).toContain(
'GOVERNANCE_FILE_0=/Users/test/workspace/.gitignore',
);
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
);
// .git should be a subpath deny
expect(args).toContain('GOVERNANCE_FILE_2=/Users/test/workspace/.git');
expect(profile).toContain(
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
);
vi.restoreAllMocks();
});
it('should protect both the symlink and the real path if they differ', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/workspace/.gitignore') return '/test/real/.gitignore';
return p.toString();
});
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'lstatSync').mockImplementation(
() =>
({
isDirectory: () => false,
isFile: () => true,
}) as unknown as fs.Stats,
);
const args = buildSeatbeltArgs({ workspace: '/test/workspace' });
const profile = args[1];
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
expect(args).toContain('REAL_GOVERNANCE_FILE_0=/test/real/.gitignore');
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
);
expect(profile).toContain(
'(deny file-write* (literal (param "REAL_GOVERNANCE_FILE_0")))',
);
vi.restoreAllMocks();
});
});
});
@@ -0,0 +1,247 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
BASE_SEATBELT_PROFILE,
NETWORK_SEATBELT_PROFILE,
} from './baseProfile.js';
import {
type SandboxPermissions,
sanitizePaths,
GOVERNANCE_FILES,
} from '../../services/sandboxManager.js';
/**
* Options for building macOS Seatbelt arguments.
*/
export interface SeatbeltArgsOptions {
/** The primary workspace path to allow access to. */
workspace: string;
/** Additional paths to allow access to. */
allowedPaths?: string[];
/** Absolute paths to explicitly deny read/write access to (overrides allowlists). */
forbiddenPaths?: string[];
/** Whether to allow network access. */
networkAccess?: boolean;
/** Granular additional permissions. */
additionalPermissions?: SandboxPermissions;
/** Whether to allow write access to the workspace. */
workspaceWrite?: boolean;
}
/**
* 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.
*/
function tryRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
/**
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
*
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
* Does not include the final '--' separator or the command to run.
*/
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
let profile = BASE_SEATBELT_PROFILE + '\n';
const args: string[] = [];
const workspacePath = tryRealpath(options.workspace);
args.push('-D', `WORKSPACE=${workspacePath}`);
args.push('-D', `WORKSPACE_RAW=${options.workspace}`);
profile += `(allow file-read* (subpath (param "WORKSPACE_RAW")))\n`;
if (options.workspaceWrite) {
profile += `(allow file-write* (subpath (param "WORKSPACE_RAW")))\n`;
}
if (options.workspaceWrite) {
profile += `(allow file-write* (subpath (param "WORKSPACE")))\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).
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path);
const realGovernanceFile = tryRealpath(governanceFile);
// Determine if it should be treated as a directory (subpath) or a file (literal).
// .git is generally a directory, while ignore files are literals.
let isDirectory = GOVERNANCE_FILES[i].isDirectory;
try {
if (fs.existsSync(realGovernanceFile)) {
isDirectory = fs.lstatSync(realGovernanceFile).isDirectory();
}
} catch {
// Ignore errors, use default guess
}
const ruleType = isDirectory ? 'subpath' : 'literal';
args.push('-D', `GOVERNANCE_FILE_${i}=${governanceFile}`);
profile += `(deny file-write* (${ruleType} (param "GOVERNANCE_FILE_${i}")))\n`;
if (realGovernanceFile !== governanceFile) {
args.push('-D', `REAL_GOVERNANCE_FILE_${i}=${realGovernanceFile}`);
profile += `(deny file-write* (${ruleType} (param "REAL_GOVERNANCE_FILE_${i}")))\n`;
}
}
// Auto-detect and support git worktrees by granting read and write access to the underlying git directory
try {
const gitPath = path.join(workspacePath, '.git');
const gitStat = fs.lstatSync(gitPath);
if (gitStat.isFile()) {
const gitContent = fs.readFileSync(gitPath, 'utf8');
const match = gitContent.match(/^gitdir:\s*(.+)$/m);
if (match && match[1]) {
let worktreeGitDir = match[1].trim();
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(workspacePath, worktreeGitDir);
}
const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir);
// Grant write access to the worktree's specific .git directory
args.push('-D', `WORKTREE_GIT_DIR=${resolvedWorktreeGitDir}`);
profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`;
// Grant write access to the main repository's .git directory (objects, refs, etc. are shared)
// resolvedWorktreeGitDir is usually like: /path/to/main-repo/.git/worktrees/worktree-name
const mainGitDir = tryRealpath(
path.dirname(path.dirname(resolvedWorktreeGitDir)),
);
if (mainGitDir && mainGitDir.endsWith('.git')) {
args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`);
profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`;
}
}
}
} catch (_e) {
// Ignore if .git doesn't exist, isn't readable, etc.
}
const tmpPath = tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
const nodeRootPath = tryRealpath(
path.dirname(path.dirname(process.execPath)),
);
args.push('-D', `NODE_ROOT=${nodeRootPath}`);
profile += `(allow file-read* (subpath (param "NODE_ROOT")))\n`;
// Add PATH directories as read-only to support nvm, homebrew, etc.
if (process.env['PATH']) {
const paths = process.env['PATH'].split(':');
let pathIndex = 0;
const addedPaths = new Set();
for (const p of paths) {
if (!p.trim()) continue;
try {
let resolved = tryRealpath(p);
// If this is a 'bin' directory (like /usr/local/bin or homebrew/bin),
// also grant read access to its parent directory so that symlinked
// assets (like Cellar or libexec) can be read.
if (resolved.endsWith('/bin')) {
resolved = path.dirname(resolved);
}
if (!addedPaths.has(resolved)) {
addedPaths.add(resolved);
args.push('-D', `SYS_PATH_${pathIndex}=${resolved}`);
profile += `(allow file-read* (subpath (param "SYS_PATH_${pathIndex}")))\n`;
pathIndex++;
}
} catch (_e) {
// Ignore paths that do not exist or are inaccessible
}
}
}
// Handle allowedPaths
const allowedPaths = sanitizePaths(options.allowedPaths) || [];
for (let i = 0; i < allowedPaths.length; i++) {
const allowedPath = tryRealpath(allowedPaths[i]);
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
}
// Handle granular additional permissions
if (options.additionalPermissions?.fileSystem) {
const { read, write } = options.additionalPermissions.fileSystem;
if (read) {
read.forEach((p, i) => {
const resolved = tryRealpath(p);
const paramName = `ADDITIONAL_READ_${i}`;
args.push('-D', `${paramName}=${resolved}`);
let isFile = false;
try {
isFile = fs.statSync(resolved).isFile();
} catch {
// Ignore error
}
if (isFile) {
profile += `(allow file-read* (literal (param "${paramName}")))\n`;
} else {
profile += `(allow file-read* (subpath (param "${paramName}")))\n`;
}
});
}
if (write) {
write.forEach((p, i) => {
const resolved = tryRealpath(p);
const paramName = `ADDITIONAL_WRITE_${i}`;
args.push('-D', `${paramName}=${resolved}`);
let isFile = false;
try {
isFile = fs.statSync(resolved).isFile();
} catch {
// Ignore error
}
if (isFile) {
profile += `(allow file-read* file-write* (literal (param "${paramName}")))\n`;
} else {
profile += `(allow file-read* file-write* (subpath (param "${paramName}")))\n`;
}
});
}
}
// Handle forbiddenPaths
const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || [];
for (let i = 0; i < forbiddenPaths.length; i++) {
const forbiddenPath = tryRealpath(forbiddenPaths[i]);
args.push('-D', `FORBIDDEN_PATH_${i}=${forbiddenPath}`);
profile += `(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_${i}")))\n`;
}
if (options.networkAccess || options.additionalPermissions?.network) {
profile += NETWORK_SEATBELT_PROFILE;
}
args.unshift('-p', profile);
return args;
}