mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-21 23:40:44 -07:00
feat(sandbox): dynamic macOS sandbox expansion and worktree support (#23301)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user