diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts index b58fe271f6..f88e9e76e2 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { LinuxSandboxManager } from './LinuxSandboxManager.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; import fs from 'node:fs'; +import * as shellUtils from '../../utils/shell-utils.js'; vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); @@ -20,17 +21,40 @@ vi.mock('node:fs', async () => { realpathSync: vi.fn((p) => p.toString()), statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats), mkdirSync: vi.fn(), + mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'), openSync: vi.fn(), closeSync: vi.fn(), writeFileSync: vi.fn(), + readdirSync: vi.fn(() => []), + chmodSync: vi.fn(), + unlinkSync: vi.fn(), + rmSync: vi.fn(), }, existsSync: vi.fn(() => true), realpathSync: vi.fn((p) => p.toString()), statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats), mkdirSync: vi.fn(), + mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'), openSync: vi.fn(), closeSync: vi.fn(), writeFileSync: vi.fn(), + readdirSync: vi.fn(() => []), + chmodSync: vi.fn(), + unlinkSync: vi.fn(), + rmSync: vi.fn(), + }; +}); + +vi.mock('../../utils/shell-utils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + spawnAsync: vi.fn(() => + Promise.resolve({ status: 0, stdout: Buffer.from('') }), + ), + initializeShellParsers: vi.fn(), + isStrictlyApproved: vi.fn().mockResolvedValue(true), }; }); @@ -452,4 +476,44 @@ describe('LinuxSandboxManager', () => { }); }); }); + + it('blocks .env and .env.* files in the workspace root', async () => { + vi.mocked(shellUtils.spawnAsync).mockImplementation((cmd, args) => { + if (cmd === 'find' && args?.[0] === workspace) { + // Assert that find is NOT excluding dotfiles + expect(args).not.toContain('-not'); + expect(args).toContain('-prune'); + + return Promise.resolve({ + status: 0, + stdout: Buffer.from( + `${workspace}/.env\0${workspace}/.env.local\0${workspace}/.env.test\0`, + ), + } as unknown as ReturnType); + } + return Promise.resolve({ + status: 0, + stdout: Buffer.from(''), + } as unknown as ReturnType); + }); + + const bwrapArgs = await getBwrapArgs({ + command: 'ls', + args: [], + cwd: workspace, + env: {}, + }); + + const bindsIndex = bwrapArgs.indexOf('--seccomp'); + const binds = bwrapArgs.slice(0, bindsIndex); + + expect(binds).toContain(`${workspace}/.env`); + expect(binds).toContain(`${workspace}/.env.local`); + expect(binds).toContain(`${workspace}/.env.test`); + + // Verify they are bound to a mask file + const envIndex = binds.indexOf(`${workspace}/.env`); + expect(binds[envIndex - 2]).toBe('--bind'); + expect(binds[envIndex - 1]).toMatch(/gemini-cli-mask-file-.*mocked\/mask/); + }); }); diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 33f12beafa..28be7ad281 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -5,7 +5,6 @@ */ import fs from 'node:fs'; -import { debugLogger } from '../../utils/debugLogger.js'; import { join, dirname, normalize } from 'node:path'; import os from 'node:os'; import { @@ -15,12 +14,15 @@ import { type SandboxedCommand, type SandboxPermissions, GOVERNANCE_FILES, + getSecretFileFindArgs, sanitizePaths, } from '../../services/sandboxManager.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; +import { debugLogger } from '../../utils/debugLogger.js'; +import { spawnAsync } from '../../utils/shell-utils.js'; import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; import { isStrictlyApproved, @@ -32,6 +34,10 @@ import { resolveGitWorktreePaths, isErrnoException, } from '../utils/fsUtils.js'; +import { + isKnownSafeCommand, + isDangerousCommand, +} from '../utils/commandSafety.js'; let cachedBpfPath: string | undefined; @@ -85,9 +91,20 @@ function getSeccompBpfPath(): string { buf.writeUInt32LE(inst.k, offset + 4); } - const bpfPath = join(os.tmpdir(), `gemini-cli-seccomp-${process.pid}.bpf`); + const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-')); + const bpfPath = join(tempDir, 'seccomp.bpf'); fs.writeFileSync(bpfPath, buf); cachedBpfPath = bpfPath; + + // Cleanup on exit + process.on('exit', () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + }); + return bpfPath; } @@ -110,11 +127,6 @@ function touch(filePath: string, isDirectory: boolean) { } } -import { - isKnownSafeCommand, - isDangerousCommand, -} from '../utils/commandSafety.js'; - /** * A SandboxManager implementation for Linux that uses Bubblewrap (bwrap). */ @@ -130,6 +142,8 @@ export interface LinuxSandboxOptions extends GlobalSandboxOptions { } export class LinuxSandboxManager implements SandboxManager { + private static maskFilePath: string | undefined; + constructor(private readonly options: LinuxSandboxOptions) {} isKnownSafeCommand(args: string[]): boolean { @@ -140,6 +154,31 @@ export class LinuxSandboxManager implements SandboxManager { return isDangerousCommand(args); } + private getMaskFilePath(): string { + if ( + LinuxSandboxManager.maskFilePath && + fs.existsSync(LinuxSandboxManager.maskFilePath) + ) { + return LinuxSandboxManager.maskFilePath; + } + const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-')); + const maskPath = join(tempDir, 'mask'); + fs.writeFileSync(maskPath, ''); + fs.chmodSync(maskPath, 0); + LinuxSandboxManager.maskFilePath = maskPath; + + // Cleanup on exit + process.on('exit', () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + }); + + return maskPath; + } + async prepareCommand(req: SandboxRequest): Promise { const isReadonlyMode = this.options.modeConfig?.readonly ?? true; const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; @@ -319,6 +358,11 @@ export class LinuxSandboxManager implements SandboxManager { } } + // Mask secret files (.env, .env.*) + bwrapArgs.push( + ...(await this.getSecretFilesArgs(req.policy?.allowedPaths)), + ); + const bpfPath = getSeccompBpfPath(); bwrapArgs.push('--seccomp', '9'); @@ -339,4 +383,68 @@ export class LinuxSandboxManager implements SandboxManager { cwd: req.cwd, }; } + + /** + * Generates bubblewrap arguments to mask secret files. + */ + private async getSecretFilesArgs(allowedPaths?: string[]): Promise { + const args: string[] = []; + const maskPath = this.getMaskFilePath(); + const paths = sanitizePaths(allowedPaths) || []; + const searchDirs = new Set([this.options.workspace, ...paths]); + const findPatterns = getSecretFileFindArgs(); + + for (const dir of searchDirs) { + try { + // Use the native 'find' command for performance and to catch nested secrets. + // We limit depth to 3 to keep it fast while covering common nested structures. + // We use -prune to skip heavy directories efficiently while matching dotfiles. + const findResult = await spawnAsync('find', [ + dir, + '-maxdepth', + '3', + '-type', + 'd', + '(', + '-name', + '.git', + '-o', + '-name', + 'node_modules', + '-o', + '-name', + '.venv', + '-o', + '-name', + '__pycache__', + '-o', + '-name', + 'dist', + '-o', + '-name', + 'build', + ')', + '-prune', + '-o', + '-type', + 'f', + ...findPatterns, + '-print0', + ]); + + const files = findResult.stdout.toString().split('\0'); + for (const file of files) { + if (file.trim()) { + args.push('--bind', maskPath, file.trim()); + } + } + } catch (e) { + debugLogger.log( + `LinuxSandboxManager: Failed to find or mask secret files in ${dir}`, + e, + ); + } + } + return args; + } } diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index cfdcee1687..a610331d88 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -15,6 +15,7 @@ import { type SandboxPermissions, sanitizePaths, GOVERNANCE_FILES, + SECRET_FILES, } from '../../services/sandboxManager.js'; import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js'; @@ -89,6 +90,34 @@ export function buildSeatbeltArgs(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]; + + for (const basePath of searchPaths) { + const resolvedBase = tryRealpath(basePath); + for (const secret of SECRET_FILES) { + // Map pattern to Seatbelt regex + let regexPattern: string; + const escapedBase = escapeRegex(resolvedBase); + if (secret.pattern.endsWith('*')) { + // .env.* -> .env\..+ (match .env followed by dot and something) + // We anchor the secret file name to either a directory separator or the start of the relative path. + const basePattern = secret.pattern.slice(0, -1).replace(/\./g, '\\\\.'); + regexPattern = `^${escapedBase}/(.*/)?${basePattern}[^/]+$`; + } else { + // .env -> \.env$ + const basePattern = secret.pattern.replace(/\./g, '\\\\.'); + regexPattern = `^${escapedBase}/(.*/)?${basePattern}$`; + } + profile += `(deny file-read* file-write* (regex #"${regexPattern}"))\n`; + } + } + // Auto-detect and support git worktrees by granting read and write access to the underlying git directory const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath); if (worktreeGitDir) { @@ -206,3 +235,23 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] { return args; } + +/** + * Escapes a string for use within a Seatbelt regex literal #"..." + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\"]/g, (c) => { + if (c === '"') { + // Escape double quotes for the Scheme string literal + return '\\"'; + } + if (c === '\\') { + // A literal backslash needs to be \\ in the regex. + // To get \\ in the regex engine, we need \\\\ in the Scheme string literal. + return '\\\\\\\\'; + } + // For other regex special characters (like .), we need \c in the regex. + // To get \c in the regex engine, we need \\c in the Scheme string literal. + return '\\\\' + c; + }); +} diff --git a/packages/core/src/sandbox/windows/GeminiSandbox.cs b/packages/core/src/sandbox/windows/GeminiSandbox.cs index 8c3fc9de06..eff5ec703a 100644 --- a/packages/core/src/sandbox/windows/GeminiSandbox.cs +++ b/packages/core/src/sandbox/windows/GeminiSandbox.cs @@ -5,45 +5,28 @@ */ using System; -using System.Runtime.InteropServices; using System.Collections.Generic; using System.Diagnostics; -using System.Security.Principal; using System.IO; +using System.Runtime.InteropServices; +using System.Security; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +/** + * A native C# helper for the Gemini CLI sandbox on Windows. + * This helper uses Restricted Tokens and Job Objects to isolate processes. + * It also supports internal commands for safe file I/O within the sandbox. + */ public class GeminiSandbox { - [StructLayout(LayoutKind.Sequential)] - public struct STARTUPINFO { - public uint cb; - public string lpReserved; - public string lpDesktop; - public string lpTitle; - public uint dwX; - public uint dwY; - public uint dwXSize; - public uint dwYSize; - public uint dwXCountChars; - public uint dwYCountChars; - public uint dwFillAttribute; - public uint dwFlags; - public ushort wShowWindow; - public ushort cbReserved2; - public IntPtr lpReserved2; - public IntPtr hStdInput; - public IntPtr hStdOutput; - public IntPtr hStdError; - } + // P/Invoke constants and structures + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const uint JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION = 0x00000400; + private const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008; [StructLayout(LayoutKind.Sequential)] - public struct PROCESS_INFORMATION { - public IntPtr hProcess; - public IntPtr hThread; - public uint dwProcessId; - public uint dwThreadId; - } - - [StructLayout(LayoutKind.Sequential)] - public struct JOBOBJECT_BASIC_LIMIT_INFORMATION { + struct JOBOBJECT_BASIC_LIMIT_INFORMATION { public Int64 PerProcessUserTimeLimit; public Int64 PerJobUserTimeLimit; public uint LimitFlags; @@ -56,17 +39,7 @@ public class GeminiSandbox { } [StructLayout(LayoutKind.Sequential)] - public struct IO_COUNTERS { - public ulong ReadOperationCount; - public ulong WriteOperationCount; - public ulong OtherOperationCount; - public ulong ReadTransferCount; - public ulong WriteTransferCount; - public ulong OtherTransferCount; - } - - [StructLayout(LayoutKind.Sequential)] - public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; public IO_COUNTERS IoInfo; public UIntPtr ProcessMemoryLimit; @@ -76,139 +49,153 @@ public class GeminiSandbox { } [StructLayout(LayoutKind.Sequential)] - public struct SID_AND_ATTRIBUTES { + struct IO_COUNTERS { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle); + + [DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)] + static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetStdHandle(int nStdHandle); + + [StructLayout(LayoutKind.Sequential)] + struct STARTUPINFO { + public uint cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + struct PROCESS_INFORMATION { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool ImpersonateLoggedOnUser(IntPtr hToken); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool RevertToSelf(); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + static extern uint GetLongPathName(string lpszShortPath, [Out] StringBuilder lpszLongPath, uint cchBuffer); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)] + static extern bool ConvertStringSidToSid(string StringSid, out IntPtr ptrSid); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength); + + [StructLayout(LayoutKind.Sequential)] + struct SID_AND_ATTRIBUTES { public IntPtr Sid; public uint Attributes; } [StructLayout(LayoutKind.Sequential)] - public struct TOKEN_MANDATORY_LABEL { + struct TOKEN_MANDATORY_LABEL { public SID_AND_ATTRIBUTES Label; } - public enum JobObjectInfoClass { - ExtendedLimitInformation = 9 - } - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr GetCurrentProcess(); - - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle); - - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoClass JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern uint ResumeThread(IntPtr hThread); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool CloseHandle(IntPtr hObject); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr GetStdHandle(int nStdHandle); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern bool ConvertStringSidToSid(string StringSid, out IntPtr Sid); - - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr LocalFree(IntPtr hMem); - - public const uint TOKEN_DUPLICATE = 0x0002; - public const uint TOKEN_QUERY = 0x0008; - public const uint TOKEN_ASSIGN_PRIMARY = 0x0001; - public const uint TOKEN_ADJUST_DEFAULT = 0x0080; - public const uint DISABLE_MAX_PRIVILEGE = 0x1; - public const uint CREATE_SUSPENDED = 0x00000004; - public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400; - public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; - public const uint STARTF_USESTDHANDLES = 0x00000100; - public const int TokenIntegrityLevel = 25; - public const uint SE_GROUP_INTEGRITY = 0x00000020; - public const uint INFINITE = 0xFFFFFFFF; + private const int TokenIntegrityLevel = 25; + private const uint SE_GROUP_INTEGRITY = 0x00000020; static int Main(string[] args) { if (args.Length < 3) { - Console.WriteLine("Usage: GeminiSandbox.exe [args...]"); + Console.WriteLine("Usage: GeminiSandbox.exe [--forbidden-manifest ] [args...]"); Console.WriteLine("Internal commands: __read , __write "); return 1; } bool networkAccess = args[0] == "1"; string cwd = args[1]; - string command = args[2]; + HashSet forbiddenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + int argIndex = 2; + + if (argIndex < args.Length && args[argIndex] == "--forbidden-manifest") { + if (argIndex + 1 < args.Length) { + string manifestPath = args[argIndex + 1]; + if (File.Exists(manifestPath)) { + foreach (string line in File.ReadAllLines(manifestPath)) { + if (!string.IsNullOrWhiteSpace(line)) { + forbiddenPaths.Add(GetNormalizedPath(line.Trim())); + } + } + } + argIndex += 2; + } + } + + if (argIndex >= args.Length) { + Console.WriteLine("Error: Missing command"); + return 1; + } + + string command = args[argIndex]; IntPtr hToken = IntPtr.Zero; IntPtr hRestrictedToken = IntPtr.Zero; - IntPtr hJob = IntPtr.Zero; - IntPtr pSidsToDisable = IntPtr.Zero; - IntPtr pSidsToRestrict = IntPtr.Zero; - IntPtr networkSid = IntPtr.Zero; - IntPtr restrictedSid = IntPtr.Zero; IntPtr lowIntegritySid = IntPtr.Zero; try { - // 1. Setup Token - IntPtr hCurrentProcess = GetCurrentProcess(); - if (!OpenProcessToken(hCurrentProcess, TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT, out hToken)) { - Console.Error.WriteLine("Failed to open process token"); + // 1. Create Restricted Token + if (!OpenProcessToken(GetCurrentProcess(), 0x0002 /* TOKEN_DUPLICATE */ | 0x0008 /* TOKEN_QUERY */ | 0x0080 /* TOKEN_ADJUST_DEFAULT */, out hToken)) { + Console.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")"); return 1; } - uint sidCount = 0; - uint restrictCount = 0; - - // "networkAccess == false" implies Strict Sandbox Level 1. - if (!networkAccess) { - if (ConvertStringSidToSid("S-1-5-2", out networkSid)) { - sidCount = 1; - int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)); - pSidsToDisable = Marshal.AllocHGlobal(saaSize); - SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES(); - saa.Sid = networkSid; - saa.Attributes = 0; - Marshal.StructureToPtr(saa, pSidsToDisable, false); - } - - // S-1-5-12 is Restricted Code SID - if (ConvertStringSidToSid("S-1-5-12", out restrictedSid)) { - restrictCount = 1; - int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)); - pSidsToRestrict = Marshal.AllocHGlobal(saaSize); - SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES(); - saa.Sid = restrictedSid; - saa.Attributes = 0; - Marshal.StructureToPtr(saa, pSidsToRestrict, false); - } - } - - if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, sidCount, pSidsToDisable, 0, IntPtr.Zero, restrictCount, pSidsToRestrict, out hRestrictedToken)) { - Console.Error.WriteLine("Failed to create restricted token"); + // Flags: 0x1 (DISABLE_MAX_PRIVILEGE) + if (!CreateRestrictedToken(hToken, 1, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) { + Console.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")"); return 1; } - // 2. Set Integrity Level to Low + // 2. Lower Integrity Level to Low + // S-1-16-4096 is the SID for "Low Mandatory Level" if (ConvertStringSidToSid("S-1-16-4096", out lowIntegritySid)) { TOKEN_MANDATORY_LABEL tml = new TOKEN_MANDATORY_LABEL(); tml.Label.Sid = lowIntegritySid; @@ -217,154 +204,184 @@ public class GeminiSandbox { IntPtr pTml = Marshal.AllocHGlobal(tmlSize); try { Marshal.StructureToPtr(tml, pTml, false); - SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize); + if (!SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize)) { + Console.WriteLine("Error: SetTokenInformation failed (" + Marshal.GetLastWin32Error() + ")"); + return 1; + } } finally { Marshal.FreeHGlobal(pTml); } } - // 3. Handle Internal Commands or External Process + // 3. Setup Job Object for cleanup + IntPtr hJob = CreateJobObject(IntPtr.Zero, null); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION; + + IntPtr lpJobLimits = Marshal.AllocHGlobal(Marshal.SizeOf(jobLimits)); + Marshal.StructureToPtr(jobLimits, lpJobLimits, false); + SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits)); + Marshal.FreeHGlobal(lpJobLimits); + + // 4. Handle Internal Commands or External Process if (command == "__read") { - string path = args[3]; + if (argIndex + 1 >= args.Length) { + Console.WriteLine("Error: Missing path for __read"); + return 1; + } + string path = args[argIndex + 1]; + CheckForbidden(path, forbiddenPaths); return RunInImpersonation(hRestrictedToken, () => { try { using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (StreamReader sr = new StreamReader(fs, System.Text.Encoding.UTF8)) { - char[] buffer = new char[4096]; - int bytesRead; - while ((bytesRead = sr.Read(buffer, 0, buffer.Length)) > 0) { - Console.Write(buffer, 0, bytesRead); - } + using (Stream stdout = Console.OpenStandardOutput()) { + fs.CopyTo(stdout); } return 0; } catch (Exception e) { - Console.Error.WriteLine(e.Message); + Console.Error.WriteLine("Error reading file: " + e.Message); return 1; } }); } else if (command == "__write") { - string path = args[3]; + if (argIndex + 1 >= args.Length) { + Console.WriteLine("Error: Missing path for __write"); + return 1; + } + string path = args[argIndex + 1]; + CheckForbidden(path, forbiddenPaths); return RunInImpersonation(hRestrictedToken, () => { try { using (StreamReader reader = new StreamReader(Console.OpenStandardInput(), System.Text.Encoding.UTF8)) using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) using (StreamWriter writer = new StreamWriter(fs, System.Text.Encoding.UTF8)) { - char[] buffer = new char[4096]; - int bytesRead; - while ((bytesRead = reader.Read(buffer, 0, buffer.Length)) > 0) { - writer.Write(buffer, 0, bytesRead); - } + writer.Write(reader.ReadToEnd()); } return 0; } catch (Exception e) { - Console.Error.WriteLine(e.Message); + Console.Error.WriteLine("Error writing file: " + e.Message); return 1; } }); } - // 4. Setup Job Object for external process - hJob = CreateJobObject(IntPtr.Zero, null); - if (hJob != IntPtr.Zero) { - JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); - limitInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - int limitSize = Marshal.SizeOf(limitInfo); - IntPtr pLimit = Marshal.AllocHGlobal(limitSize); - try { - Marshal.StructureToPtr(limitInfo, pLimit, false); - SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize); - } finally { - Marshal.FreeHGlobal(pLimit); - } - } - - // 5. Launch Process + // External Process STARTUPINFO si = new STARTUPINFO(); si.cb = (uint)Marshal.SizeOf(si); - si.dwFlags = STARTF_USESTDHANDLES; + si.dwFlags = 0x00000100; // STARTF_USESTDHANDLES si.hStdInput = GetStdHandle(-10); si.hStdOutput = GetStdHandle(-11); si.hStdError = GetStdHandle(-12); string commandLine = ""; - for (int i = 2; i < args.Length; i++) { - if (i > 2) commandLine += " "; + for (int i = argIndex; i < args.Length; i++) { + if (i > argIndex) commandLine += " "; commandLine += QuoteArgument(args[i]); } - PROCESS_INFORMATION pi; - if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, IntPtr.Zero, cwd, ref si, out pi)) { - Console.Error.WriteLine("Failed to create process. Error: " + Marshal.GetLastWin32Error()); + PROCESS_INFORMATION pi = new PROCESS_INFORMATION(); + // Creation Flags: 0x04000000 (CREATE_BREAKAWAY_FROM_JOB) to allow job assignment if parent is in job + uint creationFlags = 0; + if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, creationFlags, IntPtr.Zero, cwd, ref si, out pi)) { + Console.WriteLine("Error: CreateProcessAsUser failed (" + Marshal.GetLastWin32Error() + ") Command: " + commandLine); return 1; } - try { - if (hJob != IntPtr.Zero) { - AssignProcessToJobObject(hJob, pi.hProcess); - } + AssignProcessToJobObject(hJob, pi.hProcess); + + // Wait for exit + uint waitResult = WaitForSingleObject(pi.hProcess, 0xFFFFFFFF); + uint exitCode = 0; + GetExitCodeProcess(pi.hProcess, out exitCode); - ResumeThread(pi.hThread); - WaitForSingleObject(pi.hProcess, INFINITE); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + CloseHandle(hJob); - uint exitCode = 0; - GetExitCodeProcess(pi.hProcess, out exitCode); - return (int)exitCode; - } finally { - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - } - } catch (Exception e) { - Console.Error.WriteLine("Unexpected error: " + e.Message); - return 1; + return (int)exitCode; } finally { - if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken); if (hToken != IntPtr.Zero) CloseHandle(hToken); - if (hJob != IntPtr.Zero) CloseHandle(hJob); - if (pSidsToDisable != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToDisable); - if (pSidsToRestrict != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToRestrict); - if (networkSid != IntPtr.Zero) LocalFree(networkSid); - if (restrictedSid != IntPtr.Zero) LocalFree(restrictedSid); - if (lowIntegritySid != IntPtr.Zero) LocalFree(lowIntegritySid); + if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); + + private static int RunInImpersonation(IntPtr hToken, Func action) { + if (!ImpersonateLoggedOnUser(hToken)) { + Console.WriteLine("Error: ImpersonateLoggedOnUser failed (" + Marshal.GetLastWin32Error() + ")"); + return 1; + } + try { + return action(); + } finally { + RevertToSelf(); + } + } + + private static string GetNormalizedPath(string path) { + string fullPath = Path.GetFullPath(path); + StringBuilder longPath = new StringBuilder(1024); + uint result = GetLongPathName(fullPath, longPath, (uint)longPath.Capacity); + if (result > 0 && result < longPath.Capacity) { + return longPath.ToString(); + } + return fullPath; + } + + private static void CheckForbidden(string path, HashSet forbiddenPaths) { + string fullPath = GetNormalizedPath(path); + foreach (string forbidden in forbiddenPaths) { + if (fullPath.Equals(forbidden, StringComparison.OrdinalIgnoreCase) || fullPath.StartsWith(forbidden + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { + throw new UnauthorizedAccessException("Access to forbidden path is denied: " + path); + } } } private static string QuoteArgument(string arg) { if (string.IsNullOrEmpty(arg)) return "\"\""; - bool hasSpace = arg.IndexOfAny(new char[] { ' ', '\t' }) != -1; - if (!hasSpace && arg.IndexOf('\"') == -1) return arg; + bool needsQuotes = false; + foreach (char c in arg) { + if (char.IsWhiteSpace(c) || c == '\"') { + needsQuotes = true; + break; + } + } - // Windows command line escaping for arguments is complex. - // Rule: Backslashes only need escaping if they precede a double quote or the end of the string. - System.Text.StringBuilder sb = new System.Text.StringBuilder(); + if (!needsQuotes) return arg; + + StringBuilder sb = new StringBuilder(); sb.Append('\"'); for (int i = 0; i < arg.Length; i++) { - int backslashCount = 0; - while (i < arg.Length && arg[i] == '\\') { - backslashCount++; - i++; - } + char c = arg[i]; + if (c == '\"') { + sb.Append("\\\""); + } else if (c == '\\') { + int backslashCount = 0; + while (i < arg.Length && arg[i] == '\\') { + backslashCount++; + i++; + } - if (i == arg.Length) { - // Escape backslashes before the closing double quote - sb.Append('\\', backslashCount * 2); - } else if (arg[i] == '\"') { - // Escape backslashes before a literal double quote - sb.Append('\\', backslashCount * 2 + 1); - sb.Append('\"'); + if (i == arg.Length) { + sb.Append('\\', backslashCount * 2); + } else if (arg[i] == '\"') { + sb.Append('\\', backslashCount * 2 + 1); + sb.Append('\"'); + } else { + sb.Append('\\', backslashCount); + sb.Append(arg[i]); + } } else { - // Backslashes don't need escaping here - sb.Append('\\', backslashCount); - sb.Append(arg[i]); + sb.Append(c); } } sb.Append('\"'); return sb.ToString(); } - - private static int RunInImpersonation(IntPtr hToken, Func action) { - using (WindowsIdentity.Impersonate(hToken)) { - return action(); - } - } } diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 2c7e08a730..37b01be9bc 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -60,7 +60,14 @@ describe('WindowsSandboxManager', () => { const result = await manager.prepareCommand(req); expect(result.program).toContain('GeminiSandbox.exe'); - expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']); + expect(result.args).toEqual([ + '0', + testCwd, + '--forbidden-manifest', + expect.stringMatching(/manifest\.txt$/), + 'whoami', + '/groups', + ]); }); it('should handle networkAccess from config', async () => { diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index a213d7b619..a07241366a 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -13,6 +13,7 @@ import { type SandboxRequest, type SandboxedCommand, GOVERNANCE_FILES, + findSecretFiles, type GlobalSandboxOptions, sanitizePaths, tryRealpath, @@ -269,43 +270,96 @@ export class WindowsSandboxManager implements SandboxManager { await this.grantLowIntegrityAccess(writePath); } - // Denies access to forbiddenPaths for Low Integrity processes. - const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || []; - for (const forbiddenPath of forbiddenPaths) { - await this.denyLowIntegrityAccess(forbiddenPath); + // 2. Collect secret files and apply protective ACLs + // On Windows, we explicitly deny access to secret files for Low Integrity + // processes to ensure they cannot be read or written. + const secretsToBlock: string[] = []; + const searchDirs = new Set([this.options.workspace, ...allowedPaths]); + for (const dir of searchDirs) { + try { + // We use maxDepth 3 to catch common nested secrets while keeping performance high. + const secretFiles = await findSecretFiles(dir, 3); + for (const secretFile of secretFiles) { + try { + secretsToBlock.push(secretFile); + await this.denyLowIntegrityAccess(secretFile); + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to secure secret file ${secretFile}`, + e, + ); + } + } + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to find secret files in ${dir}`, + e, + ); + } } - // 2. Protected governance files + // Denies access to forbiddenPaths for Low Integrity processes. + // Note: Denying access to arbitrary paths (like system files) via icacls + // 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(req.policy?.forbiddenPaths) || []; + for (const forbiddenPath of forbiddenPaths) { + try { + await this.denyLowIntegrityAccess(forbiddenPath); + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to secure forbidden path ${forbiddenPath}`, + e, + ); + } + } + + // 3. Protected governance files // These must exist on the host before running the sandbox to prevent // the sandboxed process from creating them with Low integrity. // By being created as Medium integrity, they are write-protected from Low processes. for (const file of GOVERNANCE_FILES) { const filePath = path.join(this.options.workspace, file.path); this.touch(filePath, file.isDirectory); - - // We resolve real paths to ensure protection for both the symlink and its target. - try { - const realPath = fs.realpathSync(filePath); - if (realPath !== filePath) { - // If it's a symlink, the target is already implicitly protected - // if it's outside the Low integrity workspace (likely Medium). - // If it's inside, we ensure it's not accidentally Low. - } - } catch { - // Ignore realpath errors - } } - // 3. Construct the helper command - // GeminiSandbox.exe [args...] + // 4. Forbidden paths manifest + // We use a manifest file to avoid command-line length limits. + const allForbidden = Array.from( + new Set([...secretsToBlock, ...forbiddenPaths]), + ); + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-forbidden-'), + ); + const manifestPath = path.join(tempDir, 'manifest.txt'); + fs.writeFileSync(manifestPath, allForbidden.join('\n')); + + // Cleanup on exit + process.on('exit', () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + }); + + // 5. Construct the helper command + // GeminiSandbox.exe --forbidden-manifest [args...] const program = this.helperPath; const defaultNetwork = this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; const networkAccess = defaultNetwork || mergedAdditional.network; - // If the command starts with __, it's an internal command for the sandbox helper itself. - const args = [networkAccess ? '1' : '0', req.cwd, req.command, ...req.args]; + const args = [ + networkAccess ? '1' : '0', + req.cwd, + '--forbidden-manifest', + manifestPath, + req.command, + ...req.args, + ]; return { program, @@ -342,17 +396,7 @@ export class WindowsSandboxManager implements SandboxManager { return; } - // Never modify integrity levels for system directories - const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; - const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; - const programFilesX86 = - process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; - - if ( - resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || - resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || - resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) - ) { + if (this.isSystemDirectory(resolvedPath)) { return; } @@ -381,6 +425,11 @@ export class WindowsSandboxManager implements SandboxManager { return; } + // Never modify ACEs for system directories + if (this.isSystemDirectory(resolvedPath)) { + return; + } + // S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity) const LOW_INTEGRITY_SID = '*S-1-16-4096'; @@ -417,4 +466,17 @@ export class WindowsSandboxManager implements SandboxManager { ); } } + + private isSystemDirectory(resolvedPath: string): boolean { + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; + const programFilesX86 = + process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + + return ( + resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) + ); + } } diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index a677c790b1..a62a7e50cb 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -3,20 +3,120 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ + import os from 'node:os'; import path from 'node:path'; -import fs from 'node:fs/promises'; +import fsPromises from 'node:fs/promises'; import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; import { NoopSandboxManager, LocalSandboxManager, sanitizePaths, + findSecretFiles, + isSecretFile, tryRealpath, } from './sandboxManager.js'; import { createSandboxManager } from './sandboxManagerFactory.js'; import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js'; +import type fs from 'node:fs'; + +vi.mock('node:fs/promises', async () => { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + return { + ...actual, + default: { + ...actual, + readdir: vi.fn(), + realpath: vi.fn(), + stat: vi.fn(), + }, + readdir: vi.fn(), + realpath: vi.fn(), + stat: vi.fn(), + }; +}); + +describe('isSecretFile', () => { + it('should return true for .env', () => { + expect(isSecretFile('.env')).toBe(true); + }); + + it('should return true for .env.local', () => { + expect(isSecretFile('.env.local')).toBe(true); + }); + + it('should return true for .env.production', () => { + expect(isSecretFile('.env.production')).toBe(true); + }); + + it('should return false for regular files', () => { + expect(isSecretFile('package.json')).toBe(false); + expect(isSecretFile('index.ts')).toBe(false); + expect(isSecretFile('.gitignore')).toBe(false); + }); + + it('should return false for files starting with .env but not matching pattern', () => { + // This depends on the pattern ".env.*". ".env-backup" would match ".env*" but not ".env.*" + expect(isSecretFile('.env-backup')).toBe(false); + }); +}); + +describe('findSecretFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should find secret files in the root directory', async () => { + vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => { + if (dir === '/workspace') { + return Promise.resolve([ + { name: '.env', isDirectory: () => false, isFile: () => true }, + { + name: 'package.json', + isDirectory: () => false, + isFile: () => true, + }, + { name: 'src', isDirectory: () => true, isFile: () => false }, + ] as unknown as fs.Dirent[]); + } + return Promise.resolve([] as unknown as fs.Dirent[]); + }) as unknown as typeof fsPromises.readdir); + + const secrets = await findSecretFiles('/workspace'); + expect(secrets).toEqual([path.join('/workspace', '.env')]); + }); + + it('should NOT find secret files recursively (shallow scan only)', async () => { + vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => { + if (dir === '/workspace') { + return Promise.resolve([ + { name: '.env', isDirectory: () => false, isFile: () => true }, + { name: 'packages', isDirectory: () => true, isFile: () => false }, + ] as unknown as fs.Dirent[]); + } + if (dir === path.join('/workspace', 'packages')) { + return Promise.resolve([ + { name: '.env.local', isDirectory: () => false, isFile: () => true }, + ] as unknown as fs.Dirent[]); + } + return Promise.resolve([] as unknown as fs.Dirent[]); + }) as unknown as typeof fsPromises.readdir); + + const secrets = await findSecretFiles('/workspace'); + expect(secrets).toEqual([path.join('/workspace', '.env')]); + // Should NOT have called readdir for subdirectories + expect(fsPromises.readdir).toHaveBeenCalledTimes(1); + expect(fsPromises.readdir).not.toHaveBeenCalledWith( + path.join('/workspace', 'packages'), + expect.anything(), + ); + }); +}); describe('SandboxManager', () => { afterEach(() => vi.restoreAllMocks()); @@ -48,24 +148,30 @@ describe('SandboxManager', () => { }); it('should return the realpath if the file exists', async () => { - vi.spyOn(fs, 'realpath').mockResolvedValue('/real/path/to/file.txt'); + vi.mocked(fsPromises.realpath).mockResolvedValue( + '/real/path/to/file.txt' as never, + ); const result = await tryRealpath('/some/symlink/to/file.txt'); expect(result).toBe('/real/path/to/file.txt'); - expect(fs.realpath).toHaveBeenCalledWith('/some/symlink/to/file.txt'); + expect(fsPromises.realpath).toHaveBeenCalledWith( + '/some/symlink/to/file.txt', + ); }); it('should fallback to parent directory if file does not exist (ENOENT)', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async (p) => { + vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => { if (p === '/workspace/nonexistent.txt') { - throw Object.assign(new Error('ENOENT: no such file or directory'), { - code: 'ENOENT', - }); + return Promise.reject( + Object.assign(new Error('ENOENT: no such file or directory'), { + code: 'ENOENT', + }), + ); } if (p === '/workspace') { - return '/real/workspace'; + return Promise.resolve('/real/workspace'); } - throw new Error(`Unexpected path: ${p}`); - }); + return Promise.reject(new Error(`Unexpected path: ${p}`)); + }) as never); const result = await tryRealpath('/workspace/nonexistent.txt'); @@ -74,18 +180,22 @@ describe('SandboxManager', () => { }); it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async (p) => { + vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => { if (p === '/workspace/missing_dir/missing_file.txt') { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return Promise.reject( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); } if (p === '/workspace/missing_dir') { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return Promise.reject( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); } if (p === '/workspace') { - return '/real/workspace'; + return Promise.resolve('/real/workspace'); } - throw new Error(`Unexpected path: ${p}`); - }); + return Promise.reject(new Error(`Unexpected path: ${p}`)); + }) as never); const result = await tryRealpath( '/workspace/missing_dir/missing_file.txt', @@ -99,20 +209,22 @@ describe('SandboxManager', () => { it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => { const rootPath = path.resolve('/'); - vi.spyOn(fs, 'realpath').mockImplementation(async () => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); + vi.mocked(fsPromises.realpath).mockImplementation(() => + Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })), + ); const result = await tryRealpath(rootPath); expect(result).toBe(rootPath); }); it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async () => { - throw Object.assign(new Error('EACCES: permission denied'), { - code: 'EACCES', - }); - }); + vi.mocked(fsPromises.realpath).mockImplementation(() => + Promise.reject( + Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }), + ), + ); await expect(tryRealpath('/secret/file.txt')).rejects.toThrow( 'EACCES: permission denied', diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index ea18e5857d..0028ba9f24 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -21,6 +21,7 @@ import { getSecureSanitizationConfig, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; + export interface SandboxPermissions { /** Filesystem permissions. */ fileSystem?: { @@ -120,6 +121,87 @@ export const GOVERNANCE_FILES = [ { path: '.git', isDirectory: true }, ] as const; +/** + * Files that contain sensitive secrets or credentials and should be + * completely hidden (deny read/write) in any sandbox. + */ +export const SECRET_FILES = [ + { pattern: '.env' }, + { pattern: '.env.*' }, +] as const; + +/** + * Checks if a given file name matches any of the secret file patterns. + */ +export function isSecretFile(fileName: string): boolean { + return SECRET_FILES.some((s) => { + if (s.pattern.endsWith('*')) { + const prefix = s.pattern.slice(0, -1); + return fileName.startsWith(prefix); + } + return fileName === s.pattern; + }); +} + +/** + * Returns arguments for the Linux 'find' command to locate secret files. + */ +export function getSecretFileFindArgs(): string[] { + const args: string[] = ['(']; + SECRET_FILES.forEach((s, i) => { + if (i > 0) args.push('-o'); + args.push('-name', s.pattern); + }); + args.push(')'); + return args; +} + +/** + * Finds all secret files in a directory up to a certain depth. + * Default is shallow scan (depth 1) for performance. + */ +export async function findSecretFiles( + baseDir: string, + maxDepth = 1, +): Promise { + const secrets: string[] = []; + const skipDirs = new Set([ + 'node_modules', + '.git', + '.venv', + '__pycache__', + 'dist', + 'build', + '.next', + '.idea', + '.vscode', + ]); + + async function walk(dir: string, depth: number) { + if (depth > maxDepth) return; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + await walk(fullPath, depth + 1); + } + } else if (entry.isFile()) { + if (isSecretFile(entry.name)) { + secrets.push(fullPath); + } + } + } + } catch { + // Ignore read errors + } + } + + await walk(baseDir, 1); + return secrets; +} + /** * A no-op implementation of SandboxManager that silently passes commands * through while applying environment sanitization.