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;
}
}