feat(sandbox): implement secret visibility lockdown for env files (#23712)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
David Pierce
2026-03-26 20:35:21 +00:00
committed by GitHub
parent 84f1c19265
commit 30397816da
8 changed files with 800 additions and 299 deletions
@@ -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<typeof import('node:fs')>('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<typeof import('../../utils/shell-utils.js')>();
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<typeof shellUtils.spawnAsync>);
}
return Promise.resolve({
status: 0,
stdout: Buffer.from(''),
} as unknown as ReturnType<typeof shellUtils.spawnAsync>);
});
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/);
});
});
@@ -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<SandboxedCommand> {
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<string[]> {
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;
}
}
@@ -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;
});
}
+252 -235
View File
@@ -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 <network:0|1> <cwd> <command> [args...]");
Console.WriteLine("Usage: GeminiSandbox.exe <network:0|1> <cwd> [--forbidden-manifest <path>] <command> [args...]");
Console.WriteLine("Internal commands: __read <path>, __write <path>");
return 1;
}
bool networkAccess = args[0] == "1";
string cwd = args[1];
string command = args[2];
HashSet<string> forbiddenPaths = new HashSet<string>(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<int> 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<string> 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<int> action) {
using (WindowsIdentity.Impersonate(hToken)) {
return action();
}
}
}
@@ -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 () => {
@@ -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 <network:0|1> <cwd> <command> [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 <network:0|1> <cwd> --forbidden-manifest <path> <command> [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())
);
}
}
+136 -24
View File
@@ -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<typeof import('node:fs/promises')>(
'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',
@@ -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<string[]> {
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.