mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
feat(sandbox): implement secret visibility lockdown for env files (#23712)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user