Compare commits

...

10 Commits

Author SHA1 Message Date
gemini-cli-robot 4d73f34139 chore(release): v0.39.1 2026-04-24 02:03:53 +00:00
Emily Hedlund 3d0536a6c6 update FatalUntrustedWorkspaceError message to include doc link (#25874) 2026-04-23 16:57:39 -07:00
Gal Zahavi 6e64110b59 feat(core): enhance shell command validation and add core tools allowlist (#25720)
Co-authored-by: David Pierce <davidapierce@google.com>
Co-authored-by: Keith Schaab <keithsc@google.com>
Co-authored-by: Keith Schaab <keith.schaab@gmail.com>
Co-authored-by: Emily Hedlund <ehedlund@google.com>
2026-04-23 16:57:39 -07:00
Emily Hedlund 77ab9e6ac0 feat(cli): secure .env loading and enforce workspace trust in headless mode (#25814)
Co-authored-by: galz10 <galzahavi@google.com>
Co-authored-by: davidapierce <davidapierce@google.com>
2026-04-23 16:56:40 -07:00
gemini-cli-robot 398f78dcaa chore(release): v0.39.0 2026-04-23 04:10:28 +00:00
gemini-cli-robot a70149e153 chore(release): v0.39.0-preview.2 2026-04-22 00:37:38 +00:00
gemini-cli-robot 23f55e0d17 fix(patch): cherry-pick d6f88f8 to release/v0.39.0-preview.1-pr-25670 to patch version v0.39.0-preview.1 and create version 0.39.0-preview.2 (#25776)
Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
2026-04-21 16:57:45 -07:00
gemini-cli-robot 5cdce61e8f chore(release): v0.39.0-preview.1 2026-04-21 22:44:53 +00:00
gemini-cli-robot 8d4bd62d39 fix(patch): cherry-pick a4e98c0 to release/v0.39.0-preview.0-pr-25138 to patch version v0.39.0-preview.0 and create version 0.39.0-preview.1 (#25766)
Co-authored-by: Mahima Shanware <mahima.shanware@gmail.com>
2026-04-21 15:04:04 -07:00
gemini-cli-robot 1710e6fe11 chore(release): v0.39.0-preview.0 2026-04-14 23:04:47 +00:00
69 changed files with 2304 additions and 975 deletions
+1
View File
@@ -28,6 +28,7 @@ runs:
- name: 'Run Tests'
env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
GEMINI_CLI_TRUST_WORKSPACE: true
working-directory: '${{ inputs.working-directory }}'
run: |-
echo "::group::Build"
@@ -98,6 +98,7 @@ runs:
working-directory: '${{ inputs.working-directory }}'
env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
GEMINI_CLI_TRUST_WORKSPACE: true
INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
# We must diable CI mode here because it interferes with interactive tests.
# See https://github.com/google-gemini/gemini-cli/issues/10517
+3
View File
@@ -167,6 +167,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
VERBOSE: 'true'
BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max'
@@ -212,6 +213,7 @@ jobs:
if: "${{runner.os != 'Windows'}}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
@@ -288,6 +290,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
+3
View File
@@ -173,6 +173,7 @@ jobs:
- name: 'Run tests and generate reports'
env:
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
if [[ "${{ matrix.shard }}" == "cli" ]]; then
npm run test:ci --workspace "@google/gemini-cli"
@@ -261,6 +262,7 @@ jobs:
- name: 'Run tests and generate reports'
env:
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
if [[ "${{ matrix.shard }}" == "cli" ]]; then
npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false
@@ -424,6 +426,7 @@ jobs:
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
UV_THREADPOOL_SIZE: '32'
NODE_ENV: 'test'
+3
View File
@@ -62,6 +62,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}"
KEEP_OUTPUT: 'true'
RUNS: '${{ github.event.inputs.runs }}'
@@ -105,6 +106,7 @@ jobs:
if: "runner.os != 'Windows'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
RUNS: '${{ github.event.inputs.runs }}'
SANDBOX: 'sandbox:none'
@@ -159,6 +161,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
+1
View File
@@ -141,6 +141,7 @@ jobs:
if: "github.event_name != 'pull_request'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
echo "Running integration tests with binary..."
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
+1
View File
@@ -52,6 +52,7 @@ These commands are available within the interactive REPL.
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--skip-trust` | - | boolean | `false` | Trust the current workspace for this session, skipping the folder trust check. |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
+1 -2
View File
@@ -329,7 +329,6 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation.
#!/usr/bin/env bash
# Extract the plan filename from the tool input JSON
plan_filename=$(jq -r '.tool_input.plan_filename // empty')
plan_filename=$(basename -- "$plan_filename")
# Construct the absolute path using the GEMINI_PLANS_DIR environment variable
plan_path="$GEMINI_PLANS_DIR/$plan_filename"
@@ -358,7 +357,7 @@ To register this `AfterTool` hook, add it to your `settings.json`:
{
"name": "archive-plan",
"type": "command",
"command": "./.gemini/hooks/archive-plan.sh"
"command": "~/.gemini/hooks/archive-plan.sh"
}
]
}
+28
View File
@@ -100,6 +100,34 @@ protect you. In this mode, the following features are disabled:
Granting trust to a folder unlocks the full functionality of Gemini CLI for that
workspace.
## Headless and automated environments
When running Gemini CLI in a headless environment (for example, a CI/CD
pipeline) where interactive prompts are not possible, the trust dialog cannot be
displayed. If the folder is untrusted and the Folder Trust feature is enabled,
the CLI will throw a `FatalUntrustedWorkspaceError` and exit.
To proceed in these environments, you can bypass the trust check using one of
the following methods:
- **Command-line flag:** Run the CLI with the `--skip-trust` flag.
- **Environment variable:** Set the `GEMINI_CLI_TRUST_WORKSPACE=true`
environment variable.
These methods will trust the current workspace for the duration of the session
without prompting.
For detailed instructions on managing folder trust within CI/CD workflows,
review the
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
## Overriding the trust file location
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
need to store this file in a different location, you can set the
`GEMINI_CLI_TRUSTED_FOLDERS_PATH` environment variable to the desired absolute
file path.
## Managing your trust settings
If you need to change a decision or see all your settings, you have a couple of
+14
View File
@@ -1437,6 +1437,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.confirmationRequired`** (array):
- **Description:** Tool names that always require user confirmation. Takes
precedence over allowed tools and core tool allowlists.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.exclude`** (array):
- **Description:** Tool names to exclude from discovery.
- **Default:** `undefined`
@@ -2099,6 +2105,14 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell:
`$env:GEMINI_MODEL="gemini-3-flash-preview"`)
- **`GEMINI_CLI_TRUST_WORKSPACE`**:
- If set to `"true"`, trusts the current workspace for the duration of the
session, bypassing the folder trust check.
- Useful for headless environments (for example, CI/CD pipelines).
- **`GEMINI_CLI_TRUSTED_FOLDERS_PATH`**:
- Overrides the default location for the `trustedFolders.json` file.
- Useful if you want to store this configuration in a custom location instead
of the default `~/.gemini/`.
- **`GEMINI_CLI_IDE_PID`**:
- Manually specifies the PID of the IDE process to use for integration. This
is useful when running Gemini CLI in a standalone terminal while still
+45 -1
View File
@@ -303,7 +303,7 @@ describe('plan_mode', () => {
settings,
},
prompt:
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
'I agree with your strategy. Please enter plan mode and draft the plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
assert: async (rig, result) => {
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
expect(
@@ -372,4 +372,48 @@ describe('plan_mode', () => {
assertModelHasOutput(result);
},
});
evalTest('USUALLY_PASSES', {
name: 'should handle nested plan directories correctly',
suiteName: 'plan_mode',
suiteType: 'behavioral',
approvalMode: ApprovalMode.PLAN,
params: {
settings,
},
prompt:
'Please create a new architectural plan in a nested folder called "architecture/frontend-v2.md" within the plans directory. The plan should contain the text "# Frontend V2 Plan". Then, exit plan mode',
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const writeCalls = toolLogs.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
);
const wroteToNestedPath = writeCalls.some((log) => {
try {
const args = JSON.parse(log.toolRequest.args);
if (!args.file_path) return false;
// In plan mode, paths can be passed as relative (architecture/frontend-v2.md)
// or they might be resolved as absolute by the tool depending on the exact mock state.
// We strictly ensure it ends exactly with the expected nested path and doesn't contain extra nesting.
const normalizedPath = args.file_path.replace(/\\/g, '/');
return (
normalizedPath === 'architecture/frontend-v2.md' ||
normalizedPath.endsWith('/plans/architecture/frontend-v2.md')
);
} catch {
return false;
}
});
expect(
wroteToNestedPath,
'Expected model to successfully target the nested plan file path',
).toBe(true);
assertModelHasOutput(result);
},
});
});
+1
View File
@@ -172,6 +172,7 @@ export async function internalEvalTest(evalCase: EvalCase) {
timeout: evalCase.timeout,
env: {
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
GEMINI_CLI_TRUST_WORKSPACE: 'true',
},
});
+501 -346
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.39.1",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.1"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.39.1",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.39.1",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+29 -12
View File
@@ -23,6 +23,7 @@ import {
FileDiscoveryService,
resolveTelemetrySettings,
FatalConfigError,
getErrorMessage,
getPty,
debugLogger,
loadServerHierarchicalMemory,
@@ -59,6 +60,7 @@ import {
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { isRecord } from '../utils/settingsUtils.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -105,6 +107,7 @@ export interface CliArgs {
startupMessages?: string[];
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
skipTrust: boolean | undefined;
isCommand: boolean | undefined;
}
@@ -288,6 +291,11 @@ export async function parseArguments(
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('skip-trust', {
type: 'boolean',
description: 'Trust the current workspace for this session.',
default: false,
})
.option('worktree', {
alias: 'w',
type: 'string',
@@ -456,9 +464,16 @@ export async function parseArguments(
yargsInstance.wrap(yargsInstance.terminalWidth());
let result;
try {
result = await yargsInstance.parse();
const parsed = await yargsInstance.parse();
if (!isRecord(parsed)) {
throw new Error('Failed to parse arguments');
}
result = parsed;
if (result['skip-trust']) {
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const msg = getErrorMessage(e);
debugLogger.error(msg);
yargsInstance.showHelp();
await runExitCleanup();
@@ -472,11 +487,13 @@ export async function parseArguments(
}
// Normalize query args: handle both quoted "@path file" and unquoted @path file
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const queryArg = (result as { query?: string | string[] | undefined }).query;
const q: string | undefined = Array.isArray(queryArg)
? queryArg.join(' ')
: queryArg;
const queryArg = result['query'];
let q: string | undefined;
if (Array.isArray(queryArg)) {
q = queryArg.join(' ');
} else if (typeof queryArg === 'string') {
q = queryArg;
}
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
if (q && !result['prompt']) {
@@ -491,8 +508,8 @@ export async function parseArguments(
}
// Keep CliArgs.query as a string for downstream typing
(result as Record<string, unknown>)['query'] = q || undefined;
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
result['query'] = q || undefined;
result['startupMessages'] = startupMessages;
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
@@ -544,7 +561,7 @@ export async function loadCliConfig(
? false
: (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder =
isWorkspaceTrusted(settings, cwd, undefined, {
isWorkspaceTrusted(settings, cwd, {
prompt: argv.prompt,
query: argv.query,
})?.isTrusted ?? false;
@@ -590,7 +607,7 @@ export async function loadCliConfig(
return resolveToRealPath(trimmedPath) !== realCwd;
} catch (e) {
debugLogger.debug(
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`,
);
return false;
}
@@ -1092,7 +1109,7 @@ async function resolveWorktreeSettings(
worktreeBaseSha = stdout.trim();
} catch (e: unknown) {
debugLogger.debug(
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
`Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`,
);
}
+5 -1
View File
@@ -26,6 +26,7 @@ import {
loadAgentsFromDirectory,
loadSkillsFromDir,
getRealPath,
normalizePath,
} from '@google/gemini-cli-core';
import {
loadSettings,
@@ -1420,6 +1421,7 @@ name = "yolo-checker"
'.gemini',
'trustedFolders.json',
);
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
@@ -1438,7 +1440,9 @@ name = "yolo-checker"
const trustedFolders = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'),
);
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
'TRUST_FOLDER',
);
});
describe.each([true, false])(
+12 -6
View File
@@ -1912,6 +1912,9 @@ describe('Settings Loading and Merging', () => {
const geminiEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
);
const workspaceEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, '.env'),
);
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
isTrusted: isWorkspaceTrustedValue,
@@ -1919,9 +1922,11 @@ describe('Settings Loading and Merging', () => {
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
const normalizedP = path.resolve(p.toString());
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
normalizedP,
);
return [
path.resolve(USER_SETTINGS_PATH),
geminiEnvPath,
workspaceEnvPath,
].includes(normalizedP);
});
const userSettingsContent: Settings = {
ui: {
@@ -1941,7 +1946,7 @@ describe('Settings Loading and Merging', () => {
const normalizedP = path.resolve(p.toString());
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (normalizedP === geminiEnvPath)
if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath)
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
return '{}';
},
@@ -1970,7 +1975,7 @@ describe('Settings Loading and Merging', () => {
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('does load env files from untrusted spaces when NOT sandboxed', () => {
it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
@@ -1978,7 +1983,8 @@ describe('Settings Loading and Merging', () => {
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).toEqual('1234');
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files when trust is undefined and sandboxed', () => {
+15 -11
View File
@@ -494,13 +494,15 @@ export class LoadedSettings {
}
}
function findEnvFile(startDir: string): string | null {
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
if (isTrusted) {
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
}
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
@@ -509,9 +511,11 @@ function findEnvFile(startDir: string): string | null {
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
if (isTrusted) {
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
}
const homeEnvPath = path.join(homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
@@ -554,10 +558,10 @@ export function loadEnvironment(
workspaceDir: string,
isWorkspaceTrustedFn = isWorkspaceTrusted,
): void {
const envFilePath = findEnvFile(workspaceDir);
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
const isTrusted = trustResult.isTrusted ?? false;
const envFilePath = findEnvFile(workspaceDir, isTrusted);
// Check settings OR check process.argv directly since this might be called
// before arguments are fully parsed. This is a best-effort sniffing approach
// that happens early in the CLI lifecycle. It is designed to detect the
@@ -592,8 +596,8 @@ export function loadEnvironment(
for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) {
let value = parsedEnv[key];
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
if (!isTrusted && isSandboxed) {
// If the workspace is untrusted, only allow whitelisted variables.
if (!isTrusted) {
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
continue;
}
+13
View File
@@ -1591,6 +1591,19 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
items: { type: 'string' },
},
confirmationRequired: {
type: 'array',
label: 'Confirmation Required',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: oneLine`
Tool names that always require user confirmation.
Takes precedence over allowed tools and core tool allowlists.
`,
showInDialog: false,
items: { type: 'string' },
},
exclude: {
type: 'array',
label: 'Exclude Tools',
+49 -41
View File
@@ -11,7 +11,7 @@ import * as os from 'node:os';
import {
FatalConfigError,
ideContextStore,
coreEvents,
normalizePath,
} from '@google/gemini-cli-core';
import {
loadTrustedFolders,
@@ -32,9 +32,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual,
homedir: () => '/mock/home/user',
isHeadlessMode: vi.fn(() => false),
coreEvents: {
emitFeedback: vi.fn(),
},
coreEvents: Object.assign(
Object.create(Object.getPrototypeOf(actual.coreEvents)),
actual.coreEvents,
{
emitFeedback: vi.fn(),
},
),
FatalConfigError: actual.FatalConfigError,
};
});
@@ -53,6 +58,7 @@ describe('Trusted Folders', () => {
// Reset the internal state
resetTrustedFoldersForTesting();
vi.clearAllMocks();
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
});
afterEach(() => {
@@ -70,8 +76,14 @@ describe('Trusted Folders', () => {
// Start two concurrent calls
// These will race to acquire the lock on the real file system
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER);
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER);
const p1 = loadedFolders.setValue(
path.resolve('/path1'),
TrustLevel.TRUST_FOLDER,
);
const p2 = loadedFolders.setValue(
path.resolve('/path2'),
TrustLevel.TRUST_FOLDER,
);
await Promise.all([p1, p2]);
@@ -80,8 +92,8 @@ describe('Trusted Folders', () => {
const config = JSON.parse(content);
expect(config).toEqual({
'/path1': TrustLevel.TRUST_FOLDER,
'/path2': TrustLevel.TRUST_FOLDER,
[normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
[normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
});
});
});
@@ -95,13 +107,16 @@ describe('Trusted Folders', () => {
it('should load rules from the configuration file', () => {
const config = {
'/user/folder': TrustLevel.TRUST_FOLDER,
[normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
{
path: normalizePath('/user/folder'),
trustLevel: TrustLevel.TRUST_FOLDER,
},
]);
expect(errors).toEqual([]);
});
@@ -143,14 +158,14 @@ describe('Trusted Folders', () => {
const content = `
{
// This is a comment
"/path": "TRUST_FOLDER"
"${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
}
`;
fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER },
{ path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
]);
expect(errors).toEqual([]);
});
@@ -216,15 +231,18 @@ describe('Trusted Folders', () => {
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
const loadedFolders = loadTrustedFolders();
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
await loadedFolders.setValue(
normalizePath('/new/path'),
TrustLevel.TRUST_FOLDER,
);
expect(loadedFolders.user.config['/new/path']).toBe(
expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe(
TrustLevel.TRUST_FOLDER,
);
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
const config = JSON.parse(content);
expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER);
expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should throw FatalConfigError if there were load errors', async () => {
@@ -237,28 +255,6 @@ describe('Trusted Folders', () => {
loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER),
).rejects.toThrow(FatalConfigError);
});
it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => {
// Initialize with valid JSON
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
const loadedFolders = loadTrustedFolders();
// Corrupt the file after initial load
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
expect.stringContaining('may be corrupted'),
expect.any(Error),
);
// Should have overwritten the corrupted file with new valid config
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
const config = JSON.parse(content);
expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER });
});
});
describe('isWorkspaceTrusted Integration', () => {
@@ -427,16 +423,28 @@ describe('Trusted Folders', () => {
},
};
it('should return true when isHeadlessMode is true, ignoring config', async () => {
it('should NOT return true when isHeadlessMode is true, ignoring config', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
isTrusted: undefined,
source: undefined,
});
});
it('should return true when GEMINI_CLI_TRUST_WORKSPACE is true', async () => {
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
try {
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
source: 'env',
});
} finally {
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
}
});
it('should fall back to config when isHeadlessMode is false', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
@@ -449,12 +457,12 @@ describe('Trusted Folders', () => {
);
});
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
const folders = loadTrustedFolders();
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined);
});
});
+31 -365
View File
@@ -4,330 +4,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import {
FatalConfigError,
getErrorMessage,
isWithinRoot,
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
type HeadlessModeOptions,
checkPathTrust,
isHeadlessMode,
loadTrustedFolders as loadCoreTrustedFolders,
type LoadedTrustedFolders,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
const { promises: fsPromises } = fs;
export {
TrustLevel,
isTrustLevel,
resetTrustedFoldersForTesting,
saveTrustedFolders,
} from '@google/gemini-cli-core';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export function getUserSettingsDir(): string {
return path.join(homedir(), GEMINI_DIR);
}
export function getTrustedFoldersPath(): string {
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
}
return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME);
}
export enum TrustLevel {
TRUST_FOLDER = 'TRUST_FOLDER',
TRUST_PARENT = 'TRUST_PARENT',
DO_NOT_TRUST = 'DO_NOT_TRUST',
}
export function isTrustLevel(
value: string | number | boolean | object | null | undefined,
): value is TrustLevel {
return (
typeof value === 'string' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(TrustLevel).includes(value as TrustLevel)
);
}
export interface TrustRule {
path: string;
trustLevel: TrustLevel;
}
export interface TrustedFoldersError {
message: string;
path: string;
}
export interface TrustedFoldersFile {
config: Record<string, TrustLevel>;
path: string;
}
export interface TrustResult {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | undefined;
}
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
*/
export function clearRealPathCacheForTesting(): void {
realPathCache.clear();
}
function getRealPath(location: string): string {
let realPath = realPathCache.get(location);
if (realPath !== undefined) {
return realPath;
}
try {
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
} catch {
realPath = location;
}
realPathCache.set(location, realPath);
return realPath;
}
export class LoadedTrustedFolders {
constructor(
readonly user: TrustedFoldersFile,
readonly errors: TrustedFoldersError[],
) {}
get rules(): TrustRule[] {
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
path,
trustLevel,
}));
}
/**
* Returns true or false if the path should be "trusted". This function
* should only be invoked when the folder trust setting is active.
*
* @param location path
* @returns
*/
isPathTrusted(
location: string,
config?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): boolean | undefined {
if (isHeadlessMode(headlessOptions)) {
return true;
}
const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison
const realLocation = getRealPath(location);
let longestMatchLen = -1;
let longestMatchTrust: TrustLevel | undefined = undefined;
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
const effectivePath =
trustLevel === TrustLevel.TRUST_PARENT
? path.dirname(rulePath)
: rulePath;
// Resolve effectivePath to its realpath for canonical comparison
const realEffectivePath = getRealPath(effectivePath);
if (isWithinRoot(realLocation, realEffectivePath)) {
if (rulePath.length > longestMatchLen) {
longestMatchLen = rulePath.length;
longestMatchTrust = trustLevel;
}
}
}
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
if (
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
longestMatchTrust === TrustLevel.TRUST_PARENT
)
return true;
return undefined;
}
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fsPromises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
try {
// Re-read the file to handle concurrent updates
const content = await fsPromises.readFile(this.user.path, 'utf-8');
let config: Record<string, TrustLevel>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
config = {};
}
const originalTrustLevel = config[folderPath];
config[folderPath] = trustLevel;
this.user.config[folderPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[folderPath];
} else {
this.user.config[folderPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
let loadedTrustedFolders: LoadedTrustedFolders | undefined;
/**
* FOR TESTING PURPOSES ONLY.
* Resets the in-memory cache of the trusted folders configuration.
*/
export function resetTrustedFoldersForTesting(): void {
loadedTrustedFolders = undefined;
clearRealPathCacheForTesting();
}
export function loadTrustedFolders(): LoadedTrustedFolders {
if (loadedTrustedFolders) {
return loadedTrustedFolders;
}
const errors: TrustedFoldersError[] = [];
const userConfig: Record<string, TrustLevel> = {};
const userPath = getTrustedFoldersPath();
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed)
) {
errors.push({
message: 'Trusted folders file is not a valid JSON object.',
path: userPath,
});
} else {
for (const [path, trustLevel] of Object.entries(parsed)) {
if (isTrustLevel(trustLevel)) {
userConfig[path] = trustLevel;
} else {
const possibleValues = Object.values(TrustLevel).join(', ');
errors.push({
message: `Invalid trust level "${trustLevel}" for path "${path}". Possible values are: ${possibleValues}.`,
path: userPath,
});
}
}
}
}
} catch (error) {
errors.push({
message: getErrorMessage(error),
path: userPath,
});
}
loadedTrustedFolders = new LoadedTrustedFolders(
{ path: userPath, config: userConfig },
errors,
);
return loadedTrustedFolders;
}
export function saveTrustedFolders(
trustedFoldersFile: TrustedFoldersFile,
): void {
// Ensure the directory exists
const dirPath = path.dirname(trustedFoldersFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
export type {
TrustRule,
TrustedFoldersError,
TrustedFoldersFile,
TrustResult,
LoadedTrustedFolders,
} from '@google/gemini-cli-core';
/** Is folder trust feature enabled per the current applied settings */
export function isFolderTrustEnabled(settings: Settings): boolean {
@@ -335,57 +34,24 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
return folderTrustSetting;
}
function getWorkspaceTrustFromLocalConfig(
workspaceDir: string,
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult {
const folders = loadTrustedFolders();
const configToUse = trustConfig ?? folders.user.config;
if (folders.errors.length > 0) {
const errorMessages = folders.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`,
);
}
const isTrusted = folders.isPathTrusted(
workspaceDir,
configToUse,
headlessOptions,
);
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
};
export function loadTrustedFolders(): LoadedTrustedFolders {
return loadCoreTrustedFolders();
}
/**
* Returns true or false if the workspace is considered "trusted".
*/
export function isWorkspaceTrusted(
settings: Settings,
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult {
if (isHeadlessMode(headlessOptions)) {
return { isTrusted: true, source: undefined };
}
if (!isFolderTrustEnabled(settings)) {
return { isTrusted: true, source: undefined };
}
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
if (ideTrust !== undefined) {
return { isTrusted: ideTrust, source: 'ide' };
}
// Fall back to the local user configuration
return getWorkspaceTrustFromLocalConfig(
workspaceDir,
trustConfig,
headlessOptions,
);
): {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | 'env' | undefined;
} {
return checkPathTrust({
path: workspaceDir,
isFolderTrustEnabled: isFolderTrustEnabled(settings),
isHeadless: isHeadlessMode(headlessOptions),
});
}
+3
View File
@@ -280,6 +280,7 @@ describe('gemini.tsx main function', () => {
vi.stubEnv('GEMINI_SANDBOX', '');
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SHPOOL_SESSION_NAME', '');
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
initialUnhandledRejectionListeners =
process.listeners('unhandledRejection');
@@ -555,6 +556,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
});
await act(async () => {
@@ -613,6 +615,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
});
await act(async () => {
+6
View File
@@ -644,6 +644,12 @@ export async function main() {
cliStartupHandle?.end();
if (!config.isInteractive()) {
for (const warning of startupWarnings) {
writeToStderr(warning.message + '\n');
}
}
// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
// Earlier initialization phases (like TerminalCapabilityManager resolving
+1
View File
@@ -181,6 +181,7 @@ describe('gemini.tsx main function cleanup', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
});
afterEach(() => {
@@ -159,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers.
isTrustedFolder: () => true,
getPreferredEditor: () => undefined,
getSessionId: () => 'test-session-id',
getProjectRoot: () => mockTargetDir,
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -466,6 +467,7 @@ Implement a comprehensive authentication system with multiple providers.
getIdeMode: () => false,
isTrustedFolder: () => true,
getSessionId: () => 'test-session-id',
getProjectRoot: () => mockTargetDir,
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -85,6 +85,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const pathError = await validatePlanPath(
planPath,
config.storage.getPlansDir(),
config.getProjectRoot(),
);
if (ignore) return;
if (pathError) {
@@ -52,6 +52,7 @@ describe('ToolConfirmationQueue', () => {
getModel: () => 'gemini-pro',
getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir',
getProjectRoot: () => '/mock/project/root',
getFileSystemService: () => ({
readFile: vi.fn().mockResolvedValue('Plan content'),
}),
@@ -168,6 +168,13 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
@@ -34,6 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual,
homedir: () => os.homedir(),
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
isHeadlessMode: vi.fn().mockReturnValue(false),
WarningPriority: {
Low: 'low',
High: 'high',
@@ -143,6 +144,51 @@ describe('getUserStartupWarnings', () => {
});
});
describe('folder trust check', () => {
it('should throw FatalUntrustedWorkspaceError when untrusted in headless mode', async () => {
const { isHeadlessMode, FatalUntrustedWorkspaceError } = await import(
'@google/gemini-cli-core'
);
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockImplementation(() => {
throw new FatalUntrustedWorkspaceError(
'Gemini CLI is not running in a trusted directory',
);
});
vi.mocked(isHeadlessMode).mockReturnValue(true);
await expect(
getUserStartupWarnings({}, testRootDir),
).rejects.toThrowError(FatalUntrustedWorkspaceError);
});
it('should not return a warning when trusted in headless mode', async () => {
const { isHeadlessMode } = await import('@google/gemini-cli-core');
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(isHeadlessMode).mockReturnValue(true);
const warnings = await getUserStartupWarnings({}, testRootDir);
expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined();
});
it('should not return a warning when untrusted in interactive mode', async () => {
const { isHeadlessMode } = await import('@google/gemini-cli-core');
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
vi.mocked(isHeadlessMode).mockReturnValue(false);
const warnings = await getUserStartupWarnings({}, testRootDir);
expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined();
});
});
describe('compatibility warnings', () => {
it('should include compatibility warnings by default', async () => {
const compWarning = {
@@ -12,6 +12,8 @@ import {
getCompatibilityWarnings,
WarningPriority,
type StartupWarning,
isHeadlessMode,
FatalUntrustedWorkspaceError,
} from '@google/gemini-cli-core';
import type { Settings } from '../config/settingsSchema.js';
import {
@@ -79,10 +81,36 @@ const rootDirectoryCheck: WarningCheck = {
},
};
const folderTrustCheck: WarningCheck = {
id: 'folder-trust',
priority: WarningPriority.High,
check: async (workspaceRoot: string, settings: Settings) => {
if (!isFolderTrustEnabled(settings)) {
return null;
}
const { isTrusted } = isWorkspaceTrusted(settings, workspaceRoot);
if (isTrusted === true) {
return null;
}
if (isHeadlessMode()) {
throw new FatalUntrustedWorkspaceError(
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, ' +
'set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode. ' +
'For more details, see https://geminicli.com/docs/cli/trusted-folders/#headless-and-automated-environments',
);
}
return null;
},
};
// All warning checks
const WARNING_CHECKS: readonly WarningCheck[] = [
homeDirectoryCheck,
rootDirectoryCheck,
folderTrustCheck,
];
export async function getUserStartupWarnings(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.39.1",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+47
View File
@@ -538,5 +538,52 @@ describe('a2aUtils', () => {
expect(output).toContain('Artifact (Data):');
expect(output).not.toContain('Answer from history');
});
it('should return message log as activity items', () => {
const reassembler = new A2AResultReassembler();
reassembler.update({
kind: 'status-update',
taskId: 't1',
contextId: 'ctx1',
status: {
state: 'working',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'Message 1' }],
} as Message,
},
} as unknown as SendMessageResult);
reassembler.update({
kind: 'status-update',
taskId: 't1',
contextId: 'ctx1',
status: {
state: 'working',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'Message 2' }],
} as Message,
},
} as unknown as SendMessageResult);
const items = reassembler.toActivityItems();
expect(items).toHaveLength(2);
expect(items[0]).toEqual({
id: 'msg-0',
type: 'thought',
content: 'Message 1',
status: 'completed',
});
expect(items[1]).toEqual({
id: 'msg-1',
type: 'thought',
content: 'Message 2',
status: 'completed',
});
});
});
});
+31 -15
View File
@@ -124,6 +124,7 @@ export class A2AResultReassembler {
private pushMessage(message: Message | undefined) {
if (!message) return;
if (message.role === 'user') return; // Skip user messages reflected by server
const text = extractPartsText(message.parts, '');
if (text && this.messageLog[this.messageLog.length - 1] !== text) {
this.messageLog.push(text);
@@ -135,21 +136,36 @@ export class A2AResultReassembler {
*/
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',
},
];
const items: SubagentActivityItem[] = [];
if (isAuthRequired) {
items.push({
id: 'auth-required',
type: 'thought',
content: AUTH_REQUIRED_MSG,
status: 'running',
});
}
this.messageLog.forEach((msg, index) => {
items.push({
id: `msg-${index}`,
type: 'thought',
content: msg.trim(),
status: 'completed',
});
});
if (items.length === 0 && !isAuthRequired) {
items.push({
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
});
}
return items;
}
/**
@@ -95,8 +95,8 @@ Test System Prompt`;
});
// Trigger the refresh action that follows reloading
// @ts-expect-error accessing private method for testing
await config.onAgentsRefreshed();
await config.getAgentRegistry().reload();
// 4. Verify the agent is UNREGISTERED
const finalAgents = agentRegistry.getAllDefinitions().map((d) => d.name);
@@ -237,8 +237,8 @@ Test System Prompt`;
});
// Trigger the refresh action that follows reloading
// @ts-expect-error accessing private method for testing
await config.onAgentsRefreshed();
await config.getAgentRegistry().reload();
expect(agentRegistry.getAllDefinitions().map((d) => d.name)).toContain(
agentName,
-2
View File
@@ -3739,8 +3739,6 @@ export class Config implements McpContext, AgentLoopContext {
}
private onAgentsRefreshed = async () => {
await this.agentRegistry.initialize();
// Propagate updates to the active chat session
const client = this.geminiClient;
if (client?.isInitialized()) {
+8
View File
@@ -20,6 +20,7 @@ import { ProjectRegistry } from './projectRegistry.js';
import { StorageMigration } from './storageMigration.js';
export const OAUTH_FILE = 'oauth_creds.json';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
const AGENTS_DIR_NAME = '.agents';
@@ -78,6 +79,13 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), GOOGLE_ACCOUNTS_FILENAME);
}
static getTrustedFoldersPath(): string {
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
}
return path.join(Storage.getGlobalGeminiDir(), TRUSTED_FOLDERS_FILENAME);
}
static getUserCommandsDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'commands');
}
@@ -95,7 +95,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`../plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -111,8 +111,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`../plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`../plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -136,7 +136,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`../plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
@@ -275,7 +275,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`../plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -291,8 +291,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`../plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`../plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -316,7 +316,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`../plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
@@ -326,7 +326,7 @@ Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to
ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval.
## Approved Plan
An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
An approved plan is available for this task at \`../plans/feature-x.md\`.
- **Read First:** You MUST read this file using the \`read_file\` tool before proposing any changes or starting discovery.
- **Iterate:** Default to refining the existing approved plan.
- **New Plan:** Only create a new plan file if the user explicitly asks for a "new plan".
@@ -576,7 +576,7 @@ For example:
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`plans/\` and get user approval before editing source code.
## Available Tools
The following tools are available in Plan Mode:
@@ -592,8 +592,8 @@ The following tools are available in Plan Mode:
</available_tools>
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
@@ -617,7 +617,7 @@ The depth of your consultation should be proportional to the task's complexity.
**CRITICAL:** You MUST NOT proceed to Step 3 (Draft) or Step 4 (Review & Approval) in the same turn as your initial strategy proposal. You MUST wait for user feedback and reach a clear agreement before drafting or submitting the plan.
### 3. Draft
Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task:
Write the implementation plan to \`plans/\`. The plan's structure adapts to the task:
- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps.
- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**.
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
+1
View File
@@ -93,6 +93,7 @@ describe('Core System Prompt (prompts.ts)', () => {
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getProjectRoot: vi.fn().mockReturnValue('/tmp/project-temp'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
+3
View File
@@ -294,3 +294,6 @@ export type { Content, Part, FunctionCall } from '@google/genai';
// Export context types and profiles
export * from './context/types.js';
export * from './context/profiles.js';
// Export trust utility
export * from './utils/trust.js';
+87 -10
View File
@@ -74,7 +74,9 @@ export const ADMIN_POLICY_TIER = 5;
export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9;
export const EXCLUDE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.4;
export const CONFIRMATION_REQUIRED_PRIORITY = USER_POLICY_TIER + 0.35;
export const ALLOWED_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.3;
export const CORE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.25;
export const TRUSTED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.2;
export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1;
@@ -434,10 +436,21 @@ export async function createPolicyEngineConfig(
}
}
// Tools that are explicitly allowed in the settings.
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
if (settings.tools?.allowed) {
for (const tool of settings.tools.allowed) {
const nonPlanModes = [
ApprovalMode.DEFAULT,
ApprovalMode.AUTO_EDIT,
ApprovalMode.YOLO,
];
const mapToolsToRules = (
tools: string[],
priority: number,
source: string,
modes?: ApprovalMode[],
addDefaultDenyForTools = false,
) => {
const toolsWithNarrowing = new Set<string>();
for (const tool of tools) {
// Check for legacy format: toolName(args)
const match = tool.match(/^([a-zA-Z0-9_-]+)\((.*)\)$/);
if (match) {
@@ -449,15 +462,17 @@ export async function createPolicyEngineConfig(
// Treat args as a command prefix for shell tool
if (toolName === SHELL_TOOL_NAME) {
toolsWithNarrowing.add(toolName);
const patterns = buildArgsPatterns(undefined, args);
for (const pattern of patterns) {
if (pattern) {
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
priority,
argsPattern: new RegExp(pattern),
source: 'Settings (Tools Allowed)',
source,
modes,
});
}
}
@@ -467,8 +482,9 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
source: 'Settings (Tools Allowed)',
priority,
source,
modes,
});
}
} else {
@@ -479,11 +495,70 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
source: 'Settings (Tools Allowed)',
priority,
source,
modes,
});
}
}
if (addDefaultDenyForTools) {
for (const toolName of toolsWithNarrowing) {
rules.push({
toolName,
decision: PolicyDecision.DENY,
priority: priority - 0.01,
source: `${source} (Narrowing Enforcement)`,
modes,
});
}
}
};
// Tools that are explicitly allowed in the settings.
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
if (settings.tools?.allowed) {
mapToolsToRules(
settings.tools.allowed,
ALLOWED_TOOLS_FLAG_PRIORITY,
'Settings (Tools Allowed)',
undefined,
true,
);
}
// Tools that explicitly require confirmation in the settings.
// Priority: CONFIRMATION_REQUIRED_PRIORITY (overrides allowed and core)
if (settings.tools?.confirmationRequired) {
for (const tool of settings.tools.confirmationRequired) {
rules.push({
toolName: SHELL_TOOL_NAMES.includes(tool) ? SHELL_TOOL_NAME : tool,
decision: PolicyDecision.ASK_USER,
priority: CONFIRMATION_REQUIRED_PRIORITY,
source: 'Settings (Confirmation Required)',
});
}
}
// Core tools that are restricted in the settings.
// Priority: CORE_TOOLS_FLAG_PRIORITY (user tier - core tool allowlist)
if (settings.tools?.core) {
mapToolsToRules(
settings.tools.core,
CORE_TOOLS_FLAG_PRIORITY,
'Settings (Core Tools)',
nonPlanModes,
);
// If core tools are restricted, we should add a default DENY rule for everything else
// at a slightly lower priority than the explicit allows.
rules.push({
toolName: '*',
decision: PolicyDecision.DENY,
priority: CORE_TOOLS_FLAG_PRIORITY - 0.01,
source: 'Settings (Core Tools Allowlist Enforcement)',
modes: nonPlanModes,
});
}
// MCP servers that are trusted in the settings.
@@ -501,6 +576,7 @@ export async function createPolicyEngineConfig(
decision: PolicyDecision.ALLOW,
priority: TRUSTED_MCP_SERVER_PRIORITY,
source: 'Settings (MCP Trusted)',
modes: nonPlanModes,
});
}
}
@@ -519,6 +595,7 @@ export async function createPolicyEngineConfig(
decision: PolicyDecision.ALLOW,
priority: ALLOWED_MCP_SERVER_PRIORITY,
source: 'Settings (MCP Allowed)',
modes: nonPlanModes,
});
}
}
@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { createPolicyEngineConfig } from './config.js';
import { PolicyEngine } from './policy-engine.js';
import { PolicyDecision, ApprovalMode } from './types.js';
describe('PolicyEngine - Core Tools Mapping', () => {
it('should allow tools explicitly listed in settings.tools.core', async () => {
const settings = {
tools: {
core: ['run_shell_command(ls)', 'run_shell_command(git status)'],
},
};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
undefined,
true, // interactive
);
const engine = new PolicyEngine(config);
// Test simple tool name
const result1 = await engine.check(
{ name: 'run_shell_command', args: { command: 'ls' } },
undefined,
);
expect(result1.decision).toBe(PolicyDecision.ALLOW);
// Test tool name with args
const result2 = await engine.check(
{ name: 'run_shell_command', args: { command: 'git status' } },
undefined,
);
expect(result2.decision).toBe(PolicyDecision.ALLOW);
// Test tool not in core list
const result3 = await engine.check(
{ name: 'run_shell_command', args: { command: 'npm test' } },
undefined,
);
// Should be DENIED because of strict allowlist
expect(result3.decision).toBe(PolicyDecision.DENY);
});
it('should allow tools in tools.core even if they are restricted by default policies', async () => {
// By default run_shell_command is ASK_USER.
// Putting it in tools.core should make it ALLOW.
const settings = {
tools: {
core: ['run_shell_command'],
},
};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
undefined,
true,
);
const engine = new PolicyEngine(config);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'any command' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
+34 -2
View File
@@ -43,6 +43,35 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
}
return [command];
}),
parseCommandDetails: vi.fn().mockImplementation((command: string) => {
// Basic mock implementation for PolicyEngine test needs
const commands = command.includes('&&')
? command.split('&&').map((c) => c.trim())
: [command.trim()];
// Detect $(...) or `...` and add as sub-commands for recursion tests
const subCommands = [...commands];
for (const cmd of commands) {
const subMatch = cmd.match(/\$\((.*)\)/) || cmd.match(/`(.*)`/);
if (subMatch?.[1]) {
subCommands.push(subMatch[1].trim());
}
}
return {
details: subCommands.map((c, i) => ({
name: c.split(' ')[0],
text: c,
startIndex: i === 0 ? 0 : -1, // Simple root indication
})),
hasError: false,
};
}),
stripShellWrapper: vi.fn().mockImplementation((command: string) => {
// Simple mock for stripping wrappers
const match = command.match(/^(?:bash|sh|zsh)\s+-c\s+["'](.*)["']$/i);
return match ? match[1] : command;
}),
hasRedirection: vi.fn().mockImplementation(
(command: string) =>
// Simple mock: true if '>' is present, unless it looks like "-> arrow"
@@ -1829,7 +1858,6 @@ describe('PolicyEngine', () => {
});
it('should return ASK_USER in non-YOLO mode if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
@@ -1844,7 +1872,11 @@ describe('PolicyEngine', () => {
});
// Simulate parsing failure
vi.mocked(splitCommands).mockReturnValueOnce([]);
const { parseCommandDetails } = await import('../utils/shell-utils.js');
vi.mocked(parseCommandDetails).mockReturnValueOnce({
details: [],
hasError: true,
});
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'complex command' } },
+70 -73
View File
@@ -7,8 +7,10 @@
import { type FunctionCall } from '@google/genai';
import {
SHELL_TOOL_NAMES,
REDIRECTION_NAMES,
initializeShellParsers,
splitCommands,
parseCommandDetails,
stripShellWrapper,
hasRedirection,
extractStringFromParseEntry,
} from '../utils/shell-utils.js';
@@ -352,7 +354,8 @@ export class PolicyEngine {
}
await initializeShellParsers();
const subCommands = splitCommands(command);
const parsed = parseCommandDetails(command);
const subCommands = parsed?.details ?? [];
if (subCommands.length === 0) {
// If the matched rule says DENY, we should respect it immediately even if parsing fails.
@@ -373,115 +376,109 @@ export class PolicyEngine {
);
// Parsing logic failed, we can't trust it. Use default decision ASK_USER (or DENY in non-interactive).
// We return the rule that matched so the evaluation loop terminates.
return {
decision: this.defaultDecision,
rule,
};
}
// If there are multiple parts, or if we just want to validate the single part against DENY rules
if (subCommands.length > 0) {
debugLogger.debug(
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
);
debugLogger.debug(
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
);
if (ruleDecision === PolicyDecision.DENY) {
return { decision: PolicyDecision.DENY, rule };
}
if (ruleDecision === PolicyDecision.DENY) {
return { decision: PolicyDecision.DENY, rule };
}
// Start optimistically. If all parts are ALLOW, the whole is ALLOW.
// We will downgrade if any part is ASK_USER or DENY.
let aggregateDecision = PolicyDecision.ALLOW;
let responsibleRule: PolicyRule | undefined;
// Start with the decision from the rule or heuristics.
// If the tool call was already downgraded (e.g. by heuristics), we start there.
let aggregateDecision = ruleDecision;
// Check for redirection on the full command string
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
// If heuristics downgraded the decision, we don't blame the rule.
let responsibleRule: PolicyRule | undefined =
rule && ruleDecision === rule.decision ? rule : undefined;
// Check for redirection on the full command string.
// Redirection always downgrades ALLOW to ASK_USER (it never upgrades).
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
if (aggregateDecision === PolicyDecision.ALLOW) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${command}`,
);
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined; // Inherent policy
}
}
for (const rawSubCmd of subCommands) {
const subCmd = rawSubCmd.trim();
// Prevent infinite recursion for the root command
if (subCmd === command) {
if (this.shouldDowngradeForRedirection(subCmd, allowRedirection)) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
);
// Redirection always downgrades ALLOW to ASK_USER
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined; // Inherent policy
}
for (const detail of subCommands) {
if (REDIRECTION_NAMES.has(detail.name)) {
continue;
}
const subCmd = detail.text.trim();
const isAtomic =
subCmd === command ||
(detail.startIndex === 0 && detail.text.length === command.length);
// Recursive check for shell wrappers (bash -c, etc.)
const stripped = stripShellWrapper(subCmd);
if (stripped !== subCmd) {
const wrapperResult = await this.check(
{ name: toolName, args: { command: stripped, dir_path } },
serverName,
toolAnnotations,
subagent,
true,
);
if (wrapperResult.decision === PolicyDecision.DENY)
return wrapperResult;
if (wrapperResult.decision === PolicyDecision.ASK_USER) {
if (aggregateDecision === PolicyDecision.ALLOW) {
responsibleRule = wrapperResult.rule;
} else {
// Atomic command matching the rule.
if (
ruleDecision === PolicyDecision.ASK_USER &&
aggregateDecision === PolicyDecision.ALLOW
) {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = rule;
}
responsibleRule ??= wrapperResult.rule;
}
continue;
aggregateDecision = PolicyDecision.ASK_USER;
}
}
if (!isAtomic) {
const subResult = await this.check(
{ name: toolName, args: { command: subCmd, dir_path } },
serverName,
toolAnnotations,
subagent,
true,
);
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
const subDecision = subResult.decision;
if (subResult.decision === PolicyDecision.DENY) return subResult;
// If any part is DENIED, the whole command is DENY
if (subDecision === PolicyDecision.DENY) {
return {
decision: PolicyDecision.DENY,
rule: subResult.rule,
};
}
// If any part requires ASK_USER, the whole command requires ASK_USER
if (subDecision === PolicyDecision.ASK_USER) {
aggregateDecision = PolicyDecision.ASK_USER;
if (!responsibleRule) {
if (subResult.decision === PolicyDecision.ASK_USER) {
if (aggregateDecision === PolicyDecision.ALLOW) {
responsibleRule = subResult.rule;
} else {
responsibleRule ??= subResult.rule;
}
aggregateDecision = PolicyDecision.ASK_USER;
}
// Check for redirection in allowed sub-commands
// Downgrade if sub-command has redirection
if (
subDecision === PolicyDecision.ALLOW &&
subResult.decision === PolicyDecision.ALLOW &&
this.shouldDowngradeForRedirection(subCmd, allowRedirection)
) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
);
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined;
}
}
}
return {
decision: aggregateDecision,
// If we stayed at ALLOW, we return the original rule (if any).
// If we downgraded, we return the responsible rule (or undefined if implicit).
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
};
}
return {
decision: ruleDecision,
rule,
decision: aggregateDecision,
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
};
}
@@ -494,6 +491,7 @@ export class PolicyEngine {
serverName: string | undefined,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
skipHeuristics = false,
): Promise<CheckResult> {
// Case 1: Metadata injection is the primary and safest way to identify an MCP server.
// If we have explicit `_serverName` metadata (usually injected by tool-registry for active tools), use it.
@@ -587,6 +585,7 @@ export class PolicyEngine {
let ruleDecision = rule.decision;
if (
!skipHeuristics &&
isShellCommand &&
command &&
!('commandPrefix' in rule) &&
@@ -608,12 +607,10 @@ export class PolicyEngine {
subagent,
);
decision = shellResult.decision;
if (shellResult.rule) {
matchedRule = shellResult.rule;
break;
}
matchedRule = shellResult.rule;
break;
} else {
decision = rule.decision;
decision = ruleDecision;
matchedRule = rule;
break;
}
@@ -636,7 +633,7 @@ export class PolicyEngine {
);
if (toolName && SHELL_TOOL_NAMES.includes(toolName)) {
let heuristicDecision = this.defaultDecision;
if (command) {
if (!skipHeuristics && command) {
heuristicDecision = await this.applyShellHeuristics(
command,
heuristicDecision,
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { PolicyEngine } from './policy-engine.js';
import { PolicyDecision, ApprovalMode } from './types.js';
import { initializeShellParsers } from '../utils/shell-utils.js';
import { buildArgsPatterns } from './utils.js';
describe('PolicyEngine - Shell Safety Regression Suite', () => {
let engine: PolicyEngine;
beforeAll(async () => {
await initializeShellParsers();
});
const setupEngine = (allowedCommands: string[]) => {
const rules = allowedCommands.map((cmd) => ({
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
argsPattern: new RegExp(buildArgsPatterns(undefined, cmd)[0]!),
priority: 10,
}));
return new PolicyEngine({
rules,
approvalMode: ApprovalMode.DEFAULT,
defaultDecision: PolicyDecision.ASK_USER,
});
};
it('should block unauthorized chained command with &&', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi && ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should allow authorized chained command with &&', async () => {
engine = setupEngine(['echo', 'ls']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi && ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should block unauthorized chained command with ||', async () => {
engine = setupEngine(['false']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'false || ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should block unauthorized chained command with ;', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi; ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should block unauthorized command in pipe |', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi | grep "hi"' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should allow authorized command in pipe |', async () => {
engine = setupEngine(['echo', 'grep']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi | grep "hi"' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should block unauthorized chained command with &', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi & ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should allow authorized chained command with &', async () => {
engine = setupEngine(['echo', 'ls']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi & ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should block unauthorized command in nested substitution', async () => {
engine = setupEngine(['echo', 'cat']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo $(cat $(ls))' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should allow authorized command in nested substitution', async () => {
engine = setupEngine(['echo', 'cat', 'ls']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo $(cat $(ls))' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should block command redirection if not explicitly allowed', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi > /tmp/test' } },
undefined,
);
// Inherent policy: redirection downgrades to ASK_USER
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
});
@@ -59,6 +59,30 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
return {
...actual,
initializeShellParsers: vi.fn(),
parseCommandDetails: (command: string) => {
if (Object.prototype.hasOwnProperty.call(commandMap, command)) {
const subcommands = commandMap[command];
return {
details: subcommands.map((text) => ({
name: text.split(' ')[0],
text,
startIndex: command.indexOf(text),
})),
hasError: subcommands.length === 0 && command.includes('&&&'),
};
}
return {
details: [
{
name: command.split(' ')[0],
text: command,
startIndex: 0,
},
],
hasError: false,
};
},
stripShellWrapper: (command: string) => command,
splitCommands: (command: string) => {
if (Object.prototype.hasOwnProperty.call(commandMap, command)) {
return commandMap[command];
@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, describe, it, beforeAll, vi } from 'vitest';
import { PolicyEngine } from './policy-engine.js';
import { PolicyDecision } from './types.js';
import { initializeShellParsers } from '../utils/shell-utils.js';
// Mock node:os to ensure shell-utils logic always thinks it's on a POSIX-like system.
// This ensures that internal calls to getShellConfiguration() and isWindows()
// within the shell-utils module return 'bash' configuration, even on Windows CI.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
default: {
...actual,
platform: () => 'linux',
},
platform: () => 'linux',
};
});
// Mock shell-utils to ensure consistent behavior across platforms (especially Windows CI)
// We want to test PolicyEngine logic with Bash syntax rules.
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/shell-utils.js')>();
return {
...actual,
getShellConfiguration: () => ({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
}),
};
});
describe('PolicyEngine Command Substitution Validation', () => {
beforeAll(async () => {
await initializeShellParsers();
});
const setupEngine = (blockedCmd: string) =>
new PolicyEngine({
defaultDecision: PolicyDecision.ALLOW,
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(`"command":"${blockedCmd}"`),
decision: PolicyDecision.DENY,
},
],
});
it('should block echo $(dangerous_cmd) when dangerous_cmd is explicitly blocked', async () => {
const engine = setupEngine('dangerous_cmd');
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo $(dangerous_cmd)' } },
'test-server',
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should block backtick substitution `dangerous_cmd`', async () => {
const engine = setupEngine('dangerous_cmd');
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo `dangerous_cmd`' } },
'test-server',
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should block commands inside subshells (dangerous_cmd)', async () => {
const engine = setupEngine('dangerous_cmd');
const result = await engine.check(
{ name: 'run_shell_command', args: { command: '(dangerous_cmd)' } },
'test-server',
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should handle nested substitutions deeply', async () => {
const engine = setupEngine('deep_danger');
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo $(ls $(deep_danger))' },
},
'test-server',
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
});
+2
View File
@@ -335,8 +335,10 @@ export interface PolicySettings {
allowed?: string[];
};
tools?: {
core?: string[];
exclude?: string[];
allowed?: string[];
confirmationRequired?: string[];
};
mcpServers?: Record<string, { trust?: boolean }>;
// User provided policies that will replace the USER level policies in ~/.gemini/policies
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PromptProvider } from './promptProvider.js';
import type { Config } from '../config/config.js';
import { makeRelative } from '../utils/paths.js';
import {
getAllGeminiMdFilenames,
DEFAULT_CONTEXT_FILENAME,
@@ -58,6 +59,7 @@ describe('PromptProvider', () => {
).getToolRegistry?.() as unknown as ToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getProjectRoot: vi.fn().mockReturnValue('/tmp/project-temp'),
topicState: new TopicState(),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
@@ -236,7 +238,14 @@ describe('PromptProvider', () => {
expect(prompt).toContain(
'`write_file` and `replace` may ONLY be used to write .md plan files',
);
expect(prompt).toContain('/tmp/project-temp/plans/');
const expectedRelativePath = makeRelative(
mockConfig.storage.getPlansDir(),
mockConfig.getProjectRoot(),
).replaceAll('\\', '/');
expect(prompt).toContain(
`write .md plan files to \`${expectedRelativePath}/\``,
);
});
});
+14 -3
View File
@@ -8,7 +8,7 @@ import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import type { HierarchicalMemory } from '../config/memory.js';
import { GEMINI_DIR } from '../utils/paths.js';
import { GEMINI_DIR, makeRelative } from '../utils/paths.js';
import { ApprovalMode } from '../policy/types.js';
import * as snippets from './snippets.js';
import * as legacySnippets from './snippets.legacy.js';
@@ -195,8 +195,19 @@ export class PromptProvider {
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.storage.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),
plansDir: makeRelative(
context.config.storage.getPlansDir(),
context.config.getProjectRoot(),
).replaceAll('\\', '/'),
approvedPlanPath: (() => {
const approvedPath = context.config.getApprovedPlanPath();
return approvedPath
? makeRelative(
approvedPath,
context.config.getProjectRoot(),
).replaceAll('\\', '/')
: undefined;
})(),
}),
isPlanMode,
),
+3 -2
View File
@@ -107,6 +107,7 @@ describe('EditTool', () => {
getGeminiClient: vi.fn().mockReturnValue(geminiClient),
getBaseLlmClient: vi.fn().mockReturnValue(baseLlmClient),
getTargetDir: () => rootDir,
getProjectRoot: () => rootDir,
getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(),
getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
@@ -1336,8 +1337,8 @@ function doIt() {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
const filePath = path.join(rootDir, 'test-file.txt');
const planFilePath = path.join(plansDir, 'test-file.txt');
const filePath = 'test-file.txt';
const planFilePath = path.join(plansDir, filePath);
const initialContent = 'some initial content';
fs.writeFileSync(planFilePath, initialContent, 'utf8');
+27 -6
View File
@@ -58,6 +58,7 @@ import { EDIT_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
const ENABLE_FUZZY_MATCH_RECOVERY = true;
const FUZZY_MATCH_THRESHOLD = 0.1; // Allow up to 10% weighted difference
@@ -465,11 +466,21 @@ class EditToolInvocation
() => this.config.getApprovalMode(),
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
try {
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} catch (e) {
debugLogger.error(
'Failed to resolve plan path during EditTool invocation setup',
e,
);
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
// It's safer to store it so validation in execute() or getConfirmationDetails() catches it.
this.resolvedPath = this.params.file_path;
}
} else if (!path.isAbsolute(this.params.file_path)) {
const result = correctPath(this.params.file_path, this.config);
if (result.success) {
@@ -1054,7 +1065,17 @@ export class EditTool
}
let resolvedPath: string;
if (!path.isAbsolute(params.file_path)) {
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
params.file_path,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else if (!path.isAbsolute(params.file_path)) {
const result = correctPath(params.file_path, this.config);
if (result.success) {
resolvedPath = result.correctedPath;
+10 -3
View File
@@ -42,6 +42,7 @@ describe('ExitPlanModeTool', () => {
mockConfig = {
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
getProjectRoot: vi.fn().mockReturnValue(tempRootDir),
setApprovalMode: vi.fn(),
setApprovedPlanPath: vi.fn(),
storage: {
@@ -72,8 +73,10 @@ describe('ExitPlanModeTool', () => {
const createPlanFile = (name: string, content: string) => {
const filePath = path.join(mockPlansDir, name);
// Ensure parent directory exists for nested tests
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
return path.join('plans', name);
return name;
};
describe('shouldConfirmExecute', () => {
@@ -482,7 +485,11 @@ Ask the user for specific feedback on how to improve the plan.`,
});
it('should reject non-existent plan file', async () => {
const result = await validatePlanPath('ghost.md', mockPlansDir);
const result = await validatePlanPath(
'ghost.md',
mockPlansDir,
tempRootDir,
);
expect(result).toContain('Plan file does not exist');
});
@@ -497,7 +504,7 @@ Ask the user for specific feedback on how to improve the plan.`,
});
expect(result).toBe(
`Access denied: plan path (${path.join(mockPlansDir, 'malicious.md')}) must be within the designated plans directory (${mockPlansDir}).`,
`Access denied: plan path (malicious.md) must be within the designated plans directory (${mockPlansDir}).`,
);
});
+19 -16
View File
@@ -19,9 +19,12 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
import type { Config } from '../config/config.js';
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
import {
validatePlanPath,
validatePlanContent,
resolveAndValidatePlanPath,
} from '../utils/planUtils.js';
import { ApprovalMode } from '../policy/types.js';
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
import { logPlanExecution } from '../telemetry/loggers.js';
import { PlanExecutionEvent } from '../telemetry/types.js';
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
@@ -59,18 +62,14 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
if (!params.plan_filename || params.plan_filename.trim() === '') {
return 'plan_filename is required.';
}
const safeFilename = path.basename(params.plan_filename);
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
const resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
const realPath = resolveToRealPath(resolvedPath);
if (!isSubpath(plansDir, realPath)) {
return `Access denied: plan path (${resolvedPath}) must be within the designated plans directory (${plansDir}).`;
try {
resolveAndValidatePlanPath(
params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
return null;
@@ -122,6 +121,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const pathError = await validatePlanPath(
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
if (pathError) {
this.planValidationError = pathError;
@@ -179,8 +179,11 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
* Note: Validation is done in validateToolParamValues, so this assumes the path is valid.
*/
private getResolvedPlanPath(): string {
const safeFilename = path.basename(this.params.plan_filename);
return path.join(this.config.storage.getPlansDir(), safeFilename);
return resolveAndValidatePlanPath(
this.params.plan_filename,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
}
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
@@ -76,6 +76,7 @@ vi.mocked(IdeClient.getInstance).mockResolvedValue(
const fsService = new StandardFileSystemService();
const mockConfigInternal = {
getTargetDir: () => rootDir,
getProjectRoot: () => rootDir,
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(),
getGeminiClient: vi.fn(), // Initialize as a plain mock function
@@ -1113,4 +1114,26 @@ describe('WriteFileTool', () => {
);
});
});
describe('plan mode path handling', () => {
const abortSignal = new AbortController().signal;
it('should correctly resolve nested paths in plan mode', async () => {
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
// Extend storage mock with getPlansDir
mockConfig.storage.getPlansDir = vi.fn().mockReturnValue(plansDir);
const nestedFilePath = 'conductor/tracks/test.md';
const invocation = tool.build({
file_path: nestedFilePath,
content: 'nested content',
});
await invocation.execute({ abortSignal });
const expectedWritePath = path.join(plansDir, 'conductor/tracks/test.md');
expect(fs.existsSync(expectedWritePath)).toBe(true);
expect(fs.readFileSync(expectedWritePath, 'utf8')).toBe('nested content');
});
});
});
+29 -6
View File
@@ -49,6 +49,7 @@ import { debugLogger } from '../utils/debugLogger.js';
import { WRITE_FILE_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
import { isGemini3Model } from '../config/models.js';
import { discoverJitContext, appendJitContext } from './jit-context.js';
@@ -168,11 +169,20 @@ class WriteFileToolInvocation extends BaseToolInvocation<
);
if (this.config.isPlanMode()) {
const safeFilename = path.basename(this.params.file_path);
this.resolvedPath = path.join(
this.config.storage.getPlansDir(),
safeFilename,
);
try {
this.resolvedPath = resolveAndValidatePlanPath(
this.params.file_path,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} catch (e) {
debugLogger.error(
'Failed to resolve plan path during WriteFileTool invocation setup',
e,
);
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
this.resolvedPath = this.params.file_path;
}
} else {
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
@@ -499,7 +509,20 @@ export class WriteFileTool
return `Missing or empty "file_path"`;
}
const resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
let resolvedPath: string;
if (this.config.isPlanMode()) {
try {
resolvedPath = resolveAndValidatePlanPath(
filePath,
this.config.storage.getPlansDir(),
this.config.getProjectRoot(),
);
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
} else {
resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
}
const validationError = this.config.validatePathAccess(resolvedPath);
if (validationError) {
+6
View File
@@ -114,6 +114,12 @@ export class FatalToolExecutionError extends FatalError {
this.name = 'FatalToolExecutionError';
}
}
export class FatalUntrustedWorkspaceError extends FatalError {
constructor(message: string) {
super(message, 55);
this.name = 'FatalUntrustedWorkspaceError';
}
}
export class FatalCancellationError extends FatalError {
constructor(message: string) {
super(message, 130); // Standard exit code for SIGINT
+1
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
export const GEMINI_DIR = '.gemini';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
/**
* Returns the home directory.
+15 -9
View File
@@ -31,30 +31,36 @@ describe('planUtils', () => {
describe('validatePlanPath', () => {
it('should return null for a valid path within plans directory', async () => {
const planPath = path.join('plans', 'test.md');
const fullPath = path.join(tempRootDir, planPath);
const planPath = 'test.md';
const fullPath = path.join(plansDir, planPath);
fs.writeFileSync(fullPath, '# My Plan');
const result = await validatePlanPath(planPath, plansDir);
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toBeNull();
});
it('should return error for non-existent file', async () => {
const planPath = path.join('plans', 'ghost.md');
const result = await validatePlanPath(planPath, plansDir);
const planPath = 'ghost.md';
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toContain('Plan file does not exist');
});
it('should detect path traversal via symbolic links', async () => {
const maliciousPath = path.join('plans', 'malicious.md');
const fullMaliciousPath = path.join(tempRootDir, maliciousPath);
const outsideFile = path.join(tempRootDir, 'outside.txt');
const maliciousPath = 'malicious.md';
const fullMaliciousPath = path.join(plansDir, maliciousPath);
// Create a file outside the plans directory
const outsideFile = path.join(tempRootDir, 'outside.md');
fs.writeFileSync(outsideFile, 'secret content');
// Create a symbolic link pointing outside the plans directory
fs.symlinkSync(outsideFile, fullMaliciousPath);
const result = await validatePlanPath(maliciousPath, plansDir);
const result = await validatePlanPath(
maliciousPath,
plansDir,
tempRootDir,
);
expect(result).toContain('Access denied');
});
});
+69 -14
View File
@@ -22,31 +22,86 @@ export const PlanErrorMessages = {
READ_FAILURE: (detail: string) => `Failed to read plan file: ${detail}`,
} as const;
/**
* Resolves a plan file path and strictly validates it against the plans directory boundary.
* Useful for tools that need to write or read plans.
* @param planPath The untrusted file path provided by the model.
* @param plansDir The authorized project plans directory.
* @returns The safely resolved path string.
* @throws Error if the path is empty, malicious, or escapes boundaries.
*/
export function resolveAndValidatePlanPath(
planPath: string,
plansDir: string,
projectRoot: string,
): string {
const trimmedPath = planPath.trim();
if (!trimmedPath) {
throw new Error('Plan file path must be non-empty.');
}
// 1. Handle case where agent provided an absolute path
if (path.isAbsolute(trimmedPath)) {
if (
isSubpath(resolveToRealPath(plansDir), resolveToRealPath(trimmedPath))
) {
return trimmedPath;
}
}
// 2. Handle case where agent provided a path relative to the project root
const resolvedFromProjectRoot = path.resolve(projectRoot, trimmedPath);
if (
isSubpath(
resolveToRealPath(plansDir),
resolveToRealPath(resolvedFromProjectRoot),
)
) {
return resolvedFromProjectRoot;
}
// 3. Handle default case where agent provided a path relative to the plans directory
const resolvedPath = path.resolve(plansDir, trimmedPath);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);
if (!isSubpath(realPlansDir, realPath)) {
throw new Error(
PlanErrorMessages.PATH_ACCESS_DENIED(trimmedPath, plansDir),
);
}
return resolvedPath;
}
/**
* Validates a plan file path for safety (traversal) and existence.
* @param planPath The untrusted path to the plan file.
* @param plansDir The authorized project plans directory.
* @param targetDir The current working directory (project root).
* @param projectRoot The root directory of the project.
* @returns An error message if validation fails, or null if successful.
*/
export async function validatePlanPath(
planPath: string,
plansDir: string,
projectRoot: string,
): Promise<string | null> {
const safeFilename = path.basename(planPath);
const resolvedPath = path.join(plansDir, safeFilename);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);
if (!isSubpath(realPlansDir, realPath)) {
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
try {
const resolvedPath = resolveAndValidatePlanPath(
planPath,
plansDir,
projectRoot,
);
if (!(await fileExists(resolvedPath))) {
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
}
return null;
} catch {
return PlanErrorMessages.PATH_ACCESS_DENIED(
planPath,
resolveToRealPath(plansDir),
);
}
if (!(await fileExists(resolvedPath))) {
return PlanErrorMessages.FILE_NOT_FOUND(planPath);
}
return null;
}
/**
+13 -1
View File
@@ -240,11 +240,15 @@ foreach ($commandAst in $commandAsts) {
'utf16le',
).toString('base64');
const REDIRECTION_NAMES = new Set([
export const REDIRECTION_NAMES = new Set([
'redirection (<)',
'redirection (>)',
'heredoc (<<)',
'herestring (<<<)',
'command substitution',
'backtick substitution',
'process substitution',
'subshell',
]);
function createParser(): Parser | null {
@@ -360,6 +364,14 @@ function extractNameFromNode(node: Node): string | null {
return 'heredoc (<<)';
case 'herestring_redirect':
return 'herestring (<<<)';
case 'command_substitution':
return 'command substitution';
case 'backtick_substitution':
return 'backtick substitution';
case 'process_substitution':
return 'process substitution';
case 'subshell':
return 'subshell';
default:
return null;
}
+207
View File
@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
TrustLevel,
loadTrustedFolders,
resetTrustedFoldersForTesting,
checkPathTrust,
} from './trust.js';
import { Storage } from '../config/storage.js';
import { lock } from 'proper-lockfile';
import { ideContextStore } from '../ide/ideContext.js';
import * as headless from './headless.js';
import { coreEvents } from './events.js';
vi.mock('proper-lockfile');
vi.mock('./headless.js', async (importOriginal) => {
const original = await importOriginal<typeof import('./headless.js')>();
return {
...original,
isHeadlessMode: vi.fn(),
};
});
describe('Trust Utility (Core)', () => {
const tempDir = path.join(
os.tmpdir(),
'gemini-trust-test-' + Math.random().toString(36).slice(2),
);
const trustedFoldersPath = path.join(tempDir, 'trustedFolders.json');
beforeEach(() => {
fs.mkdirSync(tempDir, { recursive: true });
vi.spyOn(Storage, 'getTrustedFoldersPath').mockReturnValue(
trustedFoldersPath,
);
vi.mocked(lock).mockResolvedValue(vi.fn().mockResolvedValue(undefined));
vi.mocked(headless.isHeadlessMode).mockReturnValue(false);
ideContextStore.clear();
resetTrustedFoldersForTesting();
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('should load empty config if file does not exist', () => {
const folders = loadTrustedFolders();
expect(folders.user.config).toEqual({});
expect(folders.errors).toEqual([]);
});
it('should load config from file', () => {
const config = {
[path.resolve('/trusted/path')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
// Use path.resolve for platform consistency in tests
const normalizedKey = path.resolve('/trusted/path').replace(/\\/g, '/');
const isWindows = process.platform === 'win32';
const finalKey = isWindows ? normalizedKey.toLowerCase() : normalizedKey;
expect(folders.user.config[finalKey]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should handle isPathTrusted with longest match', () => {
const config = {
[path.resolve('/a')]: TrustLevel.TRUST_FOLDER,
[path.resolve('/a/b')]: TrustLevel.DO_NOT_TRUST,
[path.resolve('/a/b/c')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
expect(folders.isPathTrusted(path.resolve('/a/file.txt'))).toBe(true);
expect(folders.isPathTrusted(path.resolve('/a/b/file.txt'))).toBe(false);
expect(folders.isPathTrusted(path.resolve('/a/b/c/file.txt'))).toBe(true);
expect(folders.isPathTrusted(path.resolve('/other'))).toBeUndefined();
});
it('should handle TRUST_PARENT', () => {
const config = {
[path.resolve('/project/.gemini')]: TrustLevel.TRUST_PARENT,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const folders = loadTrustedFolders();
expect(folders.isPathTrusted(path.resolve('/project/file.txt'))).toBe(true);
expect(
folders.isPathTrusted(path.resolve('/project/.gemini/config.yaml')),
).toBe(true);
});
it('should save config correctly', async () => {
const folders = loadTrustedFolders();
const testPath = path.resolve('/new/trusted/path');
await folders.setValue(testPath, TrustLevel.TRUST_FOLDER);
const savedContent = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'),
);
const normalizedKey = testPath.replace(/\\/g, '/');
const isWindows = process.platform === 'win32';
const finalKey = isWindows ? normalizedKey.toLowerCase() : normalizedKey;
expect(savedContent[finalKey]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should handle comments in JSON', () => {
const content = `
{
// This is a comment
"path": "TRUST_FOLDER"
}
`;
fs.writeFileSync(trustedFoldersPath, content);
const folders = loadTrustedFolders();
expect(folders.errors).toHaveLength(0);
});
describe('checkPathTrust', () => {
it('should NOT return trusted if headless mode is on by default', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
isHeadless: true,
});
expect(result).toEqual({ isTrusted: undefined, source: undefined });
});
it('should return trusted if folder trust is disabled', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: false,
});
expect(result).toEqual({ isTrusted: true, source: undefined });
});
it('should return IDE trust if available', () => {
ideContextStore.set({
workspaceState: { isTrusted: true },
});
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: true, source: 'ide' });
});
it('should fall back to file trust', () => {
const config = {
[path.resolve('/trusted')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config));
const result = checkPathTrust({
path: path.resolve('/trusted/file.txt'),
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: true, source: 'file' });
});
it('should return undefined trust if no rule matches', () => {
const result = checkPathTrust({
path: '/any',
isFolderTrustEnabled: true,
});
expect(result).toEqual({ isTrusted: undefined, source: undefined });
});
});
describe('coreEvents.emitFeedback', () => {
it('should report corrupted config via coreEvents.emitFeedback in setValue', async () => {
const folders = loadTrustedFolders();
const testPath = path.resolve('/new/path');
// Initialize with valid JSON
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
// Corrupt the file after initial load
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
const spy = vi.spyOn(coreEvents, 'emitFeedback');
await folders.setValue(testPath, TrustLevel.TRUST_FOLDER);
expect(spy).toHaveBeenCalledWith(
'error',
expect.stringContaining('may be corrupted'),
expect.any(Error),
);
});
});
});
+356
View File
@@ -0,0 +1,356 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import stripJsonComments from 'strip-json-comments';
import { Storage } from '../config/storage.js';
import { normalizePath, isSubpath } from './paths.js';
import { FatalConfigError, getErrorMessage } from './errors.js';
import { coreEvents } from './events.js';
import { ideContextStore } from '../ide/ideContext.js';
export enum TrustLevel {
TRUST_FOLDER = 'TRUST_FOLDER',
TRUST_PARENT = 'TRUST_PARENT',
DO_NOT_TRUST = 'DO_NOT_TRUST',
}
export interface TrustResult {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | 'env' | undefined;
}
export interface TrustOptions {
path: string;
isFolderTrustEnabled: boolean;
isHeadless?: boolean;
}
export function isTrustLevel(value: unknown): value is TrustLevel {
return (
typeof value === 'string' &&
Object.values(TrustLevel).some((v) => v === value)
);
}
/**
* Checks if a path is trusted based on headless mode, folder trust settings,
* IDE context, and local configuration file.
*/
export function checkPathTrust(options: TrustOptions): TrustResult {
if (process.env['GEMINI_CLI_TRUST_WORKSPACE'] === 'true') {
return { isTrusted: true, source: 'env' };
}
if (!options.isFolderTrustEnabled) {
return { isTrusted: true, source: undefined };
}
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
if (ideTrust !== undefined) {
return { isTrusted: ideTrust, source: 'ide' };
}
const folders = loadTrustedFolders();
if (folders.errors.length > 0) {
const errorMessages = folders.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`,
);
}
const isTrusted = folders.isPathTrusted(options.path);
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
};
}
export interface TrustRule {
path: string;
trustLevel: TrustLevel;
}
export interface TrustedFoldersError {
message: string;
path: string;
}
export interface TrustedFoldersFile {
config: Record<string, TrustLevel>;
path: string;
}
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
*/
export function clearRealPathCacheForTesting(): void {
realPathCache.clear();
}
function getRealPath(location: string): string {
let realPath = realPathCache.get(location);
if (realPath !== undefined) {
return realPath;
}
try {
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
} catch {
realPath = location;
}
realPathCache.set(location, realPath);
return realPath;
}
export class LoadedTrustedFolders {
constructor(
readonly user: TrustedFoldersFile,
readonly errors: TrustedFoldersError[],
) {}
get rules(): TrustRule[] {
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
path,
trustLevel,
}));
}
/**
* Returns true or false if the path should be "trusted" based on the configuration.
*
* @param location path
* @param config optional config override
* @returns boolean if trusted/distrusted, undefined if no rule matches
*/
isPathTrusted(
location: string,
config?: Record<string, TrustLevel>,
): boolean | undefined {
const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison
const realLocation = getRealPath(location);
const normalizedLocation = normalizePath(realLocation);
let longestMatchLen = -1;
let longestMatchTrust: TrustLevel | undefined = undefined;
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
const effectivePath =
trustLevel === TrustLevel.TRUST_PARENT
? path.dirname(rulePath)
: rulePath;
// Resolve effectivePath to its realpath for canonical comparison
const realEffectivePath = getRealPath(effectivePath);
const normalizedEffectivePath = normalizePath(realEffectivePath);
if (isSubpath(normalizedEffectivePath, normalizedLocation)) {
if (rulePath.length > longestMatchLen) {
longestMatchLen = rulePath.length;
longestMatchTrust = trustLevel;
}
}
}
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
if (
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
longestMatchTrust === TrustLevel.TRUST_PARENT
) {
return true;
}
return undefined;
}
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fs.promises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
// Restrict file access to read/write for the owner only
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
const normalizedPath = normalizePath(folderPath);
const originalTrustLevel = this.user.config[normalizedPath];
try {
// Re-read the file to handle concurrent updates
const content = await fs.promises.readFile(this.user.path, 'utf-8');
const config: Record<string, TrustLevel> = {};
try {
const parsed = parseTrustedFoldersJson(content);
if (isRecord(parsed)) {
for (const [rawPath, value] of Object.entries(parsed)) {
if (isTrustLevel(value)) {
config[rawPath] = value;
}
}
}
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
}
// Use normalized path as key
config[normalizedPath] = trustLevel;
this.user.config[normalizedPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[normalizedPath];
} else {
this.user.config[normalizedPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
let loadedTrustedFolders: LoadedTrustedFolders | undefined;
/**
* FOR TESTING PURPOSES ONLY.
* Resets the in-memory cache of the trusted folders configuration.
*/
export function resetTrustedFoldersForTesting(): void {
loadedTrustedFolders = undefined;
clearRealPathCacheForTesting();
}
export function loadTrustedFolders(): LoadedTrustedFolders {
if (loadedTrustedFolders) {
return loadedTrustedFolders;
}
const errors: TrustedFoldersError[] = [];
const userConfig: Record<string, TrustLevel> = {};
const userPath = Storage.getTrustedFoldersPath();
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = parseTrustedFoldersJson(content);
if (!isRecord(parsed)) {
errors.push({
message: 'Trusted folders file is not a valid JSON object.',
path: userPath,
});
} else {
for (const [rawPath, trustLevel] of Object.entries(parsed)) {
const normalizedPath = normalizePath(rawPath);
if (isTrustLevel(trustLevel)) {
userConfig[normalizedPath] = trustLevel;
} else {
const possibleValues = Object.values(TrustLevel).join(', ');
errors.push({
message: `Invalid trust level "${trustLevel}" for path "${rawPath}". Possible values are: ${possibleValues}.`,
path: userPath,
});
}
}
}
}
} catch (error) {
errors.push({
message: getErrorMessage(error),
path: userPath,
});
}
loadedTrustedFolders = new LoadedTrustedFolders(
{ path: userPath, config: userConfig },
errors,
);
return loadedTrustedFolders;
}
export function saveTrustedFolders(
trustedFoldersFile: TrustedFoldersFile,
): void {
// Ensure the directory exists
const dirPath = path.dirname(trustedFoldersFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
// Restrict file access to read/write for the owner only
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.39.1",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.39.1",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.39.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.39.1",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+9
View File
@@ -2486,6 +2486,15 @@
"type": "string"
}
},
"confirmationRequired": {
"title": "Confirmation Required",
"description": "Tool names that always require user confirmation. Takes precedence over allowed tools and core tool allowlists.",
"markdownDescription": "Tool names that always require user confirmation. Takes precedence over allowed tools and core tool allowlists.\n\n- Category: `Advanced`\n- Requires restart: `yes`",
"type": "array",
"items": {
"type": "string"
}
},
"exclude": {
"title": "Exclude Tools",
"description": "Tool names to exclude from discovery.",