Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4bb85ac
feat(sandbox): implement secret visibility lockdown for env files
scidomino Mar 24, 2026
92af861
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 24, 2026
150673d
merge: merge main into sandbox_visibility_lock and resolve conflicts
DavidAPierce Mar 25, 2026
3c6d91b
test(sandbox): update linux sandbox tests to match merged implementation
DavidAPierce Mar 25, 2026
534135c
test(sandbox): update linux sandbox tests to match merged implementation
DavidAPierce Mar 25, 2026
52202e3
Merge remote-tracking branch 'refs/remotes/origin/sandbox_visibility_…
DavidAPierce Mar 25, 2026
782dbb9
Fix for temp mask file predictable name
DavidAPierce Mar 25, 2026
89f0754
Fix for non escaping backslash characters in macos seatbelArgsBuilder.
DavidAPierce Mar 25, 2026
5843848
merge: merge main into sandbox_visibility_lock and resolve conflicts
DavidAPierce Mar 25, 2026
1b5a938
test(sandbox): update WindowsSandboxManager expectations for manifest…
DavidAPierce Mar 25, 2026
02f8e9a
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 25, 2026
79fed19
Fix Incomplete string escaping
DavidAPierce Mar 25, 2026
4a758db
Merge remote-tracking branch 'refs/remotes/origin/sandbox_visibility_…
DavidAPierce Mar 25, 2026
c74f282
fix(sandbox): address string escaping vulnerability in macOS Seatbelt…
DavidAPierce Mar 25, 2026
0d49d97
Delete whitespace commit packages/core/.geminiignore
DavidAPierce Mar 25, 2026
09fd78a
Delete whitespace commit packages/core/.gitignore
DavidAPierce Mar 25, 2026
3b3f28b
lint npm run format fix
DavidAPierce Mar 25, 2026
cab55f1
Merge remote-tracking branch 'refs/remotes/origin/sandbox_visibility_…
DavidAPierce Mar 25, 2026
0b32d7d
Address 8.3 short name comment.
DavidAPierce Mar 25, 2026
e450356
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 25, 2026
d97867f
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 25, 2026
c00047d
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 25, 2026
ec7d0db
merge: merge main into sandbox_visibility_lock and resolve conflicts
DavidAPierce Mar 26, 2026
8f7a6c0
merge: merge main into sandbox_visibility_lock and resolve conflicts
DavidAPierce Mar 26, 2026
af66a2c
merge: merge main into sandbox_visibility_lock and resolve conflicts
DavidAPierce Mar 26, 2026
440f9f7
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 26, 2026
39dfad1
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 26, 2026
c890369
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 26, 2026
678d835
Merge branch 'main' into sandbox_visibility_lock
DavidAPierce Mar 26, 2026
20de985
Update to address Regression pointed out be recent comment.
DavidAPierce Mar 26, 2026
2c4b19a
quick fix to update missed test.
DavidAPierce Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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),
};
});

Expand Down Expand Up @@ -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/);
});
});
122 changes: 115 additions & 7 deletions packages/core/src/sandbox/linux/LinuxSandboxManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -32,6 +34,10 @@ import {
resolveGitWorktreePaths,
isErrnoException,
} from '../utils/fsUtils.js';
import {
isKnownSafeCommand,
isDangerousCommand,
} from '../utils/commandSafety.js';

let cachedBpfPath: string | undefined;

Expand Down Expand Up @@ -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;
}

Expand All @@ -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).
*/
Expand All @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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;
}
}
49 changes: 49 additions & 0 deletions packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type SandboxPermissions,
sanitizePaths,
GOVERNANCE_FILES,
SECRET_FILES,
} from '../../services/sandboxManager.js';
import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
});
}
Loading
Loading