Compare commits

...

36 Commits

Author SHA1 Message Date
Sehoon Shon 4b97a5ae84 fix(build): add outDir to vscode-ide-companion to avoid linting generated files 2026-03-25 10:37:39 -04:00
Sehoon Shon c6119078c1 fix(build): add explicit devtools dependency to cli and fix output structure 2026-03-25 09:54:02 -04:00
Sehoon Shon eea2c95659 fix(build): correct devtools structure and module resolution 2026-03-25 07:50:08 -04:00
Sehoon Shon 752c96f4c3 perf(build): finalize build optimization and address PR feedback 2026-03-25 03:40:00 -04:00
Sehoon Shon 3574b9cf07 fix(build): ensure all workspace tsconfigs are explicitly composite and emit declarations 2026-03-25 03:25:42 -04:00
Sehoon Shon d1ff98f842 fix(build): correct devtools project references and tsconfig inheritence 2026-03-25 03:24:38 -04:00
Sehoon Shon 24f7f4f23d fix(build): resolve devtools project reference and emit errors 2026-03-25 03:23:50 -04:00
Sehoon Shon 31d31576f9 perf(build): optimize build speed and parallelize asset copying 2026-03-25 03:19:07 -04:00
Tommaso Sciortino 73526416cf format recently added script (#23739) 2026-03-24 19:49:55 -07:00
Emily Hedlund 5b7f7b30a7 refactor(core): standardize OS-specific sandbox tests and extract linux helper methods (#23715) 2026-03-25 02:37:32 +00:00
Gal Zahavi a6c7affedb fix(core): conditionally expose additional_permissions in shell tool (#23729)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-03-25 01:46:15 +00:00
Emily Hedlund 578d656de9 feat(sandbox): implement forbiddenPaths for OS-specific sandbox managers (#23282)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-03-25 01:23:51 +00:00
matt korwel f74f2b0780 feat(skills): add ci skill for automated failure replication (#23720) 2026-03-24 17:43:04 -07:00
matt korwel 0552464eed fix(test): move flaky ctrl-c-exit test to non-blocking suite (#23732) 2026-03-25 00:22:23 +00:00
matt korwel bbdd8457df fix(cli): stabilize copy mode to prevent flickering and cursor resets (#22584)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-24 23:16:48 +00:00
Alisa 71a9131709 chore(tools): add toJSON to tools and invocations to reduce logging verbosity (#22899) 2026-03-24 23:08:29 +00:00
Yuna Seol 397ff84b0e Refine onboarding metrics to log the duration explicitly and use the tier name. (#23678) 2026-03-24 22:19:36 +00:00
Tommaso Sciortino 1f07efb5d8 fix(cli): only show updating spinner when auto-update is in progress (#23709) 2026-03-24 15:43:03 -07:00
Adam Weidman ee425aefa6 fix(core): improve agent loader error formatting for empty paths (#23690) 2026-03-24 22:04:28 +00:00
cynthialong0-0 466671eed4 feat(browser): add maxActionsPerTask for browser agent setting (#23216) 2026-03-24 21:40:48 +00:00
Jack Wotherspoon 11dc33eab7 fix(core): prevent premature MCP discovery completion (#23637) 2026-03-24 20:53:21 +00:00
Adib234 e591b51919 fix(plan): update telemetry attribute keys and add timestamp (#23685) 2026-03-24 20:49:50 +00:00
Abhi 055ff92276 revert: chore(config): disable agents by default (#23672) 2026-03-24 20:14:48 +00:00
Samee Zahid 84f40768a1 feat(evals): centralize test agents into test-utils for reuse (#23616)
Co-authored-by: Samee Zahid <sameez@google.com>
2026-03-24 19:50:48 +00:00
Adib234 bf80e27dbc test(evals): fix overlapping act() deadlock in app-test-helper (#23666) 2026-03-24 19:12:22 +00:00
Tommaso Sciortino 122b8d43ca chore(deps): fix npm audit vulnerabilities (#23679) 2026-03-24 18:53:03 +00:00
Adam Weidman 999ae7827d feat(core): change user-facing auth type from oauth2 to oauth (#23639)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-24 18:46:12 +00:00
Tommaso Sciortino 1c3d397782 use enableAutoUpdate in test rig (#23681) 2026-03-24 10:23:57 -07:00
Christian Gunderman 6b7dc4d822 refactor(core): stop gemini CLI from producing unsafe casts (#23611) 2026-03-24 16:19:59 +00:00
Sehoon Shon 139cc7b97c perf(cli): optimize --version startup time (#23671) 2026-03-24 15:58:41 +00:00
Adam Weidman 91d756f391 fix(core): improve remote agent streaming UI and UX (#23633) 2026-03-24 15:34:04 +00:00
Gal Zahavi fc18768155 refactor(sandbox): reorganize Windows sandbox files (#23645) 2026-03-24 14:32:20 +00:00
Sehoon Shon 893c7d3880 feat(ui): allow immediate user input during startup (#23661) 2026-03-24 13:33:17 +00:00
Adib234 dcedc42979 fix(plan): sandbox path resolution in Plan Mode to prevent hallucinations (#22737)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-24 13:19:29 +00:00
Sri Pasumarthi 46fd7b4864 fix(acp): Pass the cwd to AcpFileSystemService to avoid looping failures in asking for perms to write plan md file (#23612) 2026-03-24 05:34:08 +00:00
Gal Zahavi 36e6445dba feat(sandbox): dynamic macOS sandbox expansion and worktree support (#23301) 2026-03-24 04:48:13 +00:00
151 changed files with 5905 additions and 1567 deletions
+66
View File
@@ -0,0 +1,66 @@
---
name: ci
description:
A specialized skill for Gemini CLI that provides high-performance, fail-fast
monitoring of GitHub Actions workflows and automated local verification of CI
failures. It handles run discovery automatically—simply provide the branch name.
---
# CI Replicate & Status
This skill enables the agent to efficiently monitor GitHub Actions, triage
failures, and bridge remote CI errors to local development. It defaults to
**automatic replication** of failures to streamline the fix cycle.
## Core Capabilities
- **Automatic Replication**: Automatically monitors CI and immediately executes
suggested test or lint commands locally upon failure.
- **Real-time Monitoring**: Aggregated status line for all concurrent workflows
on the current branch.
- **Fail-Fast Triage**: Immediately stops on the first job failure to provide a
structured report.
## Workflow
### 1. CI Replicate (`replicate`) - DEFAULT
Use this as the primary path to monitor CI and **automatically** replicate
failures locally for immediate triage and fixing.
- **Behavior**: When this workflow is triggered, the agent will monitor the CI
and **immediately and automatically execute** all suggested test or lint
commands (marked with 🚀) as soon as a failure is detected.
- **Tool**: `node .gemini/skills/ci/scripts/ci.mjs [branch]`
- **Discovery**: The script **automatically** finds the latest active or recent
run for the branch. Do NOT manually search for run IDs.
- **Goal**: Reproduce the failure locally without manual intervention, then
proceed to analyze and fix the code.
### 1. CI Status (`status`)
Use this when you have pushed changes and need to monitor the CI and reproduce
any failures locally.
- **Tool**: `node .gemini/skills/ci/scripts/ci.mjs [branch] [run_id]`
- **Discovery**: The script **automatically** finds the latest active or recent
run for the branch. You should NOT manually search for \`run_id\` using \`gh run list\`
unless a specific historical run is requested. Simply provide the branch name.
- **Step 1 (Monitor)**: Execute the tool with the branch name.
- **Step 2 (Extract)**: Extract suggested \`npm test\` or \`npm run lint\` commands
from the output (marked with 🚀).
- **Step 3 (Reproduce)**: Execute those commands locally to confirm the failure.
- **Behavior**: It will poll every 15 seconds. If it detects a failure, it will
exit with a structured report and provide the exact commands to run locally.
## Failure Categories & Actions
- **Test Failures**: Agent should run the specific `npm test -w <pkg> -- <path>`
command suggested.
- **Lint Errors**: Agent should run `npm run lint:all` or the specific package
lint command.
- **Build Errors**: Agent should check `tsc` output or build logs to resolve
compilation issues.
- **Job Errors**: Investigate `gh run view --job <job_id> --log` for
infrastructure or setup failures.
## Noise Filtering
The underlying scripts automatically filter noise (Git logs, NPM warnings, stack
trace overhead). The agent should focus on the "Structured Failure Report"
provided by the tool.
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
const BRANCH =
process.argv[2] || execSync('git branch --show-current').toString().trim();
const RUN_ID_OVERRIDE = process.argv[3];
let REPO;
try {
const remoteUrl = execSync('git remote get-url origin').toString().trim();
REPO = remoteUrl
.replace(/.*github\.com[\/:]/, '')
.replace(/\.git$/, '')
.trim();
} catch (e) {
REPO = 'google-gemini/gemini-cli';
}
const FAILED_FILES = new Set();
function runGh(args) {
try {
return execSync(`gh ${args}`, {
stdio: ['ignore', 'pipe', 'ignore'],
}).toString();
} catch (e) {
return null;
}
}
function fetchFailuresViaApi(jobId) {
try {
const cmd = `gh api repos/${REPO}/actions/jobs/${jobId}/logs | grep -iE " FAIL |❌|ERROR|Lint failed|Build failed|Exception|failed with exit code"`;
return execSync(cmd, {
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 10 * 1024 * 1024,
}).toString();
} catch (e) {
return '';
}
}
function isNoise(line) {
const lower = line.toLowerCase();
return (
lower.includes('* [new branch]') ||
lower.includes('npm warn') ||
lower.includes('fetching updates') ||
lower.includes('node:internal/errors') ||
lower.includes('at ') || // Stack traces
lower.includes('checkexecsyncerror') ||
lower.includes('node_modules')
);
}
function extractTestFile(failureText) {
const cleanLine = failureText
.replace(/[|#\[\]()]/g, ' ')
.replace(/<[^>]*>/g, ' ')
.trim();
const fileMatch = cleanLine.match(/([\w\/._-]+\.test\.[jt]sx?)/);
if (fileMatch) return fileMatch[1];
return null;
}
function generateTestCommand(failedFilesMap) {
const workspaceToFiles = new Map();
for (const [file, info] of failedFilesMap.entries()) {
if (
['Job Error', 'Unknown File', 'Build Error', 'Lint Error'].includes(file)
)
continue;
let workspace = '@google/gemini-cli';
let relPath = file;
if (file.startsWith('packages/core/')) {
workspace = '@google/gemini-cli-core';
relPath = file.replace('packages/core/', '');
} else if (file.startsWith('packages/cli/')) {
workspace = '@google/gemini-cli';
relPath = file.replace('packages/cli/', '');
}
relPath = relPath.replace(/^.*packages\/[^\/]+\//, '');
if (!workspaceToFiles.has(workspace))
workspaceToFiles.set(workspace, new Set());
workspaceToFiles.get(workspace).add(relPath);
}
const commands = [];
for (const [workspace, files] of workspaceToFiles.entries()) {
commands.push(`npm test -w ${workspace} -- ${Array.from(files).join(' ')}`);
}
return commands.join(' && ');
}
async function monitor() {
let targetRunIds = [];
if (RUN_ID_OVERRIDE) {
targetRunIds = [RUN_ID_OVERRIDE];
} else {
// 1. Get runs directly associated with the branch
const runListOutput = runGh(
`run list --branch "${BRANCH}" --limit 10 --json databaseId,status,workflowName,createdAt`,
);
if (runListOutput) {
const runs = JSON.parse(runListOutput);
const activeRuns = runs.filter((r) => r.status !== 'completed');
if (activeRuns.length > 0) {
targetRunIds = activeRuns.map((r) => r.databaseId);
} else if (runs.length > 0) {
const latestTime = new Date(runs[0].createdAt).getTime();
targetRunIds = runs
.filter((r) => latestTime - new Date(r.createdAt).getTime() < 60000)
.map((r) => r.databaseId);
}
}
// 2. Get runs associated with commit statuses (handles chained/indirect runs)
try {
const headSha = execSync(`git rev-parse "${BRANCH}"`).toString().trim();
const statusOutput = runGh(
`api repos/${REPO}/commits/${headSha}/status -q '.statuses[] | select(.target_url | contains("actions/runs/")) | .target_url'`,
);
if (statusOutput) {
const statusRunIds = statusOutput
.split('\n')
.filter(Boolean)
.map((url) => {
const match = url.match(/actions\/runs\/(\d+)/);
return match ? parseInt(match[1], 10) : null;
})
.filter(Boolean);
for (const runId of statusRunIds) {
if (!targetRunIds.includes(runId)) {
targetRunIds.push(runId);
}
}
}
} catch (e) {
// Ignore if branch/SHA not found or API fails
}
if (targetRunIds.length > 0) {
const runNames = [];
for (const runId of targetRunIds) {
const runInfo = runGh(`run view "${runId}" --json workflowName`);
if (runInfo) {
runNames.push(JSON.parse(runInfo).workflowName);
}
}
console.log(`Monitoring workflows: ${[...new Set(runNames)].join(', ')}`);
}
}
if (targetRunIds.length === 0) {
console.log(`No runs found for branch ${BRANCH}.`);
process.exit(0);
}
while (true) {
let allPassed = 0,
allFailed = 0,
allRunning = 0,
allQueued = 0,
totalJobs = 0;
let anyRunInProgress = false;
const fileToTests = new Map();
let failuresFoundInLoop = false;
for (const runId of targetRunIds) {
const runOutput = runGh(
`run view "${runId}" --json databaseId,status,conclusion,workflowName`,
);
if (!runOutput) continue;
const run = JSON.parse(runOutput);
if (run.status !== 'completed') anyRunInProgress = true;
const jobsOutput = runGh(`run view "${runId}" --json jobs`);
if (jobsOutput) {
const { jobs } = JSON.parse(jobsOutput);
totalJobs += jobs.length;
const failedJobs = jobs.filter((j) => j.conclusion === 'failure');
if (failedJobs.length > 0) {
failuresFoundInLoop = true;
for (const job of failedJobs) {
const failures = fetchFailuresViaApi(job.databaseId);
if (failures.trim()) {
failures.split('\n').forEach((line) => {
if (!line.trim() || isNoise(line)) return;
const file = extractTestFile(line);
const filePath =
file ||
(line.toLowerCase().includes('lint')
? 'Lint Error'
: line.toLowerCase().includes('build')
? 'Build Error'
: 'Unknown File');
let testName = line;
if (line.includes(' > ')) {
testName = line.split(' > ').slice(1).join(' > ').trim();
}
if (!fileToTests.has(filePath))
fileToTests.set(filePath, new Set());
fileToTests.get(filePath).add(testName);
});
} else {
const step =
job.steps?.find((s) => s.conclusion === 'failure')?.name ||
'unknown';
const category = step.toLowerCase().includes('lint')
? 'Lint Error'
: step.toLowerCase().includes('build')
? 'Build Error'
: 'Job Error';
if (!fileToTests.has(category))
fileToTests.set(category, new Set());
fileToTests
.get(category)
.add(`${job.name}: Failed at step "${step}"`);
}
}
}
for (const job of jobs) {
if (job.status === 'in_progress') allRunning++;
else if (job.status === 'queued') allQueued++;
else if (job.conclusion === 'success') allPassed++;
else if (job.conclusion === 'failure') allFailed++;
}
}
}
if (failuresFoundInLoop) {
console.log(
`\n\n❌ Failures detected across ${allFailed} job(s). Stopping monitor...`,
);
console.log('\n--- Structured Failure Report (Noise Filtered) ---');
for (const [file, tests] of fileToTests.entries()) {
console.log(`\nCategory/File: ${file}`);
// Limit output per file if it's too large
const testsArr = Array.from(tests).map((t) =>
t.length > 500 ? t.substring(0, 500) + '... [TRUNCATED]' : t,
);
testsArr.slice(0, 10).forEach((t) => console.log(` - ${t}`));
if (testsArr.length > 10)
console.log(` ... and ${testsArr.length - 10} more`);
}
const testCmd = generateTestCommand(fileToTests);
if (testCmd) {
console.log('\n🚀 Run this to verify fixes:');
console.log(testCmd);
} else if (
Array.from(fileToTests.keys()).some((k) => k.includes('Lint'))
) {
console.log('\n🚀 Run this to verify lint fixes:\nnpm run lint:all');
}
console.log('---------------------------------');
process.exit(1);
}
const completed = allPassed + allFailed;
process.stdout.write(
`\r⏳ Monitoring ${targetRunIds.length} runs... ${completed}/${totalJobs} jobs (${allPassed} passed, ${allFailed} failed, ${allRunning} running, ${allQueued} queued) `,
);
if (!anyRunInProgress) {
console.log('\n✅ All workflows passed!');
process.exit(0);
}
await new Promise((r) => setTimeout(r, 15000));
}
}
monitor().catch((err) => {
console.error('\nMonitor error:', err.message);
process.exit(1);
});
+5 -5
View File
@@ -104,7 +104,7 @@ Gemini CLI supports the following authentication types:
| `apiKey` | Send a static API key as an HTTP header. |
| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). |
| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. |
| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
| `oauth` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
### Dynamic values
@@ -263,7 +263,7 @@ hosts:
Requests to any other host will be rejected with an error. If your agent is
hosted on a different domain, use one of the other auth types (`apiKey`, `http`,
or `oauth2`).
or `oauth`).
#### Examples
@@ -297,7 +297,7 @@ auth:
---
```
### OAuth 2.0 (`oauth2`)
### OAuth 2.0 (`oauth`)
Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first
use, Gemini CLI opens your browser for sign-in and persists the resulting tokens
@@ -305,7 +305,7 @@ for subsequent requests.
| Field | Type | Required | Description |
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `oauth2`. |
| `type` | string | Yes | Must be `oauth`. |
| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. |
| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. |
| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. |
@@ -318,7 +318,7 @@ kind: remote
name: oauth-agent
agent_card_url: https://example.com/.well-known/agent.json
auth:
type: oauth2
type: oauth
client_id: my-client-id.apps.example.com
---
```
+6 -1
View File
@@ -1215,6 +1215,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
- **`agents.browser.maxActionsPerTask`** (number):
- **Description:** The maximum number of tool calls allowed per browser task.
Enforcement is hard: the agent will be terminated when the limit is reached.
- **Default:** `100`
- **`agents.browser.confirmSensitiveActions`** (boolean):
- **Description:** Require manual confirmation for sensitive browser actions
(e.g., fill_form, evaluate_script).
@@ -1540,7 +1545,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `false`
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.worktrees`** (boolean):
+1 -1
View File
@@ -79,7 +79,7 @@ export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
}
// Render the app!
rig.render();
await rig.render();
// Wait for initial ready state
await rig.waitForIdle();
+50
View File
@@ -136,6 +136,32 @@ describe('plan_mode', () => {
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const exitPlanCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
expect(
exitPlanCall,
'Expected to find exit_plan_mode in tool logs',
).toBeDefined();
const args = JSON.parse(exitPlanCall!.toolRequest.args);
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
'string',
);
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
/\.md$/,
);
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('/');
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('\\');
assertModelHasOutput(result);
},
});
@@ -199,6 +225,30 @@ describe('plan_mode', () => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const exitPlanCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
expect(
exitPlanCall,
'Expected to find exit_plan_mode in tool logs',
).toBeDefined();
const args = JSON.parse(exitPlanCall!.toolRequest.args);
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
'string',
);
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
/\.md$/,
);
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('/');
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('\\');
// Check if plan was written
const planWrite = toolLogs.find(
(log) =>
+82
View File
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import path from 'node:path';
import fs from 'node:fs/promises';
describe('redundant_casts', () => {
evalTest('USUALLY_PASSES', {
name: 'should not add redundant or unsafe casts when modifying typescript code',
files: {
'src/cast_example.ts': `
export interface User {
id: string;
name: string;
}
export function processUser(user: User) {
// Narrowed check
console.log("Processing user: " + user.name);
}
export function handleUnknown(data: unknown) {
// Goal: log data.id if it exists
console.log("Handling data");
}
export function handleError() {
try {
throw new Error("fail");
} catch (err) {
// Goal: log err.message
console.error("Error happened");
}
}
`,
},
prompt: `
1. In src/cast_example.ts, update processUser to return the name in uppercase.
2. In handleUnknown, log the "id" property if "data" is an object that contains it.
3. In handleError, log the error message from "err".
`,
assert: async (rig) => {
const filePath = path.join(rig.testDir!, 'src/cast_example.ts');
const content = await fs.readFile(filePath, 'utf-8');
// 1. Redundant Cast Check (Same type)
// Bad: (user.name as string).toUpperCase()
expect(content, 'Should not cast a known string to string').not.toContain(
'as string',
);
// 2. Unsafe Cast Check (Unknown object)
// Bad: (data as any).id or (data as {id: string}).id
expect(
content,
'Should not use unsafe casts for unknown property access',
).not.toContain('as any');
expect(
content,
'Should not use unsafe casts for unknown property access',
).not.toContain('as {');
// 3. Unsafe Cast Check (Error handling)
// Bad: (err as Error).message
// Good: if (err instanceof Error) { ... }
expect(
content,
'Should prefer instanceof over casting for errors',
).not.toContain('as Error');
// Verify implementation
expect(content).toContain('toUpperCase()');
expect(content).toContain('message');
expect(content).toContain('id');
},
});
});
+42
View File
@@ -0,0 +1,42 @@
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);
},
});
});
+17 -32
View File
@@ -9,27 +9,7 @@ import path from 'node:path';
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
const DOCS_AGENT_DEFINITION = `---
name: docs-agent
description: An agent with expertise in updating documentation.
tools:
- read_file
- write_file
---
You are the docs agent. Update documentation clearly and accurately.
`;
const TEST_AGENT_DEFINITION = `---
name: test-agent
description: An agent with expertise in writing and updating tests.
tools:
- read_file
- write_file
---
You are the test agent. Add or update tests.
`;
import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
@@ -62,12 +42,12 @@ describe('subagent eval test cases', () => {
},
prompt: 'Please update README.md with a description of this library.',
files: {
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
...TEST_AGENTS.DOCS_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
},
assert: async (rig, _result) => {
await rig.expectToolCallSuccess(['docs-agent']);
await rig.expectToolCallSuccess([TEST_AGENTS.DOCS_AGENT.name]);
},
});
@@ -92,7 +72,7 @@ describe('subagent eval test cases', () => {
prompt:
'Rename the exported function in index.ts from add to sum and update the file directly.',
files: {
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
...TEST_AGENTS.DOCS_AGENT.asFile(),
'index.ts': INDEX_TS,
},
assert: async (rig, _result) => {
@@ -102,9 +82,11 @@ describe('subagent eval test cases', () => {
}>;
expect(updatedIndex).toContain('export const sum =');
expect(toolLogs.some((l) => l.toolRequest.name === 'docs-agent')).toBe(
false,
);
expect(
toolLogs.some(
(l) => l.toolRequest.name === TEST_AGENTS.DOCS_AGENT.name,
),
).toBe(false);
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
@@ -133,7 +115,7 @@ describe('subagent eval test cases', () => {
},
prompt: 'Please add a small test file that verifies add(1, 2) returns 3.',
files: {
'.gemini/agents/test-agent.md': TEST_AGENT_DEFINITION,
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': JSON.stringify(
{
@@ -150,7 +132,7 @@ describe('subagent eval test cases', () => {
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['test-agent']);
await rig.expectToolCallSuccess([TEST_AGENTS.TESTING_AGENT.name]);
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
@@ -178,8 +160,8 @@ describe('subagent eval test cases', () => {
prompt:
'Add a short README description for this library and also add a test file that verifies add(1, 2) returns 3.',
files: {
'.gemini/agents/docs-agent.md': DOCS_AGENT_DEFINITION,
'.gemini/agents/test-agent.md': TEST_AGENT_DEFINITION,
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': JSON.stringify(
@@ -198,7 +180,10 @@ describe('subagent eval test cases', () => {
}>;
const readme = readProjectFile(rig, 'README.md');
await rig.expectToolCallSuccess(['docs-agent', 'test-agent']);
await rig.expectToolCallSuccess([
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
]);
expect(readme).not.toContain('TODO: update the README.');
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
-6
View File
@@ -63,9 +63,6 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => {
rig.setup('browser-policy-skip-confirmation', {
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
settings: {
experimental: {
enableAgents: true,
},
agents: {
overrides: {
browser_agent: {
@@ -183,9 +180,6 @@ priority = 200
rig.setup('browser-session-warning', {
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
settings: {
experimental: {
enableAgents: true,
},
general: {
enableAutoUpdateNotification: false,
},
+2 -2
View File
@@ -6,9 +6,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import { TestRig } from './test-helper.js';
import { TestRig, skipFlaky } from './test-helper.js';
describe('Ctrl+C exit', () => {
describe.skipIf(skipFlaky)('Ctrl+C exit', () => {
let rig: TestRig;
beforeEach(() => {
+7 -1
View File
@@ -183,11 +183,17 @@ 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 = 100
priority = 90
`,
expectAllowed: false,
expectedDenialString: 'Tool execution denied by policy',
+9 -3
View File
@@ -58,12 +58,18 @@ function getDisallowedFileReadCommand(testFile: string): {
const quotedPath = `"${testFile}"`;
switch (shell) {
case 'powershell':
return { command: `Get-Content ${quotedPath}`, tool: 'Get-Content' };
return {
command: `powershell -Command "Get-Content ${quotedPath}"`,
tool: 'powershell',
};
case 'cmd':
return { command: `type ${quotedPath}`, tool: 'type' };
return { command: `cmd /c type ${quotedPath}`, tool: 'cmd' };
case 'bash':
default:
return { command: `cat ${quotedPath}`, tool: 'cat' };
return {
command: `node -e "console.log(require('fs').readFileSync('${testFile}', 'utf8'))"`,
tool: 'node',
};
}
}
+53 -24
View File
@@ -486,7 +486,8 @@
"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)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1489,6 +1490,7 @@
"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"
@@ -2195,6 +2197,7 @@
"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",
@@ -2375,6 +2378,7 @@
"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"
}
@@ -2424,6 +2428,7 @@
"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"
},
@@ -2798,6 +2803,7 @@
"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"
@@ -2831,6 +2837,7 @@
"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"
@@ -2885,6 +2892,7 @@
"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",
@@ -4121,6 +4129,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4395,6 +4404,7 @@
"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",
@@ -5268,6 +5278,7 @@
"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"
},
@@ -7402,7 +7413,8 @@
"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"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7986,6 +7998,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8503,6 +8516,7 @@
"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",
@@ -8696,9 +8710,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz",
"integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
@@ -8711,9 +8725,9 @@
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.3.tgz",
"integrity": "sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==",
"version": "5.5.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
"funding": [
{
"type": "github",
@@ -8722,9 +8736,9 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.2",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2"
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -8900,9 +8914,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -9815,6 +9829,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10093,6 +10108,7 @@
"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",
@@ -13200,9 +13216,9 @@
}
},
"node_modules/path-expression-matcher": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"funding": [
{
"type": "github",
@@ -13850,6 +13866,7 @@
"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"
}
@@ -13860,6 +13877,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15465,9 +15483,9 @@
}
},
"node_modules/strnum": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
@@ -16009,6 +16027,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16231,7 +16250,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16239,6 +16259,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16404,6 +16425,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16469,9 +16491,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz",
"integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==",
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -16626,6 +16648,7 @@
"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",
@@ -16739,6 +16762,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16751,6 +16775,7 @@
"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",
@@ -17398,6 +17423,7 @@
"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"
}
@@ -17576,6 +17602,7 @@
"gemini": "dist/index.js"
},
"devDependencies": {
"@google/gemini-cli-devtools": "file:../devtools",
"@google/gemini-cli-test-utils": "file:../test-utils",
"@types/command-exists": "^1.2.3",
"@types/hast": "^3.0.4",
@@ -17841,6 +17868,7 @@
"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"
@@ -17944,6 +17972,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+3
View File
@@ -32,6 +32,9 @@
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
"build": "node scripts/build.js",
"build:assets": "npm run copy:assets --workspaces --if-present",
"build:clients": "npm run build:client --workspaces --if-present",
"build:devs": "npm run build:dev --workspaces --if-present",
"build-and-start": "npm run build && npm run start --",
"build:vscode": "node scripts/build_vscode_companion.js",
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
+2 -1
View File
@@ -13,7 +13,8 @@
"gemini-cli-a2a-server": "dist/a2a-server.mjs"
},
"scripts": {
"build": "node ../../scripts/build_package.js",
"copy:assets": "node ../../scripts/copy_files.js",
"build": "tsc -b && npm run copy:assets",
"start": "node dist/src/http/server.js",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
@@ -29,6 +29,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
PRIORITY_YOLO_ALLOW_ALL: 998,
Config: vi.fn().mockImplementation((params) => {
const mockConfig = {
...params,
@@ -341,11 +342,11 @@ describe('loadConfig', () => {
);
});
it('should default enableAgents to false when not provided', async () => {
it('should default enableAgents to true when not provided', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableAgents: false,
enableAgents: true,
}),
);
});
+1 -1
View File
@@ -128,7 +128,7 @@ export async function loadConfig(
interactive: !isHeadlessMode(),
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? false,
enableAgents: settings.experimental?.enableAgents ?? true,
};
const fileService = new FileDiscoveryService(workspaceDir, {
+1
View File
@@ -4,6 +4,7 @@
"outDir": "dist",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"composite": true,
"declaration": true,
"types": ["node", "vitest/globals"]
},
"include": ["index.ts", "src/**/*.ts", "src/**/*.json"],
+21 -5
View File
@@ -6,12 +6,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { main } from './src/gemini.js';
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Fast Path for Version ---
// We check for version flags at the very top to avoid loading any heavy dependencies.
// process.env.CLI_VERSION is defined during the build process by esbuild.
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log(process.env['CLI_VERSION'] || 'unknown');
process.exit(0);
}
// --- Global Entry Point ---
let writeToStderrFn: (message: string) => void = (msg) =>
process.stderr.write(msg);
// Suppress known race condition error in node-pty on Windows
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
@@ -28,13 +35,22 @@ process.on('uncaughtException', (error) => {
// For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it.
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
writeToStderrFn(error.stack + '\n');
} else {
writeToStderr(String(error) + '\n');
writeToStderrFn(String(error) + '\n');
}
process.exit(1);
});
const [{ main }, { FatalError, writeToStderr }, { runExitCleanup }] =
await Promise.all([
import('./src/gemini.js'),
import('@google/gemini-cli-core'),
import('./src/utils/cleanup.js'),
]);
writeToStderrFn = writeToStderr;
main().catch(async (error) => {
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
+3 -1
View File
@@ -13,7 +13,8 @@
"gemini": "dist/index.js"
},
"scripts": {
"build": "node ../../scripts/build_package.js",
"copy:assets": "node ../../scripts/copy_files.js",
"build": "tsc -b && npm run copy:assets",
"start": "node dist/index.js",
"debug": "node --inspect-brk dist/index.js",
"lint": "eslint . --ext .ts,.tsx",
@@ -73,6 +74,7 @@
},
"devDependencies": {
"@google/gemini-cli-test-utils": "file:../test-utils",
"@google/gemini-cli-devtools": "file:../devtools",
"@types/command-exists": "^1.2.3",
"@types/hast": "^3.0.4",
"@types/node": "^20.11.24",
+15 -11
View File
@@ -300,6 +300,7 @@ export class GeminiAgent {
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
@@ -357,16 +358,6 @@ export class GeminiAgent {
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const geminiClient = config.getGeminiClient();
@@ -440,7 +431,19 @@ export class GeminiAgent {
throw acp.RequestError.authRequired();
}
// 3. Now that we are authenticated, it is safe to initialize the config
// 3. Set the ACP FileSystemService (if supported) before config initialization
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
// 4. Now that we are authenticated, it is safe to initialize the config
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
@@ -1625,6 +1628,7 @@ function toPermissionOptions(
case 'info':
case 'ask_user':
case 'exit_plan_mode':
case 'sandbox_expansion':
break;
default: {
const unreachable: never = confirmation;
+135 -12
View File
@@ -4,10 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import { AcpFileSystemService } from './fileSystemService.js';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import type { FileSystemService } from '@google/gemini-cli-core';
import os from 'node:os';
vi.mock('node:os', () => ({
default: {
homedir: vi.fn(),
},
}));
describe('AcpFileSystemService', () => {
let mockConnection: Mocked<AgentSideConnection>;
@@ -25,13 +40,19 @@ describe('AcpFileSystemService', () => {
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
};
vi.mocked(os.homedir).mockReturnValue('/home/user');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('readTextFile', () => {
it.each([
{
capability: true,
desc: 'connection if capability exists',
path: '/path/to/file',
desc: 'connection if capability exists and file is inside root',
setup: () => {
mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
},
@@ -45,6 +66,7 @@ describe('AcpFileSystemService', () => {
},
{
capability: false,
path: '/path/to/file',
desc: 'fallback if capability missing',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
@@ -56,19 +78,72 @@ describe('AcpFileSystemService', () => {
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, setup, verify }) => {
{
capability: true,
path: '/outside/file',
desc: 'fallback if capability exists but file is outside root',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/outside/file',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/home/user/.gemini/tmp/file.md',
root: '/home/user',
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/home/user/.gemini/tmp/file.md',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
])(
'should use $desc',
async ({ capability, path, root, setup, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: capability, writeTextFile: true },
mockFallback,
root || '/path/to',
);
setup();
const result = await service.readTextFile(path);
expect(result).toBe('content');
verify();
},
);
it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: capability, writeTextFile: true },
{ readTextFile: true, writeTextFile: true },
mockFallback,
'/path/to',
);
mockConnection.readTextFile.mockRejectedValue(
new Error('Resource not found for document'),
);
setup();
const result = await service.readTextFile('/path/to/file');
expect(result).toBe('content');
verify();
await expect(
service.readTextFile('/path/to/missing'),
).rejects.toMatchObject({
code: 'ENOENT',
message: 'Resource not found for document',
});
});
});
@@ -76,7 +151,8 @@ describe('AcpFileSystemService', () => {
it.each([
{
capability: true,
desc: 'connection if capability exists',
path: '/path/to/file',
desc: 'connection if capability exists and file is inside root',
verify: () => {
expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
path: '/path/to/file',
@@ -88,6 +164,7 @@ describe('AcpFileSystemService', () => {
},
{
capability: false,
path: '/path/to/file',
desc: 'fallback if capability missing',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
@@ -97,17 +174,63 @@ describe('AcpFileSystemService', () => {
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, verify }) => {
{
capability: true,
path: '/outside/file',
desc: 'fallback if capability exists but file is outside root',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/outside/file',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/home/user/.gemini/tmp/file.md',
root: '/home/user',
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/home/user/.gemini/tmp/file.md',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, path, root, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ writeTextFile: capability, readTextFile: true },
mockFallback,
root || '/path/to',
);
await service.writeTextFile('/path/to/file', 'content');
await service.writeTextFile(path, 'content');
verify();
});
it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: true, writeTextFile: true },
mockFallback,
'/path/to',
);
mockConnection.writeTextFile.mockRejectedValue(
new Error('Resource not found for directory'),
);
await expect(
service.writeTextFile('/path/to/missing', 'content'),
).rejects.toMatchObject({
code: 'ENOENT',
message: 'Resource not found for directory',
});
});
});
});
+53 -15
View File
@@ -4,44 +4,82 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { FileSystemService } from '@google/gemini-cli-core';
import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
import os from 'node:os';
import path from 'node:path';
/**
* ACP client-based implementation of FileSystemService
*/
export class AcpFileSystemService implements FileSystemService {
private readonly geminiDir = path.join(os.homedir(), '.gemini');
constructor(
private readonly connection: acp.AgentSideConnection,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapabilities,
private readonly fallback: FileSystemService,
private readonly root: string,
) {}
private shouldUseFallback(filePath: string): boolean {
// Files inside the global CLI directory must always use the native file system,
// even if the user runs the CLI directly from their home directory (which
// would make the IDE's project root overlap with the global directory).
return (
!isWithinRoot(filePath, this.root) ||
isWithinRoot(filePath, this.geminiDir)
);
}
private normalizeFileSystemError(err: unknown): never {
const errorMessage = err instanceof Error ? err.message : String(err);
if (
errorMessage.includes('Resource not found') ||
errorMessage.includes('ENOENT') ||
errorMessage.includes('does not exist') ||
errorMessage.includes('No such file')
) {
const newErr = new Error(errorMessage) as NodeJS.ErrnoException;
newErr.code = 'ENOENT';
throw newErr;
}
throw err;
}
async readTextFile(filePath: string): Promise<string> {
if (!this.capabilities.readTextFile) {
if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) {
return this.fallback.readTextFile(filePath);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
}
async writeTextFile(filePath: string, content: string): Promise<void> {
if (!this.capabilities.writeTextFile) {
if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) {
return this.fallback.writeTextFile(filePath, content);
}
await this.connection.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
try {
await this.connection.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
}
}
@@ -400,7 +400,7 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false);
expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe('Enable local and remote subagents.');
+11 -1
View File
@@ -1208,6 +1208,16 @@ const SETTINGS_SCHEMA = {
'Disable user input on browser window during automation.',
showInDialog: false,
},
maxActionsPerTask: {
type: 'number',
label: 'Max Actions Per Task',
category: 'Advanced',
requiresRestart: false,
default: 100,
description:
'The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.',
showInDialog: false,
},
confirmSensitiveActions: {
type: 'boolean',
label: 'Confirm Sensitive Actions',
@@ -1932,7 +1942,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Agents',
category: 'Experimental',
requiresRestart: true,
default: false,
default: true,
description: 'Enable local and remote subagents.',
showInDialog: false,
},
+29 -9
View File
@@ -700,7 +700,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Derive auth state variables for backward compatibility with UIStateContext
const isAuthDialogOpen = authState === AuthState.Updating;
const isAuthenticating = authState === AuthState.Unauthenticated;
// TODO: Consider handling other auth types that should also skip the blocking screen
const isAuthenticating =
authState === AuthState.Unauthenticated &&
settings.merged.security.auth.selectedType !== AuthType.USE_GEMINI;
// Session browser and resume functionality
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
@@ -1300,7 +1303,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}
if (isSlash || (isIdle && isMcpReady)) {
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -1323,10 +1327,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (isIdle && !isMcpReady && messageQueue.length === 0) {
if (isIdle && !isMcpOrConfigReady && messageQueue.length === 0) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
!isConfigInitialized
? 'Initializing... Prompts will be queued.'
: 'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
);
}
addMessage(submittedValue);
@@ -1350,6 +1356,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
reset,
handleHintSubmit,
isConfigInitialized,
triggerExpandHint,
],
);
@@ -1380,17 +1387,28 @@ Logging in with Google... Restarting Gemini CLI to continue.
* - Any future streaming states not explicitly allowed
*/
const isInputActive =
isConfigInitialized &&
!initError &&
!isProcessing &&
!isResuming &&
!!slashCommands &&
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
!proQuotaRequest;
!proQuotaRequest &&
!copyModeEnabled;
const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
useLayoutEffect(() => {
if (!copyModeEnabled && controlsHeight > 0) {
setLastNonCopyControlsHeight(controlsHeight);
}
}, [copyModeEnabled, controlsHeight]);
const stableControlsHeight =
copyModeEnabled && lastNonCopyControlsHeight > 0
? lastNonCopyControlsHeight
: controlsHeight;
useLayoutEffect(() => {
if (mainControlsRef.current) {
@@ -1402,10 +1420,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]);
// Compute available terminal height based on controls measurement
// Compute available terminal height based on stable controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - backgroundShellHeight - 1,
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -2264,6 +2282,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
contextFileNames,
errorCount,
availableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
@@ -2385,6 +2404,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
contextFileNames,
errorCount,
availableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
@@ -34,12 +34,11 @@ Tips for getting started:
Notifications
Composer
"
`;
@@ -100,12 +99,11 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = `
Notifications
DialogManager
"
`;
@@ -147,9 +145,8 @@ HistoryItemDisplay
Notifications
Composer
"
`;
@@ -1,271 +1,266 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="666" viewBox="0 0 920 666">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="700" fill="#000000" />
<rect width="920" height="666" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="19" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<rect x="0" y="0" width="900" height="17" fill="#141414" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#141414" />
<rect x="9" y="17" width="18" height="17" fill="#141414" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="324" height="17" fill="#141414" />
<text x="27" y="19" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
<rect x="351" y="17" width="549" height="17" fill="#141414" />
<rect x="0" y="34" width="900" height="17" fill="#141414" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="51" width="9" height="17" fill="#141414" />
<rect x="9" y="51" width="18" height="17" fill="#141414" />
<text x="9" y="53" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="51" width="324" height="17" fill="#141414" />
<text x="27" y="53" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
<rect x="351" y="51" width="549" height="17" fill="#141414" />
<rect x="0" y="68" width="900" height="17" fill="#141414" />
<text x="0" y="70" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="87" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="53" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
<text x="882" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="104" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
<text x="18" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
<text x="45" y="104" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
<text x="90" y="104" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... =&gt; return kittyProto</text>
<text x="864" y="104" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
<text x="45" y="138" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
<text x="90" y="138" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... =&gt; return kittyProto</text>
<text x="864" y="138" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
<text x="882" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
<text x="63" y="155" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="155" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
<text x="171" y="155" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="155" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="155" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="172" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
<text x="18" y="172" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
<text x="63" y="172" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="172" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
<text x="171" y="172" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="172" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="172" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="172" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
<text x="63" y="189" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
<text x="171" y="189" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="189" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="189" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
<text x="63" y="206" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
<text x="171" y="206" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="206" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="206" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
<text x="63" y="223" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
<text x="171" y="223" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="223" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="223" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
<text x="63" y="240" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
<text x="171" y="240" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="240" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="240" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
<text x="63" y="257" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
<text x="171" y="257" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="257" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="257" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
<text x="63" y="274" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
<text x="171" y="274" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="274" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="274" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
<text x="63" y="291" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
<text x="171" y="291" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="291" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="291" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
<text x="63" y="308" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
<text x="171" y="308" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="308" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="308" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
<text x="63" y="325" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
<text x="171" y="325" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="325" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="325" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
<text x="63" y="342" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
<text x="171" y="342" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="342" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="342" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
<text x="63" y="359" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
<text x="171" y="359" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="359" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="359" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
<text x="63" y="376" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
<text x="171" y="376" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="376" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
<text x="63" y="393" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
<text x="171" y="393" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="393" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="393" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
<text x="63" y="410" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
<text x="171" y="410" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="410" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="410" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
<text x="63" y="427" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="427" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
<text x="171" y="427" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="427" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="427" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<rect x="18" y="425" width="18" height="17" fill="#5f0000" />
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="425" width="9" height="17" fill="#5f0000" />
<rect x="45" y="425" width="9" height="17" fill="#5f0000" />
<text x="45" y="427" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="54" y="425" width="9" height="17" fill="#5f0000" />
<rect x="63" y="425" width="9" height="17" fill="#5f0000" />
<rect x="72" y="425" width="54" height="17" fill="#5f0000" />
<text x="72" y="427" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="425" width="234" height="17" fill="#5f0000" />
<text x="126" y="427" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="882" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
<text x="63" y="444" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="444" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
<text x="171" y="444" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="444" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="444" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<rect x="18" y="442" width="18" height="17" fill="#005f00" />
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="442" width="9" height="17" fill="#005f00" />
<rect x="45" y="442" width="9" height="17" fill="#005f00" />
<text x="45" y="444" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="54" y="442" width="9" height="17" fill="#005f00" />
<rect x="63" y="442" width="9" height="17" fill="#005f00" />
<rect x="72" y="442" width="54" height="17" fill="#005f00" />
<text x="72" y="444" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="442" width="234" height="17" fill="#005f00" />
<text x="126" y="444" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="882" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="459" width="18" height="17" fill="#5f0000" />
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="459" width="9" height="17" fill="#5f0000" />
<rect x="45" y="459" width="9" height="17" fill="#5f0000" />
<text x="45" y="461" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="54" y="459" width="9" height="17" fill="#5f0000" />
<rect x="63" y="459" width="9" height="17" fill="#5f0000" />
<rect x="72" y="459" width="54" height="17" fill="#5f0000" />
<text x="72" y="461" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="459" width="234" height="17" fill="#5f0000" />
<text x="126" y="461" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
<text x="63" y="461" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
<text x="882" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="476" width="18" height="17" fill="#005f00" />
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="476" width="9" height="17" fill="#005f00" />
<rect x="45" y="476" width="9" height="17" fill="#005f00" />
<text x="45" y="478" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="54" y="476" width="9" height="17" fill="#005f00" />
<rect x="63" y="476" width="9" height="17" fill="#005f00" />
<rect x="72" y="476" width="54" height="17" fill="#005f00" />
<text x="72" y="478" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="476" width="234" height="17" fill="#005f00" />
<text x="126" y="478" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
<text x="72" y="478" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
<text x="144" y="478" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
<text x="171" y="478" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
<text x="216" y="478" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
<text x="234" y="478" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
<text x="288" y="478" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) =&gt; </text>
<text x="333" y="478" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
<text x="369" y="478" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="495" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
<text x="63" y="495" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
<text x="18" y="495" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
<text x="882" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
<text x="72" y="512" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
<text x="144" y="512" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
<text x="171" y="512" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
<text x="216" y="512" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
<text x="234" y="512" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
<text x="288" y="512" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) =&gt; </text>
<text x="333" y="512" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
<text x="369" y="512" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="529" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
<rect x="18" y="527" width="9" height="17" fill="#001a00" />
<text x="18" y="529" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="527" width="9" height="17" fill="#001a00" />
<rect x="36" y="527" width="18" height="17" fill="#001a00" />
<text x="36" y="529" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="54" y="527" width="9" height="17" fill="#001a00" />
<rect x="63" y="527" width="90" height="17" fill="#001a00" />
<text x="63" y="529" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="153" y="527" width="288" height="17" fill="#001a00" />
<text x="882" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="546" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
<text x="63" y="546" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
<text x="882" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="561" width="9" height="17" fill="#001a00" />
<text x="18" y="563" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="561" width="9" height="17" fill="#001a00" />
<rect x="36" y="561" width="18" height="17" fill="#001a00" />
<text x="36" y="563" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="54" y="561" width="9" height="17" fill="#001a00" />
<rect x="63" y="561" width="90" height="17" fill="#001a00" />
<text x="63" y="563" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="153" y="561" width="288" height="17" fill="#001a00" />
<text x="36" y="563" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
<text x="63" y="563" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
<text x="882" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
<text x="63" y="580" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
<text x="63" y="580" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
<text x="882" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
<text x="63" y="597" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
<text x="63" y="597" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
<text x="882" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="614" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
<text x="63" y="614" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
<text x="882" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="631" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
<text x="63" y="631" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
<text x="882" y="631" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="648" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

@@ -1,9 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
"3. Ask coding questions, edit code or run commands
4. Be specific for the best results
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Can you edit InputPrompt.tsx for me?
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
@@ -11,9 +9,9 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo
│ │
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
│ │
│ ... first 44 lines hidden (Ctrl+O to show) ... │
│ 45 const line45 = true; │
│ 46 const line46 = true; │
│ ... first 44 lines hidden (Ctrl+O to show) ... │
│ 45 const line45 = true; │
│ 46 const line46 = true; │
│ 47 const line47 = true; │█
│ 48 const line48 = true; │█
│ 49 const line49 = true; │█
+1 -1
View File
@@ -108,7 +108,7 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
{updateInfo?.isUpdating && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
+6 -7
View File
@@ -518,12 +518,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0}
flexShrink={0}
>
{(!uiState.slashCommands ||
!uiState.isConfigInitialized ||
uiState.isResuming) && (
<ConfigInitDisplay
message={uiState.isResuming ? 'Resuming session...' : undefined}
/>
{uiState.isResuming && (
<ConfigInitDisplay message="Resuming session..." />
)}
{showUiDetails && (
@@ -592,12 +588,15 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
copyModeEnabled={uiState.copyModeEnabled}
/>
)}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
!isScreenReaderEnabled && <Footer />}
!isScreenReaderEnabled && (
<Footer copyModeEnabled={uiState.copyModeEnabled} />
)}
</Box>
);
};
@@ -12,16 +12,14 @@ import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useUIState();
if (!copyModeEnabled) {
return null;
}
return (
<Box>
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key
to exit.
</Text>
<Box height={1}>
{copyModeEnabled && (
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
key to exit.
</Text>
)}
</Box>
);
};
@@ -80,7 +80,6 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const pathError = await validatePlanPath(
planPath,
config.storage.getPlansDir(),
config.getTargetDir(),
);
if (ignore) return;
if (pathError) {
+18 -2
View File
@@ -175,12 +175,18 @@ interface FooterColumn {
isHighPriority: boolean;
}
export const Footer: React.FC = () => {
export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
copyModeEnabled = false,
}) => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
if (copyModeEnabled) {
return <Box height={1} />;
}
const {
model,
targetDir,
@@ -353,7 +359,17 @@ export const Footer: React.FC = () => {
break;
}
case 'memory-usage': {
addCol(id, header, () => <MemoryUsageDisplay color={itemColor} />, 10);
addCol(
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
),
10,
);
break;
}
case 'session-id': {
@@ -119,6 +119,7 @@ export interface InputPromptProps {
popAllMessages?: () => string | undefined;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
@@ -212,6 +213,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
popAllMessages,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
}) => {
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
@@ -331,7 +333,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isShellSuggestionsVisible,
} = completion;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
// Notify parent component about escape prompt state changes
useEffect(() => {
@@ -11,13 +11,18 @@ import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatBytes } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
color = theme.text.primary,
}) => {
export const MemoryUsageDisplay: React.FC<{
color?: string;
isActive?: boolean;
}> = ({ color = theme.text.primary, isActive = true }) => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
useEffect(() => {
if (!isActive) {
return;
}
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage));
@@ -25,10 +30,11 @@ export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, [color]);
}, [color, isActive]);
return (
<Box>
@@ -47,6 +47,7 @@ describe('ToolConfirmationQueue', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getApprovalMode: () => 'default',
getDisableAlwaysAllow: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,
@@ -22,6 +22,7 @@ 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 () => {
@@ -153,7 +153,7 @@ export const SubagentProgressDisplay: React.FC<
})}
</Box>
{progress.state === 'completed' && progress.result && (
{progress.result && (
<Box flexDirection="column" marginTop={1}>
{progress.terminateReason && progress.terminateReason !== 'GOAL' && (
<Box marginBottom={1}>
@@ -164,7 +164,7 @@ export const SubagentProgressDisplay: React.FC<
)}
<MarkdownDisplay
text={safeJsonToMarkdown(progress.result)}
isPending={false}
isPending={progress.state !== 'completed'}
terminalWidth={terminalWidth}
/>
</Box>
@@ -40,6 +40,7 @@ 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 () => {
@@ -324,6 +325,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -345,6 +347,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => false,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
@@ -380,6 +383,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -406,6 +410,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
@@ -447,6 +452,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => false,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -473,6 +479,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -499,6 +506,7 @@ describe('ToolConfirmationMessage', () => {
isTrustedFolder: () => true,
getIdeMode: () => true,
getDisableAlwaysAllow: () => false,
getApprovalMode: () => 'default',
} as unknown as Config;
vi.mocked(useToolActions).mockReturnValue({
confirm: vi.fn(),
@@ -15,6 +15,7 @@ import {
type ToolConfirmationPayload,
ToolConfirmationOutcome,
type EditorType,
ApprovalMode,
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
@@ -314,6 +315,31 @@ 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',
@@ -546,6 +572,8 @@ 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;
@@ -573,6 +601,52 @@ 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;
@@ -587,7 +661,8 @@ export const ToolConfirmationMessage: React.FC<
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (containsRedirection) {
const isAutoEdit = config.getApprovalMode() === ApprovalMode.AUTO_EDIT;
if (containsRedirection && !isAutoEdit) {
// Calculate lines needed for Note and Tip
const safeWidth = Math.max(terminalWidth, 1);
const noteLength =
@@ -737,6 +812,7 @@ export const ToolConfirmationMessage: React.FC<
isTrustedFolder,
allowPermanentApproval,
settings,
config,
]);
const bodyOverflowDirection: 'top' | 'bottom' =
@@ -180,6 +180,7 @@ export interface UIState {
contextFileNames: string[];
errorCount: number;
availableTerminalHeight: number | undefined;
stableControlsHeight: number;
mainAreaWidth: number;
staticAreaMaxItemHeight: number;
staticExtraHeight: number;
@@ -31,6 +31,7 @@ export const DefaultAppLayout: React.FC = () => {
flexDirection="column"
width={uiState.terminalWidth}
height={isAlternateBuffer ? terminalHeight : undefined}
paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
@@ -62,6 +63,9 @@ export const DefaultAppLayout: React.FC = () => {
flexShrink={0}
flexGrow={0}
width={uiState.terminalWidth}
height={
uiState.copyModeEnabled ? uiState.stableControlsHeight : undefined
}
>
<Notifications />
<CopyModeWarning />
+1
View File
@@ -27,6 +27,7 @@ export interface UpdateInfo {
export interface UpdateObject {
message: string;
update: UpdateInfo;
isUpdating?: boolean;
}
/**
@@ -197,7 +197,9 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nPlease update manually.',
isUpdating: false,
});
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -236,7 +238,9 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nCannot determine update command.',
isUpdating: false,
});
expect(mockSpawn).not.toHaveBeenCalled();
});
@@ -253,7 +257,9 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledTimes(1);
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-received', {
...mockUpdateInfo,
message: 'An update is available!\nThis is an additional message.',
isUpdating: false,
});
});
+10 -5
View File
@@ -102,17 +102,22 @@ export function handleAutoUpdate(
combinedMessage += `\n${installationInfo.updateMessage}`;
}
updateEventEmitter.emit('update-received', {
message: combinedMessage,
});
if (
!installationInfo.updateCommand ||
!settings.merged.general.enableAutoUpdate
) {
updateEventEmitter.emit('update-received', {
...info,
message: combinedMessage,
isUpdating: false,
});
return;
}
updateEventEmitter.emit('update-received', {
...info,
message: combinedMessage,
isUpdating: true,
});
if (_updateInProgress) {
return;
}
+4 -1
View File
@@ -66,7 +66,10 @@ beforeEach(() => {
? stackLines.slice(lastReactFrameIndex + 1).join('\n')
: stackLines.slice(1).join('\n');
if (relevantStack.includes('OverflowContext.tsx')) {
if (
relevantStack.includes('OverflowContext.tsx') ||
relevantStack.includes('useTimedMessage.ts')
) {
return;
}
+6 -1
View File
@@ -2,6 +2,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"composite": true,
"declaration": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"types": ["node", "vitest/globals"]
@@ -14,5 +16,8 @@
"./package.json"
],
"exclude": ["node_modules", "dist"],
"references": [{ "path": "../core" }]
"references": [
{ "path": "../core" },
{ "path": "../devtools/tsconfig.build.json" }
]
}
+3 -1
View File
@@ -11,7 +11,9 @@
"main": "dist/index.js",
"scripts": {
"bundle:browser-mcp": "node scripts/bundle-browser-mcp.mjs",
"build": "node ../../scripts/build_package.js",
"copy:assets": "node ../../scripts/copy_files.js",
"build:client": "npm run bundle:browser-mcp",
"build": "tsc -b && npm run copy:assets",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
"test": "vitest run",
@@ -26,15 +26,15 @@ function compileWindowsSandbox() {
const srcHelperPath = path.resolve(
__dirname,
'../src/services/scripts/GeminiSandbox.exe',
'../src/sandbox/windows/GeminiSandbox.exe',
);
const distHelperPath = path.resolve(
__dirname,
'../dist/src/services/scripts/GeminiSandbox.exe',
'../dist/src/sandbox/windows/GeminiSandbox.exe',
);
const sourcePath = path.resolve(
__dirname,
'../src/services/scripts/GeminiSandbox.cs',
'../src/sandbox/windows/GeminiSandbox.cs',
);
if (!fs.existsSync(sourcePath)) {
+1 -1
View File
@@ -403,7 +403,7 @@ describe('a2aUtils', () => {
const output = reassembler.toString();
expect(output).toBe(
'Analyzing...\n\nProcessing...\n\nArtifact (Code):\nprint("Done")',
'Analyzing...Processing...\n\nArtifact (Code):\nprint("Done")',
);
});
+25 -2
View File
@@ -16,6 +16,7 @@ import type {
AgentInterface,
} from '@a2a-js/sdk';
import type { SendMessageResult } from './a2a-client-manager.js';
import type { SubagentActivityItem } from './types.js';
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
@@ -123,17 +124,39 @@ export class A2AResultReassembler {
private pushMessage(message: Message | undefined) {
if (!message) return;
const text = extractPartsText(message.parts, '\n');
const text = extractPartsText(message.parts, '');
if (text && this.messageLog[this.messageLog.length - 1] !== text) {
this.messageLog.push(text);
}
}
/**
* Returns an array of activity items representing the current reassembled state.
*/
toActivityItems(): SubagentActivityItem[] {
const isAuthRequired = this.messageLog.includes(AUTH_REQUIRED_MSG);
return [
isAuthRequired
? {
id: 'auth-required',
type: 'thought',
content: AUTH_REQUIRED_MSG,
status: 'running',
}
: {
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
},
];
}
/**
* Returns a human-readable string representation of the current reassembled state.
*/
toString(): string {
const joinedMessages = this.messageLog.join('\n\n');
const joinedMessages = this.messageLog.join('');
const artifactsOutput = Array.from(this.artifacts.keys())
.map((id) => {
+121 -9
View File
@@ -242,6 +242,99 @@ Body`);
/Name must be a valid slug/,
);
});
describe('error formatting and kind inference', () => {
it('should only show local agent errors when kind is inferred as local (via kind field)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: local
name: invalid-local
# missing description
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('description: Required');
expect(error.message).not.toContain('Remote Agent');
});
it('should only show local agent errors when kind is inferred as local (via local-specific keys)', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-local
# missing description
tools:
- run_shell_command
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('description: Required');
expect(error.message).not.toContain('Remote Agent');
});
it('should only show remote agent errors when kind is inferred as remote (via kind field)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-remote
# missing agent_card_url
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('agent_card_url: Required');
expect(error.message).not.toContain('Local Agent');
});
it('should only show remote agent errors when kind is inferred as remote (via remote-specific keys)', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-remote
auth:
type: apiKey
key: my_key
# missing agent_card_url
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('agent_card_url: Required');
expect(error.message).not.toContain('Local Agent');
});
it('should show errors for both types when kind cannot be inferred', async () => {
const filePath = await writeAgentMarkdown(`---
name: invalid-unknown
# missing description and missing agent_card_url, no specific keys
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain('Validation failed');
expect(error.message).toContain('(Local Agent)');
expect(error.message).toContain('(Remote Agent)');
expect(error.message).toContain('description: Required');
expect(error.message).toContain('agent_card_url: Required');
});
it('should format errors without a stray colon when the path is empty (e.g. strict object with unknown keys)', async () => {
const filePath = await writeAgentMarkdown(`---
kind: local
name: my-agent
description: test
unknown_field: true
---
Body`);
const error = await parseAgentMarkdown(filePath).catch((e) => e);
expect(error).toBeInstanceOf(AgentLoadError);
expect(error.message).toContain(
"Unrecognized key(s) in object: 'unknown_field'",
);
expect(error.message).not.toContain(': Unrecognized key(s)');
expect(error.message).not.toContain('Required');
});
});
});
describe('markdownToAgentDefinition', () => {
@@ -617,7 +710,7 @@ kind: remote
name: oauth2-agent
agent_card_url: https://example.com/card
auth:
type: oauth2
type: oauth
client_id: $MY_OAUTH_CLIENT_ID
scopes:
- read
@@ -630,7 +723,7 @@ auth:
kind: 'remote',
name: 'oauth2-agent',
auth: {
type: 'oauth2',
type: 'oauth',
client_id: '$MY_OAUTH_CLIENT_ID',
scopes: ['read', 'write'],
},
@@ -643,7 +736,7 @@ kind: remote
name: oauth2-full-agent
agent_card_url: https://example.com/card
auth:
type: oauth2
type: oauth
client_id: my-client-id
client_secret: my-client-secret
scopes:
@@ -659,7 +752,7 @@ auth:
kind: 'remote',
name: 'oauth2-full-agent',
auth: {
type: 'oauth2',
type: 'oauth',
client_id: 'my-client-id',
client_secret: 'my-client-secret',
scopes: ['openid', 'profile'],
@@ -675,7 +768,7 @@ kind: remote
name: oauth2-minimal-agent
agent_card_url: https://example.com/card
auth:
type: oauth2
type: oauth
---
`);
const result = await parseAgentMarkdown(filePath);
@@ -684,7 +777,7 @@ auth:
kind: 'remote',
name: 'oauth2-minimal-agent',
auth: {
type: 'oauth2',
type: 'oauth',
},
});
});
@@ -695,7 +788,7 @@ kind: remote
name: invalid-oauth2-agent
agent_card_url: https://example.com/card
auth:
type: oauth2
type: oauth
client_id: my-client
authorization_url: not-a-valid-url
---
@@ -709,7 +802,7 @@ kind: remote
name: invalid-oauth2-agent
agent_card_url: https://example.com/card
auth:
type: oauth2
type: oauth
client_id: my-client
token_url: not-a-valid-url
---
@@ -723,7 +816,7 @@ auth:
name: 'oauth2-convert-agent',
agent_card_url: 'https://example.com/card',
auth: {
type: 'oauth2' as const,
type: 'oauth' as const,
client_id: '$MY_CLIENT_ID',
scopes: ['read'],
authorization_url: 'https://auth.example.com/authorize',
@@ -744,5 +837,24 @@ auth:
},
});
});
it('should throw an error for an unknown auth type in markdownToAgentDefinition', () => {
const markdown = {
kind: 'remote' as const,
name: 'unknown-auth-agent',
agent_card_url: 'https://example.com/card',
auth: {
type: 'apiKey' as const,
key: 'some-key',
},
};
// Mutate the object at runtime to bypass TypeScript compile-time checks cleanly
Object.assign(markdown.auth, { type: 'some-unknown-type' });
expect(() => markdownToAgentDefinition(markdown)).toThrow(
/Unknown auth type: some-unknown-type/,
);
});
});
});
+106 -171
View File
@@ -21,79 +21,6 @@ import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
/**
* DTO for Markdown parsing - represents the structure from frontmatter.
*/
interface FrontmatterBaseAgentDefinition {
name: string;
display_name?: string;
}
interface FrontmatterMCPServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
http_url?: string;
headers?: Record<string, string>;
tcp?: string;
type?: 'sse' | 'http';
timeout?: number;
trust?: boolean;
description?: string;
include_tools?: string[];
exclude_tools?: string[];
}
interface FrontmatterLocalAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'local';
description: string;
tools?: string[];
mcp_servers?: Record<string, FrontmatterMCPServerConfig>;
system_prompt: string;
model?: string;
temperature?: number;
max_turns?: number;
timeout_mins?: number;
}
/**
* Authentication configuration for remote agents in frontmatter format.
*/
interface FrontmatterAuthConfig {
type: 'apiKey' | 'http' | 'google-credentials' | 'oauth2';
// API Key
key?: string;
name?: string;
// HTTP
scheme?: string;
token?: string;
username?: string;
password?: string;
value?: string;
// Google Credentials
scopes?: string[];
// OAuth2
client_id?: string;
client_secret?: string;
authorization_url?: string;
token_url?: string;
}
interface FrontmatterRemoteAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'remote';
description?: string;
agent_card_url: string;
auth?: FrontmatterAuthConfig;
}
type FrontmatterAgentDefinition =
| FrontmatterLocalAgentDefinition
| FrontmatterRemoteAgentDefinition;
/**
* Error thrown when an agent definition is invalid or cannot be loaded.
*/
@@ -159,15 +86,13 @@ const localAgentSchema = z
})
.strict();
/**
* Base fields shared by all auth configs.
*/
type FrontmatterLocalAgentDefinition = z.infer<typeof localAgentSchema> & {
system_prompt: string;
};
// Base fields shared by all auth configs.
const baseAuthFields = {};
/**
* API Key auth schema.
* Supports sending key in header, query parameter, or cookie.
*/
const apiKeyAuthSchema = z.object({
...baseAuthFields,
type: z.literal('apiKey'),
@@ -175,11 +100,6 @@ const apiKeyAuthSchema = z.object({
name: z.string().optional(),
});
/**
* HTTP auth schema (Bearer or Basic).
* Note: Validation for scheme-specific fields is applied in authConfigSchema
* since discriminatedUnion doesn't support refined schemas directly.
*/
const httpAuthSchema = z.object({
...baseAuthFields,
type: z.literal('http'),
@@ -190,22 +110,15 @@ const httpAuthSchema = z.object({
value: z.string().min(1).optional(),
});
/**
* Google Credentials auth schema.
*/
const googleCredentialsAuthSchema = z.object({
...baseAuthFields,
type: z.literal('google-credentials'),
scopes: z.array(z.string()).optional(),
});
/**
* OAuth2 auth schema.
* authorization_url and token_url can be discovered from the agent card if omitted.
*/
const oauth2AuthSchema = z.object({
...baseAuthFields,
type: z.literal('oauth2'),
type: z.literal('oauth'),
client_id: z.string().optional(),
client_secret: z.string().optional(),
scopes: z.array(z.string()).optional(),
@@ -222,18 +135,16 @@ const authConfigSchema = z
])
.superRefine((data, ctx) => {
if (data.type === 'http') {
if (data.value) {
// Raw mode - only scheme and value are needed
return;
}
if (data.scheme === 'Bearer' && !data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bearer scheme requires "token"',
path: ['token'],
});
}
if (data.scheme === 'Basic') {
if (data.value) return;
if (data.scheme === 'Bearer') {
if (!data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bearer scheme requires "token"',
path: ['token'],
});
}
} else if (data.scheme === 'Basic') {
if (!data.username) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -248,10 +159,18 @@ const authConfigSchema = z
path: ['password'],
});
}
} else {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `HTTP scheme "${data.scheme}" requires "value"`,
path: ['value'],
});
}
}
});
type FrontmatterAuthConfig = z.infer<typeof authConfigSchema>;
const remoteAgentSchema = z
.object({
kind: z.literal('remote').optional().default('remote'),
@@ -263,8 +182,12 @@ const remoteAgentSchema = z
})
.strict();
// Use a Zod union to automatically discriminate between local and remote
// agent types.
type FrontmatterRemoteAgentDefinition = z.infer<typeof remoteAgentSchema>;
type FrontmatterAgentDefinition =
| FrontmatterLocalAgentDefinition
| FrontmatterRemoteAgentDefinition;
const agentUnionOptions = [
{ schema: localAgentSchema, label: 'Local Agent' },
{ schema: remoteAgentSchema, label: 'Remote Agent' },
@@ -277,23 +200,62 @@ const markdownFrontmatterSchema = z.union([
agentUnionOptions[1].schema,
]);
function formatZodError(error: z.ZodError, context: string): string {
function guessIntendedKind(rawInput: unknown): 'local' | 'remote' | undefined {
if (typeof rawInput !== 'object' || rawInput === null) return undefined;
const input = rawInput as Partial<FrontmatterLocalAgentDefinition> &
Partial<FrontmatterRemoteAgentDefinition>;
if (input.kind === 'local') return 'local';
if (input.kind === 'remote') return 'remote';
const hasLocalKeys =
'tools' in input ||
'mcp_servers' in input ||
'model' in input ||
'temperature' in input ||
'max_turns' in input ||
'timeout_mins' in input;
const hasRemoteKeys = 'agent_card_url' in input || 'auth' in input;
if (hasLocalKeys && !hasRemoteKeys) return 'local';
if (hasRemoteKeys && !hasLocalKeys) return 'remote';
return undefined;
}
function formatZodError(
error: z.ZodError,
context: string,
rawInput?: unknown,
): string {
const intendedKind = rawInput ? guessIntendedKind(rawInput) : undefined;
const issues = error.issues
.map((i) => {
// Handle union errors specifically to give better context
if (i.code === z.ZodIssueCode.invalid_union) {
return i.unionErrors
.map((unionError, index) => {
const label =
agentUnionOptions[index]?.label ?? `Agent type #${index + 1}`;
if (intendedKind === 'local' && label === 'Remote Agent')
return null;
if (intendedKind === 'remote' && label === 'Local Agent')
return null;
const unionIssues = unionError.issues
.map((u) => `${u.path.join('.')}: ${u.message}`)
.map((u) => {
const pathStr = u.path.join('.');
return pathStr ? `${pathStr}: ${u.message}` : u.message;
})
.join(', ');
return `(${label}) ${unionIssues}`;
})
.filter(Boolean)
.join('\n');
}
return `${i.path.join('.')}: ${i.message}`;
const pathStr = i.path.join('.');
return pathStr ? `${pathStr}: ${i.message}` : i.message;
})
.join('\n');
return `${context}:\n${issues}`;
@@ -343,8 +305,7 @@ export async function parseAgentMarkdown(
} catch (error) {
throw new AgentLoadError(
filePath,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`YAML frontmatter parsing failed: ${(error as Error).message}`,
`YAML frontmatter parsing failed: ${getErrorMessage(error)}`,
);
}
@@ -368,7 +329,7 @@ export async function parseAgentMarkdown(
if (!result.success) {
throw new AgentLoadError(
filePath,
`Validation failed: ${formatZodError(result.error, 'Agent Definition')}`,
`Validation failed: ${formatZodError(result.error, 'Agent Definition', rawFrontmatter)}`,
);
}
@@ -383,17 +344,14 @@ export async function parseAgentMarkdown(
];
}
// Local agent validation
// Validate tools
// Construct the local agent definition
const agentDef: FrontmatterLocalAgentDefinition = {
...frontmatter,
kind: 'local',
system_prompt: body.trim(),
};
return [agentDef];
return [
{
...frontmatter,
kind: 'local',
system_prompt: body.trim(),
},
];
}
/**
@@ -403,15 +361,9 @@ export async function parseAgentMarkdown(
function convertFrontmatterAuthToConfig(
frontmatter: FrontmatterAuthConfig,
): A2AAuthConfig {
const base = {};
switch (frontmatter.type) {
case 'apiKey':
if (!frontmatter.key) {
throw new Error('Internal error: API key missing after validation.');
}
return {
...base,
type: 'apiKey',
key: frontmatter.key,
name: frontmatter.name,
@@ -419,20 +371,13 @@ function convertFrontmatterAuthToConfig(
case 'google-credentials':
return {
...base,
type: 'google-credentials',
scopes: frontmatter.scopes,
};
case 'http': {
if (!frontmatter.scheme) {
throw new Error(
'Internal error: HTTP scheme missing after validation.',
);
}
case 'http':
if (frontmatter.value) {
return {
...base,
type: 'http',
scheme: frontmatter.scheme,
value: frontmatter.value,
@@ -440,40 +385,29 @@ function convertFrontmatterAuthToConfig(
}
switch (frontmatter.scheme) {
case 'Bearer':
if (!frontmatter.token) {
throw new Error(
'Internal error: Bearer token missing after validation.',
);
}
// Token is required by schema validation
return {
...base,
type: 'http',
scheme: 'Bearer',
token: frontmatter.token,
token: frontmatter.token!,
};
case 'Basic':
if (!frontmatter.username || !frontmatter.password) {
throw new Error(
'Internal error: Basic auth credentials missing after validation.',
);
}
// Username/password are required by schema validation
return {
...base,
type: 'http',
scheme: 'Basic',
username: frontmatter.username,
password: frontmatter.password,
};
default: {
// Other IANA schemes without a value should not reach here after validation
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
}
}
}
case 'oauth2':
username: frontmatter.username!,
password: frontmatter.password!,
};
default:
throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`);
}
case 'oauth':
return {
...base,
type: 'oauth2',
client_id: frontmatter.client_id,
client_secret: frontmatter.client_secret,
@@ -483,8 +417,12 @@ function convertFrontmatterAuthToConfig(
};
default: {
const exhaustive: never = frontmatter.type;
throw new Error(`Unknown auth type: ${exhaustive}`);
const exhaustive: never = frontmatter;
const raw: unknown = exhaustive;
if (typeof raw === 'object' && raw !== null && 'type' in raw) {
throw new Error(`Unknown auth type: ${String(raw['type'])}`);
}
throw new Error('Unknown auth type');
}
}
}
@@ -533,7 +471,7 @@ export function markdownToAgentDefinition(
const modelName = markdown.model || 'inherit';
const mcpServers: Record<string, MCPServerConfig> = {};
if (markdown.kind === 'local' && markdown.mcp_servers) {
if (markdown.mcp_servers) {
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
mcpServers[name] = new MCPServerConfig(
config.command,
@@ -606,15 +544,13 @@ export async function loadAgentsFromDirectory(
dirEntries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
// If directory doesn't exist, just return empty
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return result;
}
result.errors.push(
new AgentLoadError(
dir,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`Could not list directory: ${(error as Error).message}`,
`Could not list directory: ${getErrorMessage(error)}`,
),
);
return result;
@@ -644,8 +580,7 @@ export async function loadAgentsFromDirectory(
result.errors.push(
new AgentLoadError(
filePath,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
`Unexpected error: ${(error as Error).message}`,
`Unexpected error: ${getErrorMessage(error)}`,
),
);
}
@@ -112,6 +112,7 @@ Some errors are unrecoverable and retrying will never help. When you see ANY of
- "Could not connect to Chrome" or "Failed to connect to Chrome" or "Timed out connecting to Chrome" Include the full error message with its remediation steps in your summary verbatim. Do NOT paraphrase or omit instructions.
- "Browser closed" or "Target closed" or "Session closed" The browser process has terminated. Include the error and tell the user to try again.
- "net::ERR_" network errors on the SAME URL after 2 retries the site is unreachable. Report the URL and error.
- "reached maximum action limit" You have performed too many actions in this task. Stop immediately and report this limit to the user.
- Any error that appears IDENTICALLY 3+ times in a row it will not resolve by retrying.
Do NOT keep retrying terminal errors. Report them with actionable remediation steps and exit immediately.
@@ -697,4 +697,28 @@ describe('BrowserManager', () => {
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
});
describe('Rate limiting', () => {
it('should terminate task when maxActionsPerTask is reached', async () => {
const limitedConfig = makeFakeConfig({
agents: {
browser: {
maxActionsPerTask: 3,
},
},
});
const manager = new BrowserManager(limitedConfig);
// First 3 calls should succeed
await manager.callTool('take_snapshot', {});
await manager.callTool('take_snapshot', { some: 'args' });
await manager.callTool('take_snapshot', { other: 'args' });
await manager.callTool('take_snapshot', { other: 'new args' });
// 4th call should throw
await expect(manager.callTool('take_snapshot', {})).rejects.toThrow(
/maximum action limit \(3\)/,
);
});
});
});
@@ -97,6 +97,10 @@ export class BrowserManager {
private mcpTransport: StdioClientTransport | undefined;
private discoveredTools: McpTool[] = [];
/** State for action rate limiting */
private actionCounter = 0;
private readonly maxActionsPerTask: number;
/**
* Whether to inject the automation overlay.
* Always false in headless mode (no visible window to decorate).
@@ -108,6 +112,8 @@ export class BrowserManager {
const browserConfig = config.getBrowserAgentConfig();
this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
this.shouldDisableInput = config.shouldDisableBrowserUserInput();
this.maxActionsPerTask =
browserConfig?.customConfig.maxActionsPerTask ?? 100;
}
/**
@@ -151,6 +157,16 @@ export class BrowserManager {
throw signal.reason ?? new Error('Operation cancelled');
}
// Hard enforcement of per-action rate limit
if (this.actionCounter > this.maxActionsPerTask) {
const error = new Error(
`Browser agent reached maximum action limit (${this.maxActionsPerTask}). ` +
`Task terminated to prevent runaway execution. To config the limit, use maxActionsPerTask in the settings.`,
);
throw error;
}
this.actionCounter++;
const errorMessage = this.checkNavigationRestrictions(toolName, args);
if (errorMessage) {
return {
@@ -20,7 +20,7 @@ import {
type A2AClientManager,
} from './a2a-client-manager.js';
import type { RemoteAgentDefinition } from './types.js';
import type { RemoteAgentDefinition, SubagentProgress } from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { A2AAuthProvider } from './auth-provider/types.js';
@@ -266,9 +266,11 @@ describe('RemoteAgentInvocation', () => {
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.message).toContain(
"Failed to create auth provider for agent 'test-agent'",
);
expect(result.returnDisplay).toMatchObject({
result: expect.stringContaining(
"Failed to create auth provider for agent 'test-agent'",
),
});
});
it('should not load the agent if already present', async () => {
@@ -325,7 +327,9 @@ describe('RemoteAgentInvocation', () => {
// Execute first time
const result1 = await invocation1.execute(new AbortController().signal);
expect(result1.returnDisplay).toBe('Response 1');
expect(result1.returnDisplay).toMatchObject({
result: 'Response 1',
});
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
'test-agent',
'first',
@@ -355,7 +359,9 @@ describe('RemoteAgentInvocation', () => {
mockMessageBus,
);
const result2 = await invocation2.execute(new AbortController().signal);
expect(result2.returnDisplay).toBe('Response 2');
expect((result2.returnDisplay as SubagentProgress).result).toBe(
'Response 2',
);
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
'test-agent',
@@ -444,8 +450,22 @@ describe('RemoteAgentInvocation', () => {
);
await invocation.execute(new AbortController().signal, updateOutput);
expect(updateOutput).toHaveBeenCalledWith('Hello');
expect(updateOutput).toHaveBeenCalledWith('Hello\n\nHello World');
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: 'running',
recentActivity: expect.arrayContaining([
expect.objectContaining({ content: 'Working...' }),
]),
}),
);
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: 'completed',
result: 'HelloHello World',
}),
);
});
it('should abort when signal is aborted during streaming', async () => {
@@ -478,8 +498,7 @@ describe('RemoteAgentInvocation', () => {
);
const result = await invocation.execute(controller.signal);
expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Operation aborted');
expect(result.returnDisplay).toMatchObject({ state: 'error' });
});
it('should handle errors gracefully', async () => {
@@ -501,9 +520,10 @@ describe('RemoteAgentInvocation', () => {
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Network error');
expect(result.returnDisplay).toContain('Network error');
expect(result.returnDisplay).toMatchObject({
state: 'error',
result: expect.stringContaining('Network error'),
});
});
it('should use a2a helpers for extracting text', async () => {
@@ -534,7 +554,9 @@ describe('RemoteAgentInvocation', () => {
const result = await invocation.execute(new AbortController().signal);
// Just check that text is present, exact formatting depends on helper
expect(result.returnDisplay).toContain('Extracted text');
expect((result.returnDisplay as SubagentProgress).result).toContain(
'Extracted text',
);
});
it('should handle mixed response types during streaming (TaskStatusUpdateEvent + Message)', async () => {
@@ -577,9 +599,25 @@ describe('RemoteAgentInvocation', () => {
updateOutput,
);
expect(updateOutput).toHaveBeenCalledWith('Thinking...');
expect(updateOutput).toHaveBeenCalledWith('Thinking...\n\nFinal Answer');
expect(result.returnDisplay).toBe('Thinking...\n\nFinal Answer');
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: 'running',
recentActivity: expect.arrayContaining([
expect.objectContaining({ content: 'Working...' }),
]),
}),
);
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
state: 'completed',
result: 'Thinking...Final Answer',
}),
);
expect(result.returnDisplay).toMatchObject({
result: 'Thinking...Final Answer',
});
});
it('should handle artifact reassembly with append: true', async () => {
@@ -635,12 +673,21 @@ describe('RemoteAgentInvocation', () => {
);
await invocation.execute(new AbortController().signal, updateOutput);
expect(updateOutput).toHaveBeenCalledWith('Generating...');
expect(updateOutput).toHaveBeenCalledWith(
'Generating...\n\nArtifact (Result):\nPart 1',
expect.objectContaining({
isSubagentProgress: true,
state: 'running',
recentActivity: expect.arrayContaining([
expect.objectContaining({ content: 'Working...' }),
]),
}),
);
expect(updateOutput).toHaveBeenCalledWith(
'Generating...\n\nArtifact (Result):\nPart 1 Part 2',
expect.objectContaining({
isSubagentProgress: true,
state: 'completed',
result: 'Generating...\n\nArtifact (Result):\nPart 1 Part 2',
}),
);
});
});
@@ -694,8 +741,10 @@ describe('RemoteAgentInvocation', () => {
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.returnDisplay).toContain(a2aError.userMessage);
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
a2aError.userMessage,
);
});
it('should use generic message for non-A2AAgentError errors', async () => {
@@ -712,8 +761,8 @@ describe('RemoteAgentInvocation', () => {
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.returnDisplay).toContain(
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
'Error calling remote agent: something unexpected',
);
});
@@ -741,10 +790,14 @@ describe('RemoteAgentInvocation', () => {
);
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.returnDisplay).toMatchObject({ state: 'error' });
// Should contain both the partial output and the error message
expect(result.returnDisplay).toContain('Partial response');
expect(result.returnDisplay).toContain('connection reset');
expect(result.returnDisplay).toMatchObject({
result: expect.stringContaining('Partial response'),
});
expect(result.returnDisplay).toMatchObject({
result: expect.stringContaining('connection reset'),
});
});
});
});
+53 -6
View File
@@ -15,6 +15,7 @@ import {
type RemoteAgentInputs,
type RemoteAgentDefinition,
type AgentInputs,
type SubagentProgress,
} from './types.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -25,7 +26,6 @@ import type {
import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { debugLogger } from '../utils/debugLogger.js';
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import { A2AAgentError } from './a2a-errors.js';
@@ -125,13 +125,30 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
async execute(
_signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: string | AnsiOutput | SubagentProgress) => void,
): Promise<ToolResult> {
// 1. Ensure the agent is loaded (cached by manager)
// We assume the user has provided an access token via some mechanism (TODO),
// or we rely on ADC.
const reassembler = new A2AResultReassembler();
const agentName = this.definition.displayName ?? this.definition.name;
try {
if (updateOutput) {
updateOutput({
isSubagentProgress: true,
agentName,
state: 'running',
recentActivity: [
{
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
},
],
});
}
const priorState = RemoteAgentInvocation.sessionState.get(
this.definition.name,
);
@@ -172,7 +189,13 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
reassembler.update(chunk);
if (updateOutput) {
updateOutput(reassembler.toString());
updateOutput({
isSubagentProgress: true,
agentName,
state: 'running',
recentActivity: reassembler.toActivityItems(),
result: reassembler.toString(),
});
}
const {
@@ -198,9 +221,21 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
`[RemoteAgent] Final response from ${this.definition.name}:\n${JSON.stringify(finalResponse, null, 2)}`,
);
const finalProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: 'completed',
result: finalOutput,
recentActivity: reassembler.toActivityItems(),
};
if (updateOutput) {
updateOutput(finalProgress);
}
return {
llmContent: [{ text: finalOutput }],
returnDisplay: safeJsonToMarkdown(finalOutput),
returnDisplay: finalProgress,
};
} catch (error: unknown) {
const partialOutput = reassembler.toString();
@@ -209,10 +244,22 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
const fullDisplay = partialOutput
? `${partialOutput}\n\n${errorMessage}`
: errorMessage;
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: 'error',
result: fullDisplay,
recentActivity: reassembler.toActivityItems(),
};
if (updateOutput) {
updateOutput(errorProgress);
}
return {
llmContent: [{ text: fullDisplay }],
returnDisplay: fullDisplay,
error: { message: errorMessage },
returnDisplay: errorProgress,
};
} finally {
// Persist state even on partial failures or aborts to maintain conversational continuity.
+33 -1
View File
@@ -15,8 +15,20 @@ import { CodeAssistServer } from '../code_assist/server.js';
import type { OAuth2Client } from 'google-auth-library';
import { UserTierId, type GeminiUserTier } from './types.js';
import type { Config } from '../config/config.js';
import {
logOnboardingSuccess,
OnboardingSuccessEvent,
} from '../telemetry/index.js';
vi.mock('../code_assist/server.js');
vi.mock('../telemetry/index.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../telemetry/index.js')>();
return {
...actual,
logOnboardingStart: vi.fn(),
logOnboardingSuccess: vi.fn(),
};
});
const mockPaidTier: GeminiUserTier = {
id: UserTierId.STANDARD,
@@ -214,7 +226,20 @@ describe('setupUser', () => {
mockLoad.mockResolvedValue({
allowedTiers: [mockPaidTier],
});
const userData = await setupUser({} as OAuth2Client, mockConfig);
mockOnboardUser.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 1500));
return {
done: true,
response: {
cloudaicompanionProject: {
id: 'server-project',
},
},
};
});
const userDataPromise = setupUser({} as OAuth2Client, mockConfig);
await vi.advanceTimersByTimeAsync(1500);
const userData = await userDataPromise;
expect(mockOnboardUser).toHaveBeenCalledWith(
expect.objectContaining({
tierId: UserTierId.STANDARD,
@@ -227,6 +252,13 @@ describe('setupUser', () => {
userTierName: 'paid',
hasOnboardedPreviously: false,
});
expect(logOnboardingSuccess).toHaveBeenCalledWith(
mockConfig,
expect.any(OnboardingSuccessEvent),
);
const event = vi.mocked(logOnboardingSuccess).mock.calls[0][1];
expect(event.userTier).toBe('paid');
expect(event.duration_ms).toBeGreaterThanOrEqual(1500);
});
it('should onboard a new free user when project ID is not set', async () => {
+5 -2
View File
@@ -251,6 +251,7 @@ async function _doSetupUser(
}
logOnboardingStart(config, new OnboardingStartEvent());
const onboardingStartTime = Date.now();
let lroRes = await caServer.onboardUser(onboardReq);
if (!lroRes.done && lroRes.name) {
@@ -261,8 +262,10 @@ async function _doSetupUser(
}
}
const userTier = tier.id ?? UserTierId.STANDARD;
logOnboardingSuccess(config, new OnboardingSuccessEvent(userTier));
logOnboardingSuccess(
config,
new OnboardingSuccessEvent(tier.name, Date.now() - onboardingStartTime),
);
if (!lroRes.response?.cloudaicompanionProject?.id) {
if (projectId) {
+16
View File
@@ -1474,6 +1474,22 @@ describe('Server Config (config.ts)', () => {
expect(browserConfig.customConfig.visualModel).toBe(
'custom-visual-model',
);
expect(browserConfig.customConfig.maxActionsPerTask).toBe(100); // default
});
it('should return custom maxActionsPerTask', () => {
const params: ConfigParameters = {
...baseParams,
agents: {
browser: {
maxActionsPerTask: 50,
},
},
};
const config = new Config(params);
const browserConfig = config.getBrowserAgentConfig();
expect(browserConfig.customConfig.maxActionsPerTask).toBe(50);
});
it('should apply defaults for partial custom config', () => {
+65 -7
View File
@@ -6,6 +6,7 @@
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';
@@ -330,6 +331,8 @@ export interface BrowserAgentCustomConfig {
allowedDomains?: string[];
/** Disable user input on the browser window during automation. Default: true in non-headless mode */
disableUserInput?: boolean;
/** Maximum number of actions (tool calls) allowed per task. Default: 100 */
maxActionsPerTask?: number;
/** Whether to confirm sensitive actions (e.g., fill_form, evaluate_script). */
confirmSensitiveActions?: boolean;
/** Whether to block file uploads. */
@@ -730,7 +733,8 @@ export class Config implements McpContext, AgentLoopContext {
private readonly telemetrySettings: TelemetrySettings;
private readonly usageStatisticsEnabled: boolean;
private _geminiClient!: GeminiClient;
private readonly _sandboxManager: SandboxManager;
private _sandboxManager: SandboxManager;
private readonly _sandboxPolicyManager: SandboxPolicyManager;
private baseLlmClient!: BaseLlmClient;
private localLiteRtLmClient?: LocalLiteRtLmClient;
private modelRouterService: ModelRouterService;
@@ -905,14 +909,14 @@ export class Config implements McpContext, AgentLoopContext {
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.sandbox = params.sandbox
? {
enabled: params.sandbox.enabled ?? false,
enabled: params.sandbox.enabled || params.toolSandboxing || false,
allowedPaths: params.sandbox.allowedPaths ?? [],
networkAccess: params.sandbox.networkAccess ?? false,
command: params.sandbox.command,
image: params.sandbox.image,
}
: {
enabled: false,
enabled: params.toolSandboxing || false,
allowedPaths: [],
networkAccess: false,
};
@@ -931,6 +935,30 @@ 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, []);
@@ -1001,7 +1029,7 @@ export class Config implements McpContext, AgentLoopContext {
this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false;
this.enableAgents = params.enableAgents ?? true;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? true;
@@ -1160,12 +1188,19 @@ 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:
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
approvalMode: engineApprovalMode,
disableAlwaysAllow: this.disableAlwaysAllow,
toolSandboxEnabled: this.getSandboxEnabled(),
sandboxApprovedTools:
this.sandboxPolicyManager?.getModeConfig(engineApprovalMode)
?.approvedTools ?? [],
},
checkerRunner,
);
@@ -1560,6 +1595,20 @@ 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;
}
@@ -2288,6 +2337,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.policyEngine.getApprovalMode();
}
isPlanMode(): boolean {
return this.getApprovalMode() === ApprovalMode.PLAN;
}
getPolicyUpdateConfirmationRequest():
| PolicyUpdateConfirmationRequest
| undefined {
@@ -2339,7 +2392,11 @@ export class Config implements McpContext, AgentLoopContext {
);
}
this.policyEngine.setApprovalMode(mode);
this.policyEngine.setApprovalMode(
mode,
this.sandboxPolicyManager?.getModeConfig(mode)?.approvedTools ?? [],
);
this.refreshSandboxManager();
const isPlanModeTransition =
currentMode !== mode &&
@@ -3139,6 +3196,7 @@ export class Config implements McpContext, AgentLoopContext {
visualModel: customConfig.visualModel,
allowedDomains: customConfig.allowedDomains,
disableUserInput: customConfig.disableUserInput,
maxActionsPerTask: customConfig.maxActionsPerTask ?? 100,
confirmSensitiveActions: customConfig.confirmSensitiveActions,
blockFileUploads: customConfig.blockFileUploads,
},
@@ -22,6 +22,7 @@ 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,6 +11,7 @@ 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',
@@ -78,6 +79,14 @@ 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;
@@ -42,6 +42,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -213,6 +214,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -503,6 +505,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -674,6 +677,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -845,6 +849,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, you must work autonomously as no further user input is available. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -968,6 +973,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, you must work autonomously as no further user input is available. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -1564,6 +1570,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -1731,6 +1738,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -1889,6 +1897,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -2047,6 +2056,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -2201,6 +2211,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -2355,6 +2366,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -2503,6 +2515,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -2656,6 +2669,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -2934,6 +2948,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -3340,6 +3355,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -3494,6 +3510,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -3760,6 +3777,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -3914,6 +3932,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your change—behavioral, structural, and stylistic—is correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. For Directives, only clarify if critically underspecified; otherwise, work autonomously. You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
+2
View File
@@ -89,6 +89,7 @@ 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'),
@@ -418,6 +419,7 @@ 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'),
},
+6 -2
View File
@@ -88,7 +88,11 @@ export * from './utils/approvalModeUtils.js';
export * from './utils/fileDiffUtils.js';
export * from './utils/retry.js';
export * from './utils/shell-utils.js';
export { PolicyDecision, ApprovalMode } from './policy/types.js';
export {
PolicyDecision,
ApprovalMode,
PRIORITY_YOLO_ALLOW_ALL,
} from './policy/types.js';
export * from './utils/tool-utils.js';
export * from './utils/terminalSerializer.js';
export * from './utils/systemEncoding.js';
@@ -126,7 +130,7 @@ export * from './services/FolderTrustDiscoveryService.js';
export * from './services/chatRecordingService.js';
export * from './services/fileSystemService.js';
export * from './services/sandboxedFileSystemService.js';
export * from './services/windowsSandboxManager.js';
export * from './sandbox/windows/WindowsSandboxManager.js';
export * from './services/sessionSummaryUtils.js';
export * from './services/contextManager.js';
export * from './services/trackerService.js';
@@ -0,0 +1,19 @@
[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,7 +329,11 @@ describe('PolicyEngine', () => {
);
// Switch to autoEdit mode
engine.setApprovalMode(ApprovalMode.AUTO_EDIT);
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.AUTO_EDIT,
toolSandboxEnabled: true,
});
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
PolicyDecision.ALLOW,
);
@@ -1427,14 +1431,14 @@ describe('PolicyEngine', () => {
engine = new PolicyEngine({ rules });
// Atomic command "whoami" matches the wildcard rule (ASK_USER).
// Atomic command "unknown_command" matches the wildcard rule (ASK_USER).
// It should NOT be upgraded to ALLOW.
expect(
(
await engine.check(
{
name: 'run_shell_command',
args: { command: 'whoami' },
args: { command: 'unknown_command' },
},
undefined,
)
@@ -1572,7 +1576,7 @@ describe('PolicyEngine', () => {
},
];
engine = new PolicyEngine({ rules });
engine = new PolicyEngine({ rules, toolSandboxEnabled: true });
engine.setApprovalMode(ApprovalMode.AUTO_EDIT);
const result = await engine.check(
+80 -9
View File
@@ -5,6 +5,11 @@
*/
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,
@@ -192,6 +197,8 @@ 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(
@@ -242,13 +249,18 @@ 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): void {
setApprovalMode(mode: ApprovalMode, sandboxApprovedTools?: string[]): void {
this.approvalMode = mode;
if (sandboxApprovedTools !== undefined) {
this.sandboxApprovedTools = sandboxApprovedTools;
}
}
/**
@@ -269,17 +281,58 @@ export class PolicyEngine {
command: string,
allowRedirection?: boolean,
): boolean {
return (
!allowRedirection &&
hasRedirection(command) &&
this.approvalMode !== ApprovalMode.AUTO_EDIT &&
this.approvalMode !== ApprovalMode.YOLO
);
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;
}
/**
* 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,
@@ -522,11 +575,21 @@ 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,
rule.decision,
ruleDecision,
serverName,
shellDirPath,
rule.allowRedirection,
@@ -562,10 +625,18 @@ 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,
this.defaultDecision,
heuristicDecision,
serverName,
shellDirPath,
false,
@@ -0,0 +1,216 @@
/**
* @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,6 +309,15 @@ 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,6 +54,7 @@ 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'),
+4 -1
View File
@@ -195,7 +195,10 @@ export class PromptProvider {
memoryManagerEnabled: context.config.isMemoryManagerEnabled(),
}),
),
sandbox: this.withSection('sandbox', () => getSandboxMode()),
sandbox: this.withSection('sandbox', () => ({
mode: getSandboxMode(),
toolSandboxingEnabled: context.config.getSandboxEnabled(),
})),
interactiveYoloMode: this.withSection(
'interactiveYoloMode',
() => true,
+11 -4
View File
@@ -36,7 +36,7 @@ export interface SystemPromptOptions {
planningWorkflow?: PlanningWorkflowOptions;
taskTracker?: boolean;
operationalGuidelines?: OperationalGuidelinesOptions;
sandbox?: SandboxMode;
sandbox?: SandboxOptions;
interactiveYoloMode?: boolean;
gitRepo?: GitRepoOptions;
finalReminder?: FinalReminderOptions;
@@ -72,6 +72,11 @@ export interface OperationalGuidelinesOptions {
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
export interface SandboxOptions {
mode: SandboxMode;
toolSandboxingEnabled: boolean;
}
export interface GitRepoOptions {
interactive: boolean;
}
@@ -290,8 +295,9 @@ ${shellEfficiencyGuidelines(options.enableShellEfficiency)}
`.trim();
}
export function renderSandbox(mode?: SandboxMode): string {
if (!mode) return '';
export function renderSandbox(options?: SandboxOptions): string {
if (!options || !options.mode) return '';
const mode = options.mode;
if (mode === 'macos-seatbelt') {
return `
# macOS Seatbelt
@@ -300,11 +306,12 @@ 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 {
} else if (mode === 'outside') {
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 {
+37 -5
View File
@@ -46,7 +46,7 @@ export interface SystemPromptOptions {
planningWorkflow?: PlanningWorkflowOptions;
taskTracker?: boolean;
operationalGuidelines?: OperationalGuidelinesOptions;
sandbox?: SandboxMode;
sandbox?: SandboxOptions;
interactiveYoloMode?: boolean;
gitRepo?: GitRepoOptions;
}
@@ -84,6 +84,11 @@ export interface OperationalGuidelinesOptions {
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
export interface SandboxOptions {
mode: SandboxMode;
toolSandboxingEnabled: boolean;
}
export interface GitRepoOptions {
interactive: boolean;
}
@@ -222,6 +227,7 @@ Use the following guidelines to optimize your search and read patterns.
## Engineering Standards
- **Contextual Precedence:** Instructions found in ${formattedFilenames} files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
- **Types, warnings and linters:** NEVER use hacks like disabling or suppressing warnings or bypassing the type system (i.e.: casts in TypeScript) unless explicitly instructed to by the user. Instead, use idiomatic language features (e.g.: type guard functions).
- **Libraries/Frameworks:** NEVER assume a library/framework is available. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', etc.) before employing it.
- **Technical Integrity:** You are responsible for the entire lifecycle: implementation, testing, and validation. Within the scope of your changes, prioritize readability and long-term maintainability by consolidating logic into clean abstractions rather than threading state across unrelated layers. Align strictly with the requested architectural direction, ensuring the final implementation is focused and free of redundant "just-in-case" alternatives. Validation is not merely running tests; it is the exhaustive process of ensuring that every aspect of your changebehavioral, structural, and stylisticis correct and fully compatible with the broader project. For bug fixes, you must empirically reproduce the failure with a new test case or reproduction script before applying the fix.
- **Expertise & Intent Alignment:** Provide proactive technical opinions grounded in research while strictly adhering to the user's intended workflow. Distinguish between **Directives** (unambiguous requests for action or implementation) and **Inquiries** (requests for analysis, advice, or observations). Assume all requests are Inquiries unless they contain an explicit instruction to perform a task. For Inquiries, your scope is strictly limited to research and analysis; you may propose a solution or strategy, but you MUST NOT modify files until a corresponding Directive is issued. Do not initiate implementation based on observations of bugs or statements of fact. Once an Inquiry is resolved, or while waiting for a Directive, stop and wait for the next user instruction. ${options.interactive ? 'For Directives, only clarify if critically underspecified; otherwise, work autonomously.' : 'For Directives, you must work autonomously as no further user input is available.'} You should only seek user intervention if you have exhausted all possible routes or if a proposed solution would take the workspace in a significantly different architectural direction.
@@ -389,18 +395,44 @@ export function renderOperationalGuidelines(
`.trim();
}
export function renderSandbox(mode?: SandboxMode): string {
if (!mode) return '';
export function renderSandbox(options?: SandboxOptions): string {
if (!options || !options.mode) return '';
const { mode, toolSandboxingEnabled } = options;
if (mode === 'macos-seatbelt') {
return `
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 `
# 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') {
return `
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 `
# 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,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import * as sandboxManager from '../../services/sandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import fs from 'node:fs';
@@ -43,6 +44,10 @@ describe('LinuxSandboxManager', () => {
manager = new LinuxSandboxManager({ workspace });
});
afterEach(() => {
vi.restoreAllMocks();
});
const getBwrapArgs = async (req: SandboxRequest) => {
const result = await manager.prepareCommand(req);
expect(result.program).toBe('sh');
@@ -55,63 +60,19 @@ describe('LinuxSandboxManager', () => {
return result.args.slice(4);
};
it('correctly outputs bwrap as the program with appropriate isolation flags', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
});
expect(bwrapArgs).toEqual([
'--unshare-all',
'--new-session',
'--die-with-parent',
'--ro-bind',
'/',
'/',
'--dev',
'/dev',
'--proc',
'/proc',
'--tmpfs',
'/tmp',
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
'--seccomp',
'9',
'--',
'ls',
'-la',
]);
});
it('maps allowedPaths to bwrap binds', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'node',
args: ['script.js'],
cwd: workspace,
env: {},
policy: {
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
},
});
// Verify the specific bindings were added correctly
/**
* Helper to verify only the dynamic, policy-based binds (e.g. allowedPaths, forbiddenPaths).
* It asserts that the base workspace and governance files are present exactly once,
* then strips them away, leaving only the dynamic binds for a focused, non-brittle assertion.
*/
const expectDynamicBinds = (
bwrapArgs: string[],
expectedDynamicBinds: string[],
) => {
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
const allBinds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
expect(binds).toEqual([
const baseBinds = [
'--bind',
workspace,
workspace,
@@ -124,85 +85,353 @@ describe('LinuxSandboxManager', () => {
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
'--bind-try',
'/tmp/cache',
'/tmp/cache',
'--bind-try',
'/opt/tools',
'/opt/tools',
]);
});
];
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();
// Verify the base binds are present exactly at the beginning
expect(allBinds.slice(0, baseBinds.length)).toEqual(baseBinds);
// Extract the remaining dynamic binds
const dynamicBinds = allBinds.slice(baseBinds.length);
expect(dynamicBinds).toEqual(expectedDynamicBinds);
};
describe('prepareCommand', () => {
it('should correctly format the base command and args', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
});
expect(bwrapArgs).toEqual([
'--unshare-all',
'--new-session',
'--die-with-parent',
'--ro-bind',
'/',
'/',
'--dev',
'/dev',
'--proc',
'/proc',
'--tmpfs',
'/tmp',
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
'--seccomp',
'9',
'--',
'ls',
'-la',
]);
});
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
it('should correctly pass through the cwd to the resulting command', async () => {
const req: SandboxRequest = {
command: 'ls',
args: [],
cwd: '/different/cwd',
env: {},
};
const result = await manager.prepareCommand(req);
expect(result.cwd).toBe('/different/cwd');
});
expect(bwrapArgs).toContain('--ro-bind');
expect(bwrapArgs).toContain(`${workspace}/.gitignore`);
expect(bwrapArgs).toContain('/shared/global.gitignore');
it('should apply environment sanitization via the default mechanisms', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: workspace,
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
},
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['PATH'],
blockedEnvironmentVariables: ['API_KEY'],
enableEnvironmentVariableRedaction: true,
},
},
};
// 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: {},
const result = await manager.prepareCommand(req);
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['API_KEY']).toBeUndefined();
});
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.openSync).toHaveBeenCalled();
});
it('should allow network when networkAccess is true', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
networkAccess: true,
},
});
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: [workspace + '/'],
},
expect(bwrapArgs).toContain('--unshare-user');
expect(bwrapArgs).toContain('--unshare-ipc');
expect(bwrapArgs).toContain('--unshare-pid');
expect(bwrapArgs).toContain('--unshare-uts');
expect(bwrapArgs).toContain('--unshare-cgroup');
expect(bwrapArgs).not.toContain('--unshare-all');
});
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
describe('governance files', () => {
it('should ensure governance files exist', async () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
// 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`,
]);
await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
});
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.openSync).toHaveBeenCalled();
});
it('should protect both the symlink and the real path if they differ', 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',
);
});
});
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'node',
args: ['script.js'],
cwd: workspace,
env: {},
policy: {
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
},
});
// Verify the specific bindings were added correctly
expectDynamicBinds(bwrapArgs, [
'--bind-try',
'/tmp/cache',
'/tmp/cache',
'--bind-try',
'/opt/tools',
'/opt/tools',
]);
});
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: [workspace + '/'],
},
});
// Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash
expectDynamicBinds(bwrapArgs, []);
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(async (p) => {
// Mock /tmp/cache as a directory, and /opt/secret.txt as a file
if (p.toString().includes('cache')) {
return { isDirectory: () => true } as fs.Stats;
}
return { isDirectory: () => false } as fs.Stats;
});
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
);
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'],
},
});
expectDynamicBinds(bwrapArgs, [
'--tmpfs',
'/tmp/cache',
'--remount-ro',
'/tmp/cache',
'--ro-bind-try',
'/dev/null',
'/opt/secret.txt',
]);
});
it('resolves forbidden symlink paths to their real paths', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => false }) as fs.Stats,
);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt';
return p.toString();
},
);
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
forbiddenPaths: ['/tmp/forbidden-symlink'],
},
});
// Should explicitly mask both the resolved path and the original symlink path
expectDynamicBinds(bwrapArgs, [
'--ro-bind-try',
'/dev/null',
'/opt/real-target.txt',
'--ro-bind-try',
'/dev/null',
'/tmp/forbidden-symlink',
]);
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.spyOn(fs.promises, 'stat').mockRejectedValue(error);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
);
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
forbiddenPaths: ['/tmp/not-here.txt'],
},
});
expectDynamicBinds(bwrapArgs, [
'--symlink',
'/.forbidden',
'/tmp/not-here.txt',
]);
});
it('masks directory symlinks with tmpfs for both paths', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => true }) as fs.Stats,
);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/tmp/dir-link') return '/opt/real-dir';
return p.toString();
},
);
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
forbiddenPaths: ['/tmp/dir-link'],
},
});
expectDynamicBinds(bwrapArgs, [
'--tmpfs',
'/opt/real-dir',
'--remount-ro',
'/opt/real-dir',
'--tmpfs',
'/tmp/dir-link',
'--remount-ro',
'/tmp/dir-link',
]);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
vi.spyOn(fs.promises, 'stat').mockImplementation(
async () => ({ isDirectory: () => true }) as fs.Stats,
);
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
);
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
},
});
expectDynamicBinds(bwrapArgs, [
'--bind-try',
'/tmp/conflict',
'/tmp/conflict',
'--tmpfs',
'/tmp/conflict',
'--remount-ro',
'/tmp/conflict',
]);
});
});
});
});
@@ -14,11 +14,13 @@ import {
type SandboxedCommand,
GOVERNANCE_FILES,
sanitizePaths,
tryRealpath,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { isNodeError } from '../../utils/errors.js';
let cachedBpfPath: string | undefined;
@@ -111,53 +113,13 @@ export class LinuxSandboxManager implements SandboxManager {
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const bwrapArgs: string[] = [
'--unshare-all',
'--new-session', // Isolate session
'--die-with-parent', // Prevent orphaned runaway processes
'--ro-bind',
'/',
'/',
'--dev', // Creates a safe, minimal /dev (replaces --dev-bind)
'/dev',
'--proc', // Creates a fresh procfs for the unshared PID namespace
'/proc',
'--tmpfs', // Provides an isolated, writable /tmp directory
'/tmp',
// Note: --dev /dev sets up /dev/pts automatically
'--bind',
this.options.workspace,
this.options.workspace,
...this.getNetworkArgs(req),
...this.getBaseArgs(),
...this.getGovernanceArgs(),
...this.getAllowedPathsArgs(req.policy?.allowedPaths),
...(await this.getForbiddenPathsArgs(req.policy?.forbiddenPaths)),
];
// 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(
/\/$/,
'',
);
for (const allowedPath of allowedPaths) {
const normalizedAllowedPath = normalize(allowedPath).replace(/\/$/, '');
if (normalizedAllowedPath !== normalizedWorkspace) {
bwrapArgs.push('--bind-try', allowedPath, allowedPath);
}
}
// TODO: handle forbidden paths
const bpfPath = getSeccompBpfPath();
bwrapArgs.push('--seccomp', '9');
@@ -175,6 +137,145 @@ export class LinuxSandboxManager implements SandboxManager {
program: 'sh',
args: shArgs,
env: sanitizedEnv,
cwd: req.cwd,
};
}
/**
* Generates arguments for network isolation.
*/
private getNetworkArgs(req: SandboxRequest): string[] {
return req.policy?.networkAccess
? [
'--unshare-user',
'--unshare-ipc',
'--unshare-pid',
'--unshare-uts',
'--unshare-cgroup',
]
: ['--unshare-all'];
}
/**
* Generates the base bubblewrap arguments for isolation.
*/
private getBaseArgs(): string[] {
return [
'--new-session', // Isolate session
'--die-with-parent', // Prevent orphaned runaway processes
'--ro-bind',
'/',
'/',
'--dev', // Creates a safe, minimal /dev (replaces --dev-bind)
'/dev',
'--proc', // Creates a fresh procfs for the unshared PID namespace
'/proc',
'--tmpfs', // Provides an isolated, writable /tmp directory
'/tmp',
// Note: --dev /dev sets up /dev/pts automatically
'--bind',
this.options.workspace,
this.options.workspace,
];
}
/**
* Generates arguments for protected governance files.
*/
private getGovernanceArgs(): string[] {
const args: string[] = [];
// 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);
args.push('--ro-bind', filePath, filePath);
if (realPath !== filePath) {
args.push('--ro-bind', realPath, realPath);
}
}
return args;
}
/**
* Generates arguments for allowed paths.
*/
private getAllowedPathsArgs(allowedPaths?: string[]): string[] {
const args: string[] = [];
const paths = sanitizePaths(allowedPaths) || [];
const normalizedWorkspace = this.normalizePath(this.options.workspace);
for (const p of paths) {
if (this.normalizePath(p) !== normalizedWorkspace) {
args.push('--bind-try', p, p);
}
}
return args;
}
/**
* Generates arguments for forbidden paths.
*/
private async getForbiddenPathsArgs(
forbiddenPaths?: string[],
): Promise<string[]> {
const args: string[] = [];
const paths = sanitizePaths(forbiddenPaths) || [];
for (const p of paths) {
try {
const originalPath = this.normalizePath(p);
const resolvedPath = await tryRealpath(originalPath);
// Mask the resolved path to prevent access to the underlying file.
const resolvedMask = await this.getMaskArgs(resolvedPath);
args.push(...resolvedMask);
// If the original path was a symlink, mask it as well to prevent access
// through the link itself.
if (resolvedPath !== originalPath) {
const originalMask = await this.getMaskArgs(originalPath);
args.push(...originalMask);
}
} catch (e) {
throw new Error(
`Failed to deny access to forbidden path: ${p}. ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
return args;
}
/**
* Generates bubblewrap arguments to mask a forbidden path.
*/
private async getMaskArgs(path: string): Promise<string[]> {
try {
const stats = await fs.promises.stat(path);
if (stats.isDirectory()) {
// Directories are masked by mounting an empty, read-only tmpfs.
return ['--tmpfs', path, '--remount-ro', path];
}
// Existing files are masked by binding them to /dev/null.
return ['--ro-bind-try', '/dev/null', path];
} catch (e) {
if (isNodeError(e) && e.code === 'ENOENT') {
// Non-existent paths are masked by a broken symlink. This prevents
// creation within the sandbox while avoiding host remnants.
return ['--symlink', '/.forbidden', path];
}
throw e;
}
}
private normalizePath(p: string): string {
return normalize(p).replace(/\/$/, '');
}
}
@@ -1,206 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import { ShellExecutionService } from '../../services/shellExecutionService.js';
import { getSecureSanitizationConfig } from '../../services/environmentSanitization.js';
import { type SandboxedCommand } from '../../services/sandboxManager.js';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
/**
* A simple asynchronous wrapper for execFile that returns the exit status,
* stdout, and stderr. Unlike spawnSync, this does not block the Node.js
* event loop, allowing the local HTTP test server to function.
*/
async function runCommand(command: SandboxedCommand) {
try {
const { stdout, stderr } = await promisify(execFile)(
command.program,
command.args,
{
cwd: command.cwd,
env: command.env,
encoding: 'utf-8',
},
);
return { status: 0, stdout, stderr };
} catch (error: unknown) {
const err = error as {
code?: number;
stdout?: string;
stderr?: string;
};
return {
status: err.code ?? 1,
stdout: err.stdout ?? '',
stderr: err.stderr ?? '',
};
}
}
describe.skipIf(os.platform() !== 'darwin')(
'MacOsSandboxManager Integration',
() => {
describe('Basic Execution', () => {
it('should execute commands within the workspace', async () => {
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
const command = await manager.prepareCommand({
command: 'echo',
args: ['sandbox test'],
cwd: process.cwd(),
env: process.env,
});
const execResult = await runCommand(command);
expect(execResult.status).toBe(0);
expect(execResult.stdout.trim()).toBe('sandbox test');
});
it('should support interactive pseudo-terminals (node-pty)', async () => {
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
const abortController = new AbortController();
// Verify that node-pty file descriptors are successfully allocated inside the sandbox
// by using the bash [ -t 1 ] idiom to check if stdout is a TTY.
const handle = await ShellExecutionService.execute(
'bash -c "if [ -t 1 ]; then echo True; else echo False; fi"',
process.cwd(),
() => {},
abortController.signal,
true,
{
sanitizationConfig: getSecureSanitizationConfig(),
sandboxManager: manager,
},
);
const result = await handle.result;
expect(result.error).toBeNull();
expect(result.exitCode).toBe(0);
expect(result.output).toContain('True');
});
});
describe('File System Access', () => {
it('should block file system access outside the workspace', async () => {
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
const blockedPath = '/Users/Shared/.gemini_test_sandbox_blocked';
const command = await manager.prepareCommand({
command: 'touch',
args: [blockedPath],
cwd: process.cwd(),
env: process.env,
});
const execResult = await runCommand(command);
expect(execResult.status).not.toBe(0);
expect(execResult.stderr).toContain('Operation not permitted');
});
it('should grant file system access to explicitly allowed paths', async () => {
// Create a unique temporary directory to prevent artifacts and test flakiness
const allowedDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-sandbox-test-'),
);
try {
const manager = new MacOsSandboxManager({
workspace: process.cwd(),
});
const testFile = path.join(allowedDir, 'test.txt');
const command = await manager.prepareCommand({
command: 'touch',
args: [testFile],
cwd: process.cwd(),
env: process.env,
policy: {
allowedPaths: [allowedDir],
},
});
const execResult = await runCommand(command);
expect(execResult.status).toBe(0);
} finally {
fs.rmSync(allowedDir, { recursive: true, force: true });
}
});
});
describe('Network Access', () => {
let testServer: http.Server;
let testServerUrl: string;
beforeAll(async () => {
testServer = http.createServer((_, res) => {
// Ensure connections are closed immediately to prevent hanging
res.setHeader('Connection', 'close');
res.writeHead(200);
res.end('ok');
});
await new Promise<void>((resolve, reject) => {
testServer.on('error', reject);
testServer.listen(0, '127.0.0.1', () => {
const address = testServer.address() as import('net').AddressInfo;
testServerUrl = `http://127.0.0.1:${address.port}`;
resolve();
});
});
});
afterAll(async () => {
if (testServer) {
await new Promise<void>((resolve) => {
testServer.close(() => resolve());
});
}
});
it('should block network access by default', async () => {
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
const command = await manager.prepareCommand({
command: 'curl',
args: ['-s', '--connect-timeout', '1', testServerUrl],
cwd: process.cwd(),
env: process.env,
});
const execResult = await runCommand(command);
expect(execResult.status).not.toBe(0);
});
it('should grant network access when explicitly allowed', async () => {
const manager = new MacOsSandboxManager({
workspace: process.cwd(),
});
const command = await manager.prepareCommand({
command: 'curl',
args: ['-s', '--connect-timeout', '1', testServerUrl],
cwd: process.cwd(),
env: process.env,
policy: {
networkAccess: true,
},
});
const execResult = await runCommand(command);
expect(execResult.status).toBe(0);
expect(execResult.stdout.trim()).toBe('ok');
});
});
},
);
@@ -6,6 +6,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -35,8 +36,14 @@ describe('MacOsSandboxManager', () => {
};
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
// Mock realpathSync to just return the path for testing
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
// Mock the seatbelt args builder to isolate manager tests
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockResolvedValue([
'-p',
'(mock profile)',
'-D',
'MOCK_VAR=value',
]);
});
afterEach(() => {
@@ -48,78 +55,7 @@ describe('MacOsSandboxManager', () => {
});
describe('prepareCommand', () => {
it('should build a strict allowlist profile allowing the workspace via param', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {},
policy: { networkAccess: false },
});
expect(result.program).toBe('/usr/bin/sandbox-exec');
const profile = result.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(result.args).toContain('-D');
expect(result.args).toContain(`WORKSPACE=${mockWorkspace}`);
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 () => {
const result = await manager.prepareCommand({
command: 'curl',
args: ['example.com'],
cwd: mockWorkspace,
env: {},
policy: { networkAccess: true },
});
const profile = result.args[1];
expect(profile).toContain('(allow network*)');
});
it('should parameterize allowed paths and normalize them', async () => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink') return '/test/real_path';
return p as string;
});
const result = await manager.prepareCommand({
command: 'ls',
args: ['/custom/path1'],
cwd: mockWorkspace,
env: {},
policy: {
allowedPaths: ['/custom/path1', '/test/symlink'],
},
});
const profile = result.args[1];
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
expect(result.args).toContain('-D');
expect(result.args).toContain('ALLOWED_PATH_0=/custom/path1');
expect(result.args).toContain('ALLOWED_PATH_1=/test/real_path');
});
it('should format the executable and arguments correctly for sandbox-exec', async () => {
it('should correctly format the base command and args', async () => {
const result = await manager.prepareCommand({
command: 'echo',
args: ['hello'],
@@ -128,8 +64,31 @@ describe('MacOsSandboxManager', () => {
policy: mockPolicy,
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith({
workspace: mockWorkspace,
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
forbiddenPaths: undefined,
workspaceWrite: false,
additionalPermissions: {
fileSystem: {
read: [],
write: [],
},
network: true,
},
});
expect(result.program).toBe('/usr/bin/sandbox-exec');
expect(result.args.slice(-3)).toEqual(['--', 'echo', 'hello']);
expect(result.args).toEqual([
'-p',
'(mock profile)',
'-D',
'MOCK_VAR=value',
'--',
'echo',
'hello',
]);
});
it('should correctly pass through the cwd to the resulting command', async () => {
@@ -160,62 +119,118 @@ describe('MacOsSandboxManager', () => {
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
});
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) {
const error = new Error('ENOENT');
Object.assign(error, { code: 'ENOENT' });
throw error;
}
if (p === realPath) {
return path.join(baseTmpDir, 'resolved_path');
}
return p as string;
it('should allow network when networkAccess is true', async () => {
await manager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: mockWorkspace,
env: {},
policy: { ...mockPolicy, networkAccess: true },
});
try {
const dynamicManager = new MacOsSandboxManager({
workspace: nonexistentFile,
});
const dynamicResult = await dynamicManager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: nonexistentFile,
env: {},
});
expect(dynamicResult.args).toContain(
`WORKSPACE=${path.join(baseTmpDir, 'resolved_path', 'nonexistent.txt')}`,
);
} finally {
fs.rmSync(baseTmpDir, { recursive: true, force: true });
}
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({ networkAccess: true }),
);
});
it('should throw if realpathSync throws a non-ENOENT error', async () => {
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
const error = new Error('Permission denied');
Object.assign(error, { code: 'EACCES' });
throw error;
});
const errorManager = new MacOsSandboxManager({
workspace: mockWorkspace,
});
await expect(
errorManager.prepareCommand({
describe('governance files', () => {
it('should ensure governance files exist', async () => {
await manager.prepareCommand({
command: 'echo',
args: ['hello'],
args: [],
cwd: mockWorkspace,
env: {},
}),
).rejects.toThrow('Permission denied');
policy: mockPolicy,
});
// The seatbelt builder internally handles governance files, so we simply verify
// it is invoked correctly with the right workspace.
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({ workspace: mockWorkspace }),
);
});
});
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
allowedPaths: ['/tmp/allowed1', '/tmp/allowed2'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({
allowedPaths: ['/tmp/allowed1', '/tmp/allowed2'],
}),
);
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
forbiddenPaths: ['/tmp/forbidden1'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({
forbiddenPaths: ['/tmp/forbidden1'],
}),
);
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
forbiddenPaths: ['/tmp/does-not-exist'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({
forbiddenPaths: ['/tmp/does-not-exist'],
}),
);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
await manager.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
},
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
expect.objectContaining({
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
}),
);
});
});
});
});
@@ -4,41 +4,164 @@
* 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 ExecutionPolicy,
sanitizePaths,
GOVERNANCE_FILES,
type SandboxPermissions,
type GlobalSandboxOptions,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from '../../services/environmentSanitization.js';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import {
BASE_SEATBELT_PROFILE,
NETWORK_SEATBELT_PROFILE,
} from './baseProfile.js';
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;
}
/**
* A SandboxManager implementation for macOS that uses Seatbelt.
*/
export class MacOsSandboxManager implements SandboxManager {
constructor(private readonly options: GlobalSandboxOptions) {}
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);
}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
await initializeShellParsers();
const sanitizationConfig = getSecureSanitizationConfig(
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
const sandboxArgs = this.buildSeatbeltArgs(this.options, req.policy);
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 = await buildSeatbeltArgs({
workspace: this.options.workspace,
allowedPaths: [...(req.policy?.allowedPaths || [])],
forbiddenPaths: req.policy?.forbiddenPaths,
networkAccess: mergedAdditional.network,
workspaceWrite,
additionalPermissions: mergedAdditional,
});
return {
program: '/usr/bin/sandbox-exec',
@@ -47,124 +170,4 @@ 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}`);
// Add explicit deny rules for governance files in the workspace.
// These are added after the workspace allow rule (which is in BASE_SEATBELT_PROFILE)
// 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);
// Ensure the file/directory exists so Seatbelt rules are reliably applied.
this.touch(governanceFile, GOVERNANCE_FILES[i].isDirectory);
const realGovernanceFile = this.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 isActuallyDirectory = GOVERNANCE_FILES[i].isDirectory;
try {
if (fs.existsSync(realGovernanceFile)) {
isActuallyDirectory = fs.lstatSync(realGovernanceFile).isDirectory();
}
} catch {
// Ignore errors, use default guess
}
const ruleType = isActuallyDirectory ? 'subpath' : 'literal';
args.push('-D', `GOVERNANCE_FILE_${i}=${governanceFile}`);
profileLines.push(
`(deny file-write* (${ruleType} (param "GOVERNANCE_FILE_${i}")))`,
);
if (realGovernanceFile !== governanceFile) {
args.push('-D', `REAL_GOVERNANCE_FILE_${i}=${realGovernanceFile}`);
profileLines.push(
`(deny file-write* (${ruleType} (param "REAL_GOVERNANCE_FILE_${i}")))`,
);
}
}
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;
}
/**
* Ensures a file or directory exists.
*/
private 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 {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
/**
* 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;
}
}
}
+96 -8
View File
@@ -16,11 +16,101 @@ 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* (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 basic read access to system frameworks and libraries required to run
(allow file-read*
@@ -38,11 +128,6 @@ 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")
@@ -53,9 +138,10 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
)
; Workspace access using parameterized paths
(allow file-read* file-write*
(allow file-read*
(subpath (param "WORKSPACE"))
)
`;
/**
@@ -66,7 +152,9 @@ export const BASE_SEATBELT_PROFILE = `(version 1)
*/
export const NETWORK_SEATBELT_PROFILE = `
; Network Access
(allow network*)
(allow network-outbound)
(allow network-inbound)
(allow network-bind)
(allow system-socket
(require-all
@@ -0,0 +1,469 @@
/**
* @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;
}
@@ -0,0 +1,237 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
import * as sandboxManager from '../../services/sandboxManager.js';
import fs from 'node:fs';
import os from 'node:os';
describe('seatbeltArgsBuilder', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('buildSeatbeltArgs', () => {
it('should build a strict allowlist profile allowing the workspace via param', async () => {
// Mock tryRealpath to just return the path for testing
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await 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()}`);
});
it('should allow network when networkAccess is true', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({
workspace: '/test',
networkAccess: true,
});
const profile = args[1];
expect(profile).toContain('(allow network-outbound)');
});
describe('governance files', () => {
it('should inject explicit deny rules for governance files', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (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 = await 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")))',
);
});
it('should protect both the symlink and the real path if they differ', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (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 = await 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")))',
);
});
});
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
},
);
const args = await 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');
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/secret/path'],
});
const profile = args[1];
expect(args).toContain('-D');
expect(args).toContain('FORBIDDEN_PATH_0=/secret/path');
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('resolves forbidden symlink paths to their real paths', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => {
if (p === '/test/symlink') return '/test/real_path';
return p;
},
);
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/test/symlink'],
});
const profile = args[1];
// The builder should resolve the symlink and explicitly deny the real target path
expect(args).toContain('-D');
expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path');
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({
workspace: '/test',
forbiddenPaths: ['/test/missing-dir/missing-file.txt'],
});
const profile = args[1];
expect(args).toContain('-D');
expect(args).toContain(
'FORBIDDEN_PATH_0=/test/missing-dir/missing-file.txt',
);
expect(profile).toContain(
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))',
);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(
async (p) => p,
);
const args = await buildSeatbeltArgs({
workspace: '/test',
allowedPaths: ['/custom/path1'],
forbiddenPaths: ['/custom/path1'],
});
const profile = args[1];
const allowString =
'(allow file-read* file-write* (subpath (param "ALLOWED_PATH_0")))';
const denyString =
'(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_0")))';
expect(profile).toContain(allowString);
expect(profile).toContain(denyString);
// Verify ordering: The explicit deny must appear AFTER the explicit allow in the profile string
// Seatbelt rules are evaluated in order where the latest rule matching a path wins
const allowIndex = profile.indexOf(allowString);
const denyIndex = profile.indexOf(denyString);
expect(denyIndex).toBeGreaterThan(allowIndex);
});
});
});
});
@@ -0,0 +1,230 @@
/**
* @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,
tryRealpath,
} 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;
}
/**
* 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 async function buildSeatbeltArgs(
options: SeatbeltArgsOptions,
): Promise<string[]> {
let profile = BASE_SEATBELT_PROFILE + '\n';
const args: string[] = [];
const workspacePath = await 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 = await 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 = await 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 = await 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 = await tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
const nodeRootPath = await 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 = await 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 = await 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) {
for (let i = 0; i < read.length; i++) {
const resolved = await tryRealpath(read[i]);
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) {
for (let i = 0; i < write.length; i++) {
const resolved = await tryRealpath(write[i]);
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 = await 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;
}
@@ -0,0 +1,274 @@
/**
* @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 os from 'node:os';
import path from 'node:path';
import { WindowsSandboxManager } from './WindowsSandboxManager.js';
import * as sandboxManager from '../../services/sandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import { spawnAsync } from '../../utils/shell-utils.js';
vi.mock('../../utils/shell-utils.js', () => ({
spawnAsync: vi.fn(),
}));
describe('WindowsSandboxManager', () => {
let manager: WindowsSandboxManager;
let testCwd: string;
beforeEach(() => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) =>
p.toString(),
);
testCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
manager = new WindowsSandboxManager({ workspace: testCwd });
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(testCwd, { recursive: true, force: true });
});
describe('prepareCommand', () => {
it('should correctly format the base command and args', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
cwd: testCwd,
env: { TEST_VAR: 'test_value' },
policy: {
networkAccess: false,
},
};
const result = await manager.prepareCommand(req);
expect(result.program).toContain('GeminiSandbox.exe');
expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']);
});
it('should correctly pass through the cwd to the resulting command', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: '/different/cwd',
env: {},
};
const result = await manager.prepareCommand(req);
expect(result.cwd).toBe('/different/cwd');
});
it('should apply environment sanitization via the default mechanisms', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
},
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['PATH'],
blockedEnvironmentVariables: ['API_KEY'],
enableEnvironmentVariableRedaction: true,
},
},
};
const result = await manager.prepareCommand(req);
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['API_KEY']).toBeUndefined();
});
it('should allow network when networkAccess is true', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: testCwd,
env: {},
policy: {
networkAccess: true,
},
};
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
});
describe('governance files', () => {
it('should ensure governance files exist', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
};
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,
);
});
});
describe('allowedPaths', () => {
it('should parameterize allowed paths and normalize them', 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 });
}
});
});
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
const forbiddenPath = path.join(
os.tmpdir(),
'gemini-cli-test-forbidden',
);
if (!fs.existsSync(forbiddenPath)) {
fs.mkdirSync(forbiddenPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [forbiddenPath],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(forbiddenPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
} finally {
fs.rmSync(forbiddenPath, { recursive: true, force: true });
}
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
const missingPath = path.join(
os.tmpdir(),
'gemini-cli-test-missing',
'does-not-exist.txt',
);
// Ensure it definitely doesn't exist
if (fs.existsSync(missingPath)) {
fs.rmSync(missingPath, { recursive: true, force: true });
}
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [missingPath],
},
};
await manager.prepareCommand(req);
// Should NOT have called icacls to deny the missing path
expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [
path.resolve(missingPath),
'/deny',
'*S-1-16-4096:(OI)(CI)(F)',
]);
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict');
if (!fs.existsSync(conflictPath)) {
fs.mkdirSync(conflictPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
allowedPaths: [conflictPath],
forbiddenPaths: [conflictPath],
},
};
await manager.prepareCommand(req);
const spawnMock = vi.mocked(spawnAsync);
const allowCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/setintegritylevel') &&
call[0] === 'icacls' &&
call[1][0] === path.resolve(conflictPath),
);
const denyCallIndex = spawnMock.mock.calls.findIndex(
(call) =>
call[1] &&
call[1].includes('/deny') &&
call[0] === 'icacls' &&
call[1][0] === path.resolve(conflictPath),
);
// Both should have been called
expect(allowCallIndex).toBeGreaterThan(-1);
expect(denyCallIndex).toBeGreaterThan(-1);
// Verify order: explicitly denying must happen after the explicit allow
expect(allowCallIndex).toBeLessThan(denyCallIndex);
} finally {
fs.rmSync(conflictPath, { recursive: true, force: true });
}
});
});
});
});
@@ -15,13 +15,15 @@ import {
GOVERNANCE_FILES,
type GlobalSandboxOptions,
sanitizePaths,
} from './sandboxManager.js';
tryRealpath,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
} from './environmentSanitization.js';
import { debugLogger } from '../utils/debugLogger.js';
import { spawnAsync } from '../utils/shell-utils.js';
} from '../../services/environmentSanitization.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import { isNodeError } from '../../utils/errors.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -34,10 +36,11 @@ const __dirname = path.dirname(__filename);
export class WindowsSandboxManager implements SandboxManager {
private readonly helperPath: string;
private initialized = false;
private readonly lowIntegrityCache = new Set<string>();
private readonly allowedCache = new Set<string>();
private readonly deniedCache = new Set<string>();
constructor(private readonly options: GlobalSandboxOptions) {
this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe');
this.helperPath = path.resolve(__dirname, 'GeminiSandbox.exe');
}
/**
@@ -185,7 +188,11 @@ export class WindowsSandboxManager implements SandboxManager {
await this.grantLowIntegrityAccess(allowedPath);
}
// TODO: handle forbidden paths
// Denies access to forbiddenPaths for Low Integrity processes.
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
for (const forbiddenPath of forbiddenPaths) {
await this.denyLowIntegrityAccess(forbiddenPath);
}
// 2. Protected governance files
// These must exist on the host before running the sandbox to prevent
@@ -224,6 +231,7 @@ export class WindowsSandboxManager implements SandboxManager {
program,
args,
env: sanitizedEnv,
cwd: req.cwd,
};
}
@@ -235,8 +243,8 @@ export class WindowsSandboxManager implements SandboxManager {
return;
}
const resolvedPath = path.resolve(targetPath);
if (this.lowIntegrityCache.has(resolvedPath)) {
const resolvedPath = await tryRealpath(targetPath);
if (this.allowedCache.has(resolvedPath)) {
return;
}
@@ -256,7 +264,7 @@ export class WindowsSandboxManager implements SandboxManager {
try {
await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']);
this.lowIntegrityCache.add(resolvedPath);
this.allowedCache.add(resolvedPath);
} catch (e) {
debugLogger.log(
'WindowsSandboxManager: icacls failed for',
@@ -265,4 +273,54 @@ export class WindowsSandboxManager implements SandboxManager {
);
}
}
/**
* Explicitly denies access to a path for Low Integrity processes using icacls.
*/
private async denyLowIntegrityAccess(targetPath: string): Promise<void> {
if (os.platform() !== 'win32') {
return;
}
const resolvedPath = await tryRealpath(targetPath);
if (this.deniedCache.has(resolvedPath)) {
return;
}
// S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity)
const LOW_INTEGRITY_SID = '*S-1-16-4096';
// icacls flags: (OI) Object Inherit, (CI) Container Inherit, (F) Full Access Deny.
// Omit /T (recursive) for performance; (OI)(CI) ensures inheritance for new items.
// Windows dynamically evaluates existing items, though deep explicit Allow ACEs
// could potentially bypass this inherited Deny rule.
const DENY_ALL_INHERIT = '(OI)(CI)(F)';
// icacls fails on non-existent paths, so we cannot explicitly deny
// paths that do not yet exist (unlike macOS/Linux).
// Skip to prevent sandbox initialization failure.
try {
await fs.promises.stat(resolvedPath);
} catch (e: unknown) {
if (isNodeError(e) && e.code === 'ENOENT') {
return;
}
throw e;
}
try {
await spawnAsync('icacls', [
resolvedPath,
'/deny',
`${LOW_INTEGRITY_SID}:${DENY_ALL_INHERIT}`,
]);
this.deniedCache.add(resolvedPath);
} catch (e) {
throw new Error(
`Failed to deny access to forbidden path: ${resolvedPath}. ${
e instanceof Error ? e.message : String(e)
}`,
);
}
}
}
+2 -1
View File
@@ -77,7 +77,8 @@ 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.isClientInitiated &&
!toolCall.request.args?.['additional_permissions']
) {
return {
decision: PolicyDecision.ALLOW,
+104
View File
@@ -792,6 +792,110 @@ 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,
@@ -0,0 +1,475 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createSandboxManager } from './sandboxManagerFactory.js';
import { ShellExecutionService } from './shellExecutionService.js';
import { getSecureSanitizationConfig } from './environmentSanitization.js';
import {
type SandboxedCommand,
NoopSandboxManager,
LocalSandboxManager,
} from './sandboxManager.js';
import { execFile, execSync } from 'node:child_process';
import { promisify } from 'node:util';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
/**
* Abstracts platform-specific shell commands for integration testing.
*/
const Platform = {
isWindows: os.platform() === 'win32',
/** Returns a command to create an empty file. */
touch(filePath: string) {
return this.isWindows
? { command: 'cmd.exe', args: ['/c', `type nul > "${filePath}"`] }
: { command: 'touch', args: [filePath] };
},
/** Returns a command to read a file's content. */
cat(filePath: string) {
return this.isWindows
? { command: 'cmd.exe', args: ['/c', `type "${filePath}"`] }
: { command: 'cat', args: [filePath] };
},
/** Returns a command to echo a string. */
echo(text: string) {
return this.isWindows
? { command: 'cmd.exe', args: ['/c', `echo ${text}`] }
: { command: 'echo', args: [text] };
},
/** Returns a command to perform a network request. */
curl(url: string) {
return this.isWindows
? {
command: 'powershell.exe',
args: ['-Command', `Invoke-WebRequest -Uri ${url} -TimeoutSec 1`],
}
: { command: 'curl', args: ['-s', '--connect-timeout', '1', url] };
},
/** Returns a command that checks if the current terminal is interactive. */
isPty() {
return this.isWindows
? 'cmd.exe /c echo True'
: 'bash -c "if [ -t 1 ]; then echo True; else echo False; fi"';
},
/** Returns a path that is strictly outside the workspace and likely blocked. */
getExternalBlockedPath() {
return this.isWindows
? 'C:\\Windows\\System32\\drivers\\etc\\hosts'
: '/Users/Shared/.gemini_test_blocked';
},
};
async function runCommand(command: SandboxedCommand) {
try {
const { stdout, stderr } = await promisify(execFile)(
command.program,
command.args,
{
cwd: command.cwd,
env: command.env,
encoding: 'utf-8',
},
);
return { status: 0, stdout, stderr };
} catch (error: unknown) {
const err = error as { code?: number; stdout?: string; stderr?: string };
return {
status: err.code ?? 1,
stdout: err.stdout ?? '',
stderr: err.stderr ?? '',
};
}
}
/**
* Determines if the system has the necessary binaries to run the sandbox.
*/
function isSandboxAvailable(): boolean {
if (os.platform() === 'win32') {
// Windows sandboxing relies on icacls, which is a core system utility and
// always available.
return true;
}
if (os.platform() === 'darwin') {
return fs.existsSync('/usr/bin/sandbox-exec');
}
if (os.platform() === 'linux') {
// TODO: Install bubblewrap (bwrap) in Linux CI environments to enable full
// integration testing.
try {
execSync('which bwrap', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
return false;
}
describe('SandboxManager Integration', () => {
const workspace = process.cwd();
const manager = createSandboxManager({ enabled: true }, workspace);
// Skip if we are on an unsupported platform or if it's a NoopSandboxManager
const shouldSkip =
manager instanceof NoopSandboxManager ||
manager instanceof LocalSandboxManager ||
!isSandboxAvailable();
describe.skipIf(shouldSkip)('Cross-platform Sandbox Behavior', () => {
describe('Basic Execution', () => {
it('executes commands within the workspace', async () => {
const { command, args } = Platform.echo('sandbox test');
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('sandbox test');
});
it('supports interactive pseudo-terminals (node-pty)', async () => {
const handle = await ShellExecutionService.execute(
Platform.isPty(),
workspace,
() => {},
new AbortController().signal,
true,
{
sanitizationConfig: getSecureSanitizationConfig(),
sandboxManager: manager,
},
);
const result = await handle.result;
expect(result.exitCode).toBe(0);
expect(result.output).toContain('True');
});
});
describe('File System Access', () => {
it('blocks access outside the workspace', async () => {
const blockedPath = Platform.getExternalBlockedPath();
const { command, args } = Platform.touch(blockedPath);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed);
expect(result.status).not.toBe(0);
});
it('grants access to explicitly allowed paths', async () => {
const allowedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowed-'));
const testFile = path.join(allowedDir, 'test.txt');
try {
const { command, args } = Platform.touch(testFile);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
policy: { allowedPaths: [allowedDir] },
});
const result = await runCommand(sandboxed);
expect(result.status).toBe(0);
expect(fs.existsSync(testFile)).toBe(true);
} finally {
if (fs.existsSync(testFile)) fs.unlinkSync(testFile);
fs.rmSync(allowedDir, { recursive: true, force: true });
}
});
it('blocks access to forbidden paths within the workspace', async () => {
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const forbiddenDir = path.join(tempWorkspace, 'forbidden');
const testFile = path.join(forbiddenDir, 'test.txt');
fs.mkdirSync(forbiddenDir);
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [forbiddenDir] },
});
const result = await runCommand(sandboxed);
expect(result.status).not.toBe(0);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('blocks access to files inside forbidden directories recursively', async () => {
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const forbiddenDir = path.join(tempWorkspace, 'forbidden');
const nestedDir = path.join(forbiddenDir, 'nested');
const nestedFile = path.join(nestedDir, 'test.txt');
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(nestedFile, 'secret');
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
);
const { command, args } = Platform.cat(nestedFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [forbiddenDir] },
});
const result = await runCommand(sandboxed);
expect(result.status).not.toBe(0);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('prioritizes forbiddenPaths over allowedPaths', async () => {
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const conflictDir = path.join(tempWorkspace, 'conflict');
const testFile = path.join(conflictDir, 'test.txt');
fs.mkdirSync(conflictDir);
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
allowedPaths: [conflictDir],
forbiddenPaths: [conflictDir],
},
});
const result = await runCommand(sandboxed);
expect(result.status).not.toBe(0);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('gracefully ignores non-existent paths in allowedPaths and forbiddenPaths', async () => {
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const nonExistentPath = path.join(tempWorkspace, 'does-not-exist');
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
);
const { command, args } = Platform.echo('survived');
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
allowedPaths: [nonExistentPath],
forbiddenPaths: [nonExistentPath],
},
});
const result = await runCommand(sandboxed);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('survived');
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('prevents creation of non-existent forbidden paths', async () => {
// Windows icacls cannot explicitly protect paths that have not yet been created.
if (Platform.isWindows) return;
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const nonExistentFile = path.join(tempWorkspace, 'never-created.txt');
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
);
// We use touch to attempt creation of the file
const { command: cmdTouch, args: argsTouch } =
Platform.touch(nonExistentFile);
const sandboxedCmd = await osManager.prepareCommand({
command: cmdTouch,
args: argsTouch,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [nonExistentFile] },
});
// Execute the command, we expect it to fail (permission denied or read-only file system)
const result = await runCommand(sandboxedCmd);
expect(result.status).not.toBe(0);
expect(fs.existsSync(nonExistentFile)).toBe(false);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
it('blocks access to both a symlink and its target when the symlink is forbidden', async () => {
if (Platform.isWindows) return;
const tempWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'workspace-'),
);
const targetFile = path.join(tempWorkspace, 'target.txt');
const symlinkFile = path.join(tempWorkspace, 'link.txt');
fs.writeFileSync(targetFile, 'secret data');
fs.symlinkSync(targetFile, symlinkFile);
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
);
// Attempt to read the target file directly
const { command: cmdTarget, args: argsTarget } =
Platform.cat(targetFile);
const commandTarget = await osManager.prepareCommand({
command: cmdTarget,
args: argsTarget,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [symlinkFile] }, // Forbid the symlink
});
const resultTarget = await runCommand(commandTarget);
expect(resultTarget.status).not.toBe(0);
// Attempt to read via the symlink
const { command: cmdLink, args: argsLink } =
Platform.cat(symlinkFile);
const commandLink = await osManager.prepareCommand({
command: cmdLink,
args: argsLink,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [symlinkFile] }, // Forbid the symlink
});
const resultLink = await runCommand(commandLink);
expect(resultLink.status).not.toBe(0);
} finally {
fs.rmSync(tempWorkspace, { recursive: true, force: true });
}
});
});
describe('Network Access', () => {
let server: http.Server;
let url: string;
beforeAll(async () => {
server = http.createServer((_, res) => {
res.setHeader('Connection', 'close');
res.writeHead(200);
res.end('ok');
});
await new Promise<void>((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as import('net').AddressInfo;
url = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
});
afterAll(async () => {
if (server) await new Promise<void>((res) => server.close(() => res()));
});
it('blocks network access by default', async () => {
const { command, args } = Platform.curl(url);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed);
expect(result.status).not.toBe(0);
});
it('grants network access when explicitly allowed', async () => {
const { command, args } = Platform.curl(url);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
policy: { networkAccess: true },
});
const result = await runCommand(sandboxed);
expect(result.status).toBe(0);
if (!Platform.isWindows) {
expect(result.stdout.trim()).toBe('ok');
}
});
});
});
});
@@ -5,12 +5,18 @@
*/
import os from 'node:os';
import { describe, expect, it, vi } from 'vitest';
import { NoopSandboxManager, sanitizePaths } from './sandboxManager.js';
import path from 'node:path';
import fs from 'node:fs/promises';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
NoopSandboxManager,
sanitizePaths,
tryRealpath,
} from './sandboxManager.js';
import { createSandboxManager } from './sandboxManagerFactory.js';
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js';
describe('sanitizePaths', () => {
it('should return undefined if no paths are provided', () => {
@@ -30,6 +36,82 @@ describe('sanitizePaths', () => {
});
});
describe('tryRealpath', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return the realpath if the file exists', async () => {
vi.spyOn(fs, 'realpath').mockResolvedValue('/real/path/to/file.txt');
const result = await tryRealpath('/some/symlink/to/file.txt');
expect(result).toBe('/real/path/to/file.txt');
expect(fs.realpath).toHaveBeenCalledWith('/some/symlink/to/file.txt');
});
it('should fallback to parent directory if file does not exist (ENOENT)', async () => {
vi.spyOn(fs, 'realpath').mockImplementation(async (p) => {
if (p === '/workspace/nonexistent.txt') {
throw Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
});
}
if (p === '/workspace') {
return '/real/workspace';
}
throw new Error(`Unexpected path: ${p}`);
});
const result = await tryRealpath('/workspace/nonexistent.txt');
// It should combine the real path of the parent with the original basename
expect(result).toBe(path.join('/real/workspace', 'nonexistent.txt'));
});
it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => {
vi.spyOn(fs, 'realpath').mockImplementation(async (p) => {
if (p === '/workspace/missing_dir/missing_file.txt') {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}
if (p === '/workspace/missing_dir') {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}
if (p === '/workspace') {
return '/real/workspace';
}
throw new Error(`Unexpected path: ${p}`);
});
const result = await tryRealpath('/workspace/missing_dir/missing_file.txt');
// It should resolve '/workspace' to '/real/workspace' and append the missing parts
expect(result).toBe(
path.join('/real/workspace', 'missing_dir', 'missing_file.txt'),
);
});
it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => {
const rootPath = path.resolve('/');
vi.spyOn(fs, 'realpath').mockImplementation(async () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const result = await tryRealpath(rootPath);
expect(result).toBe(rootPath);
});
it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => {
vi.spyOn(fs, 'realpath').mockImplementation(async () => {
throw Object.assign(new Error('EACCES: permission denied'), {
code: 'EACCES',
});
});
await expect(tryRealpath('/secret/file.txt')).rejects.toThrow(
'EACCES: permission denied',
);
});
});
describe('NoopSandboxManager', () => {
const sandboxManager = new NoopSandboxManager();
@@ -4,13 +4,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { isNodeError } from '../utils/errors.js';
import {
sanitizeEnvironment,
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.
*/
@@ -23,6 +37,8 @@ export interface ExecutionPolicy {
networkAccess?: boolean;
/** Rules for scrubbing sensitive environment variables. */
sanitizationConfig?: Partial<EnvironmentSanitizationConfig>;
/** Additional granular permissions to grant to this command. */
additionalPermissions?: SandboxPermissions;
}
/**
@@ -150,4 +166,25 @@ export function sanitizePaths(paths?: string[]): string[] | undefined {
return Array.from(uniquePathsMap.values());
}
/**
* 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.
*/
export async function tryRealpath(p: string): Promise<string> {
try {
return await fs.realpath(p);
} catch (e) {
if (isNodeError(e) && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(await tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
export { createSandboxManager } from './sandboxManagerFactory.js';
@@ -12,8 +12,9 @@ import {
} from './sandboxManager.js';
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
import { WindowsSandboxManager } from '../sandbox/windows/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.
@@ -21,7 +22,13 @@ import type { SandboxConfig } from '../config/config.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 (
@@ -36,7 +43,15 @@ export function createSandboxManager(
return new LinuxSandboxManager({ workspace });
}
if (os.platform() === 'darwin') {
return new MacOsSandboxManager({ workspace });
const modeConfig =
policyManager && approvalMode
? policyManager.getModeConfig(approvalMode)
: undefined;
return new MacOsSandboxManager({
workspace,
modeConfig,
policyManager,
});
}
return new LocalSandboxManager();
}
@@ -31,7 +31,11 @@ import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import { NoopSandboxManager, type SandboxManager } from './sandboxManager.js';
import {
NoopSandboxManager,
type SandboxManager,
type SandboxPermissions,
} from './sandboxManager.js';
import type { SandboxConfig } from '../config/config.js';
import { killProcessGroup } from '../utils/process-utils.js';
import {
@@ -84,6 +88,7 @@ export type ShellExecutionResult = ExecutionResult;
export type ShellExecutionHandle = ExecutionHandle;
export interface ShellExecutionConfig {
additionalPermissions?: SandboxPermissions;
terminalWidth?: number;
terminalHeight?: number;
pager?: string;
@@ -441,6 +446,7 @@ export class ShellExecutionService {
...shellExecutionConfig,
...(shellExecutionConfig.sandboxConfig || {}),
sanitizationConfig,
additionalPermissions: shellExecutionConfig.additionalPermissions,
},
});
@@ -1,138 +0,0 @@
/**
* @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 os from 'node:os';
import path from 'node:path';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
import type { SandboxRequest } from './sandboxManager.js';
import { spawnAsync } from '../utils/shell-utils.js';
vi.mock('../utils/shell-utils.js', () => ({
spawnAsync: vi.fn(),
}));
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 });
});
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,
env: { TEST_VAR: 'test_value' },
policy: {
networkAccess: false,
},
};
const result = await manager.prepareCommand(req);
expect(result.program).toContain('GeminiSandbox.exe');
expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']);
});
it('should handle networkAccess from config', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: testCwd,
env: {},
policy: {
networkAccess: true,
},
};
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
});
it('should sanitize environment variables', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
},
policy: {
sanitizationConfig: {
allowedEnvironmentVariables: ['PATH'],
blockedEnvironmentVariables: ['API_KEY'],
enableEnvironmentVariableRedaction: true,
},
},
};
const result = await manager.prepareCommand(req);
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['API_KEY']).toBeUndefined();
});
it('should ensure governance files exist', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
};
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);
});
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 });
}
});
});
@@ -1675,7 +1675,7 @@ describe('ClearcutLogger', () => {
describe('logOnboardingSuccessEvent', () => {
it('logs an event with proper name and user tier', () => {
const { logger } = setup();
const event = new OnboardingSuccessEvent('standard-tier');
const event = new OnboardingSuccessEvent('standard-tier', 100);
logger?.logOnboardingSuccessEvent(event);
@@ -1686,6 +1686,10 @@ describe('ClearcutLogger', () => {
EventMetadataKey.GEMINI_CLI_ONBOARDING_USER_TIER,
'standard-tier',
]);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ONBOARDING_DURATION_MS,
'100',
]);
});
});
});
@@ -1821,6 +1821,12 @@ export class ClearcutLogger {
value: event.userTier,
});
}
if (event.duration_ms !== undefined) {
data.push({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ONBOARDING_DURATION_MS,
value: event.duration_ms.toString(),
});
}
this.enqueueLogEvent(
this.createLogEvent(EventNames.ONBOARDING_SUCCESS, data),
);

Some files were not shown because too many files have changed in this diff Show More