Compare commits

..

3 Commits

Author SHA1 Message Date
Alisa Novikova a0654ac8b3 Test chaos 2026-03-23 20:45:15 -07:00
Alisa Novikova d3e33af635 Merge branch 'main' into alisa/five_hundred_api_error_2 2026-03-23 20:41:52 -07:00
Alisa Novikova 20004fb526 feat(evals): add reliability harvester and 500/503 retry support 2026-03-23 20:37:08 -07:00
69 changed files with 841 additions and 3049 deletions
+12
View File
@@ -334,8 +334,20 @@ jobs:
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_MODEL: 'gemini-3-pro-preview'
# Disable Vitest internal retries to avoid double-retrying;
# custom retry logic is handled in evals/test-helper.ts
VITEST_RETRY: 0
run: 'npm run test:always_passing_evals'
- name: 'Upload Reliability Logs'
if: "always() && steps.check_evals.outputs.should_run == 'true'"
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'eval-logs-${{ github.run_id }}-${{ github.run_attempt }}'
path: 'evals/logs/api-reliability.jsonl'
retention-days: 7
e2e:
name: 'E2E'
if: |
+2
View File
@@ -61,6 +61,8 @@ jobs:
GEMINI_MODEL: '${{ matrix.model }}'
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
# Disable Vitest internal retries to avoid double-retrying;
# custom retry logic is handled in evals/test-helper.ts
VITEST_RETRY: 0
run: |
CMD="npm run test:all_evals"
+2 -2
View File
@@ -250,8 +250,8 @@ Slash commands provide meta-level control over the CLI itself.
- **`list`** or **`ls`**:
- **Description:** List configured MCP servers and tools. This is the
default action if no subcommand is specified.
- **`reload`**:
- **Description:** Reloads all MCP servers and re-discovers their available
- **`refresh`**:
- **Description:** Restarts all MCP servers and re-discovers their available
tools.
- **`schema`**:
- **Description:** List configured MCP servers and tools with descriptions
+33
View File
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { evalTest } from './test-helper.js';
/**
* These tests are designed to trigger the "Chaos Simulation" logic in evals/test-helper.ts.
* They simulate persistent 500 and 503 API errors to verify that the reliability
* pipeline correctly retries, logs the events, and eventually skips the tests
* instead of failing the CI.
*/
evalTest('ALWAYS_PASSES', {
name: 'Chaos 500 - API Internal Error Simulation',
prompt: 'Say hello',
assert: async (rig, result) => {
// This assertion should never be reached because the chaos simulation
// throws an error before rig.run().
throw new Error('Should have been caught by chaos simulation');
},
});
evalTest('ALWAYS_PASSES', {
name: 'Chaos 503 - API Unavailable Simulation',
prompt: 'Say hello',
assert: async (rig, result) => {
// This assertion should never be reached.
throw new Error('Should have been caught by chaos simulation');
},
});
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('Sandbox recovery', () => {
evalTest('USUALLY_PASSES', {
name: 'attempts to use additional_permissions when operation not permitted',
prompt:
'Run ./script.sh. It will fail with "Operation not permitted". When it does, you must retry running it by passing the appropriate additional_permissions.',
files: {
'script.sh':
'#!/bin/bash\necho "cat: /etc/shadow: Operation not permitted" >&2\nexit 1\n',
},
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
const shellCalls = toolLogs.filter(
(log) =>
log.toolRequest?.name === 'run_shell_command' &&
log.toolRequest?.args?.includes('script.sh'),
);
// The agent should have tried running the command.
expect(
shellCalls.length,
'Agent should have called run_shell_command',
).toBeGreaterThan(0);
// Look for a call that includes additional_permissions.
const hasAdditionalPermissions = shellCalls.some((call) => {
const args =
typeof call.toolRequest.args === 'string'
? JSON.parse(call.toolRequest.args)
: call.toolRequest.args;
return args.additional_permissions !== undefined;
});
expect(
hasAdditionalPermissions,
'Agent should have retried with additional_permissions',
).toBe(true);
},
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { internalEvalTest } from './test-helper.js';
import { TestRig } from '@google/gemini-cli-test-utils';
// Mock TestRig to control API success/failure
vi.mock('@google/gemini-cli-test-utils', () => {
return {
TestRig: vi.fn().mockImplementation(() => ({
setup: vi.fn(),
run: vi.fn(),
cleanup: vi.fn(),
readToolLogs: vi.fn().mockReturnValue([]),
_lastRunStderr: '',
})),
};
});
describe('evalTest reliability logic', () => {
const LOG_DIR = path.resolve(process.cwd(), 'evals/logs');
const RELIABILITY_LOG = path.join(LOG_DIR, 'api-reliability.jsonl');
beforeEach(() => {
vi.clearAllMocks();
if (fs.existsSync(RELIABILITY_LOG)) {
fs.unlinkSync(RELIABILITY_LOG);
}
});
afterEach(() => {
if (fs.existsSync(RELIABILITY_LOG)) {
fs.unlinkSync(RELIABILITY_LOG);
}
});
it('should retry 3 times on 500 INTERNAL error and then SKIP', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate permanent 500 error
mockRig.run.mockRejectedValue(new Error('status: INTERNAL - API Down'));
// Execute the test function directly
await internalEvalTest({
name: 'test-api-failure',
prompt: 'do something',
assert: async () => {},
});
// Verify retries: 1 initial + 3 retries = 4 setups/runs
expect(mockRig.run).toHaveBeenCalledTimes(4);
// Verify log content
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
expect(logContent.length).toBe(4);
const entries = logContent.map((line) => JSON.parse(line));
expect(entries[0].status).toBe('RETRY');
expect(entries[0].attempt).toBe(0);
expect(entries[3].status).toBe('SKIP');
expect(entries[3].attempt).toBe(3);
expect(entries[3].testName).toBe('test-api-failure');
});
it('should fail immediately on non-500 errors (like assertion failures)', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate a real logic error/bug
mockRig.run.mockResolvedValue('Success');
const assertError = new Error('Assertion failed: expected foo to be bar');
// Expect the test function to throw immediately
await expect(
internalEvalTest({
name: 'test-logic-failure',
prompt: 'do something',
assert: async () => {
throw assertError;
},
}),
).rejects.toThrow('Assertion failed');
// Verify NO retries: only 1 attempt
expect(mockRig.run).toHaveBeenCalledTimes(1);
// Verify NO reliability log was created (it's not an API error)
expect(fs.existsSync(RELIABILITY_LOG)).toBe(false);
});
it('should recover if a retry succeeds', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Fail once, then succeed
mockRig.run
.mockRejectedValueOnce(new Error('status: INTERNAL'))
.mockResolvedValueOnce('Success');
await internalEvalTest({
name: 'test-recovery',
prompt: 'do something',
assert: async () => {},
});
// Ran twice: initial (fail) + retry 1 (success)
expect(mockRig.run).toHaveBeenCalledTimes(2);
// Log should only have the one RETRY entry
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
expect(logContent.length).toBe(1);
expect(JSON.parse(logContent[0]).status).toBe('RETRY');
});
it('should retry 3 times on 503 UNAVAILABLE error and then SKIP', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate permanent 503 error
mockRig.run.mockRejectedValue(
new Error('status: UNAVAILABLE - Service Busy'),
);
await internalEvalTest({
name: 'test-api-503',
prompt: 'do something',
assert: async () => {},
});
expect(mockRig.run).toHaveBeenCalledTimes(4);
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
const entries = logContent.map((line) => JSON.parse(line));
expect(entries[0].errorCode).toBe('503');
expect(entries[3].status).toBe('SKIP');
});
it('should throw if an absolute path is used in files', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp');
if (!fs.existsSync(mockRig.testDir)) {
fs.mkdirSync(mockRig.testDir, { recursive: true });
}
try {
await expect(
internalEvalTest({
name: 'test-absolute-path',
prompt: 'do something',
files: {
'/etc/passwd': 'hacked',
},
assert: async () => {},
}),
).rejects.toThrow('Invalid file path in test case: /etc/passwd');
} finally {
if (fs.existsSync(mockRig.testDir)) {
fs.rmSync(mockRig.testDir, { recursive: true, force: true });
}
}
});
it('should throw if directory traversal is detected in files', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp');
// Create a mock test-dir
if (!fs.existsSync(mockRig.testDir)) {
fs.mkdirSync(mockRig.testDir, { recursive: true });
}
try {
await expect(
internalEvalTest({
name: 'test-traversal',
prompt: 'do something',
files: {
'../sensitive.txt': 'hacked',
},
assert: async () => {},
}),
).rejects.toThrow('Invalid file path in test case: ../sensitive.txt');
} finally {
if (fs.existsSync(mockRig.testDir)) {
fs.rmSync(mockRig.testDir, { recursive: true, force: true });
}
}
});
});
+182 -71
View File
@@ -39,87 +39,43 @@ export * from '@google/gemini-cli-test-utils';
export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES';
export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
const fn = async () => {
runEval(
policy,
evalCase.name,
() => internalEvalTest(evalCase),
evalCase.timeout,
);
}
export async function internalEvalTest(evalCase: EvalCase) {
const maxRetries = 3;
let attempt = 0;
while (attempt <= maxRetries) {
const rig = new TestRig();
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`);
const logFile = path.join(logDir, `${sanitizedName}.log`);
let isSuccess = false;
try {
rig.setup(evalCase.name, evalCase.params);
// Symlink node modules to reduce the amount of time needed to
// bootstrap test projects.
symlinkNodeModules(rig.testDir || '');
if (evalCase.files) {
const acknowledgedAgents: Record<string, Record<string, string>> = {};
const projectRoot = fs.realpathSync(rig.testDir!);
for (const [filePath, content] of Object.entries(evalCase.files)) {
const fullPath = path.join(rig.testDir!, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
// If it's an agent file, calculate hash for acknowledgement
if (
filePath.startsWith('.gemini/agents/') &&
filePath.endsWith('.md')
) {
const hash = crypto
.createHash('sha256')
.update(content)
.digest('hex');
try {
const agentDefs = await parseAgentMarkdown(fullPath, content);
if (agentDefs.length > 0) {
const agentName = agentDefs[0].name;
if (!acknowledgedAgents[projectRoot]) {
acknowledgedAgents[projectRoot] = {};
}
acknowledgedAgents[projectRoot][agentName] = hash;
}
} catch (error) {
console.warn(
`Failed to parse agent for test acknowledgement: ${filePath}`,
error,
);
}
}
}
// Write acknowledged_agents.json to the home directory
if (Object.keys(acknowledgedAgents).length > 0) {
const ackPath = path.join(
rig.homeDir!,
'.gemini',
'acknowledgments',
'agents.json',
);
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
fs.writeFileSync(
ackPath,
JSON.stringify(acknowledgedAgents, null, 2),
);
}
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
execSync('git init', execOptions);
execSync('git config user.email "test@example.com"', execOptions);
execSync('git config user.name "Test User"', execOptions);
// Temporarily disable the interactive editor and git pager
// to avoid hanging the tests. It seems the the agent isn't
// consistently honoring the instructions to avoid interactive
// commands.
execSync('git config core.editor "true"', execOptions);
execSync('git config core.pager "cat"', execOptions);
execSync('git config commit.gpgsign false', execOptions);
execSync('git add .', execOptions);
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
await setupTestFiles(rig, evalCase.files);
}
// --- CHAOS SIMULATION ---
if (evalCase.name.includes('Chaos')) {
const errorCode = evalCase.name.includes('503') ? '503' : '500';
throw new Error(
`status: INTERNAL - Simulated ${errorCode} error for testing pipeline`,
);
}
// ------------------------
symlinkNodeModules(rig.testDir || '');
// If messages are provided, write a session file so --resume can load it.
let sessionId: string | undefined;
if (evalCase.messages) {
@@ -188,6 +144,38 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
await evalCase.assert(rig, result);
isSuccess = true;
return; // Success! Exit the retry loop.
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorCode = getApiErrorCode(errorMessage);
if (errorCode) {
const status = attempt < maxRetries ? 'RETRY' : 'SKIP';
logReliabilityEvent(
evalCase.name,
attempt,
status,
errorCode,
errorMessage,
);
if (attempt < maxRetries) {
attempt++;
console.warn(
`[Eval] Attempt ${attempt} failed with ${errorCode} Error. Retrying...`,
);
await rig.cleanup();
continue; // Retry
}
console.warn(
`[Eval] '${evalCase.name}' failed after ${maxRetries} retries due to persistent API errors. Skipping failure to avoid blocking PR.`,
);
return; // Gracefully exit without failing the test
}
throw error; // Real failure
} finally {
if (isSuccess) {
await fs.promises.unlink(activityLogFile).catch((err) => {
@@ -206,9 +194,132 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
);
await rig.cleanup();
}
}
}
function getApiErrorCode(message: string): '500' | '503' | undefined {
if (
message.includes('status: UNAVAILABLE') ||
message.includes('code: 503') ||
message.includes('Service Unavailable') ||
message.includes('Simulated 503 error')
) {
return '503';
}
if (
message.includes('status: INTERNAL') ||
message.includes('code: 500') ||
message.includes('Internal error encountered')
) {
return '500';
}
return undefined;
}
/**
* Log reliability event for later harvesting.
*
* Note: Uses synchronous file I/O to ensure the log is persisted even if the
* test process is abruptly terminated by a timeout or CI crash. Performance
* impact is negligible compared to long-running evaluation tests.
*/
function logReliabilityEvent(
testName: string,
attempt: number,
status: 'RETRY' | 'SKIP',
errorCode: '500' | '503',
errorMessage: string,
) {
const reliabilityLog = {
timestamp: new Date().toISOString(),
testName,
model: process.env.GEMINI_MODEL || 'unknown',
attempt,
status,
errorCode,
error: errorMessage,
};
runEval(policy, evalCase.name, fn, evalCase.timeout);
try {
const relDir = path.resolve(process.cwd(), 'evals/logs');
fs.mkdirSync(relDir, { recursive: true });
fs.appendFileSync(
path.join(relDir, 'api-reliability.jsonl'),
JSON.stringify(reliabilityLog) + '\n',
);
} catch (logError) {
console.error('Failed to write reliability log:', logError);
}
}
/**
* Helper to setup test files and git repository.
*
* Note: While this is an async function (due to parseAgentMarkdown), it
* intentionally uses synchronous filesystem and child_process operations
* for simplicity and to ensure sequential environment preparation.
*/
async function setupTestFiles(rig: TestRig, files: Record<string, string>) {
const acknowledgedAgents: Record<string, Record<string, string>> = {};
const projectRoot = fs.realpathSync(rig.testDir!);
for (const [filePath, content] of Object.entries(files)) {
if (filePath.includes('..') || path.isAbsolute(filePath)) {
throw new Error(`Invalid file path in test case: ${filePath}`);
}
const fullPath = path.join(projectRoot, filePath);
if (!fullPath.startsWith(projectRoot)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
if (filePath.startsWith('.gemini/agents/') && filePath.endsWith('.md')) {
const hash = crypto.createHash('sha256').update(content).digest('hex');
try {
const agentDefs = await parseAgentMarkdown(fullPath, content);
if (agentDefs.length > 0) {
const agentName = agentDefs[0].name;
if (!acknowledgedAgents[projectRoot]) {
acknowledgedAgents[projectRoot] = {};
}
acknowledgedAgents[projectRoot][agentName] = hash;
}
} catch (error) {
console.warn(
`Failed to parse agent for test acknowledgement: ${filePath}`,
error,
);
}
}
}
if (Object.keys(acknowledgedAgents).length > 0) {
const ackPath = path.join(
rig.homeDir!,
'.gemini',
'acknowledgments',
'agents.json',
);
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
fs.writeFileSync(ackPath, JSON.stringify(acknowledgedAgents, null, 2));
}
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
execSync('git init --initial-branch=main', execOptions);
execSync('git config user.email "test@example.com"', execOptions);
execSync('git config user.name "Test User"', execOptions);
// Temporarily disable the interactive editor and git pager
// to avoid hanging the tests. It seems the the agent isn't
// consistently honoring the instructions to avoid interactive
// commands.
execSync('git config core.editor "true"', execOptions);
execSync('git config core.pager "cat"', execOptions);
execSync('git config commit.gpgsign false', execOptions);
execSync('git add .', execOptions);
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
}
/**
+1 -7
View File
@@ -183,17 +183,11 @@ describe('Policy Engine Headless Mode', () => {
responsesFile: 'policy-headless-shell-denied.responses',
promptCommand: ECHO_PROMPT,
policyContent: `
[[rule]]
toolName = "run_shell_command"
commandPrefix = "echo"
decision = "deny"
priority = 100
[[rule]]
toolName = "run_shell_command"
commandPrefix = "node"
decision = "allow"
priority = 90
priority = 100
`,
expectAllowed: false,
expectedDenialString: 'Tool execution denied by policy',
+3 -9
View File
@@ -58,18 +58,12 @@ function getDisallowedFileReadCommand(testFile: string): {
const quotedPath = `"${testFile}"`;
switch (shell) {
case 'powershell':
return {
command: `powershell -Command "Get-Content ${quotedPath}"`,
tool: 'powershell',
};
return { command: `Get-Content ${quotedPath}`, tool: 'Get-Content' };
case 'cmd':
return { command: `cmd /c type ${quotedPath}`, tool: 'cmd' };
return { command: `type ${quotedPath}`, tool: 'type' };
case 'bash':
default:
return {
command: `node -e "console.log(require('fs').readFileSync('${testFile}', 'utf8'))"`,
tool: 'node',
};
return { command: `cat ${quotedPath}`, tool: 'cat' };
}
}
+3 -31
View File
@@ -486,8 +486,7 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1490,7 +1489,6 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -2197,7 +2195,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2378,7 +2375,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2428,7 +2424,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2803,7 +2798,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2837,7 +2831,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2892,7 +2885,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4129,7 +4121,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4404,7 +4395,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5278,7 +5268,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7413,8 +7402,7 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7998,7 +7986,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8516,7 +8503,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9829,7 +9815,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10108,7 +10093,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
@@ -13866,7 +13850,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13877,7 +13860,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -16027,7 +16009,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16250,8 +16231,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16259,7 +16239,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16425,7 +16404,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16648,7 +16626,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16762,7 +16739,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16775,7 +16751,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17423,7 +17398,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17867,7 +17841,6 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -17971,7 +17944,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
-1
View File
@@ -1625,7 +1625,6 @@ function toPermissionOptions(
case 'info':
case 'ask_user':
case 'exit_plan_mode':
case 'sandbox_expansion':
break;
default: {
const unreachable: never = confirmation;
+1 -41
View File
@@ -131,43 +131,12 @@ class MockExtensionManager extends ExtensionLoader {
};
}
// Mock terminalCapabilityManager to avoid terminal setup prompt during tests
vi.mock('../ui/utils/terminalCapabilityManager.js', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../ui/utils/terminalCapabilityManager.js')
>();
const mockedManager = Object.create(
Object.getPrototypeOf(actual.terminalCapabilityManager),
);
Object.assign(mockedManager, actual.terminalCapabilityManager, {
isKittyProtocolEnabled: () => true,
enableKittyProtocol: vi.fn(),
disableKittyProtocol: vi.fn(),
enableSupportedModes: vi.fn(),
disableSupportedModes: vi.fn(),
onSupportChange: vi.fn(),
offSupportChange: vi.fn(),
});
return {
...actual,
terminalCapabilityManager: mockedManager,
};
});
vi.mock('../ui/components/GeminiSpinner.js', async () => {
const React = await import('react');
const { Text } = await import('ink');
return {
GeminiSpinner: () => React.createElement(Text, null, '...'),
};
});
// Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode.
vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => {
const React = await import('react');
const { Text } = await import('ink');
return {
GeminiSpinner: () => React.createElement(Text, null, '...'),
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
@@ -234,9 +203,6 @@ export class AppRig {
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
// Disable the terminal setup prompt globally for AppRig tests.
persistentStateMock.set('terminalSetupPromptShown', true);
const approvalMode =
this.options.configOverrides?.approvalMode ?? ApprovalMode.DEFAULT;
const policyEngineConfig = await createPolicyEngineConfig(
@@ -314,10 +280,6 @@ export class AppRig {
enabled: false,
hasSeenNudge: true,
},
ui: {
hasSeenTerminalSetupPrompt: true,
showSpinner: false,
},
},
originalSettings: {},
},
@@ -337,8 +299,6 @@ export class AppRig {
},
ui: {
useAlternateBuffer: false,
hasSeenTerminalSetupPrompt: true,
showSpinner: false,
},
},
});
+8 -16
View File
@@ -340,11 +340,9 @@ class XtermStdin extends EventEmitter {
}
write = (data: string) => {
act(() => {
this.data = data;
this.emit('readable');
this.emit('data', data);
});
this.data = data;
this.emit('readable');
this.emit('data', data);
};
setEncoding() {}
@@ -800,17 +798,11 @@ export async function renderHook<Result, Props>(
let waitUntilReady: () => Promise<void> = async () => {};
let generateSvg: () => string = () => '';
let renderResult!: Omit<
RenderInstance,
'capturedOverflowState' | 'capturedOverflowActions'
>;
await act(async () => {
renderResult = await render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
});
const renderResult = await render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
@@ -4,61 +4,78 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { AuthInProgress } from './AuthInProgress.js';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from '../../test-utils/render.js';
import { act } from 'react';
import { Text } from 'ink';
import { useKeypress } from '../hooks/useKeypress.js';
import { AuthInProgress } from './AuthInProgress.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const mockedUseKeypress = useKeypress as Mock;
vi.mock('../components/BrailleAnimation.js', () => ({
BrailleAnimation: () => <Text>[Spinner]</Text>,
vi.mock('../components/CliSpinner.js', () => ({
CliSpinner: () => '[Spinner]',
}));
describe('AuthInProgress', () => {
const onTimeout = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(debugLogger.error).mockImplementation((...args) => {
if (
// eslint-disable-next-line no-restricted-syntax
typeof args[0] === 'string' &&
args[0].includes('was not wrapped in act')
) {
return;
}
});
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders initial state with spinner', async () => {
const onTimeout = vi.fn();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for authentication...');
expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel');
unmount();
});
it('calls onTimeout when ESC is pressed', async () => {
const onTimeout = vi.fn();
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await render(
<AuthInProgress onTimeout={onTimeout} />,
);
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ name: 'escape' });
keypressHandler({ name: 'escape' } as unknown as Key);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(onTimeout).toHaveBeenCalled();
@@ -66,32 +83,28 @@ describe('AuthInProgress', () => {
});
it('calls onTimeout when Ctrl+C is pressed', async () => {
const onTimeout = vi.fn();
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await render(
<AuthInProgress onTimeout={onTimeout} />,
);
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
keypressHandler({ ctrl: true, name: 'c' });
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const onTimeout = vi.fn();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
});
// Wait for state updates to propagate
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
@@ -100,18 +113,15 @@ describe('AuthInProgress', () => {
});
it('clears timer on unmount', async () => {
const onTimeout = vi.fn();
const { unmount, waitUntilReady } = await renderWithProviders(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const { unmount } = await render(<AuthInProgress onTimeout={onTimeout} />);
unmount();
await act(async () => {
unmount();
});
await act(async () => {
vi.advanceTimersByTime(180000);
});
expect(onTimeout).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -7,7 +7,7 @@
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { BrailleAnimation } from '../components/BrailleAnimation.js';
import { CliSpinner } from '../components/CliSpinner.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -53,8 +53,8 @@ export function AuthInProgress({
) : (
<Box>
<Text>
<BrailleAnimation /> Waiting for authentication... (Press Esc or
Ctrl+C to cancel)
<CliSpinner type="dots" /> Waiting for authentication... (Press Esc
or Ctrl+C to cancel)
</Text>
</Box>
)}
@@ -5,7 +5,6 @@
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { BannedAccountDialog } from './BannedAccountDialog.js';
@@ -148,9 +147,7 @@ describe('BannedAccountDialog', () => {
/>,
);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect('open_form');
});
await onSelect('open_form');
expect(mockedOpenBrowser).toHaveBeenCalledWith(
'https://example.com/appeal',
);
@@ -168,9 +165,7 @@ describe('BannedAccountDialog', () => {
/>,
);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect('open_form');
});
onSelect('open_form');
await waitFor(() => {
expect(lastFrame()).toContain('Please open this URL in a browser');
});
@@ -187,9 +182,7 @@ describe('BannedAccountDialog', () => {
/>,
);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect('exit');
});
await onSelect('exit');
expect(mockedRunExitCleanup).toHaveBeenCalled();
expect(onExit).toHaveBeenCalled();
unmount();
@@ -204,9 +197,7 @@ describe('BannedAccountDialog', () => {
/>,
);
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
await onSelect('change_auth');
});
onSelect('change_auth');
expect(onChangeAuth).toHaveBeenCalled();
expect(onExit).not.toHaveBeenCalled();
unmount();
@@ -221,11 +212,8 @@ describe('BannedAccountDialog', () => {
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
let result: boolean;
await act(async () => {
result = keypressHandler({ name: 'escape' });
});
expect(result!).toBe(true);
const result = keypressHandler({ name: 'escape' });
expect(result).toBe(true);
unmount();
});
@@ -1,103 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { BrailleAnimation } from './BrailleAnimation.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { createMockSettings } from '../../test-utils/settings.js';
describe('BrailleAnimation', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should grow from length 1 to 5 and match verification frames', async () => {
const settings = createMockSettings({
merged: {
ui: {
showSpinner: true,
},
},
});
// renderWithProviders will call waitUntilReady once.
const renderResult = await renderWithProviders(
<BrailleAnimation interval={100} variant="Long" animate={true} />,
{ settings },
);
const { lastFrameRaw } = renderResult;
const verificationFrames = [
'⢎⠁', // 0
'⠎⠑', // 1
'⠊⠱', // 2
'⠈⡱', // 3
'⢀⡱', // 4
'⢄⡰', // 5
'⢆⡠', // 6
'⢎⡀', // 7
];
// Advance 16 ticks to reach length 5.
for (let i = 0; i < 16; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
}
// Now check the sequence.
let current = lastFrameRaw();
let startIdx = verificationFrames.findIndex((f) => current.includes(f));
if (startIdx === -1) {
for (let attempt = 0; attempt < 8; attempt++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
current = lastFrameRaw();
startIdx = verificationFrames.findIndex((f) => current.includes(f));
if (startIdx !== -1) break;
}
}
expect(
startIdx,
`Should have reached length 5 frames. Current: ${current}`,
).not.toBe(-1);
// Verify the sequence.
for (let i = 0; i < 8; i++) {
const idx = (startIdx + i) % 8;
expect(lastFrameRaw()).toContain(verificationFrames[idx]);
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
}
act(() => {
renderResult.unmount();
});
});
it('should support "Composite" variant with dynamic lengths', async () => {
const renderResult = await renderWithProviders(
<BrailleAnimation interval={100} variant="Composite" animate={true} />,
);
// Just verify it renders something
expect(renderResult.lastFrameRaw()).toBeTruthy();
act(() => {
renderResult.unmount();
});
});
});
@@ -1,116 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Text } from 'ink';
import { debugState } from '../debug.js';
import { useSettings } from '../contexts/SettingsContext.js';
// Dot bitmasks and character assignments for the 4x4 circle perimeter
// Char 0 corresponds to the first Braille character (c1), Char 1 to the second (c2).
const DOTS = [
{ char: 1, bit: 1 }, // Dot 1 (c2)
{ char: 1, bit: 16 }, // Dot 5 (c2)
{ char: 1, bit: 32 }, // Dot 6 (c2)
{ char: 1, bit: 64 }, // Dot 7 (c2)
{ char: 0, bit: 128 }, // Dot 8 (c1)
{ char: 0, bit: 4 }, // Dot 3 (c1)
{ char: 0, bit: 2 }, // Dot 2 (c1)
{ char: 0, bit: 8 }, // Dot 4 (c1)
];
const COMPOSITE_SEQUENCE = [2, 3, 4, 5, 4, 3];
export type BrailleVariant =
| 'Static'
| 'Small'
| 'Medium'
| 'Long'
| 'Composite';
interface BrailleAnimationProps {
variant?: BrailleVariant;
interval?: number;
animate?: boolean;
}
/**
* Braille Snake Animation Component
*
* Variants match the prototype style:
* - 'Static': Fixed frame '⢎⡱'
* - 'Small': Fixed length 2
* - 'Medium': Fixed length 3
* - 'Long': Phased growth (len 1, 3, 5) changing every 8 ticks
* - 'Composite': Dynamic length [2, 3, 4, 5, 4, 3] changing every 8 ticks
*/
export const BrailleAnimation: React.FC<BrailleAnimationProps> = ({
variant = 'Composite',
interval = 80,
animate = !process.env['VITEST'],
}) => {
console.error(`DEBUG: BrailleAnimation animate=${animate} VITEST=${process.env['VITEST']} NODE_ENV=${process.env['NODE_ENV']}`); // eslint-disable-line no-console
const [tick, setTick] = useState(0);
const settings = useSettings();
const shouldShow = settings.merged.ui?.showSpinner !== false;
useEffect(() => {
if (!shouldShow || !animate || variant === 'Static') return;
debugState.debugNumAnimatedComponents++;
const timer = setInterval(() => {
setTick((t) => t + 1);
}, interval);
return () => {
debugState.debugNumAnimatedComponents--;
clearInterval(timer);
};
}, [interval, shouldShow, animate, variant]);
const getLength = () => {
const cycle = Math.floor(tick / 8);
switch (variant) {
case 'Small':
return 2;
case 'Medium':
return 3;
case 'Long':
return cycle === 0 ? 1 : cycle === 1 ? 3 : 5;
case 'Composite':
return COMPOSITE_SEQUENCE[cycle % COMPOSITE_SEQUENCE.length];
case 'Static':
return 0;
default:
return 5;
}
};
const getFrame = () => {
if (variant === 'Static') {
return '⢎⡱';
}
const length = getLength();
let [c1, c2] = [0, 0];
const head = tick % 8;
for (let i = 0; i < length; i++) {
const { char, bit } = DOTS[(head - i + 80) % 8];
char === 0 ? (c1 |= bit) : (c2 |= bit);
}
return String.fromCharCode(0x2800 + c1) + String.fromCharCode(0x2800 + c2);
};
if (!shouldShow) {
return null;
}
return <Text>{getFrame()}</Text>;
};
@@ -15,13 +15,14 @@ import {
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
import { GeminiSpinner } from './GeminiSpinner.js';
interface GeminiRespondingSpinnerProps {
/**
* Optional string or component to display when not in Responding state.
* Optional string to display when not in Responding state.
* If not provided and not Responding, renders null.
*/
nonRespondingDisplay?: React.ReactNode;
spinnerType?: SpinnerName | 'dynamic';
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
/**
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
* even if the state is Responding.
@@ -34,7 +35,7 @@ export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({
nonRespondingDisplay,
spinnerType = 'dynamic',
spinnerType = 'dots',
isHookActive = false,
color,
}) => {
@@ -53,14 +54,10 @@ export const GeminiRespondingSpinner: React.FC<
}
if (nonRespondingDisplay) {
if (isScreenReaderEnabled) {
return <Text>{SCREEN_READER_LOADING}</Text>;
}
return typeof nonRespondingDisplay === 'string' ? (
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<>{nonRespondingDisplay}</>
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
);
}
@@ -1,53 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { GeminiSpinner } from './GeminiSpinner.js';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import { act } from 'react';
// Mock components to simplify testing
vi.mock('./BrailleAnimation.js', () => ({
BrailleAnimation: ({ variant }: { variant: string }) => (
<Text>BrailleAnimation-{variant}</Text>
),
GEMINI_SPINNER: { interval: 80, frames: [] },
}));
vi.mock('./CliSpinner.js', () => ({
CliSpinner: ({ type }: { type: string }) => <Text>CliSpinner-{type}</Text>,
}));
describe('GeminiSpinner', () => {
it('renders BrailleAnimation with "Composite" variant by default', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<GeminiSpinner />,
);
await waitUntilReady();
expect(lastFrame()).toContain('BrailleAnimation-Composite');
act(() => {
unmount();
});
});
it('renders CliSpinner when a specific spinnerType string is provided', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<GeminiSpinner spinnerType="dots" />,
);
await waitUntilReady();
expect(lastFrame()).toContain('CliSpinner-dots');
act(() => {
unmount();
});
});
it('renders screen reader text when screen reader is enabled', async () => {
// Note: useIsScreenReaderEnabled is used in GeminiSpinner
// We would need to mock it if we wanted to test this explicitly,
// but the default is false in our test environment.
});
});
@@ -11,23 +11,19 @@ import { CliSpinner } from './CliSpinner.js';
import type { SpinnerName } from 'cli-spinners';
import { Colors } from '../colors.js';
import tinygradient from 'tinygradient';
import { BrailleAnimation } from './BrailleAnimation.js';
import { useSettings } from '../contexts/SettingsContext.js';
const COLOR_CYCLE_DURATION_MS = 4000;
interface GeminiSpinnerProps {
spinnerType?: SpinnerName | 'dynamic';
spinnerType?: SpinnerName;
altText?: string;
}
export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
spinnerType = 'dynamic',
spinnerType = 'dots',
altText,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const settings = useSettings();
const shouldShow = settings.merged.ui?.showSpinner !== false;
const [time, setTime] = useState(0);
const googleGradient = useMemo(() => {
@@ -43,7 +39,7 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
}, []);
useEffect(() => {
if (isScreenReaderEnabled || !shouldShow) {
if (isScreenReaderEnabled) {
return;
}
@@ -52,22 +48,16 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
}, 30); // ~33fps for smooth color transitions
return () => clearInterval(interval);
}, [isScreenReaderEnabled, shouldShow]);
}, [isScreenReaderEnabled]);
const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
const currentColor = googleGradient.rgbAt(progress).toHexString();
const renderSpinner = () => {
if (spinnerType === 'dynamic') {
return <BrailleAnimation variant="Composite" />;
}
return <CliSpinner type={spinnerType} />;
};
return isScreenReaderEnabled ? (
<Text>{altText}</Text>
) : (
<Text color={currentColor}>{renderSpinner()}</Text>
<Text color={currentColor}>
<CliSpinner type={spinnerType} />
</Text>
);
};
@@ -18,13 +18,13 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: React.ReactNode;
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
} else if (nonRespondingDisplay) {
return <>{nonRespondingDisplay}</>;
return <Text>{nonRespondingDisplay}</Text>;
}
return null;
},
@@ -86,7 +86,7 @@ describe('<LoadingIndicator />', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('⢎⡱'); // Static char for WaitingForConfirmation
expect(output).toContain(''); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
@@ -208,7 +208,7 @@ describe('<LoadingIndicator />', () => {
});
await waitUntilReady();
output = lastFrame();
expect(output).toContain('⢎⡱');
expect(output).toContain('');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
@@ -11,7 +11,6 @@ import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { BrailleAnimation } from './BrailleAnimation.js';
import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
@@ -97,13 +96,9 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<GeminiRespondingSpinner
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation ? (
<Text color={theme.text.primary}>
<BrailleAnimation variant="Static" />
</Text>
) : (
''
))
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: '')
}
isHookActive={isHookActive}
/>
@@ -145,13 +140,9 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<GeminiRespondingSpinner
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation ? (
<Text color={theme.text.primary}>
<BrailleAnimation variant="Static" />
</Text>
) : (
''
))
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: '')
}
isHookActive={isHookActive}
/>
@@ -47,7 +47,6 @@ describe('ToolConfirmationQueue', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getApprovalMode: () => 'default',
getDisableAlwaysAllow: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,
@@ -6,7 +6,7 @@
import { Box, Text } from 'ink';
import type { CompressionProps } from '../../types.js';
import { BrailleAnimation } from '../BrailleAnimation.js';
import { CliSpinner } from '../CliSpinner.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { CompressionStatus } from '@google/gemini-cli-core';
@@ -61,7 +61,7 @@ export function CompressionMessage({
<Box flexDirection="row">
<Box marginRight={1}>
{isPending ? (
<BrailleAnimation />
<CliSpinner type="dots" />
) : (
<Text color={theme.text.accent}></Text>
)}
@@ -22,7 +22,6 @@ describe('ToolConfirmationMessage Redirection', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
it('should display redirection warning and tip for redirected commands', async () => {
@@ -10,8 +10,8 @@ import type { SubagentProgress } from '@google/gemini-cli-core';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Text } from 'ink';
vi.mock('../BrailleAnimation.js', () => ({
BrailleAnimation: () => <Text></Text>,
vi.mock('ink-spinner', () => ({
default: () => <Text></Text>,
}));
describe('<SubagentProgressDisplay />', () => {
@@ -7,15 +7,15 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import Spinner from 'ink-spinner';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { BrailleAnimation } from '../BrailleAnimation.js';
import {
type SubagentProgress,
type SubagentActivityItem,
safeJsonToMarkdown,
import type {
SubagentProgress,
SubagentActivityItem,
} from '@google/gemini-cli-core';
import { TOOL_STATUS } from '../../constants.js';
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
import { safeJsonToMarkdown } from '@google/gemini-cli-core';
export interface SubagentProgressDisplayProps {
progress: SubagentProgress;
@@ -106,7 +106,7 @@ export const SubagentProgressDisplay: React.FC<
} else if (item.type === 'tool_call') {
const statusSymbol =
item.status === 'running' ? (
<BrailleAnimation />
<Spinner type="dots" />
) : item.status === 'completed' ? (
<Text color={theme.status.success}>{TOOL_STATUS.SUCCESS}</Text>
) : item.status === 'cancelled' ? (
@@ -40,7 +40,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
it('should not display urls if prompt and url are the same', async () => {
@@ -325,7 +324,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -347,7 +345,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => false,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
@@ -383,7 +380,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -410,7 +406,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -452,7 +447,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -479,7 +473,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -506,7 +499,6 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -15,7 +15,6 @@ import {
type ToolConfirmationPayload,
ToolConfirmationOutcome,
type EditorType,
ApprovalMode,
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
@@ -315,31 +314,6 @@ export const ToolConfirmationMessage: React.FC<
key: 'No, suggest changes (esc)',
});
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
options.push({
label: 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
});
if (isTrustedFolder) {
options.push({
label: 'Allow for this session',
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Allow for this session',
});
if (allowPermanentApproval) {
options.push({
label: 'Allow for all future sessions',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Allow for all future sessions',
});
}
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
key: 'No, suggest changes (esc)',
});
} else if (confirmationDetails.type === 'exec') {
options.push({
label: 'Allow once',
@@ -572,8 +546,6 @@ export const ToolConfirmationMessage: React.FC<
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
question = `Allow sandbox expansion for: '${sanitizeForDisplay(confirmationDetails.rootCommand)}'?`;
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
@@ -601,52 +573,6 @@ export const ToolConfirmationMessage: React.FC<
/>
);
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
const { additionalPermissions } = confirmationDetails;
const readPaths = additionalPermissions?.fileSystem?.read || [];
const writePaths = additionalPermissions?.fileSystem?.write || [];
const network = additionalPermissions?.network;
bodyContent = (
<Box flexDirection="column" padding={1}>
<Text color={theme.text.secondary} italic>
The agent is requesting additional sandbox permissions to execute
this command:
</Text>
<Box paddingY={1}>
<Text color={theme.text.secondary}>
{sanitizeForDisplay(confirmationDetails.command)}
</Text>
</Box>
{network && (
<Box>
<Text color={theme.status.warning}> Network Access</Text>
</Box>
)}
{readPaths.length > 0 && (
<Box flexDirection="column">
<Text color={theme.status.success}> Read Access:</Text>
{readPaths.map((p, i) => (
<Text key={i} color={theme.text.secondary}>
{' '}
{sanitizeForDisplay(p)}
</Text>
))}
</Box>
)}
{writePaths.length > 0 && (
<Box flexDirection="column">
<Text color={theme.status.error}> Write Access:</Text>
{writePaths.map((p, i) => (
<Text key={i} color={theme.text.secondary}>
{' '}
{sanitizeForDisplay(p)}
</Text>
))}
</Box>
)}
</Box>
);
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
@@ -661,8 +587,7 @@ export const ToolConfirmationMessage: React.FC<
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
const isAutoEdit = config.getApprovalMode() === ApprovalMode.AUTO_EDIT;
if (containsRedirection && !isAutoEdit) {
if (containsRedirection) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const noteLength =
@@ -812,7 +737,6 @@ export const ToolConfirmationMessage: React.FC<
isTrustedFolder,
allowPermanentApproval,
settings,
config,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
@@ -6,6 +6,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import {
debugLogger,
spawnAsync,
@@ -15,7 +16,6 @@ import {
import { useKeypress } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { BrailleAnimation } from '../BrailleAnimation.js';
interface Issue {
number: number;
@@ -725,7 +725,7 @@ Return a JSON object with:
if (state.status === 'loading') {
return (
<Box>
<BrailleAnimation />
<Spinner type="dots" />
<Text> {state.message}</Text>
</Box>
);
@@ -921,7 +921,7 @@ Return a JSON object with:
justifyContent="center"
height={VISIBLE_CANDIDATES * 2}
>
<BrailleAnimation />
<Spinner type="dots" />
<Text> {state.message}</Text>
</Box>
) : (
@@ -6,6 +6,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import {
debugLogger,
spawnAsync,
@@ -17,7 +18,6 @@ import { Command } from '../../key/keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
import { useTextBuffer } from '../shared/text-buffer.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { BrailleAnimation } from '../BrailleAnimation.js';
interface Issue {
number: number;
@@ -448,7 +448,7 @@ Return a JSON object with:
if (state.status === 'loading') {
return (
<Box>
<BrailleAnimation />
<Spinner type="dots" />
<Text> {state.message}</Text>
</Box>
);
@@ -521,7 +521,7 @@ Return a JSON object with:
if (state.status === 'analyzing') {
return (
<Box>
<BrailleAnimation />
<Spinner type="dots" />
<Text> {state.message}</Text>
</Box>
);
@@ -610,7 +610,7 @@ Return a JSON object with:
>
{state.status === 'analyzing' ? (
<Box>
<BrailleAnimation />
<Spinner type="dots" />
<Text> Analyzing issue with Gemini...</Text>
</Box>
) : analysis ? (
@@ -100,10 +100,9 @@ describe('useShellHistory', () => {
it('should initialize and read the history file from the correct path', async () => {
mockedFs.readFile.mockResolvedValue('cmd1\ncmd2');
const { result, unmount, waitUntilReady } = await renderHook(() =>
const { result, unmount } = await renderHook(() =>
useShellHistory(MOCKED_PROJECT_ROOT),
);
await waitUntilReady();
await waitFor(() => {
expect(mockedFs.readFile).toHaveBeenCalledWith(
@@ -128,10 +127,9 @@ describe('useShellHistory', () => {
error.code = 'ENOENT';
mockedFs.readFile.mockRejectedValue(error);
const { result, unmount, waitUntilReady } = await renderHook(() =>
const { result, unmount } = await renderHook(() =>
useShellHistory(MOCKED_PROJECT_ROOT),
);
await waitUntilReady();
await waitFor(() => {
expect(mockedFs.readFile).toHaveBeenCalled();
@@ -148,10 +146,9 @@ describe('useShellHistory', () => {
});
it('should add a command and write to the history file', async () => {
const { result, unmount, waitUntilReady } = await renderHook(() =>
const { result, unmount } = await renderHook(() =>
useShellHistory(MOCKED_PROJECT_ROOT),
);
await waitUntilReady();
await waitFor(() => {
expect(mockedFs.readFile).toHaveBeenCalled();
@@ -182,10 +179,9 @@ describe('useShellHistory', () => {
it('should navigate history correctly with previous/next commands', async () => {
mockedFs.readFile.mockResolvedValue('cmd1\ncmd2\ncmd3');
const { result, unmount, waitUntilReady } = await renderHook(() =>
const { result, unmount } = await renderHook(() =>
useShellHistory(MOCKED_PROJECT_ROOT),
);
await waitUntilReady();
// Wait for history to be loaded: ['cmd3', 'cmd2', 'cmd1']
await waitFor(() => {
@@ -235,10 +231,9 @@ describe('useShellHistory', () => {
});
it('should not add empty or whitespace-only commands to history', async () => {
const { result, unmount, waitUntilReady } = await renderHook(() =>
const { result, unmount } = await renderHook(() =>
useShellHistory(MOCKED_PROJECT_ROOT),
);
await waitUntilReady();
await waitFor(() => {
expect(mockedFs.readFile).toHaveBeenCalled();
@@ -257,11 +252,9 @@ describe('useShellHistory', () => {
const oldCommands = Array.from({ length: 120 }, (_, i) => `old_cmd_${i}`);
mockedFs.readFile.mockResolvedValue(oldCommands.join('\n'));
const { result, unmount, waitUntilReady } = await renderHook(() =>
const { result, unmount } = await renderHook(() =>
useShellHistory(MOCKED_PROJECT_ROOT),
);
await waitUntilReady();
await waitFor(() => {
expect(mockedFs.readFile).toHaveBeenCalled();
});
@@ -291,10 +284,9 @@ describe('useShellHistory', () => {
it('should move an existing command to the top when re-added', async () => {
mockedFs.readFile.mockResolvedValue('cmd1\ncmd2\ncmd3');
const { result, unmount, waitUntilReady } = await renderHook(() =>
const { result, unmount } = await renderHook(() =>
useShellHistory(MOCKED_PROJECT_ROOT),
);
await waitUntilReady();
// Initial state: ['cmd3', 'cmd2', 'cmd1']
await waitFor(() => {
+6 -57
View File
@@ -6,7 +6,6 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { inspect } from 'node:util';
import process from 'node:process';
import { z } from 'zod';
@@ -731,8 +730,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly telemetrySettings: TelemetrySettings;
private readonly usageStatisticsEnabled: boolean;
private _geminiClient!: GeminiClient;
private _sandboxManager: SandboxManager;
private readonly _sandboxPolicyManager: SandboxPolicyManager;
private readonly _sandboxManager: SandboxManager;
private baseLlmClient!: BaseLlmClient;
private localLiteRtLmClient?: LocalLiteRtLmClient;
private modelRouterService: ModelRouterService;
@@ -907,14 +905,14 @@ export class Config implements McpContext, AgentLoopContext {
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.sandbox = params.sandbox
? {
enabled: params.sandbox.enabled || params.toolSandboxing || false,
enabled: params.sandbox.enabled ?? false,
allowedPaths: params.sandbox.allowedPaths ?? [],
networkAccess: params.sandbox.networkAccess ?? false,
command: params.sandbox.command,
image: params.sandbox.image,
}
: {
enabled: params.toolSandboxing || false,
enabled: false,
allowedPaths: [],
networkAccess: false,
};
@@ -933,30 +931,6 @@ export class Config implements McpContext, AgentLoopContext {
this.fileSystemService = new StandardFileSystemService();
}
this._sandboxPolicyManager = new SandboxPolicyManager();
const initialApprovalMode =
params.approvalMode ??
params.policyEngineConfig?.approvalMode ??
'default';
this._sandboxManager = createSandboxManager(
this.sandbox,
params.targetDir,
this._sandboxPolicyManager,
initialApprovalMode,
);
if (
!(this._sandboxManager instanceof NoopSandboxManager) &&
this.sandbox?.enabled
) {
this.fileSystemService = new SandboxedFileSystemService(
this._sandboxManager,
params.targetDir,
);
} else {
this.fileSystemService = new StandardFileSystemService();
}
this.targetDir = path.resolve(params.targetDir);
this.folderTrust = params.folderTrust ?? false;
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
@@ -1186,19 +1160,12 @@ export class Config implements McpContext, AgentLoopContext {
params.policyUpdateConfirmationRequest;
this.disableAlwaysAllow = params.disableAlwaysAllow ?? false;
const engineApprovalMode =
params.approvalMode ??
params.policyEngineConfig?.approvalMode ??
ApprovalMode.DEFAULT;
this.policyEngine = new PolicyEngine(
{
...params.policyEngineConfig,
approvalMode: engineApprovalMode,
approvalMode:
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
disableAlwaysAllow: this.disableAlwaysAllow,
toolSandboxEnabled: this.getSandboxEnabled(),
sandboxApprovedTools:
this.sandboxPolicyManager?.getModeConfig(engineApprovalMode)
?.approvedTools ?? [],
},
checkerRunner,
);
@@ -1593,20 +1560,6 @@ export class Config implements McpContext, AgentLoopContext {
return this._geminiClient;
}
private refreshSandboxManager(): void {
this._sandboxManager = createSandboxManager(
this.sandbox,
this.targetDir,
this._sandboxPolicyManager,
this.getApprovalMode(),
);
this.shellExecutionConfig.sandboxManager = this._sandboxManager;
}
get sandboxPolicyManager() {
return this._sandboxPolicyManager;
}
get sandboxManager(): SandboxManager {
return this._sandboxManager;
}
@@ -2386,11 +2339,7 @@ export class Config implements McpContext, AgentLoopContext {
);
}
this.policyEngine.setApprovalMode(
mode,
this.sandboxPolicyManager?.getModeConfig(mode)?.approvedTools ?? [],
);
this.refreshSandboxManager();
this.policyEngine.setApprovalMode(mode);
const isPlanModeTransition =
currentMode !== mode &&
@@ -22,7 +22,6 @@ vi.mock('../confirmation-bus/message-bus.js', () => ({
vi.mock('../policy/policy-engine.js', () => ({
PolicyEngine: vi.fn().mockImplementation(() => ({
getExcludedTools: vi.fn().mockReturnValue(new Set()),
getApprovalMode: vi.fn().mockReturnValue('yolo'),
})),
}));
vi.mock('../skills/skillManager.js', () => ({
@@ -11,7 +11,6 @@ import type {
DiffStat,
} from '../tools/tools.js';
import type { ToolCall } from '../scheduler/types.js';
import type { SandboxPermissions } from '../services/sandboxManager.js';
export enum MessageBusType {
TOOL_CONFIRMATION_REQUEST = 'tool-confirmation-request',
@@ -79,14 +78,6 @@ export interface ToolConfirmationResponse {
* Data-only versions of ToolCallConfirmationDetails for bus transmission.
*/
export type SerializableConfirmationDetails =
| {
type: 'sandbox_expansion';
title: string;
command: string;
rootCommand: string;
additionalPermissions: SandboxPermissions;
systemMessage?: string;
}
| {
type: 'info';
title: string;
-2
View File
@@ -89,7 +89,6 @@ describe('Core System Prompt (prompts.ts)', () => {
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
@@ -419,7 +418,6 @@ describe('Core System Prompt (prompts.ts)', () => {
const testConfig = {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
},
@@ -1,19 +0,0 @@
[modes.plan]
network = false
readonly = true
approvedTools = []
allowOverrides = false
[modes.default]
network = false
readonly = true
approvedTools = []
allowOverrides = true
[modes.accepting_edits]
network = false
readonly = false
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo']
allowOverrides = true
[commands]
@@ -329,11 +329,7 @@ describe('PolicyEngine', () => {
);
// Switch to autoEdit mode
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.AUTO_EDIT,
toolSandboxEnabled: true,
});
engine.setApprovalMode(ApprovalMode.AUTO_EDIT);
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
PolicyDecision.ALLOW,
);
@@ -1431,14 +1427,14 @@ describe('PolicyEngine', () => {
engine = new PolicyEngine({ rules });
// Atomic command "unknown_command" matches the wildcard rule (ASK_USER).
// Atomic command "whoami" matches the wildcard rule (ASK_USER).
// It should NOT be upgraded to ALLOW.
expect(
(
await engine.check(
{
name: 'run_shell_command',
args: { command: 'unknown_command' },
args: { command: 'whoami' },
},
undefined,
)
@@ -1576,7 +1572,7 @@ describe('PolicyEngine', () => {
},
];
engine = new PolicyEngine({ rules, toolSandboxEnabled: true });
engine = new PolicyEngine({ rules });
engine.setApprovalMode(ApprovalMode.AUTO_EDIT);
const result = await engine.check(
+9 -89
View File
@@ -5,11 +5,6 @@
*/
import { type FunctionCall } from '@google/genai';
import {
isDangerousCommand,
isKnownSafeCommand,
} from '../sandbox/macos/commandSafety.js';
import { parse as shellParse } from 'shell-quote';
import {
PolicyDecision,
type PolicyEngineConfig,
@@ -197,8 +192,6 @@ export class PolicyEngine {
private readonly disableAlwaysAllow: boolean;
private readonly checkerRunner?: CheckerRunner;
private approvalMode: ApprovalMode;
private toolSandboxEnabled: boolean;
private sandboxApprovedTools: string[];
constructor(config: PolicyEngineConfig = {}, checkerRunner?: CheckerRunner) {
this.rules = (config.rules ?? []).sort(
@@ -249,18 +242,13 @@ export class PolicyEngine {
this.disableAlwaysAllow = config.disableAlwaysAllow ?? false;
this.checkerRunner = checkerRunner;
this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT;
this.toolSandboxEnabled = config.toolSandboxEnabled ?? false;
this.sandboxApprovedTools = config.sandboxApprovedTools ?? [];
}
/**
* Update the current approval mode.
*/
setApprovalMode(mode: ApprovalMode, sandboxApprovedTools?: string[]): void {
setApprovalMode(mode: ApprovalMode): void {
this.approvalMode = mode;
if (sandboxApprovedTools !== undefined) {
this.sandboxApprovedTools = sandboxApprovedTools;
}
}
/**
@@ -281,58 +269,17 @@ export class PolicyEngine {
command: string,
allowRedirection?: boolean,
): boolean {
if (allowRedirection) return false;
if (!hasRedirection(command)) return false;
// Do not downgrade (do not ask user) if sandboxing is enabled and in AUTO_EDIT or YOLO
if (
this.toolSandboxEnabled &&
(this.approvalMode === ApprovalMode.AUTO_EDIT ||
this.approvalMode === ApprovalMode.YOLO)
) {
return false;
}
return true;
return (
!allowRedirection &&
hasRedirection(command) &&
this.approvalMode !== ApprovalMode.AUTO_EDIT &&
this.approvalMode !== ApprovalMode.YOLO
);
}
/**
* Check if a shell command is allowed.
*/
private async applyShellHeuristics(
command: string,
decision: PolicyDecision,
): Promise<PolicyDecision> {
await initializeShellParsers();
try {
const parsedObjArgs = shellParse(command);
if (parsedObjArgs.some((arg) => typeof arg === 'object')) return decision;
const parsedArgs = parsedObjArgs.map(String);
if (isDangerousCommand(parsedArgs)) {
debugLogger.debug(
`[PolicyEngine.check] Command evaluated as dangerous, forcing ASK_USER: ${command}`,
);
return PolicyDecision.ASK_USER;
}
const isApprovedBySandbox =
this.toolSandboxEnabled &&
this.sandboxApprovedTools.includes(parsedArgs[0]);
if (
(isKnownSafeCommand(parsedArgs) || isApprovedBySandbox) &&
decision === PolicyDecision.ASK_USER
) {
debugLogger.debug(
`[PolicyEngine.check] Command evaluated as known safe, overriding ASK_USER to ALLOW: ${command}`,
);
return PolicyDecision.ALLOW;
}
} catch {
// Ignore parsing errors
}
return decision;
}
private async checkShellCommand(
toolName: string,
command: string | undefined,
@@ -575,21 +522,11 @@ export class PolicyEngine {
`[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`,
);
let ruleDecision = rule.decision;
if (
isShellCommand &&
command &&
!('commandPrefix' in rule) &&
!rule.argsPattern
) {
ruleDecision = await this.applyShellHeuristics(command, ruleDecision);
}
if (isShellCommand && toolName) {
const shellResult = await this.checkShellCommand(
toolName,
command,
ruleDecision,
rule.decision,
serverName,
shellDirPath,
rule.allowRedirection,
@@ -625,18 +562,10 @@ export class PolicyEngine {
`[PolicyEngine.check] NO MATCH - using default decision: ${this.defaultDecision}`,
);
if (toolName && SHELL_TOOL_NAMES.includes(toolName)) {
let heuristicDecision = this.defaultDecision;
if (command) {
heuristicDecision = await this.applyShellHeuristics(
command,
heuristicDecision,
);
}
const shellResult = await this.checkShellCommand(
toolName,
command,
heuristicDecision,
this.defaultDecision,
serverName,
shellDirPath,
false,
@@ -702,15 +631,6 @@ export class PolicyEngine {
}
}
// Sandbox Expansion requests MUST always be confirmed by the user,
// even if the base command is otherwise ALLOWED by the policy engine.
if (
decision === PolicyDecision.ALLOW &&
toolCall.args?.['additional_permissions']
) {
decision = PolicyDecision.ASK_USER;
}
return {
decision: this.applyNonInteractiveMode(decision),
rule: matchedRule,
@@ -1,216 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import toml from '@iarna/toml';
import { z } from 'zod';
import { fileURLToPath } from 'node:url';
import { debugLogger } from '../utils/debugLogger.js';
import { type SandboxPermissions } from '../services/sandboxManager.js';
import { sanitizePaths } from '../services/sandboxManager.js';
export const SandboxModeConfigSchema = z.object({
network: z.boolean(),
readonly: z.boolean(),
approvedTools: z.array(z.string()),
allowOverrides: z.boolean().optional(),
});
export const PersistentCommandConfigSchema = z.object({
allowed_paths: z.array(z.string()).optional(),
allow_network: z.boolean().optional(),
});
export const SandboxTomlSchema = z.object({
modes: z.object({
plan: SandboxModeConfigSchema,
default: SandboxModeConfigSchema,
accepting_edits: SandboxModeConfigSchema,
}),
commands: z.record(z.string(), PersistentCommandConfigSchema).default({}),
});
export type SandboxModeConfig = z.infer<typeof SandboxModeConfigSchema>;
export type PersistentCommandConfig = z.infer<
typeof PersistentCommandConfigSchema
>;
export type SandboxTomlSchemaType = z.infer<typeof SandboxTomlSchema>;
export class SandboxPolicyManager {
private static _DEFAULT_CONFIG: SandboxTomlSchemaType | null = null;
private static get DEFAULT_CONFIG(): SandboxTomlSchemaType {
if (!SandboxPolicyManager._DEFAULT_CONFIG) {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const defaultPath = path.join(
__dirname,
'policies',
'sandbox-default.toml',
);
try {
const content = fs.readFileSync(defaultPath, 'utf8');
if (typeof content !== 'string') {
SandboxPolicyManager._DEFAULT_CONFIG = {
modes: {
plan: {
network: false,
readonly: true,
approvedTools: [],
allowOverrides: false,
},
default: {
network: false,
readonly: true,
approvedTools: [],
allowOverrides: true,
},
accepting_edits: {
network: false,
readonly: false,
approvedTools: ['sed', 'grep', 'awk', 'perl', 'cat', 'echo'],
allowOverrides: true,
},
},
commands: {},
};
return SandboxPolicyManager._DEFAULT_CONFIG;
}
SandboxPolicyManager._DEFAULT_CONFIG = SandboxTomlSchema.parse(
toml.parse(content),
);
} catch (e) {
debugLogger.error(`Failed to parse default sandbox policy: ${e}`);
throw new Error(`Failed to parse default sandbox policy: ${e}`);
}
}
return SandboxPolicyManager._DEFAULT_CONFIG;
}
private config: SandboxTomlSchemaType;
private readonly configPath: string;
private sessionApprovals: Record<string, SandboxPermissions> = {};
constructor(customConfigPath?: string) {
this.configPath =
customConfigPath ??
path.join(os.homedir(), '.gemini', 'policies', 'sandbox.toml');
this.config = this.loadConfig();
}
private loadConfig(): SandboxTomlSchemaType {
if (!fs.existsSync(this.configPath)) {
return SandboxPolicyManager.DEFAULT_CONFIG;
}
try {
const content = fs.readFileSync(this.configPath, 'utf8');
return SandboxTomlSchema.parse(toml.parse(content));
} catch (e) {
debugLogger.error(`Failed to parse sandbox.toml: ${e}`);
return SandboxPolicyManager.DEFAULT_CONFIG;
}
}
private saveConfig(): void {
try {
const dir = path.dirname(this.configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const content = toml.stringify(this.config as unknown as toml.JsonMap);
fs.writeFileSync(this.configPath, content);
} catch (e) {
debugLogger.error(`Failed to save sandbox.toml: ${e}`);
}
}
getModeConfig(
mode: 'plan' | 'accepting_edits' | 'default' | string,
): SandboxModeConfig {
if (mode === 'plan') return this.config.modes.plan;
if (mode === 'accepting_edits' || mode === 'autoEdit')
return this.config.modes.accepting_edits;
if (mode === 'default') return this.config.modes.default;
// Default fallback
return this.config.modes.default ?? this.config.modes.plan;
}
getCommandPermissions(commandName: string): SandboxPermissions {
const persistent = this.config.commands[commandName];
const session = this.sessionApprovals[commandName];
return {
fileSystem: {
read: [
...(persistent?.allowed_paths ?? []),
...(session?.fileSystem?.read ?? []),
],
write: [
...(persistent?.allowed_paths ?? []),
...(session?.fileSystem?.write ?? []),
],
},
network: persistent?.allow_network || session?.network || false,
};
}
addSessionApproval(
commandName: string,
permissions: SandboxPermissions,
): void {
const existing = this.sessionApprovals[commandName] || {
fileSystem: { read: [], write: [] },
network: false,
};
this.sessionApprovals[commandName] = {
fileSystem: {
read: Array.from(
new Set([
...(existing.fileSystem?.read ?? []),
...(permissions.fileSystem?.read ?? []),
]),
),
write: Array.from(
new Set([
...(existing.fileSystem?.write ?? []),
...(permissions.fileSystem?.write ?? []),
]),
),
},
network: existing.network || permissions.network || false,
};
}
addPersistentApproval(
commandName: string,
permissions: SandboxPermissions,
): void {
const existing = this.config.commands[commandName] || {
allowed_paths: [],
allow_network: false,
};
const newPathsArray: string[] = [
...(existing.allowed_paths ?? []),
...(permissions.fileSystem?.read ?? []),
...(permissions.fileSystem?.write ?? []),
];
const newPaths = new Set(sanitizePaths(newPathsArray));
this.config.commands[commandName] = {
allowed_paths: Array.from(newPaths),
allow_network: existing.allow_network || permissions.network || false,
};
this.saveConfig();
}
}
-9
View File
@@ -309,15 +309,6 @@ export interface PolicyEngineConfig {
* Used to filter rules that have specific 'modes' defined.
*/
approvalMode?: ApprovalMode;
/**
* Whether tool sandboxing is enabled.
*/
toolSandboxEnabled?: boolean;
/**
* List of tools approved by the sandbox policy for the current mode.
*/
sandboxApprovedTools?: string[];
}
export interface PolicySettings {
@@ -54,7 +54,6 @@ describe('PromptProvider', () => {
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
+1 -4
View File
@@ -195,10 +195,7 @@ export class PromptProvider {
memoryManagerEnabled: context.config.isMemoryManagerEnabled(),
}),
),
sandbox: this.withSection('sandbox', () => ({
mode: getSandboxMode(),
toolSandboxingEnabled: context.config.getSandboxEnabled(),
})),
sandbox: this.withSection('sandbox', () => getSandboxMode()),
interactiveYoloMode: this.withSection(
'interactiveYoloMode',
() => true,
+4 -11
View File
@@ -36,7 +36,7 @@ export interface SystemPromptOptions {
planningWorkflow?: PlanningWorkflowOptions;
taskTracker?: boolean;
operationalGuidelines?: OperationalGuidelinesOptions;
sandbox?: SandboxOptions;
sandbox?: SandboxMode;
interactiveYoloMode?: boolean;
gitRepo?: GitRepoOptions;
finalReminder?: FinalReminderOptions;
@@ -72,11 +72,6 @@ export interface OperationalGuidelinesOptions {
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
export interface SandboxOptions {
mode: SandboxMode;
toolSandboxingEnabled: boolean;
}
export interface GitRepoOptions {
interactive: boolean;
}
@@ -295,9 +290,8 @@ ${shellEfficiencyGuidelines(options.enableShellEfficiency)}
`.trim();
}
export function renderSandbox(options?: SandboxOptions): string {
if (!options || !options.mode) return '';
const mode = options.mode;
export function renderSandbox(mode?: SandboxMode): string {
if (!mode) return '';
if (mode === 'macos-seatbelt') {
return `
# macOS Seatbelt
@@ -306,12 +300,11 @@ You are running under macos seatbelt with limited access to files outside the pr
return `
# Sandbox
You are running in a sandbox container with limited access to files outside the project directory or system temp directory, and with limited access to host system resources such as ports. If you encounter failures that could be due to sandboxing (e.g. if a command fails with 'Operation not permitted' or similar error), when you report the error to the user, also explain why you think it could be due to sandboxing, and how the user may need to adjust their sandbox configuration.`.trim();
} else if (mode === 'outside') {
} else {
return `
# Outside of Sandbox
You are running outside of a sandbox container, directly on the user's system. For critical commands that are particularly likely to modify the user's system outside of the project directory or system temp directory, as you explain the command to the user (per the Explain Critical Commands rule above), also remind the user to consider enabling sandboxing.`.trim();
}
return '';
}
export function renderInteractiveYoloMode(enabled?: boolean): string {
+5 -36
View File
@@ -46,7 +46,7 @@ export interface SystemPromptOptions {
planningWorkflow?: PlanningWorkflowOptions;
taskTracker?: boolean;
operationalGuidelines?: OperationalGuidelinesOptions;
sandbox?: SandboxOptions;
sandbox?: SandboxMode;
interactiveYoloMode?: boolean;
gitRepo?: GitRepoOptions;
}
@@ -84,11 +84,6 @@ export interface OperationalGuidelinesOptions {
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
export interface SandboxOptions {
mode: SandboxMode;
toolSandboxingEnabled: boolean;
}
export interface GitRepoOptions {
interactive: boolean;
}
@@ -394,44 +389,18 @@ export function renderOperationalGuidelines(
`.trim();
}
export function renderSandbox(options?: SandboxOptions): string {
if (!options || !options.mode) return '';
const { mode, toolSandboxingEnabled } = options;
export function renderSandbox(mode?: SandboxMode): string {
if (!mode) return '';
if (mode === 'macos-seatbelt') {
if (toolSandboxingEnabled) {
return `
# macOS Seatbelt
You are running under macos seatbelt with limited access to files outside the project directory or system temp directory, and with limited access to host system resources such as ports.
**Sandbox Failure Recovery:**
If an interactive command (like nvim, nano, git) fails to load plugins, crashes, or hangs waiting for user input (e.g. at a '-- More --' prompt) with errors indicating missing files or 'Operation not permitted', it is likely blocked by the sandbox.
If you see these errors (e.g. Vim:E303, 'should be read/write') in the command output (even if the command timed out or was cancelled by the user):
1. IMMEDIATELY analyze the output to identify the required filesystem paths or network access.
2. WITHOUT asking the user or explaining the failure first, AUTOMATICALLY retry the command using the 'run_shell_command' tool, explicitly providing the missing permissions in the 'additional_permissions' parameter.
3. This will immediately present the user with a modal to approve the expansion for the command so they don't have to reprompt you.`.trim();
} else {
return `
return `
# macOS Seatbelt
You are running under macos seatbelt with limited access to files outside the project directory or system temp directory, and with limited access to host system resources such as ports. If you encounter failures that could be due to macOS Seatbelt (e.g. if a command fails with 'Operation not permitted' or similar error), as you report the error to the user, also explain why you think it could be due to macOS Seatbelt, and how the user may need to adjust their Seatbelt profile.`.trim();
}
} else if (mode === 'generic') {
if (toolSandboxingEnabled) {
return `
# Sandbox
You are running in a sandbox container with limited access to files outside the project directory or system temp directory, and with limited access to host system resources such as ports.
**Sandbox Failure Recovery:**
If a command fails with 'Operation not permitted' or similar sandbox errors, do NOT ask the user to adjust settings manually. Instead:
1. Analyze the command and error to identify the required filesystem paths or network access.
2. Retry the command using the 'run_shell_command' tool, providing the missing permissions in the 'additional_permissions' parameter.
3. The user will be presented with a modal to approve this expansion for the current command.`.trim();
} else {
return `
return `
# Sandbox
You are running in a sandbox container with limited access to files outside the project directory or system temp directory, and with limited access to host system resources such as ports. If you encounter failures that could be due to sandboxing (e.g. if a command fails with 'Operation not permitted' or similar error), when you report the error to the user, also explain why you think it could be due to sandboxing, and how the user may need to adjust their sandbox configuration.`.trim();
}
}
return '';
}
@@ -4,42 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import fs from 'node:fs';
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
default: {
// @ts-expect-error - Property 'default' does not exist on type 'typeof import("node:fs")'
...actual.default,
existsSync: vi.fn(() => true),
realpathSync: vi.fn((p: string | Buffer) => p.toString()),
mkdirSync: vi.fn(),
openSync: vi.fn(),
closeSync: vi.fn(),
writeFileSync: vi.fn(),
},
existsSync: vi.fn(() => true),
realpathSync: vi.fn((p: string | Buffer) => p.toString()),
mkdirSync: vi.fn(),
openSync: vi.fn(),
closeSync: vi.fn(),
writeFileSync: vi.fn(),
};
});
describe('LinuxSandboxManager', () => {
const workspace = '/home/user/workspace';
let manager: LinuxSandboxManager;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
manager = new LinuxSandboxManager({ workspace });
});
@@ -79,15 +52,6 @@ describe('LinuxSandboxManager', () => {
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
'--seccomp',
'9',
'--',
@@ -115,15 +79,6 @@ describe('LinuxSandboxManager', () => {
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
'--bind-try',
'/tmp/cache',
'/tmp/cache',
@@ -133,48 +88,6 @@ describe('LinuxSandboxManager', () => {
]);
});
it('protects real paths of governance files if they are symlinks', async () => {
vi.mocked(fs.realpathSync).mockImplementation((p) => {
if (p.toString() === `${workspace}/.gitignore`)
return '/shared/global.gitignore';
return p.toString();
});
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
});
expect(bwrapArgs).toContain('--ro-bind');
expect(bwrapArgs).toContain(`${workspace}/.gitignore`);
expect(bwrapArgs).toContain('/shared/global.gitignore');
// Check that both are bound
const gitignoreIndex = bwrapArgs.indexOf(`${workspace}/.gitignore`);
expect(bwrapArgs[gitignoreIndex - 1]).toBe('--ro-bind');
expect(bwrapArgs[gitignoreIndex + 1]).toBe(`${workspace}/.gitignore`);
const realGitignoreIndex = bwrapArgs.indexOf('/shared/global.gitignore');
expect(bwrapArgs[realGitignoreIndex - 1]).toBe('--ro-bind');
expect(bwrapArgs[realGitignoreIndex + 1]).toBe('/shared/global.gitignore');
});
it('touches governance files if they do not exist', async () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
});
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.openSync).toHaveBeenCalled();
});
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
@@ -189,20 +102,7 @@ describe('LinuxSandboxManager', () => {
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
// Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash
expect(binds).toEqual([
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
]);
// Should only contain the primary workspace bind, not the second one with a trailing slash
expect(binds).toEqual(['--bind', workspace, workspace]);
});
});
@@ -4,15 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { join, dirname, normalize } from 'node:path';
import { join, normalize } from 'node:path';
import { writeFileSync } from 'node:fs';
import os from 'node:os';
import {
type SandboxManager,
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
GOVERNANCE_FILES,
sanitizePaths,
} from '../../services/sandboxManager.js';
import {
@@ -73,30 +72,11 @@ function getSeccompBpfPath(): string {
}
const bpfPath = join(os.tmpdir(), `gemini-cli-seccomp-${process.pid}.bpf`);
fs.writeFileSync(bpfPath, buf);
writeFileSync(bpfPath, buf);
cachedBpfPath = bpfPath;
return bpfPath;
}
/**
* Ensures a file or directory exists.
*/
function touch(filePath: string, isDirectory: boolean) {
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
fs.mkdirSync(dirname(filePath), { recursive: true });
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
/**
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
*/
@@ -129,21 +109,6 @@ export class LinuxSandboxManager implements SandboxManager {
this.options.workspace,
];
// Protected governance files are bind-mounted as read-only, even if the workspace is RW.
// We ensure they exist on the host and resolve real paths to prevent symlink bypasses.
// In bwrap, later binds override earlier ones for the same path.
for (const file of GOVERNANCE_FILES) {
const filePath = join(this.options.workspace, file.path);
touch(filePath, file.isDirectory);
const realPath = fs.realpathSync(filePath);
bwrapArgs.push('--ro-bind', filePath, filePath);
if (realPath !== filePath) {
bwrapArgs.push('--ro-bind', realPath, realPath);
}
}
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
const normalizedWorkspace = normalize(this.options.workspace).replace(
/\/$/,
@@ -8,32 +8,20 @@ import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
describe('MacOsSandboxManager', () => {
let mockWorkspace: string;
let mockAllowedPaths: string[];
const mockWorkspace = '/test/workspace';
const mockAllowedPaths = ['/test/allowed'];
const mockNetworkAccess = true;
let mockPolicy: ExecutionPolicy;
const mockPolicy: ExecutionPolicy = {
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
};
let manager: MacOsSandboxManager;
beforeEach(() => {
mockWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-macos-test-'),
);
mockAllowedPaths = [
path.join(os.tmpdir(), 'gemini-cli-macos-test-allowed'),
];
if (!fs.existsSync(mockAllowedPaths[0])) {
fs.mkdirSync(mockAllowedPaths[0]);
}
mockPolicy = {
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
};
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
// Mock realpathSync to just return the path for testing
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
@@ -41,10 +29,6 @@ describe('MacOsSandboxManager', () => {
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(mockWorkspace, { recursive: true, force: true });
if (mockAllowedPaths && mockAllowedPaths[0]) {
fs.rmSync(mockAllowedPaths[0], { recursive: true, force: true });
}
});
describe('prepareCommand', () => {
@@ -63,22 +47,11 @@ describe('MacOsSandboxManager', () => {
expect(profile).toContain('(deny default)');
expect(profile).toContain('(allow process-exec)');
expect(profile).toContain('(subpath (param "WORKSPACE"))');
expect(profile).not.toContain('(allow network-outbound)');
expect(profile).not.toContain('(allow network*)');
expect(result.args).toContain('-D');
expect(result.args).toContain(`WORKSPACE=${mockWorkspace}`);
expect(result.args).toContain('WORKSPACE=/test/workspace');
expect(result.args).toContain(`TMPDIR=${os.tmpdir()}`);
// Governance files should be protected
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
); // .gitignore
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_1")))',
); // .geminiignore
expect(profile).toContain(
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
); // .git
});
it('should allow network when networkAccess is true in policy', async () => {
@@ -91,7 +64,7 @@ describe('MacOsSandboxManager', () => {
});
const profile = result.args[1];
expect(profile).toContain('(allow network-outbound)');
expect(profile).toContain('(allow network*)');
});
it('should parameterize allowed paths and normalize them', async () => {
@@ -161,41 +134,31 @@ describe('MacOsSandboxManager', () => {
});
it('should resolve parent directories if a file does not exist', async () => {
const baseTmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-macos-realpath-test-'),
);
const realPath = path.join(baseTmpDir, 'real_path');
const nonexistentFile = path.join(realPath, 'nonexistent.txt');
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === nonexistentFile) {
if (p === '/test/symlink/nonexistent.txt') {
const error = new Error('ENOENT');
Object.assign(error, { code: 'ENOENT' });
throw error;
}
if (p === realPath) {
return path.join(baseTmpDir, 'resolved_path');
if (p === '/test/symlink') {
return '/test/real_path';
}
return p as string;
});
try {
const dynamicManager = new MacOsSandboxManager({
workspace: nonexistentFile,
});
const dynamicResult = await dynamicManager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: nonexistentFile,
env: {},
});
const dynamicManager = new MacOsSandboxManager({
workspace: '/test/symlink/nonexistent.txt',
});
const dynamicResult = await dynamicManager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: '/test/symlink/nonexistent.txt',
env: {},
});
expect(dynamicResult.args).toContain(
`WORKSPACE=${path.join(baseTmpDir, 'resolved_path', 'nonexistent.txt')}`,
);
} finally {
fs.rmSync(baseTmpDir, { recursive: true, force: true });
}
expect(dynamicResult.args).toContain(
'WORKSPACE=/test/real_path/nonexistent.txt',
);
});
it('should throw if realpathSync throws a non-ENOENT error', async () => {
@@ -206,7 +169,7 @@ describe('MacOsSandboxManager', () => {
});
const errorManager = new MacOsSandboxManager({
workspace: mockWorkspace,
workspace: '/test/workspace',
});
await expect(
errorManager.prepareCommand({
@@ -4,164 +4,40 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
type SandboxManager,
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
type SandboxPermissions,
type GlobalSandboxOptions,
type ExecutionPolicy,
sanitizePaths,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import {
getCommandRoots,
initializeShellParsers,
splitCommands,
stripShellWrapper,
} from '../../utils/shell-utils.js';
import { isKnownSafeCommand } from './commandSafety.js';
import { parse as shellParse } from 'shell-quote';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import path from 'node:path';
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
/** Optional base sanitization config. */
sanitizationConfig?: EnvironmentSanitizationConfig;
/** The current sandbox mode behavior from config. */
modeConfig?: {
readonly?: boolean;
network?: boolean;
approvedTools?: string[];
allowOverrides?: boolean;
};
/** The policy manager for persistent approvals. */
policyManager?: SandboxPolicyManager;
}
BASE_SEATBELT_PROFILE,
NETWORK_SEATBELT_PROFILE,
} from './baseProfile.js';
/**
* A SandboxManager implementation for macOS that uses Seatbelt.
*/
export class MacOsSandboxManager implements SandboxManager {
constructor(private readonly options: MacOsSandboxOptions) {}
private async isStrictlyApproved(req: SandboxRequest): Promise<boolean> {
const approvedTools = this.options.modeConfig?.approvedTools;
if (!approvedTools || approvedTools.length === 0) {
return false;
}
await initializeShellParsers();
const fullCmd = [req.command, ...req.args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped);
if (roots.length === 0) return false;
const allRootsApproved = roots.every((root) =>
approvedTools.includes(root),
);
if (allRootsApproved) {
return true;
}
const pipelineCommands = splitCommands(stripped);
if (pipelineCommands.length === 0) return false;
// For safety, every command in the pipeline must be considered safe.
for (const cmdString of pipelineCommands) {
const parsedArgs = shellParse(cmdString).map(String);
if (!isKnownSafeCommand(parsedArgs)) {
return false;
}
}
return true;
}
private async getCommandName(req: SandboxRequest): Promise<string> {
await initializeShellParsers();
const fullCmd = [req.command, ...req.args].join(' ');
const stripped = stripShellWrapper(fullCmd);
const roots = getCommandRoots(stripped).filter(
(r) => r !== 'shopt' && r !== 'set',
);
if (roots.length > 0) {
return roots[0];
}
return path.basename(req.command);
}
constructor(private readonly options: GlobalSandboxOptions) {}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
await initializeShellParsers();
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
// Reject override attempts in plan mode
if (!allowOverrides && req.policy?.additionalPermissions) {
const perms = req.policy.additionalPermissions;
if (
perms.network ||
(perms.fileSystem?.write && perms.fileSystem.write.length > 0)
) {
throw new Error(
'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.',
);
}
}
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
const isApproved = allowOverrides
? await this.isStrictlyApproved(req)
: false;
const workspaceWrite = !isReadonlyMode || isApproved;
const networkAccess =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
// Fetch persistent approvals for this command
const commandName = await this.getCommandName(req);
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
: undefined;
// Merge all permissions
const mergedAdditional: SandboxPermissions = {
fileSystem: {
read: [
...(persistentPermissions?.fileSystem?.read ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.read ?? []),
],
write: [
...(persistentPermissions?.fileSystem?.write ?? []),
...(req.policy?.additionalPermissions?.fileSystem?.write ?? []),
],
},
network:
networkAccess ||
persistentPermissions?.network ||
req.policy?.additionalPermissions?.network ||
false,
};
const sandboxArgs = buildSeatbeltArgs({
workspace: this.options.workspace,
allowedPaths: [...(req.policy?.allowedPaths || [])],
forbiddenPaths: req.policy?.forbiddenPaths,
networkAccess: mergedAdditional.network,
workspaceWrite,
additionalPermissions: mergedAdditional,
});
const sandboxArgs = this.buildSeatbeltArgs(this.options, req.policy);
return {
program: '/usr/bin/sandbox-exec',
@@ -170,4 +46,65 @@ export class MacOsSandboxManager implements SandboxManager {
cwd: req.cwd,
};
}
/**
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
*
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
* Does not include the final '--' separator or the command to run.
*/
private buildSeatbeltArgs(
options: GlobalSandboxOptions,
policy?: ExecutionPolicy,
): string[] {
const profileLines = [BASE_SEATBELT_PROFILE];
const args: string[] = [];
const workspacePath = this.tryRealpath(options.workspace);
args.push('-D', `WORKSPACE=${workspacePath}`);
const tmpPath = this.tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
const allowedPaths = sanitizePaths(policy?.allowedPaths) || [];
for (let i = 0; i < allowedPaths.length; i++) {
const allowedPath = this.tryRealpath(allowedPaths[i]);
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
profileLines.push(
`(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))`,
);
}
// TODO: handle forbidden paths
if (policy?.networkAccess) {
profileLines.push(NETWORK_SEATBELT_PROFILE);
}
args.unshift('-p', profileLines.join('\n'));
return args;
}
/**
* Resolves symlinks for a given path to prevent sandbox escapes.
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
* Other errors (e.g. EACCES) are re-thrown.
*/
private tryRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(this.tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
}
+8 -96
View File
@@ -16,101 +16,11 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
(import "system.sb")
; Core execution requirements
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow process-info*)
(allow file-write-data
(require-all
(path "/dev/null")
(vnode-type CHARACTER-DEVICE)))
; sysctls permitted.
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.model")
(sysctl-name "hw.memsize")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name-prefix "hw.optional.arm.")
(sysctl-name-prefix "hw.optional.armv8_")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.logicalcpu")
(sysctl-name "hw.cpufrequency")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "machdep.cpu.brand_string")
(sysctl-name "kern.argmax")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.maxproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name "vm.loadavg")
(sysctl-name-prefix "hw.perflevel")
(sysctl-name-prefix "kern.proc.pgrp.")
(sysctl-name-prefix "kern.proc.pid.")
(sysctl-name-prefix "net.routetable.")
)
(allow sysctl-write
(sysctl-name "kern.grade_cputype"))
(allow mach-lookup
(global-name "com.apple.sysmond")
)
\n; IOKit
(allow iokit-open
(iokit-registry-entry-class "RootDomainUserClient")
)
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo")
)
; Needed for python multiprocessing on MacOS for the SemLock
(allow ipc-posix-sem)
(allow mach-lookup
(global-name "com.apple.PowerManagement.control")
)
; PTY and Terminal support
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write*
(require-all
(regex #"^/dev/ttys[0-9]+")
(extension "com.apple.sandbox.pty")))
(allow file-ioctl (regex #"^/dev/ttys[0-9]+"))
(allow process-info* (target same-sandbox))
; Allow basic read access to system frameworks and libraries required to run
(allow file-read*
@@ -128,6 +38,11 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
(subpath "/private/etc")
)
; PTY and Terminal support
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+"))
; Allow read/write access to temporary directories and common device nodes
(allow file-read* file-write*
(literal "/dev/null")
@@ -138,10 +53,9 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
)
; Workspace access using parameterized paths
(allow file-read*
(allow file-read* file-write*
(subpath (param "WORKSPACE"))
)
`;
/**
@@ -152,9 +66,7 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
*/
export const NETWORK_SEATBELT_PROFILE = `
; Network Access
(allow network-outbound)
(allow network-inbound)
(allow network-bind)
(allow network*)
(allow system-socket
(require-all
@@ -1,469 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { parse as shellParse } from 'shell-quote';
/**
* Checks if a command with its arguments is known to be safe to execute
* without requiring user confirmation. This is primarily used to allow
* harmless, read-only commands to run silently in the macOS sandbox.
*
* It handles raw command execution as well as wrapped commands like `bash -c "..."` or `bash -lc "..."`.
* For wrapped commands, it parses the script and ensures all individual
* sub-commands are in the known-safe list and no dangerous shell operators
* (like subshells or redirection) are used.
*
* @param args - The command and its arguments (e.g., ['ls', '-la'])
* @returns true if the command is considered safe, false otherwise.
*/
export function isKnownSafeCommand(args: string[]): boolean {
if (!args || args.length === 0) {
return false;
}
// Normalize zsh to bash
const normalizedArgs = args.map((a) => (a === 'zsh' ? 'bash' : a));
if (isSafeToCallWithExec(normalizedArgs)) {
return true;
}
// Support `bash -lc "..."`
if (
normalizedArgs.length === 3 &&
normalizedArgs[0] === 'bash' &&
(normalizedArgs[1] === '-lc' || normalizedArgs[1] === '-c')
) {
try {
const script = normalizedArgs[2];
// Basic check for dangerous operators that could spawn subshells or redirect output
// We allow &&, ||, |, ; but explicitly block subshells () and redirection >, >>, <
if (/[()<>]/g.test(script)) {
return false;
}
const commands = script.split(/&&|\|\||\||;/);
let allSafe = true;
for (const cmd of commands) {
const trimmed = cmd.trim();
if (!trimmed) continue;
const parsed = shellParse(trimmed).map(String);
if (parsed.length === 0) continue;
if (!isSafeToCallWithExec(parsed)) {
allSafe = false;
break;
}
}
if (allSafe && commands.length > 0) {
return true;
}
} catch {
return false;
}
}
return false;
}
/**
* Core validation logic that checks a single command and its arguments
* against an allowlist of known safe operations. It performs deep validation
* for specific tools like `base64`, `find`, `rg`, `git`, and `sed` to ensure
* unsafe flags (like `--output`, `-exec`, or mutating options) are not used.
*
* @param args - The command and its arguments.
* @returns true if the command is strictly read-only and safe.
*/
function isSafeToCallWithExec(args: string[]): boolean {
if (!args || args.length === 0) return false;
const cmd = args[0];
const safeCommands = new Set([
'cat',
'cd',
'cut',
'echo',
'expr',
'false',
'grep',
'head',
'id',
'ls',
'nl',
'paste',
'pwd',
'rev',
'seq',
'stat',
'tail',
'tr',
'true',
'uname',
'uniq',
'wc',
'which',
'whoami',
'numfmt',
'tac',
]);
if (safeCommands.has(cmd)) {
return true;
}
if (cmd === 'base64') {
const unsafeOptions = new Set(['-o', '--output']);
return !args
.slice(1)
.some(
(arg) =>
unsafeOptions.has(arg) ||
arg.startsWith('--output=') ||
(arg.startsWith('-o') && arg !== '-o'),
);
}
if (cmd === 'find') {
const unsafeOptions = new Set([
'-exec',
'-execdir',
'-ok',
'-okdir',
'-delete',
'-fls',
'-fprint',
'-fprint0',
'-fprintf',
]);
return !args.some((arg) => unsafeOptions.has(arg));
}
if (cmd === 'rg') {
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
return !args.some((arg) => {
if (unsafeWithoutArgs.has(arg)) return true;
for (const opt of unsafeWithArgs) {
if (arg === opt || arg.startsWith(opt + '=')) return true;
}
return false;
});
}
if (cmd === 'git') {
if (gitHasConfigOverrideGlobalOption(args)) {
return false;
}
const { idx, subcommand } = findGitSubcommand(args, [
'status',
'log',
'diff',
'show',
'branch',
]);
if (!subcommand) {
return false;
}
const subcommandArgs = args.slice(idx + 1);
if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
return gitSubcommandArgsAreReadOnly(subcommandArgs);
}
if (subcommand === 'branch') {
return (
gitSubcommandArgsAreReadOnly(subcommandArgs) &&
gitBranchIsReadOnly(subcommandArgs)
);
}
return false;
}
if (cmd === 'sed') {
// Special-case sed -n {N|M,N}p
if (args.length <= 4 && args[1] === '-n' && isValidSedNArg(args[2])) {
return true;
}
return false;
}
return false;
}
/**
* Helper to identify which git subcommand is being executed, skipping over
* global git options like `-c` or `--git-dir`.
*
* @param args - The full git command arguments.
* @param subcommands - A list of subcommands to look for.
* @returns An object containing the index of the subcommand and its name.
*/
function findGitSubcommand(
args: string[],
subcommands: string[],
): { idx: number; subcommand: string | null } {
let skipNext = false;
for (let idx = 1; idx < args.length; idx++) {
if (skipNext) {
skipNext = false;
continue;
}
const arg = args[idx];
if (
arg.startsWith('--config-env=') ||
arg.startsWith('--exec-path=') ||
arg.startsWith('--git-dir=') ||
arg.startsWith('--namespace=') ||
arg.startsWith('--super-prefix=') ||
arg.startsWith('--work-tree=') ||
((arg.startsWith('-C') || arg.startsWith('-c')) && arg.length > 2)
) {
continue;
}
if (
arg === '-C' ||
arg === '-c' ||
arg === '--config-env' ||
arg === '--exec-path' ||
arg === '--git-dir' ||
arg === '--namespace' ||
arg === '--super-prefix' ||
arg === '--work-tree'
) {
skipNext = true;
continue;
}
if (arg === '--' || arg.startsWith('-')) {
continue;
}
if (subcommands.includes(arg)) {
return { idx, subcommand: arg };
}
return { idx: -1, subcommand: null };
}
return { idx: -1, subcommand: null };
}
/**
* Checks if a git command contains global configuration override flags
* (e.g., `-c` or `--config-env`) which could be used maliciously to
* execute arbitrary code via git config.
*
* @param args - The git command arguments.
* @returns true if config overrides are present.
*/
function gitHasConfigOverrideGlobalOption(args: string[]): boolean {
return args.some(
(arg) =>
arg === '-c' ||
arg === '--config-env' ||
(arg.startsWith('-c') && arg.length > 2) ||
arg.startsWith('--config-env='),
);
}
/**
* Validates that the arguments for safe git subcommands (like `status`, `log`,
* `diff`, `show`) do not contain flags that could cause mutations or execute
* arbitrary commands (e.g., `--output`, `--exec`).
*
* @param args - Arguments passed to the git subcommand.
* @returns true if the arguments only represent read-only operations.
*/
function gitSubcommandArgsAreReadOnly(args: string[]): boolean {
const unsafeFlags = new Set([
'--output',
'--ext-diff',
'--textconv',
'--exec',
'--paginate',
]);
return !args.some(
(arg) =>
unsafeFlags.has(arg) ||
arg.startsWith('--output=') ||
arg.startsWith('--exec='),
);
}
/**
* Validates that `git branch` is only used for read operations
* (e.g., listing branches) rather than creating, deleting, or renaming branches.
*
* @param args - Arguments passed to `git branch`.
* @returns true if it's purely a listing/read-only branch command.
*/
function gitBranchIsReadOnly(args: string[]): boolean {
if (args.length === 0) return true;
let sawReadOnlyFlag = false;
for (const arg of args) {
if (
[
'--list',
'-l',
'--show-current',
'-a',
'--all',
'-r',
'--remotes',
'-v',
'-vv',
'--verbose',
].includes(arg)
) {
sawReadOnlyFlag = true;
} else if (arg.startsWith('--format=')) {
sawReadOnlyFlag = true;
} else {
return false;
}
}
return sawReadOnlyFlag;
}
/**
* Ensures that a `sed` command argument is a valid line-printing instruction
* (e.g., `10p` or `5,10p`), preventing unsafe script execution in `sed`.
*
* @param arg - The script argument passed to `sed -n`.
* @returns true if it's a valid, safe print command.
*/
function isValidSedNArg(arg: string | undefined): boolean {
if (!arg) return false;
if (!arg.endsWith('p')) return false;
const core = arg.slice(0, -1);
const parts = core.split(',');
if (parts.length === 1) {
const num = parts[0];
return num.length > 0 && /^\d+$/.test(num);
} else if (parts.length === 2) {
const a = parts[0];
const b = parts[1];
return a.length > 0 && b.length > 0 && /^\d+$/.test(a) && /^\d+$/.test(b);
}
return false;
}
/**
* Checks if a command with its arguments is explicitly known to be dangerous
* and should be blocked or require strict user confirmation. This catches
* destructive commands like `rm -rf`, `sudo`, and commands with execution
* flags like `find -exec`.
*
* @param args - The command and its arguments.
* @returns true if the command is identified as dangerous, false otherwise.
*/
export function isDangerousCommand(args: string[]): boolean {
if (!args || args.length === 0) {
return false;
}
const cmd = args[0];
if (cmd === 'rm') {
return args[1] === '-f' || args[1] === '-rf' || args[1] === '-fr';
}
if (cmd === 'sudo') {
return isDangerousCommand(args.slice(1));
}
if (cmd === 'find') {
const unsafeOptions = new Set([
'-exec',
'-execdir',
'-ok',
'-okdir',
'-delete',
'-fls',
'-fprint',
'-fprint0',
'-fprintf',
]);
return args.some((arg) => unsafeOptions.has(arg));
}
if (cmd === 'rg') {
const unsafeWithArgs = new Set(['--pre', '--hostname-bin']);
const unsafeWithoutArgs = new Set(['--search-zip', '-z']);
return args.some((arg) => {
if (unsafeWithoutArgs.has(arg)) return true;
for (const opt of unsafeWithArgs) {
if (arg === opt || arg.startsWith(opt + '=')) return true;
}
return false;
});
}
if (cmd === 'git') {
if (gitHasConfigOverrideGlobalOption(args)) {
return true;
}
const { idx, subcommand } = findGitSubcommand(args, [
'status',
'log',
'diff',
'show',
'branch',
]);
if (!subcommand) {
// It's a git command we don't recognize as explicitly safe.
return false;
}
const subcommandArgs = args.slice(idx + 1);
if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
return !gitSubcommandArgsAreReadOnly(subcommandArgs);
}
if (subcommand === 'branch') {
return !(
gitSubcommandArgsAreReadOnly(subcommandArgs) &&
gitBranchIsReadOnly(subcommandArgs)
);
}
return false;
}
if (cmd === 'base64') {
const unsafeOptions = new Set(['-o', '--output']);
return args
.slice(1)
.some(
(arg) =>
unsafeOptions.has(arg) ||
arg.startsWith('--output=') ||
(arg.startsWith('-o') && arg !== '-o'),
);
}
return false;
}
@@ -1,160 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import fs from 'node:fs';
import os from 'node:os';
describe('seatbeltArgsBuilder', () => {
it('should build a strict allowlist profile allowing the workspace via param', () => {
// Mock realpathSync to just return the path for testing
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' });
expect(args[0]).toBe('-p');
const profile = args[1];
expect(profile).toContain('(version 1)');
expect(profile).toContain('(deny default)');
expect(profile).toContain('(allow process-exec)');
expect(profile).toContain('(subpath (param "WORKSPACE"))');
expect(profile).not.toContain('(allow network*)');
expect(args).toContain('-D');
expect(args).toContain('WORKSPACE=/Users/test/workspace');
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
vi.restoreAllMocks();
});
it('should allow network when networkAccess is true', () => {
const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true });
const profile = args[1];
expect(profile).toContain('(allow network-outbound)');
});
it('should parameterize allowed paths and normalize them', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink') return '/test/real_path';
return p as string;
});
const args = buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1', '/test/symlink'],
});
const profile = args[1];
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
expect(args).toContain('-D');
expect(args).toContain('ALLOWED_PATH_0=/custom/path1');
expect(args).toContain('ALLOWED_PATH_1=/test/real_path');
vi.restoreAllMocks();
});
it('should resolve parent directories if a file does not exist', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink/nonexistent.txt') {
const error = new Error('ENOENT');
Object.assign(error, { code: 'ENOENT' });
throw error;
}
if (p === '/test/symlink') {
return '/test/real_path';
}
return p as string;
});
const args = buildSeatbeltArgs({
workspace: '/test/symlink/nonexistent.txt',
});
expect(args).toContain('WORKSPACE=/test/real_path/nonexistent.txt');
vi.restoreAllMocks();
});
it('should throw if realpathSync throws a non-ENOENT error', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
const error = new Error('Permission denied');
Object.assign(error, { code: 'EACCES' });
throw error;
});
expect(() =>
buildSeatbeltArgs({
workspace: '/test/workspace',
}),
).toThrow('Permission denied');
vi.restoreAllMocks();
});
describe('governance files', () => {
it('should inject explicit deny rules for governance files', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p.toString());
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'lstatSync').mockImplementation(
(p) =>
({
isDirectory: () => p.toString().endsWith('.git'),
isFile: () => !p.toString().endsWith('.git'),
}) as unknown as fs.Stats,
);
const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' });
const profile = args[1];
// .gitignore should be a literal deny
expect(args).toContain('-D');
expect(args).toContain(
'GOVERNANCE_FILE_0=/Users/test/workspace/.gitignore',
);
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
);
// .git should be a subpath deny
expect(args).toContain('GOVERNANCE_FILE_2=/Users/test/workspace/.git');
expect(profile).toContain(
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
);
vi.restoreAllMocks();
});
it('should protect both the symlink and the real path if they differ', () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/workspace/.gitignore') return '/test/real/.gitignore';
return p.toString();
});
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'lstatSync').mockImplementation(
() =>
({
isDirectory: () => false,
isFile: () => true,
}) as unknown as fs.Stats,
);
const args = buildSeatbeltArgs({ workspace: '/test/workspace' });
const profile = args[1];
expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore');
expect(args).toContain('REAL_GOVERNANCE_FILE_0=/test/real/.gitignore');
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
);
expect(profile).toContain(
'(deny file-write* (literal (param "REAL_GOVERNANCE_FILE_0")))',
);
vi.restoreAllMocks();
});
});
});
@@ -1,247 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
BASE_SEATBELT_PROFILE,
NETWORK_SEATBELT_PROFILE,
} from './baseProfile.js';
import {
type SandboxPermissions,
sanitizePaths,
GOVERNANCE_FILES,
} from '../../services/sandboxManager.js';
/**
* Options for building macOS Seatbelt arguments.
*/
export interface SeatbeltArgsOptions {
/** The primary workspace path to allow access to. */
workspace: string;
/** Additional paths to allow access to. */
allowedPaths?: string[];
/** Absolute paths to explicitly deny read/write access to (overrides allowlists). */
forbiddenPaths?: string[];
/** Whether to allow network access. */
networkAccess?: boolean;
/** Granular additional permissions. */
additionalPermissions?: SandboxPermissions;
/** Whether to allow write access to the workspace. */
workspaceWrite?: boolean;
}
/**
* Resolves symlinks for a given path to prevent sandbox escapes.
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
* Other errors (e.g. EACCES) are re-thrown.
*/
function tryRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
/**
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
*
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
* Does not include the final '--' separator or the command to run.
*/
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
let profile = BASE_SEATBELT_PROFILE + '\n';
const args: string[] = [];
const workspacePath = tryRealpath(options.workspace);
args.push('-D', `WORKSPACE=${workspacePath}`);
args.push('-D', `WORKSPACE_RAW=${options.workspace}`);
profile += `(allow file-read* (subpath (param "WORKSPACE_RAW")))\n`;
if (options.workspaceWrite) {
profile += `(allow file-write* (subpath (param "WORKSPACE_RAW")))\n`;
}
if (options.workspaceWrite) {
profile += `(allow file-write* (subpath (param "WORKSPACE")))\n`;
}
// Add explicit deny rules for governance files in the workspace.
// These are added after the workspace allow rule to ensure they take precedence
// (Seatbelt evaluates rules in order, later rules win for same path).
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path);
const realGovernanceFile = tryRealpath(governanceFile);
// Determine if it should be treated as a directory (subpath) or a file (literal).
// .git is generally a directory, while ignore files are literals.
let isDirectory = GOVERNANCE_FILES[i].isDirectory;
try {
if (fs.existsSync(realGovernanceFile)) {
isDirectory = fs.lstatSync(realGovernanceFile).isDirectory();
}
} catch {
// Ignore errors, use default guess
}
const ruleType = isDirectory ? 'subpath' : 'literal';
args.push('-D', `GOVERNANCE_FILE_${i}=${governanceFile}`);
profile += `(deny file-write* (${ruleType} (param "GOVERNANCE_FILE_${i}")))\n`;
if (realGovernanceFile !== governanceFile) {
args.push('-D', `REAL_GOVERNANCE_FILE_${i}=${realGovernanceFile}`);
profile += `(deny file-write* (${ruleType} (param "REAL_GOVERNANCE_FILE_${i}")))\n`;
}
}
// Auto-detect and support git worktrees by granting read and write access to the underlying git directory
try {
const gitPath = path.join(workspacePath, '.git');
const gitStat = fs.lstatSync(gitPath);
if (gitStat.isFile()) {
const gitContent = fs.readFileSync(gitPath, 'utf8');
const match = gitContent.match(/^gitdir:\s*(.+)$/m);
if (match && match[1]) {
let worktreeGitDir = match[1].trim();
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(workspacePath, worktreeGitDir);
}
const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir);
// Grant write access to the worktree's specific .git directory
args.push('-D', `WORKTREE_GIT_DIR=${resolvedWorktreeGitDir}`);
profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`;
// Grant write access to the main repository's .git directory (objects, refs, etc. are shared)
// resolvedWorktreeGitDir is usually like: /path/to/main-repo/.git/worktrees/worktree-name
const mainGitDir = tryRealpath(
path.dirname(path.dirname(resolvedWorktreeGitDir)),
);
if (mainGitDir && mainGitDir.endsWith('.git')) {
args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`);
profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`;
}
}
}
} catch (_e) {
// Ignore if .git doesn't exist, isn't readable, etc.
}
const tmpPath = tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
const nodeRootPath = tryRealpath(
path.dirname(path.dirname(process.execPath)),
);
args.push('-D', `NODE_ROOT=${nodeRootPath}`);
profile += `(allow file-read* (subpath (param "NODE_ROOT")))\n`;
// Add PATH directories as read-only to support nvm, homebrew, etc.
if (process.env['PATH']) {
const paths = process.env['PATH'].split(':');
let pathIndex = 0;
const addedPaths = new Set();
for (const p of paths) {
if (!p.trim()) continue;
try {
let resolved = tryRealpath(p);
// If this is a 'bin' directory (like /usr/local/bin or homebrew/bin),
// also grant read access to its parent directory so that symlinked
// assets (like Cellar or libexec) can be read.
if (resolved.endsWith('/bin')) {
resolved = path.dirname(resolved);
}
if (!addedPaths.has(resolved)) {
addedPaths.add(resolved);
args.push('-D', `SYS_PATH_${pathIndex}=${resolved}`);
profile += `(allow file-read* (subpath (param "SYS_PATH_${pathIndex}")))\n`;
pathIndex++;
}
} catch (_e) {
// Ignore paths that do not exist or are inaccessible
}
}
}
// Handle allowedPaths
const allowedPaths = sanitizePaths(options.allowedPaths) || [];
for (let i = 0; i < allowedPaths.length; i++) {
const allowedPath = tryRealpath(allowedPaths[i]);
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
}
// Handle granular additional permissions
if (options.additionalPermissions?.fileSystem) {
const { read, write } = options.additionalPermissions.fileSystem;
if (read) {
read.forEach((p, i) => {
const resolved = tryRealpath(p);
const paramName = `ADDITIONAL_READ_${i}`;
args.push('-D', `${paramName}=${resolved}`);
let isFile = false;
try {
isFile = fs.statSync(resolved).isFile();
} catch {
// Ignore error
}
if (isFile) {
profile += `(allow file-read* (literal (param "${paramName}")))\n`;
} else {
profile += `(allow file-read* (subpath (param "${paramName}")))\n`;
}
});
}
if (write) {
write.forEach((p, i) => {
const resolved = tryRealpath(p);
const paramName = `ADDITIONAL_WRITE_${i}`;
args.push('-D', `${paramName}=${resolved}`);
let isFile = false;
try {
isFile = fs.statSync(resolved).isFile();
} catch {
// Ignore error
}
if (isFile) {
profile += `(allow file-read* file-write* (literal (param "${paramName}")))\n`;
} else {
profile += `(allow file-read* file-write* (subpath (param "${paramName}")))\n`;
}
});
}
}
// Handle forbiddenPaths
const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || [];
for (let i = 0; i < forbiddenPaths.length; i++) {
const forbiddenPath = tryRealpath(forbiddenPaths[i]);
args.push('-D', `FORBIDDEN_PATH_${i}=${forbiddenPath}`);
profile += `(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_${i}")))\n`;
}
if (options.networkAccess || options.additionalPermissions?.network) {
profile += NETWORK_SEATBELT_PROFILE;
}
args.unshift('-p', profile);
return args;
}
+1 -2
View File
@@ -77,8 +77,7 @@ export async function checkPolicy(
// confirmation prompt if the policy engine's decision is 'ASK_USER'.
if (
decision === PolicyDecision.ASK_USER &&
toolCall.request.isClientInitiated &&
!toolCall.request.args?.['additional_permissions']
toolCall.request.isClientInitiated
) {
return {
decision: PolicyDecision.ALLOW,
-104
View File
@@ -792,110 +792,6 @@ export class Scheduler {
return true;
}
let isSandboxError = false;
let sandboxDetailsStr = '';
if (
result.status === CoreToolCallStatus.Error &&
result.response.errorType === 'sandbox_expansion_required'
) {
isSandboxError = true;
sandboxDetailsStr = result.response.error?.message || '';
}
if (isSandboxError) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedError = JSON.parse(sandboxDetailsStr) as {
rootCommand: string;
additionalPermissions: import('../services/sandboxManager.js').SandboxPermissions;
};
const confirmationDetails: SerializableConfirmationDetails = {
type: 'sandbox_expansion',
title: 'Sandbox Expansion Request',
command: String(
activeCall.request.args['command'] ?? parsedError.rootCommand,
),
rootCommand: parsedError.rootCommand,
additionalPermissions: parsedError.additionalPermissions,
};
const correlationId = crypto.randomUUID();
// Mutate the active call so resolveConfirmation generates the correct Sandbox Expansion details
activeCall.request.args['additional_permissions'] =
parsedError.additionalPermissions;
activeCall.invocation = activeCall.tool.build(activeCall.request.args);
// CRITICAL: We must push the new args and invocation into the state manager
// before calling resolveConfirmation, because resolveConfirmation fetches
// the tool call directly from the state manager!
this.state.updateArgs(
callId,
activeCall.request.args,
activeCall.invocation,
);
this.state.updateStatus(callId, CoreToolCallStatus.AwaitingApproval, {
confirmationDetails,
correlationId,
});
const validatingCall = {
...activeCall,
status: CoreToolCallStatus.Validating,
} as ValidatingToolCall;
const confResult = await resolveConfirmation(validatingCall, signal, {
config: this.config,
messageBus: this.messageBus,
state: this.state,
modifier: this.modifier,
getPreferredEditor: this.getPreferredEditor,
schedulerId: this.schedulerId,
onWaitingForConfirmation: this.onWaitingForConfirmation,
});
if (confResult.outcome === ToolConfirmationOutcome.Cancel) {
type LegacyHack = ToolCallResponseInfo & {
llmContent?: string;
returnDisplay?: string;
};
const errorResponse = { ...result.response } as LegacyHack;
errorResponse.llmContent =
'User cancelled sandbox expansion. The command failed with a sandbox denial. Shell output:\n' +
String(errorResponse.returnDisplay);
this.state.updateStatus(
callId,
CoreToolCallStatus.Error,
errorResponse,
);
return false;
}
activeCall.request.args['additional_permissions'] =
parsedError.additionalPermissions;
// Reset the output stream visual so it replaces the error text
this.state.updateStatus(callId, CoreToolCallStatus.Executing, {
liveOutput: undefined,
});
// Call _execute synchronously and properly return its promise to loop internally!
return await this._execute(
{
...activeCall,
status: CoreToolCallStatus.Scheduled,
} as ScheduledToolCall,
signal,
);
} catch (_e) {
// Fallback to normal error handling if parsing/looping fails
}
}
if (result.status === CoreToolCallStatus.Success) {
this.state.updateStatus(
callId,
@@ -11,18 +11,6 @@ import {
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
export interface SandboxPermissions {
/** Filesystem permissions. */
fileSystem?: {
/** Paths that should be readable by the command. */
read?: string[];
/** Paths that should be writable by the command. */
write?: string[];
};
/** Whether the command should have network access. */
network?: boolean;
}
/**
* Security boundaries and permissions applied to a specific sandboxed execution.
*/
@@ -35,8 +23,6 @@ export interface ExecutionPolicy {
networkAccess?: boolean;
/** Rules for scrubbing sensitive environment variables. */
sanitizationConfig?: Partial<EnvironmentSanitizationConfig>;
/** Additional granular permissions to grant to this command. */
additionalPermissions?: SandboxPermissions;
}
/**
@@ -90,16 +76,6 @@ export interface SandboxManager {
prepareCommand(req: SandboxRequest): Promise<SandboxedCommand>;
}
/**
* Files that represent the governance or "constitution" of the repository
* and should be write-protected in any sandbox.
*/
export const GOVERNANCE_FILES = [
{ path: '.gitignore', isDirectory: false },
{ path: '.geminiignore', isDirectory: false },
{ path: '.git', isDirectory: true },
] as const;
/**
* A no-op implementation of SandboxManager that silently passes commands
* through while applying environment sanitization.
@@ -14,7 +14,6 @@ import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
import type { SandboxConfig } from '../config/config.js';
import { type SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
/**
* Creates a sandbox manager based on the provided settings.
@@ -22,13 +21,7 @@ import { type SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
export function createSandboxManager(
sandbox: SandboxConfig | undefined,
workspace: string,
policyManager?: SandboxPolicyManager,
approvalMode?: string,
): SandboxManager {
if (approvalMode === 'yolo') {
return new NoopSandboxManager();
}
const isWindows = os.platform() === 'win32';
if (
@@ -43,15 +36,7 @@ export function createSandboxManager(
return new LinuxSandboxManager({ workspace });
}
if (os.platform() === 'darwin') {
const modeConfig =
policyManager && approvalMode
? policyManager.getModeConfig(approvalMode)
: undefined;
return new MacOsSandboxManager({
workspace,
modeConfig,
policyManager,
});
return new MacOsSandboxManager({ workspace });
}
return new LocalSandboxManager();
}
@@ -31,11 +31,7 @@ import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import {
NoopSandboxManager,
type SandboxManager,
type SandboxPermissions,
} from './sandboxManager.js';
import { NoopSandboxManager, type SandboxManager } from './sandboxManager.js';
import type { SandboxConfig } from '../config/config.js';
import { killProcessGroup } from '../utils/process-utils.js';
import {
@@ -88,7 +84,6 @@ export type ShellExecutionResult = ExecutionResult;
export type ShellExecutionHandle = ExecutionHandle;
export interface ShellExecutionConfig {
additionalPermissions?: SandboxPermissions;
terminalWidth?: number;
terminalHeight?: number;
pager?: string;
@@ -446,7 +441,6 @@ export class ShellExecutionService {
...shellExecutionConfig,
...(shellExecutionConfig.sandboxConfig || {}),
sanitizationConfig,
additionalPermissions: shellExecutionConfig.additionalPermissions,
},
});
@@ -5,7 +5,6 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
@@ -18,24 +17,21 @@ vi.mock('../utils/shell-utils.js', () => ({
describe('WindowsSandboxManager', () => {
let manager: WindowsSandboxManager;
let testCwd: string;
beforeEach(() => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
testCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
manager = new WindowsSandboxManager({ workspace: testCwd });
manager = new WindowsSandboxManager({ workspace: '/test/workspace' });
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(testCwd, { recursive: true, force: true });
});
it('should prepare a GeminiSandbox.exe command', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
cwd: testCwd,
cwd: '/test/cwd',
env: { TEST_VAR: 'test_value' },
policy: {
networkAccess: false,
@@ -45,14 +41,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', '/test/cwd', 'whoami', '/groups']);
});
it('should handle networkAccess from config', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: testCwd,
cwd: '/test/cwd',
env: {},
policy: {
networkAccess: true,
@@ -67,7 +63,7 @@ describe('WindowsSandboxManager', () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
cwd: '/test/cwd',
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
@@ -86,53 +82,29 @@ describe('WindowsSandboxManager', () => {
expect(result.env['API_KEY']).toBeUndefined();
});
it('should ensure governance files exist', async () => {
it('should grant Low Integrity access to the workspace and allowed paths', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
cwd: '/test/cwd',
env: {},
policy: {
allowedPaths: ['/test/allowed1'],
},
};
await manager.prepareCommand(req);
expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true);
expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(true);
});
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve('/test/workspace'),
'/setintegritylevel',
'Low',
]);
it('should grant Low Integrity access to the workspace and allowed paths', async () => {
const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed');
if (!fs.existsSync(allowedPath)) {
fs.mkdirSync(allowedPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
allowedPaths: [allowedPath],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(testCwd),
'/setintegritylevel',
'Low',
]);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(allowedPath),
'/setintegritylevel',
'Low',
]);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
}
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve('/test/allowed1'),
'/setintegritylevel',
'Low',
]);
});
});
@@ -12,7 +12,6 @@ import {
type SandboxManager,
type SandboxRequest,
type SandboxedCommand,
GOVERNANCE_FILES,
type GlobalSandboxOptions,
sanitizePaths,
} from './sandboxManager.js';
@@ -40,28 +39,6 @@ export class WindowsSandboxManager implements SandboxManager {
this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe');
}
/**
* Ensures a file or directory exists.
*/
private touch(filePath: string, isDirectory: boolean): void {
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
private async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (os.platform() !== 'win32') {
@@ -187,28 +164,7 @@ export class WindowsSandboxManager implements SandboxManager {
// TODO: handle forbidden paths
// 2. 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
// 2. Construct the helper command
// GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]
const program = this.helperPath;
@@ -583,35 +583,6 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
"name": "run_shell_command",
"parametersJsonSchema": {
"properties": {
"additional_permissions": {
"description": "Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".",
"properties": {
"fileSystem": {
"properties": {
"read": {
"description": "List of additional absolute paths to allow reading.",
"items": {
"type": "string",
},
"type": "array",
},
"write": {
"description": "List of additional absolute paths to allow writing.",
"items": {
"type": "string",
},
"type": "array",
},
},
"type": "object",
},
"network": {
"description": "Set to true to enable network access for this command.",
"type": "boolean",
},
},
"type": "object",
},
"command": {
"description": "Exact bash command to execute as \`bash -c <command>\`",
"type": "string",
@@ -1377,35 +1348,6 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
"name": "run_shell_command",
"parametersJsonSchema": {
"properties": {
"additional_permissions": {
"description": "Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".",
"properties": {
"fileSystem": {
"properties": {
"read": {
"description": "List of additional absolute paths to allow reading.",
"items": {
"type": "string",
},
"type": "array",
},
"write": {
"description": "List of additional absolute paths to allow writing.",
"items": {
"type": "string",
},
"type": "array",
},
},
"type": "object",
},
"network": {
"description": "Set to true to enable network access for this command.",
"type": "boolean",
},
},
"type": "object",
},
"command": {
"description": "Exact bash command to execute as \`bash -c <command>\`",
"type": "string",
@@ -122,6 +122,3 @@ export const EXIT_PLAN_PARAM_PLAN_PATH = 'plan_path';
// -- enter_plan_mode --
export const ENTER_PLAN_MODE_TOOL_NAME = 'enter_plan_mode';
export const PLAN_MODE_PARAM_REASON = 'reason';
// -- sandbox --
export const PARAM_ADDITIONAL_PERMISSIONS = 'additional_permissions';
@@ -23,7 +23,6 @@ import {
SHELL_PARAM_IS_BACKGROUND,
EXIT_PLAN_PARAM_PLAN_PATH,
SKILL_PARAM_NAME,
PARAM_ADDITIONAL_PERMISSIONS,
} from './base-declarations.js';
/**
@@ -110,35 +109,6 @@ export function getShellDeclaration(
description:
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
},
[PARAM_ADDITIONAL_PERMISSIONS]: {
type: 'object',
description:
'Sandbox permissions for the command. Use this to request additional sandboxed filesystem or network permissions if a previous command failed with "Operation not permitted".',
properties: {
network: {
type: 'boolean',
description:
'Set to true to enable network access for this command.',
},
fileSystem: {
type: 'object',
properties: {
read: {
type: 'array',
items: { type: 'string' },
description:
'List of additional absolute paths to allow reading.',
},
write: {
type: 'array',
items: { type: 'string' },
description:
'List of additional absolute paths to allow writing.',
},
},
},
},
},
},
required: [SHELL_PARAM_COMMAND],
},
-206
View File
@@ -5,12 +5,10 @@
*/
import fsPromises from 'node:fs/promises';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { debugLogger } from '../index.js';
import type { SandboxPermissions } from '../services/sandboxManager.js';
import { ToolErrorType } from './tool-error.js';
import {
BaseDeclarativeTool,
@@ -43,7 +41,6 @@ import {
hasRedirection,
} from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
@@ -59,7 +56,6 @@ export interface ShellToolParams {
description?: string;
dir_path?: string;
is_background?: boolean;
[PARAM_ADDITIONAL_PERMISSIONS]?: SandboxPermissions;
}
export class ShellToolInvocation extends BaseToolInvocation<
@@ -126,15 +122,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
return undefined;
}
override async shouldConfirmExecute(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) {
return this.getConfirmationDetails(abortSignal);
}
return super.shouldConfirmExecute(abortSignal);
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
@@ -161,32 +148,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
// Rely entirely on PolicyEngine for interactive confirmation.
// If we are here, it means PolicyEngine returned ASK_USER (or no message bus),
// so we must provide confirmation details.
// If additional_permissions are provided, it's an expansion request
if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) {
return {
type: 'sandbox_expansion',
title: 'Sandbox Expansion Request',
command: this.params.command,
rootCommand: rootCommandDisplay,
additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS],
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
const commandName = rootCommands[0] || 'shell';
this.context.config.sandboxPolicyManager.addPersistentApproval(
commandName,
this.params[PARAM_ADDITIONAL_PERMISSIONS]!,
);
} else if (outcome === ToolConfirmationOutcome.ProceedAlways) {
const commandName = rootCommands[0] || 'shell';
this.context.config.sandboxPolicyManager.addSessionApproval(
commandName,
this.params[PARAM_ADDITIONAL_PERMISSIONS]!,
);
}
},
};
}
const confirmationDetails: ToolExecuteConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Command',
@@ -332,7 +293,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
shellExecutionConfig?.sanitizationConfig ??
this.context.config.sanitizationConfig,
sandboxManager: this.context.config.sandboxManager,
additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS],
},
);
@@ -366,13 +326,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
const pgrepLines = pgrepContent.split(os.EOL).filter(Boolean);
for (const line of pgrepLines) {
if (!/^\d+$/.test(line)) {
if (
line.includes('sysmond service not found') ||
line.includes('Cannot get process list') ||
line.includes('sysmon request failed')
) {
continue;
}
debugLogger.error(`pgrep: ${line}`);
}
const pid = Number(line);
@@ -477,165 +430,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
// Heuristic Sandbox Denial Detection
const lowerOutput = (
(result.output || '') +
' ' +
(result.error?.message || '')
).toLowerCase();
const isFileDenial = [
'operation not permitted',
'vim:e303',
'should be read/write',
'sandbox_apply',
'sandbox: ',
].some((keyword) => lowerOutput.includes(keyword));
const isNetworkDenial = [
'error connecting to',
'network is unreachable',
'could not resolve host',
'connection refused',
'no address associated with hostname',
].some((keyword) => lowerOutput.includes(keyword));
// Only trigger heuristic if the command actually failed (exit code != 0 or aborted)
const failed =
!!result.error ||
!!result.signal ||
(result.exitCode !== undefined && result.exitCode !== 0) ||
result.aborted;
if (failed && (isFileDenial || isNetworkDenial)) {
const strippedCommand = stripShellWrapper(this.params.command);
const rootCommands = getCommandRoots(strippedCommand).filter(
(r) => r !== 'shopt',
);
const rootCommandDisplay =
rootCommands.length > 0 ? rootCommands[0] : 'shell';
// Extract denied paths
const deniedPaths = new Set<string>();
const regex =
/(?:^|\s)['"]?(\/[\w.-/]+)['"]?:\s*[Oo]peration not permitted/gi;
let match;
while ((match = regex.exec(result.output || '')) !== null) {
deniedPaths.add(match[1]);
}
while ((match = regex.exec(result.error?.message || '')) !== null) {
deniedPaths.add(match[1]);
}
if (isFileDenial && deniedPaths.size === 0) {
// Fallback heuristic: look for any absolute path in the output
// Avoid matching simple commands like /bin/sh
const fallbackRegex =
/(?:^|[\s"'[\]])(\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+)(?:$|[\s"'[\]:])/gi;
let m;
while ((m = fallbackRegex.exec(result.output || '')) !== null) {
const p = m[1];
if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) {
deniedPaths.add(p);
}
}
while (
(m = fallbackRegex.exec(result.error?.message || '')) !== null
) {
const p = m[1];
if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) {
deniedPaths.add(p);
}
}
}
const readPaths = new Set(
this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.read || [],
);
const writePaths = new Set(
this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.write || [],
);
for (const p of deniedPaths) {
try {
// Find an existing parent directory to add instead of a non-existent file
let currentPath = p;
try {
if (
fs.existsSync(currentPath) &&
fs.statSync(currentPath).isFile()
) {
currentPath = path.dirname(currentPath);
}
} catch (_e) {
/* ignore */
}
while (currentPath.length > 1) {
if (fs.existsSync(currentPath)) {
writePaths.add(currentPath);
readPaths.add(currentPath);
break;
}
currentPath = path.dirname(currentPath);
}
} catch (_e) {
// ignore
}
}
const additionalPermissions = {
network:
isNetworkDenial ||
this.params[PARAM_ADDITIONAL_PERMISSIONS]?.network ||
undefined,
fileSystem:
isFileDenial || writePaths.size > 0
? {
read: Array.from(readPaths),
write: Array.from(writePaths),
}
: undefined,
};
const originalReadSize =
this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.read?.length ||
0;
const originalWriteSize =
this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.write
?.length || 0;
const originalNetwork =
!!this.params[PARAM_ADDITIONAL_PERMISSIONS]?.network;
const newReadSize = additionalPermissions.fileSystem?.read?.length || 0;
const newWriteSize =
additionalPermissions.fileSystem?.write?.length || 0;
const newNetwork = !!additionalPermissions.network;
const hasNewPermissions =
newReadSize > originalReadSize ||
newWriteSize > originalWriteSize ||
(!originalNetwork && newNetwork);
if (hasNewPermissions) {
const confirmationDetails = {
type: 'sandbox_expansion',
title: 'Sandbox Expansion Request',
command: this.params.command,
rootCommand: rootCommandDisplay,
additionalPermissions,
};
return {
llmContent: 'Sandbox expansion required',
returnDisplay: returnDisplayMessage,
error: {
type: ToolErrorType.SANDBOX_EXPANSION_REQUIRED,
message: JSON.stringify(confirmationDetails),
},
};
}
// If no new permissions were found by heuristic, do not intercept.
// Just return the normal execution error so the LLM can try providing explicit paths itself.
}
const summarizeConfig =
this.context.config.getSummarizeToolOutputConfig();
const executionError = result.error
-1
View File
@@ -64,7 +64,6 @@ export enum ToolErrorType {
// Shell errors
SHELL_EXECUTE_ERROR = 'shell_execute_error',
SANDBOX_EXPANSION_REQUIRED = 'sandbox_expansion_required',
// DiscoveredTool-specific Errors
DISCOVERED_TOOL_EXECUTION_ERROR = 'discovered_tool_execution_error',
-11
View File
@@ -992,16 +992,6 @@ export type ToolConfirmationPayload =
| ToolAskUserConfirmationPayload
| ToolExitPlanModeConfirmationPayload;
export interface ToolSandboxExpansionConfirmationDetails {
type: 'sandbox_expansion';
systemMessage?: string;
title: string;
command: string;
rootCommand: string;
additionalPermissions: import('../services/sandboxManager.js').SandboxPermissions;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
}
export interface ToolExecuteConfirmationDetails {
type: 'exec';
title: string;
@@ -1058,7 +1048,6 @@ export interface ToolExitPlanModeConfirmationDetails {
}
export type ToolCallConfirmationDetails =
| ToolSandboxExpansionConfirmationDetails
| ToolEditConfirmationDetails
| ToolExecuteConfirmationDetails
| ToolMcpConfirmationDetails
+1 -1
View File
@@ -704,7 +704,7 @@ export function getCommandRoots(command: string): string[] {
export function stripShellWrapper(command: string): string {
const pattern =
/^\s*(?:(?:(?:\S+\/)?(?:sh|bash|zsh))\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
/^\s*(?:(?:sh|bash|zsh)\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
const match = command.match(pattern);
if (match) {
let newCommand = command.substring(match[0].length).trim();
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
# Gemini API Reliability Harvester
# -------------------------------
# This script gathers data about 500 API errors encountered during evaluation runs
# (eval.yml) from GitHub Actions. It is used to analyze developer friction caused
# by transient API failures.
#
# Usage:
# ./scripts/harvest_api_reliability.sh [SINCE] [LIMIT] [BRANCH]
#
# Examples:
# ./scripts/harvest_api_reliability.sh # Last 7 days, all branches
# ./scripts/harvest_api_reliability.sh 14d 500 # Last 14 days, limit 500
# ./scripts/harvest_api_reliability.sh 2026-03-01 100 my-branch # Specific date and branch
#
# Prerequisites:
# - GitHub CLI (gh) installed and authenticated (`gh auth login`)
# - jq installed
# Arguments & Defaults
if [[ -n "$1" && $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
SINCE="$1"
elif [[ -n "$1" && $1 =~ ^([0-9]+)d$ ]]; then
DAYS="${BASH_REMATCH[1]}"
if [[ "$OSTYPE" == "darwin"* ]]; then
SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d)
else
SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d)
fi
else
# Default to 7 days ago in YYYY-MM-DD format (UTC)
if [[ "$OSTYPE" == "darwin"* ]]; then
SINCE=$(date -u -v-7d +%Y-%m-%d)
else
SINCE=$(date -u -d "7 days ago" +%Y-%m-%d)
fi
fi
LIMIT=${2:-300}
BRANCH=${3:-""}
WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly")
DEST_DIR=$(mktemp -d -t gemini-reliability-XXXXXX)
MERGED_FILE="api-reliability-summary.jsonl"
# Ensure cleanup on exit
trap 'rm -rf "$DEST_DIR"' EXIT
if ! command -v gh &> /dev/null; then
echo "❌ Error: GitHub CLI (gh) is not installed."
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "❌ Error: jq is not installed."
exit 1
fi
# Clean start
rm -f "$MERGED_FILE"
# gh run list --created expects a date (YYYY-MM-DD) or a range
CREATED_QUERY=">=$SINCE"
for WORKFLOW in "${WORKFLOWS[@]}"; do
echo "🔍 Fetching runs for '$WORKFLOW' created since $SINCE (max $LIMIT runs, branch: ${BRANCH:-all})..."
# Construct arguments for gh run list
GH_ARGS=("--workflow" "$WORKFLOW" "--created" "$CREATED_QUERY" "--limit" "$LIMIT" "--json" "databaseId" "--jq" ".[].databaseId")
if [ -n "$BRANCH" ]; then
GH_ARGS+=("--branch" "$BRANCH")
fi
RUN_IDS=$(gh run list "${GH_ARGS[@]}")
if [ -z "$RUN_IDS" ]; then
echo "📭 No runs found for workflow '$WORKFLOW' since $SINCE."
continue
fi
for ID in $RUN_IDS; do
# Download artifacts named 'eval-logs-*'
# Silencing output because many older runs won't have artifacts
gh run download "$ID" -p "eval-logs-*" -D "$DEST_DIR/$ID" &>/dev/null || continue
# Append to master log
# Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID
find "$DEST_DIR/$ID" -type f -name "api-reliability.jsonl" -exec cat {} + >> "$MERGED_FILE" 2>/dev/null
done
done
if [ ! -f "$MERGED_FILE" ]; then
echo "📭 No reliability data found in the retrieved logs."
exit 0
fi
echo -e "\n✅ Harvest Complete! Data merged into: $MERGED_FILE"
echo "------------------------------------------------"
echo "📊 Gemini API Reliability Summary (Since $SINCE)"
echo "------------------------------------------------"
cat "$MERGED_FILE" | jq -s '
group_by(.model) | map({
model: .[0].model,
"500s": (map(select(.errorCode == "500")) | length),
"503s": (map(select(.errorCode == "503")) | length),
retries: (map(select(.status == "RETRY")) | length),
skips: (map(select(.status == "SKIP")) | length)
})'
echo -e "\n💡 Total events captured: $(wc -l < "$MERGED_FILE")"