Compare commits

...

16 Commits

Author SHA1 Message Date
Mahima Shanware 6f0d7b8adb fix(rebase): Resolve conflicts from rebase onto abhijit-2592
Manually resolved merge conflicts in CLI and Core config files that occurred during the rebase of the extensible plan mode feature branch onto `abhijit-2592/read-proj-dir-policy-file`.
2026-02-19 19:34:33 +00:00
Mahima Shanware 12ee556f3b Fix: Enable write_file in Plan Mode via workspace policy 2026-02-19 18:35:34 +00:00
Mahima Shanware c1dfcd9a2d feat: implement extensible plan mode with custom directory configuration
- Adds 'general.plan' configuration object for plan settings (directory).
- Updates 'experimental.plan' to a boolean flag for enablement.
- Implements dynamic high-priority policy for custom plan directories in core.
- Adds migration logic for previous configuration formats.
- Updates documentation and schema.
2026-02-19 18:34:02 +00:00
Abhijit Balaji e51876b108 test(cli): update createPolicyEngineConfig mock expectations 2026-02-18 16:03:40 -08:00
Abhijit Balaji 527161d7ef fix(cli): remove unused PolicyIntegrityManager import in AppContainer 2026-02-18 15:32:17 -08:00
Abhijit Balaji d08bc96fab refactor(cli): abstract workspace policy resolution logic
Centralized the workspace policy discovery and integrity verification
logic into a new 'resolveWorkspacePolicyState' helper in the policy
module. This significantly simplifies 'loadCliConfig' in config.ts,
reducing its imperative bloat and removing low-level core dependencies
from the main configuration flow.

- Moved workspace integrity check and directory discovery to policy.ts
- Refactored loadCliConfig to use the new declarative resolver
- Added comprehensive unit tests for the resolver using real temp dirs
- Cleaned up redundant function arguments in core and CLI calls
- Verified project integrity with 'npm run preflight'
2026-02-18 15:20:48 -08:00
Abhijit Balaji c95e05454c refactor(policy): consolidate workspacePoliciesDir into PolicySettings
Simplified createPolicyEngineConfig signature by moving workspacePoliciesDir into the PolicySettings interface. Updated all core and CLI call sites and tests to align with the consolidated settings structure.
2026-02-18 15:20:48 -08:00
Abhijit Balaji 662654c5d2 feat(policy): implement hot-reloading for workspace policies
This change eliminates the need for a CLI restart when a user accepts new or
changed project-level policies. Workspace rules are now dynamically injected
into the active PolicyEngine instance.

Key improvements:
- Added Config.loadWorkspacePolicies() to handle mid-session rule injection.
- Fully encapsulated acceptance and integrity logic within PolicyUpdateDialog.
- Integrated centralized keybindings (Command.ESCAPE) for dialog dismissal.
- Refactored PolicyIntegrityManager tests to use a real temporary directory
  instead of filesystem mocks for improved reliability.
- Updated copyright headers to 2026 across affected files.
- Added UI snapshot tests for the policy update dialog.

Addresses review feedback from PR #18682.
2026-02-18 15:20:41 -08:00
Abhijit Balaji 8feff1cc9b refactor(policy): rename "Project" policies to "Workspace" policies
Updates the terminology and configuration for the intermediate policy tier
from "Project" to "Workspace" to better align with the Gemini CLI ecosystem.

Key changes:
- Renamed `PROJECT_POLICY_TIER` to `WORKSPACE_POLICY_TIER`.
- Renamed `getProjectPoliciesDir` to `getWorkspacePoliciesDir`.
- Updated integrity scope from `project` to `workspace`.
- Updated UI dialogs and documentation.
- Renamed related test files.
2026-02-18 15:20:11 -08:00
Abhijit Balaji d8f1db6161 test(cli): improve project policy config test coverage
Updates config.test.ts to fix createPolicyEngineConfig mock expectations and expands project-policy-cli.test.ts to cover integrity check scenarios (NEW, MISMATCH) and interactive confirmation flows.
2026-02-18 15:20:10 -08:00
Abhijit Balaji 73b3cb86eb fix(policy): refactor policy dialog to remove process.exit and fix integration tests
- Refactored `PolicyUpdateDialog` to remove side effects (`process.exit`, `relaunchApp`) and delegate logic to parent.
- Updated `AppContainer` to handle relaunch logic.
- Added comprehensive unit tests for `PolicyUpdateDialog`.
- Fixed `project-policy-cli.test.ts` to correctly mock `PolicyIntegrityManager`.
- Fixed typo in `packages/core/src/policy/config.ts`.
2026-02-18 15:20:10 -08:00
Abhijit Balaji c73e47bbbe feat(policy): implement project policy integrity verification
Adds a security mechanism to detect and prompt for confirmation when project-level policies are added or modified. This prevents unauthorized policy changes from being applied silently.

- PolicyIntegrityManager calculates and persists policy directory hashes.
- Config integrates integrity checks during startup.
- PolicyUpdateDialog prompts users in interactive mode.
- --accept-changed-policies flag supports non-interactive workflows.
- toml-loader refactored to expose file reading logic.
2026-02-18 15:19:44 -08:00
Abhijit Balaji 53511d6ed4 fix: Update test expectations to match createPolicyEngineConfig signature changes from rebase 2026-02-18 15:19:01 -08:00
Abhijit Balaji 3a24b0c975 feat(policy): change priority hierarchy to Admin > User > Project > Default
Updates the policy engine to prioritize User policies over Project-specific policies.
This change is a security measure to ensure that users maintain control over their
environment and are not inadvertently compromised by policies defined in a cloned
repository.

Key Changes:
- Swapped Tier 2 (now Project) and Tier 3 (now User).
- Updated documentation to reflect the new hierarchy.
- Updated all built-in policy TOML files with correct tier information.
- Adjusted all tests and integration test expectations to match new priority values.
2026-02-18 15:19:00 -08:00
Abhijit Balaji f34a3ee1df docs(policy): document project-level policy support
Adds the 'Project' tier (Base 3) to the policy engine documentation.
Updates the priority hierarchy, location table, and formula examples
to reflect the new Project -> User precedence.
2026-02-18 15:19:00 -08:00
Abhijit Balaji 322de4309d feat(policy): implement project-level policy support
Introduces a new 'Project' tier (Tier 3) for policies, allowing users to define
project-specific rules in `$PROJECT_ROOT/.gemini/policies`.

Key Changes:
- **Core**: Added `PROJECT_POLICY_TIER` (3) and bumped `ADMIN_POLICY_TIER` to 4.
  Updated `getPolicyDirectories`, `getPolicyTier`, and `createPolicyEngineConfig` to handle
  project-level policy directories.
- **Storage**: Added `getProjectPoliciesDir()` to the `Storage` class.
- **CLI**: Updated `loadCliConfig` to securely load project policies.
  Crucially, project policies are **only loaded if the workspace is trusted**.
- **Tests**: Added comprehensive tests for both core policy logic and CLI integration,
  verifying priority hierarchy (Admin > Project > User > Default) and trust checks.

This hierarchy ensures that project-specific rules override user defaults but are still
subject to system-wide admin enforcement.
2026-02-18 15:18:59 -08:00
49 changed files with 2304 additions and 17892 deletions
+62
View File
@@ -107,6 +107,68 @@ These are the only allowed tools:
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
resources in a read-only manner)
### Customizing the Plan Directory
By default, plans are stored in a temporary directory within `~/.gemini/tmp/`.
You can customize this location, but doing so requires **two steps**:
configuring the setting and adding a policy rule.
**Important:** If you only update `settings.json`, the agent will be blocked
from writing to your custom directory by the default safety policies.
#### 1. Configure the directory in `settings.json`
Add the `plan.directory` setting to your `~/.gemini/settings.json` file. This
path can be **absolute** or **relative** to your project root, and **can be
located outside your project directory**.
```json
{
"general": {
"plan": {
"directory": "conductor"
}
}
}
```
#### 2. Add a policy to allow writing to that directory
Create a policy file (e.g., `~/.gemini/policies/custom-plans.toml`) to
explicitly allow the agent to write files to your custom directory while in Plan
Mode.
The `argsPattern` in your policy must match the `file_path` (or `path`) argument
passed to the tool.
```toml
[[rule]]
toolName = ["write_file", "replace"]
# Allow writing to any path within the "conductor/" directory
# This regex matches both relative ("conductor/file") and absolute ("/path/to/conductor/file") paths.
argsPattern = "\"(?:file_path|path)\":\"(?:.*\/)?conductor/[^\"]+\""
decision = "allow"
priority = 100
modes = ["plan"]
```
**Relative vs. Absolute Paths:**
- **Best Practice:** Use a regex that handles both relative and absolute paths
by making the prefix optional:
`\"(?:file_path|path)\":\"(?:.*\/)?conductor/[^\"]+\"`.
- **Relative Paths:** If the agent uses a relative path (e.g.,
`conductor/plan.md`), the regex `conductor/` matches.
- **Absolute Paths:** If the agent uses an absolute path (e.g.,
`/abs/path/to/conductor/plan.md`), the regex `.*/conductor/` matches.
> **Tip:** If you choose a directory inside your project, you should add it to
> your `.gitignore` file to avoid accidentally committing temporary plans to
> version control.
> **Tip:** For Windows users, you may need to adjust the regex to match
> backslashes.
### Customizing Planning with Skills
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
+17 -13
View File
@@ -92,11 +92,12 @@ rule with the highest priority wins**.
To provide a clear hierarchy, policies are organized into three tiers. Each tier
has a designated number that forms the base of the final priority calculation.
| Tier | Base | Description |
| :------ | :--- | :------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
| User | 2 | Custom policies defined by the user. |
| Admin | 3 | Policies managed by an administrator (e.g., in an enterprise environment). |
| Tier | Base | Description |
| :-------- | :--- | :------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
| Workspace | 2 | Policies defined in the current workspace's configuration directory. |
| User | 3 | Custom policies defined by the user. |
| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). |
Within a TOML policy file, you assign a priority value from **0 to 999**. The
engine transforms this into a final priority using the following formula:
@@ -105,15 +106,17 @@ engine transforms this into a final priority using the following formula:
This system guarantees that:
- Admin policies always override User and Default policies.
- User policies always override Default policies.
- Admin policies always override User, Workspace, and Default policies.
- User policies override Workspace and Default policies.
- Workspace policies override Default policies.
- You can still order rules within a single tier with fine-grained control.
For example:
- A `priority: 50` rule in a Default policy file becomes `1.050`.
- A `priority: 100` rule in a User policy file becomes `2.100`.
- A `priority: 20` rule in an Admin policy file becomes `3.020`.
- A `priority: 10` rule in a Workspace policy policy file becomes `2.010`.
- A `priority: 100` rule in a User policy file becomes `3.100`.
- A `priority: 20` rule in an Admin policy file becomes `4.020`.
### Approval modes
@@ -156,10 +159,11 @@ User, and (if configured) Admin directories.
### Policy locations
| Tier | Type | Location |
| :-------- | :----- | :-------------------------- |
| **User** | Custom | `~/.gemini/policies/*.toml` |
| **Admin** | System | _See below (OS specific)_ |
| Tier | Type | Location |
| :------------ | :----- | :---------------------------------------- |
| **User** | Custom | `~/.gemini/policies/*.toml` |
| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
| **Admin** | System | _See below (OS specific)_ |
#### System-wide policies (Admin)
+4
View File
@@ -142,6 +142,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`general.plan`** (object):
- **Description:** Configuration for planning features.
- **Default:** `{}`
- **`general.enablePromptCompletion`** (boolean):
- **Description:** Enable AI-powered prompt completion suggestions while
typing.
-17698
View File
File diff suppressed because it is too large Load Diff
+67 -2
View File
@@ -56,7 +56,10 @@ import { resolvePath } from '../utils/resolvePath.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { createPolicyEngineConfig } from './policy.js';
import {
createPolicyEngineConfig,
resolveWorkspacePolicyState,
} from './policy.js';
import { ExtensionManager } from './extension-manager.js';
import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js';
import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
@@ -94,6 +97,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
acceptChangedPolicies: boolean | undefined;
}
export async function parseArguments(
@@ -285,6 +289,11 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('accept-changed-policies', {
type: 'boolean',
description:
'Automatically accept changed workspace policies (use with caution).',
}),
)
// Register MCP subcommands
@@ -692,12 +701,58 @@ export async function loadCliConfig(
policyPaths: argv.policy,
};
const { workspacePoliciesDir, policyUpdateConfirmationRequest } =
await resolveWorkspacePolicyState({
cwd,
trustedFolder,
interactive,
acceptChangedPolicies: argv.acceptChangedPolicies ?? false,
});
const policyEngineConfig = await createPolicyEngineConfig(
effectiveSettings,
approvalMode,
workspacePoliciesDir,
cwd,
);
policyEngineConfig.nonInteractive = !interactive;
// FIX: Ensure tools allowed by high-priority policy are NOT excluded from the registry.
// This allows users to re-enable tools like write_file in Plan Mode via policy.
// We ALSO need to remove the conflicting 'Settings (Tools Excluded)' rule from policyEngineConfig,
// otherwise PolicyEngine will still consider it excluded.
const policyAllowedTools = new Set<string>();
if (policyEngineConfig.rules) {
for (const rule of policyEngineConfig.rules) {
// Logic mirrors promptProvider.ts: Priority > 1.1 means user/admin tier (or high priority default)
if (
(rule.priority ?? 0) > 1.1 &&
(rule.decision === 'allow' || rule.decision === 'ask_user') &&
rule.toolName
) {
policyAllowedTools.add(rule.toolName);
}
}
// Filter out conflicting Settings Exclude rules
policyEngineConfig.rules = policyEngineConfig.rules.filter((rule) => {
if (
rule.source === 'Settings (Tools Excluded)' &&
rule.toolName &&
policyAllowedTools.has(rule.toolName)
) {
return false;
}
return true;
});
}
// If a tool is explicitly allowed by a high-priority policy, remove it from the exclusion list
// so it gets registered in ToolRegistry.
const finalExcludeTools = excludeTools.filter(
(t) => !policyAllowedTools.has(t),
);
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const specifiedModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
@@ -758,7 +813,8 @@ export async function loadCliConfig(
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
policyEngineConfig,
excludeTools,
policyUpdateConfirmationRequest,
excludeTools: finalExcludeTools,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand,
@@ -814,6 +870,15 @@ export async function loadCliConfig(
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
planDirectory:
settings.general &&
'plan' in settings.general &&
typeof settings.general.plan === 'object' &&
settings.general.plan !== null &&
'directory' in settings.general.plan &&
typeof settings.general.plan.directory === 'string'
? settings.general.plan.directory
: undefined,
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -148,13 +148,13 @@ describe('Policy Engine Integration Tests', () => {
);
const engine = new PolicyEngine(config);
// MCP server allowed (priority 2.1) provides general allow for server
// MCP server allowed (priority 2.1) provides general allow for server
// MCP server allowed (priority 3.1) provides general allow for server
// MCP server allowed (priority 3.1) provides general allow for server
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// But specific tool exclude (priority 2.4) wins over server allow
// But specific tool exclude (priority 3.4) wins over server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
@@ -412,25 +412,25 @@ describe('Policy Engine Integration Tests', () => {
// Find rules and verify their priorities
const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool');
expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude
expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude
const blockedServerRule = rules.find(
(r) => r.toolName === 'blocked-server__*',
);
expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude
expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude
const specificToolRule = rules.find(
(r) => r.toolName === 'specific-tool',
);
expect(specificToolRule?.priority).toBe(2.3); // Command line allow
expect(specificToolRule?.priority).toBe(3.3); // Command line allow
const trustedServerRule = rules.find(
(r) => r.toolName === 'trusted-server__*',
);
expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server
expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
@@ -577,16 +577,16 @@ describe('Policy Engine Integration Tests', () => {
// Verify each rule has the expected priority
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier)
expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier)
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier)
expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier)
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier)
expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier)
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07
+158
View File
@@ -0,0 +1,158 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { resolveWorkspacePolicyState } from './policy.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock debugLogger to avoid noise in test output
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
describe('resolveWorkspacePolicyState', () => {
let tempDir: string;
let workspaceDir: string;
let policiesDir: string;
beforeEach(() => {
// Create a temporary directory for the test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
// Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
workspaceDir = path.join(tempDir, 'workspace');
fs.mkdirSync(workspaceDir);
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
vi.clearAllMocks();
});
afterEach(() => {
// Clean up temporary directory
fs.rmSync(tempDir, { recursive: true, force: true });
vi.unstubAllEnvs();
});
it('should return empty state if folder is not trusted', async () => {
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: false,
interactive: true,
acceptChangedPolicies: false,
});
expect(result).toEqual({
workspacePoliciesDir: undefined,
policyUpdateConfirmationRequest: undefined,
});
});
it('should return policy directory if integrity matches', async () => {
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
// First call to establish integrity (auto-accept)
await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: true,
});
// Second call should match
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: false,
});
expect(result.workspacePoliciesDir).toBe(policiesDir);
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should return undefined if integrity is NEW but fileCount is 0', async () => {
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: false,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should auto-accept changed policies if acceptChangedPolicies is true', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: true,
});
expect(result.workspacePoliciesDir).toBe(policiesDir);
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Auto-accepting'),
);
});
it('should return confirmation request if changed in interactive mode', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
acceptChangedPolicies: false,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toEqual({
scope: 'workspace',
identifier: workspaceDir,
policyDir: policiesDir,
newHash: expect.any(String),
});
});
it('should warn and return undefined if changed in non-interactive mode', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: false,
acceptChangedPolicies: false,
});
expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Loading default policies only'),
);
});
});
+84 -1
View File
@@ -12,12 +12,19 @@ import {
type PolicySettings,
createPolicyEngineConfig as createCorePolicyEngineConfig,
createPolicyUpdater as createCorePolicyUpdater,
PolicyIntegrityManager,
IntegrityStatus,
Storage,
type PolicyUpdateConfirmationRequest,
debugLogger,
} from '@google/gemini-cli-core';
import { type Settings } from './settings.js';
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
workspacePoliciesDir?: string,
workspaceDir?: string,
): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties.
@@ -26,9 +33,15 @@ export async function createPolicyEngineConfig(
tools: settings.tools,
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
workspacePoliciesDir,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
return createCorePolicyEngineConfig(
policySettings,
approvalMode,
undefined,
workspaceDir,
);
}
export function createPolicyUpdater(
@@ -37,3 +50,73 @@ export function createPolicyUpdater(
) {
return createCorePolicyUpdater(policyEngine, messageBus);
}
export interface WorkspacePolicyState {
workspacePoliciesDir?: string;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
}
/**
* Resolves the workspace policy state by checking folder trust and policy integrity.
*/
export async function resolveWorkspacePolicyState(options: {
cwd: string;
trustedFolder: boolean;
interactive: boolean;
acceptChangedPolicies: boolean;
}): Promise<WorkspacePolicyState> {
const { cwd, trustedFolder, interactive, acceptChangedPolicies } = options;
let workspacePoliciesDir: string | undefined;
let policyUpdateConfirmationRequest:
| PolicyUpdateConfirmationRequest
| undefined;
if (trustedFolder) {
const potentialWorkspacePoliciesDir = new Storage(
cwd,
).getWorkspacePoliciesDir();
const integrityManager = new PolicyIntegrityManager();
const integrityResult = await integrityManager.checkIntegrity(
'workspace',
cwd,
potentialWorkspacePoliciesDir,
);
if (integrityResult.status === IntegrityStatus.MATCH) {
workspacePoliciesDir = potentialWorkspacePoliciesDir;
} else if (
integrityResult.status === IntegrityStatus.NEW &&
integrityResult.fileCount === 0
) {
// No workspace policies found
workspacePoliciesDir = undefined;
} else {
// Policies changed or are new
if (acceptChangedPolicies) {
debugLogger.warn(
'WARNING: Workspace policies changed or are new. Auto-accepting due to --accept-changed-policies flag.',
);
await integrityManager.acceptIntegrity(
'workspace',
cwd,
integrityResult.hash,
);
workspacePoliciesDir = potentialWorkspacePoliciesDir;
} else if (interactive) {
policyUpdateConfirmationRequest = {
scope: 'workspace',
identifier: cwd,
policyDir: potentialWorkspacePoliciesDir,
newHash: integrityResult.hash,
};
} else {
debugLogger.warn(
'WARNING: Workspace policies changed or are new. Loading default policies only. Use --accept-changed-policies to accept.',
);
}
}
}
return { workspacePoliciesDir, policyUpdateConfirmationRequest };
}
@@ -401,6 +401,18 @@ describe('SettingsSchema', () => {
);
});
it('should have plan config in general schema', () => {
const setting = getSettingsSchema().general.properties.plan;
expect(setting).toBeDefined();
expect(setting.type).toBe('object');
expect(setting.category).toBe('General');
expect(setting.default).toStrictEqual({});
expect(setting.requiresRestart).toBe(false);
expect(setting.showInDialog).toBe(false);
expect(setting.ref).toBe('PlanConfig');
expect(setting.description).toBe('Configuration for planning features.');
});
it('should have hooksConfig.notifications setting in schema', () => {
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
expect(setting).toBeDefined();
+21
View File
@@ -266,6 +266,16 @@ const SETTINGS_SCHEMA = {
},
},
},
plan: {
type: 'object',
label: 'Plan Configuration',
category: 'General',
requiresRestart: false,
default: {},
description: 'Configuration for planning features.',
showInDialog: false,
ref: 'PlanConfig',
},
enablePromptCompletion: {
type: 'boolean',
label: 'Enable Prompt Completion',
@@ -2131,6 +2141,17 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
PlanConfig: {
type: 'object',
description: 'Planning features configuration.',
additionalProperties: false,
properties: {
directory: {
type: 'string',
description: 'Custom directory for implementation plans.',
},
},
},
TelemetrySettings: {
type: 'object',
description: 'Telemetry configuration for Gemini CLI.',
@@ -0,0 +1,267 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as path from 'node:path';
import { loadCliConfig, type CliArgs } from './config.js';
import { createTestMergedSettings } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
}));
const mockCheckIntegrity = vi.fn();
const mockAcceptIntegrity = vi.fn();
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual<typeof ServerConfig>(
'@google/gemini-cli-core',
);
return {
...actual,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
}),
createPolicyEngineConfig: vi.fn().mockResolvedValue({
rules: [],
checkers: [],
}),
getVersion: vi.fn().mockResolvedValue('test-version'),
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
checkIntegrity: mockCheckIntegrity,
acceptIntegrity: mockAcceptIntegrity,
})),
IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' },
debugLogger: {
warn: vi.fn(),
error: vi.fn(),
},
isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive
};
});
describe('Workspace-Level Policy CLI Integration', () => {
const MOCK_CWD = process.cwd();
beforeEach(() => {
vi.clearAllMocks();
// Default to MATCH for existing tests
mockCheckIntegrity.mockResolvedValue({
status: 'match',
hash: 'test-hash',
fileCount: 1,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false);
});
it('should have getWorkspacePoliciesDir on Storage class', () => {
const storage = new ServerConfig.Storage(MOCK_CWD);
expect(storage.getWorkspacePoliciesDir).toBeDefined();
expect(typeof storage.getWorkspacePoliciesDir).toBe('function');
});
it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
}),
expect.anything(),
);
});
it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'new',
hash: 'hash',
fileCount: 0,
});
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should warn and NOT pass workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'mismatch',
hash: 'new-hash',
fileCount: 1,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive
const settings = createTestMergedSettings();
const argv = { prompt: 'do something' } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Workspace policies changed or are new'),
);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should accept policies if --accept-changed-policies is passed', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'mismatch',
hash: 'new-hash',
fileCount: 1,
});
const settings = createTestMergedSettings();
const argv = { acceptChangedPolicies: true } as unknown as CliArgs;
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(mockAcceptIntegrity).toHaveBeenCalledWith(
'workspace',
MOCK_CWD,
'new-hash',
);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
}),
expect.anything(),
);
});
it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'mismatch',
hash: 'new-hash',
fileCount: 1,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
const settings = createTestMergedSettings();
const argv = {
query: 'test',
promptInteractive: 'test',
} as unknown as CliArgs;
const config = await loadCliConfig(settings, 'test-session', argv, {
cwd: MOCK_CWD,
});
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
// In interactive mode without accept flag, it waits for user confirmation (handled by UI),
// so it currently DOES NOT pass the directory to createPolicyEngineConfig yet.
// The UI will handle the confirmation and reload/update.
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
mockCheckIntegrity.mockResolvedValue({
status: 'new',
hash: 'new-hash',
fileCount: 5,
});
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive
const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs;
const config = await loadCliConfig(settings, 'test-session', argv, {
cwd: MOCK_CWD,
});
expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
});
});
+1
View File
@@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
acceptChangedPolicies: undefined,
});
await act(async () => {
+1
View File
@@ -199,6 +199,7 @@ const mockUIActions: UIActions = {
vimHandleInput: vi.fn(),
handleIdePromptComplete: vi.fn(),
handleFolderTrustSelect: vi.fn(),
setIsPolicyUpdateDialogOpen: vi.fn(),
setConstrainHeight: vi.fn(),
onEscapePromptChange: vi.fn(),
refreshStatic: vi.fn(),
+14
View File
@@ -1438,6 +1438,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
const policyUpdateConfirmationRequest =
config.getPolicyUpdateConfirmationRequest();
const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState(
!!policyUpdateConfirmationRequest,
);
const {
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
@@ -1908,6 +1915,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
(shouldShowRetentionWarning && retentionCheckComplete) ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
isPolicyUpdateDialogOpen ||
adminSettingsChanged ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
@@ -2135,6 +2143,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2257,6 +2267,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
isPolicyUpdateDialogOpen,
policyUpdateConfirmationRequest,
isTrustedFolder,
constrainHeight,
showErrorDetails,
@@ -2354,6 +2366,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
vimHandleInput,
handleIdePromptComplete,
handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen,
setConstrainHeight,
onEscapePromptChange: handleEscapePromptChange,
refreshStatic,
@@ -2438,6 +2451,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
vimHandleInput,
handleIdePromptComplete,
handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen,
setConstrainHeight,
handleEscapePromptChange,
refreshStatic,
@@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js';
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -166,6 +167,15 @@ export const DialogManager = ({
/>
);
}
if (uiState.isPolicyUpdateDialogOpen) {
return (
<PolicyUpdateDialog
config={config}
request={uiState.policyUpdateConfirmationRequest!}
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
/>
);
}
if (uiState.loopDetectionConfirmationRequest) {
return (
<LoopDetectionConfirmation
@@ -151,6 +151,7 @@ Implement a comprehensive authentication system with multiple providers.
...options,
config: {
getTargetDir: () => mockTargetDir,
getPlanDirectory: () => mockPlansDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
@@ -426,6 +427,7 @@ Implement a comprehensive authentication system with multiple providers.
useAlternateBuffer,
config: {
getTargetDir: () => mockTargetDir,
getPlanDirectory: () => mockPlansDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
@@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
try {
const pathError = await validatePlanPath(
planPath,
config.storage.getProjectTempPlansDir(),
config.getPlanDirectory(),
config.getTargetDir(),
);
if (ignore) return;
@@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const result = await processSingleFileContent(
planPath,
config.storage.getProjectTempPlansDir(),
config.getPlanDirectory(),
config.getFileSystemService(),
);
@@ -0,0 +1,135 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
import {
type Config,
type PolicyUpdateConfirmationRequest,
PolicyIntegrityManager,
} from '@google/gemini-cli-core';
// Mock PolicyIntegrityManager
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const original = (await importOriginal()) as any;
return {
...original,
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
acceptIntegrity: vi.fn().mockResolvedValue(undefined),
})),
};
});
describe('PolicyUpdateDialog', () => {
let mockConfig: Config;
let mockRequest: PolicyUpdateConfirmationRequest;
let onClose: () => void;
beforeEach(() => {
mockConfig = {
loadWorkspacePolicies: vi.fn().mockResolvedValue(undefined),
} as unknown as Config;
mockRequest = {
scope: 'workspace',
identifier: '/test/workspace/.gemini/policies',
policyDir: '/test/workspace/.gemini/policies',
newHash: 'test-hash',
} as PolicyUpdateConfirmationRequest;
onClose = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders correctly and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('New or changed workspace policies detected');
expect(output).toContain('Location: /test/workspace/.gemini/policies');
expect(output).toContain('Accept and Load');
expect(output).toContain('Ignore');
});
it('handles ACCEPT correctly', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
// Accept is the first option, so pressing enter should select it
await act(async () => {
stdin.write('\r');
});
await waitFor(() => {
expect(PolicyIntegrityManager).toHaveBeenCalled();
expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith(
mockRequest.policyDir,
);
expect(onClose).toHaveBeenCalled();
});
});
it('handles IGNORE correctly', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
// Move down to Ignore option
await act(async () => {
stdin.write('\x1B[B'); // Down arrow
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
expect(PolicyIntegrityManager).not.toHaveBeenCalled();
expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
it('calls onClose when Escape key is pressed', async () => {
const { stdin } = renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
onClose={onClose}
/>,
);
await act(async () => {
stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default)
});
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useCallback } from 'react';
import type React from 'react';
import {
type Config,
type PolicyUpdateConfirmationRequest,
PolicyIntegrityManager,
} from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
export enum PolicyUpdateChoice {
ACCEPT = 'accept',
IGNORE = 'ignore',
}
interface PolicyUpdateDialogProps {
config: Config;
request: PolicyUpdateConfirmationRequest;
onClose: () => void;
}
export const PolicyUpdateDialog: React.FC<PolicyUpdateDialogProps> = ({
config,
request,
onClose,
}) => {
const handleSelect = useCallback(
async (choice: PolicyUpdateChoice) => {
if (choice === PolicyUpdateChoice.ACCEPT) {
const integrityManager = new PolicyIntegrityManager();
await integrityManager.acceptIntegrity(
request.scope,
request.identifier,
request.newHash,
);
await config.loadWorkspacePolicies(request.policyDir);
}
onClose();
},
[config, request, onClose],
);
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onClose();
return true;
}
return false;
},
{ isActive: true },
);
const options: Array<RadioSelectItem<PolicyUpdateChoice>> = [
{
label: 'Accept and Load',
value: PolicyUpdateChoice.ACCEPT,
key: 'accept',
},
{
label: 'Ignore (Use Default Policies)',
value: PolicyUpdateChoice.IGNORE,
key: 'ignore',
},
];
return (
<Box flexDirection="column" width="100%">
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
padding={1}
marginLeft={1}
marginRight={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
New or changed {request.scope} policies detected
</Text>
<Text color={theme.text.primary}>Location: {request.identifier}</Text>
<Text color={theme.text.primary}>
Do you want to accept and load these policies?
</Text>
</Box>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={true}
/>
</Box>
</Box>
);
};
@@ -45,6 +45,7 @@ describe('ToolConfirmationQueue', () => {
getModel: () => 'gemini-pro',
getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir',
getPlanDirectory: () => '/mock/temp/plans',
getFileSystemService: () => ({
readFile: vi.fn().mockResolvedValue('Plan content'),
}),
@@ -0,0 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = `
" ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ New or changed workspace policies detected │
│ Location: /test/workspace/.gemini/policies │
│ Do you want to accept and load these policies? │
│ │
│ ● 1. Accept and Load │
│ 2. Ignore (Use Default Policies) │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -52,6 +52,7 @@ export interface UIActions {
vimHandleInput: (key: Key) => boolean;
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
setIsPolicyUpdateDialogOpen: (value: boolean) => void;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void;
@@ -27,6 +27,7 @@ import type {
FallbackIntent,
ValidationIntent,
AgentDefinition,
PolicyUpdateConfirmationRequest,
} from '@google/gemini-cli-core';
import { type TransientMessageType } from '../../utils/events.js';
import type { DOMElement } from 'ink';
@@ -112,6 +113,8 @@ export interface UIState {
isResuming: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
isPolicyUpdateDialogOpen: boolean;
policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
showErrorDetails: boolean;
+73
View File
@@ -43,6 +43,10 @@ import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
import { DEFAULT_GEMINI_MODEL } from './models.js';
import { Storage } from './storage.js';
vi.mock('glob', () => ({
glob: vi.fn().mockResolvedValue([]),
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
@@ -2510,6 +2514,25 @@ describe('Plans Directory Initialization', () => {
expect(context.getDirectories()).toContain(plansDir);
});
it('should create custom plans directory and add it to workspace context when plan.directory is provided', async () => {
const customDir = 'custom-plans';
const config = new Config({
...baseParams,
plan: true,
planDirectory: customDir,
});
await config.initialize();
const expectedDir = path.resolve(baseParams.targetDir, customDir);
expect(fs.promises.mkdir).toHaveBeenCalledWith(expectedDir, {
recursive: true,
});
const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(expectedDir);
});
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
const config = new Config({
...baseParams,
@@ -2628,3 +2651,53 @@ describe('syncPlanModeTools', () => {
expect(setToolsSpy).toHaveBeenCalled();
});
});
describe('Plan Directory', () => {
const baseParams: ConfigParameters = {
sessionId: 'test',
targetDir: '/tmp/project',
debugMode: false,
model: 'test-model',
cwd: '/tmp/project',
};
it('should return custom plan directory when provided', () => {
const customDir = 'custom-plans';
const params: ConfigParameters = {
...baseParams,
planDirectory: customDir,
};
const config = new Config(params);
expect(config.getPlanDirectory()).toBe(
path.resolve(baseParams.targetDir, customDir),
);
});
it('should return default plans directory when no custom directory is provided', () => {
const config = new Config(baseParams);
// storage.getProjectTempPlansDir() is tested in storage.test.ts, here we just check it matches
expect(config.getPlanDirectory()).toBe(
config.storage.getProjectTempPlansDir(),
);
});
it('should support absolute paths for custom plans directory within workspace', () => {
const absolutePath = path.resolve(baseParams.targetDir, 'plans');
const params: ConfigParameters = {
...baseParams,
planDirectory: absolutePath,
};
const config = new Config(params);
expect(config.getPlanDirectory()).toBe(absolutePath);
});
it('should return the configured path even when it is outside the workspace', () => {
const outsidePath = '/outside/workspace/plans';
const params: ConfigParameters = {
...baseParams,
planDirectory: outsidePath,
};
const config = new Config(params);
expect(config.getPlanDirectory()).toBe(outsidePath);
});
});
+58 -1
View File
@@ -126,6 +126,8 @@ import {
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath } from '../utils/paths.js';
import { UserHintService } from './userHintService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
export interface AccessibilitySettings {
enableLoadingPhrases?: boolean;
@@ -374,6 +376,13 @@ export interface McpEnablementCallbacks {
isFileEnabled: (serverId: string) => Promise<boolean>;
}
export interface PolicyUpdateConfirmationRequest {
scope: string;
identifier: string;
policyDir: string;
newHash: string;
}
export interface ConfigParameters {
sessionId: string;
clientVersion?: string;
@@ -454,6 +463,7 @@ export interface ConfigParameters {
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
policyEngineConfig?: PolicyEngineConfig;
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
output?: OutputSettings;
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
@@ -482,6 +492,7 @@ export interface ConfigParameters {
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean;
plan?: boolean;
planDirectory?: string;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
mcpEnabled?: boolean;
@@ -631,6 +642,9 @@ export class Config {
private readonly useWriteTodos: boolean;
private readonly messageBus: MessageBus;
private readonly policyEngine: PolicyEngine;
private readonly policyUpdateConfirmationRequest:
| PolicyUpdateConfirmationRequest
| undefined;
private readonly outputSettings: OutputSettings;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
@@ -672,6 +686,7 @@ export class Config {
private readonly experimentalJitContext: boolean;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
private readonly planDirectory?: string;
private readonly modelSteering: boolean;
private contextManager?: ContextManager;
private terminalBackground: string | undefined = undefined;
@@ -761,6 +776,7 @@ export class Config {
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? false;
this.planDirectory = params.planDirectory;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
this.skillsSupport = params.skillsSupport ?? true;
this.disabledSkills = params.disabledSkills ?? [];
@@ -846,6 +862,8 @@ export class Config {
approvalMode:
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
});
this.policyUpdateConfirmationRequest =
params.policyUpdateConfirmationRequest;
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
this.skillManager = new SkillManager();
@@ -948,7 +966,7 @@ export class Config {
// Add plans directory to workspace context for plan file storage
if (this.planEnabled) {
const plansDir = this.storage.getProjectTempPlansDir();
const plansDir = this.getPlanDirectory();
await fs.promises.mkdir(plansDir, { recursive: true });
this.workspaceContext.addDirectory(plansDir);
}
@@ -1714,6 +1732,38 @@ export class Config {
return this.policyEngine.getApprovalMode();
}
getPolicyUpdateConfirmationRequest():
| PolicyUpdateConfirmationRequest
| undefined {
return this.policyUpdateConfirmationRequest;
}
/**
* Hot-loads workspace policies from the specified directory into the active policy engine.
* This allows applying newly accepted policies without requiring an application restart.
*
* @param policyDir The directory containing the workspace policy TOML files.
*/
async loadWorkspacePolicies(policyDir: string): Promise<void> {
const { rules, checkers } = await loadPoliciesFromToml(
[policyDir],
() => WORKSPACE_POLICY_TIER,
);
for (const rule of rules) {
this.policyEngine.addRule(rule);
}
for (const checker of checkers) {
this.policyEngine.addChecker(checker);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
(this as any).policyUpdateConfirmationRequest = undefined;
debugLogger.debug(`Workspace policies loaded from: ${policyDir}`);
}
setApprovalMode(mode: ApprovalMode): void {
if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) {
throw new Error(
@@ -1995,6 +2045,13 @@ export class Config {
return this.planEnabled;
}
getPlanDirectory(): string {
if (this.planDirectory) {
return path.resolve(this.getTargetDir(), this.planDirectory);
}
return this.storage.getProjectTempPlansDir();
}
getApprovedPlanPath(): string | undefined {
return this.approvedPlanPath;
}
+8
View File
@@ -93,6 +93,10 @@ export class Storage {
);
}
static getPolicyIntegrityStoragePath(): string {
return path.join(Storage.getGlobalGeminiDir(), 'policy_integrity.json');
}
private static getSystemConfigDir(): string {
if (os.platform() === 'darwin') {
return '/Library/Application Support/GeminiCli';
@@ -136,6 +140,10 @@ export class Storage {
return path.join(tempDir, identifier);
}
getWorkspacePoliciesDir(): string {
return path.join(this.getGeminiDir(), 'policies');
}
ensureProjectTempDirExists(): void {
fs.mkdirSync(this.getProjectTempDir(), { recursive: true });
}
+99
View File
@@ -114,6 +114,10 @@ describe('Core System Prompt (prompts.ts)', () => {
}),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
getPlanDirectory: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
getPolicyEngine: vi.fn().mockReturnValue({
getRules: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;
});
@@ -401,6 +405,10 @@ describe('Core System Prompt (prompts.ts)', () => {
getSkills: vi.fn().mockReturnValue([]),
}),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
getPlanDirectory: vi.fn().mockReturnValue('/tmp/project-temp/plans'),
getPolicyEngine: vi.fn().mockReturnValue({
getRules: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;
const prompt = getCoreSystemPrompt(testConfig);
@@ -512,6 +520,7 @@ describe('Core System Prompt (prompts.ts)', () => {
vi.mocked(mockConfig.storage.getProjectTempPlansDir).mockReturnValue(
'/tmp/plans',
);
vi.mocked(mockConfig.getPlanDirectory).mockReturnValue('/tmp/plans');
});
it('should include approved plan path when set in config', () => {
@@ -554,6 +563,96 @@ describe('Core System Prompt (prompts.ts)', () => {
});
});
describe('Dynamic Plan Mode Tools', () => {
beforeEach(() => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
// Default: no extra rules
mockConfig.getPolicyEngine = vi.fn().mockReturnValue({
getRules: vi.fn().mockReturnValue([]),
});
// Ensure write_file is "enabled" in registry but not in PLAN_MODE_TOOLS by default
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([
'write_file',
'glob',
'replace',
]);
});
it('should NOT include write_file by default in Plan Mode', () => {
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('<tool>`glob`</tool>');
expect(prompt).not.toContain('<tool>`write_file`</tool>');
});
it('should include write_file if allowed by a high-priority user policy', () => {
vi.mocked(mockConfig.getPolicyEngine().getRules).mockReturnValue([
{
toolName: 'write_file',
decision: 'allow',
priority: 2.1, // Tier 2 (User)
modes: [ApprovalMode.PLAN],
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('<tool>`write_file`</tool>');
});
it('should include write_file if allowed by a high-priority policy with global mode', () => {
vi.mocked(mockConfig.getPolicyEngine().getRules).mockReturnValue([
{
toolName: 'write_file',
decision: 'allow',
priority: 2.1,
modes: undefined, // Applies to all modes
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('<tool>`write_file`</tool>');
});
it('should NOT include write_file if allowed by a low-priority policy (below Plan Mode restriction)', () => {
vi.mocked(mockConfig.getPolicyEngine().getRules).mockReturnValue([
{
toolName: 'write_file',
decision: 'allow',
priority: 1.05, // Lower than 1.06 (Default Plan Mode Restriction)
modes: [ApprovalMode.PLAN],
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('<tool>`write_file`</tool>');
});
it('should include multiple tools if allowed by policy', () => {
// This simulates toml-loader flattening ["write_file", "replace"] into two rules
vi.mocked(mockConfig.getPolicyEngine().getRules).mockReturnValue([
{
toolName: 'write_file',
decision: 'allow',
priority: 2.1,
modes: [ApprovalMode.PLAN],
},
{
toolName: 'replace',
decision: 'allow',
priority: 2.1,
modes: [ApprovalMode.PLAN],
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('<tool>`write_file`</tool>');
expect(prompt).toContain('<tool>`replace`</tool>');
});
});
describe('Platform-specific and Background Process instructions', () => {
it('should include Windows-specific shell efficiency commands on win32', () => {
mockPlatform('win32');
+1
View File
@@ -17,6 +17,7 @@ export * from './policy/types.js';
export * from './policy/policy-engine.js';
export * from './policy/toml-loader.js';
export * from './policy/config.js';
export * from './policy/integrity.js';
export * from './confirmation-bus/types.js';
export * from './confirmation-bus/message-bus.js';
+26 -26
View File
@@ -169,7 +169,7 @@ describe('createPolicyEngineConfig', () => {
r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeDefined();
expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow
expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow
});
it('should deny tools in tools.exclude', async () => {
@@ -188,7 +188,7 @@ describe('createPolicyEngineConfig', () => {
r.decision === PolicyDecision.DENY,
);
expect(rule).toBeDefined();
expect(rule?.priority).toBeCloseTo(2.4, 5); // Command line exclude
expect(rule?.priority).toBeCloseTo(3.4, 5); // Command line exclude
});
it('should allow tools from allowed MCP servers', async () => {
@@ -206,7 +206,7 @@ describe('createPolicyEngineConfig', () => {
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeDefined();
expect(rule?.priority).toBe(2.1); // MCP allowed server
expect(rule?.priority).toBe(3.1); // MCP allowed server
});
it('should deny tools from excluded MCP servers', async () => {
@@ -224,7 +224,7 @@ describe('createPolicyEngineConfig', () => {
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
);
expect(rule).toBeDefined();
expect(rule?.priority).toBe(2.9); // MCP excluded server
expect(rule?.priority).toBe(3.9); // MCP excluded server
});
it('should allow tools from trusted MCP servers', async () => {
@@ -251,7 +251,7 @@ describe('createPolicyEngineConfig', () => {
r.decision === PolicyDecision.ALLOW,
);
expect(trustedRule).toBeDefined();
expect(trustedRule?.priority).toBe(2.2); // MCP trusted server
expect(trustedRule?.priority).toBe(3.2); // MCP trusted server
// Untrusted server should not have an allow rule
const untrustedRule = config.rules?.find(
@@ -288,7 +288,7 @@ describe('createPolicyEngineConfig', () => {
r.decision === PolicyDecision.ALLOW,
);
expect(allowedRule).toBeDefined();
expect(allowedRule?.priority).toBe(2.1); // MCP allowed server
expect(allowedRule?.priority).toBe(3.1); // MCP allowed server
// Check trusted server
const trustedRule = config.rules?.find(
@@ -297,7 +297,7 @@ describe('createPolicyEngineConfig', () => {
r.decision === PolicyDecision.ALLOW,
);
expect(trustedRule).toBeDefined();
expect(trustedRule?.priority).toBe(2.2); // MCP trusted server
expect(trustedRule?.priority).toBe(3.2); // MCP trusted server
// Check excluded server
const excludedRule = config.rules?.find(
@@ -306,7 +306,7 @@ describe('createPolicyEngineConfig', () => {
r.decision === PolicyDecision.DENY,
);
expect(excludedRule).toBeDefined();
expect(excludedRule?.priority).toBe(2.9); // MCP excluded server
expect(excludedRule?.priority).toBe(3.9); // MCP excluded server
});
it('should allow all tools in YOLO mode', async () => {
@@ -387,11 +387,11 @@ describe('createPolicyEngineConfig', () => {
);
expect(serverDenyRule).toBeDefined();
expect(serverDenyRule?.priority).toBe(2.9); // MCP excluded server
expect(serverDenyRule?.priority).toBe(3.9); // MCP excluded server
expect(toolAllowRule).toBeDefined();
expect(toolAllowRule?.priority).toBeCloseTo(2.3, 5); // Command line allow
expect(toolAllowRule?.priority).toBeCloseTo(3.3, 5); // Command line allow
// Server deny (2.9) has higher priority than tool allow (2.3),
// Server deny (3.9) has higher priority than tool allow (3.3),
// so server deny wins (this is expected behavior - server-level blocks are security critical)
});
@@ -424,7 +424,7 @@ describe('createPolicyEngineConfig', () => {
expect(serverAllowRule).toBeDefined();
expect(toolDenyRule).toBeDefined();
// Command line exclude (2.4) has higher priority than MCP server trust (2.2)
// Command line exclude (3.4) has higher priority than MCP server trust (3.2)
// This is the correct behavior - specific exclusions should beat general server trust
expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!);
});
@@ -432,16 +432,16 @@ describe('createPolicyEngineConfig', () => {
it('should handle complex priority scenarios correctly', async () => {
const settings: PolicySettings = {
tools: {
allowed: ['my-server__tool1', 'other-tool'], // Priority 2.3
exclude: ['my-server__tool2', 'glob'], // Priority 2.4
allowed: ['my-server__tool1', 'other-tool'], // Priority 3.3
exclude: ['my-server__tool2', 'glob'], // Priority 3.4
},
mcp: {
allowed: ['allowed-server'], // Priority 2.1
excluded: ['excluded-server'], // Priority 2.9
allowed: ['allowed-server'], // Priority 3.1
excluded: ['excluded-server'], // Priority 3.9
},
mcpServers: {
'trusted-server': {
trust: true, // Priority 90 -> 2.2
trust: true, // Priority 90 -> 3.2
},
},
};
@@ -517,7 +517,7 @@ describe('createPolicyEngineConfig', () => {
expect(globDenyRule).toBeDefined();
expect(globAllowRule).toBeDefined();
// Deny from settings (user tier)
expect(globDenyRule!.priority).toBeCloseTo(2.4, 5); // Command line exclude
expect(globDenyRule!.priority).toBeCloseTo(3.4, 5); // Command line exclude
// Allow from default TOML: 1 + 50/1000 = 1.05
expect(globAllowRule!.priority).toBeCloseTo(1.05, 5);
@@ -530,11 +530,11 @@ describe('createPolicyEngineConfig', () => {
}))
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
// Check that the highest priority items are the excludes (user tier: 2.4 and 2.9)
// Check that the highest priority items are the excludes (user tier: 3.4 and 3.9)
const highestPriorityExcludes = priorities?.filter(
(p) =>
Math.abs(p.priority! - 2.4) < 0.01 ||
Math.abs(p.priority! - 2.9) < 0.01,
Math.abs(p.priority! - 3.4) < 0.01 ||
Math.abs(p.priority! - 3.9) < 0.01,
);
expect(
highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY),
@@ -626,7 +626,7 @@ describe('createPolicyEngineConfig', () => {
r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY,
);
expect(excludeRule).toBeDefined();
expect(excludeRule?.priority).toBeCloseTo(2.4, 5); // Command line exclude
expect(excludeRule?.priority).toBeCloseTo(3.4, 5); // Command line exclude
});
it('should support argsPattern in policy rules', async () => {
@@ -733,8 +733,8 @@ priority = 150
r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeDefined();
// Priority 150 in user tier → 2.150
expect(rule?.priority).toBeCloseTo(2.15, 5);
// Priority 150 in user tier → 3.150
expect(rule?.priority).toBeCloseTo(3.15, 5);
expect(rule?.argsPattern).toBeInstanceOf(RegExp);
expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true);
expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true);
@@ -1046,7 +1046,7 @@ name = "invalid-name"
r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeDefined();
expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow
expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow
vi.doUnmock('node:fs/promises');
});
@@ -1188,7 +1188,7 @@ modes = ["plan"]
r.modes?.includes(ApprovalMode.PLAN),
);
expect(subagentRule).toBeDefined();
expect(subagentRule?.priority).toBeCloseTo(2.1, 5);
expect(subagentRule?.priority).toBeCloseTo(3.1, 5);
vi.doUnmock('node:fs/promises');
});
+69 -44
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -31,6 +31,7 @@ import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
import { isDirectorySecure } from '../utils/security.js';
import { GEMINI_DIR } from '../utils/paths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -38,47 +39,55 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
// Policy tier constants for priority calculation
export const DEFAULT_POLICY_TIER = 1;
export const USER_POLICY_TIER = 2;
export const ADMIN_POLICY_TIER = 3;
export const WORKSPACE_POLICY_TIER = 2;
export const USER_POLICY_TIER = 3;
export const ADMIN_POLICY_TIER = 4;
/**
* Gets the list of directories to search for policy files, in order of decreasing priority
* (Admin -> User -> Default).
* Gets the list of directories to search for policy files, in order of increasing priority
* (Default -> User -> Project -> Admin).
*
* @param defaultPoliciesDir Optional path to a directory containing default policies.
* @param policyPaths Optional user-provided policy paths (from --policy flag).
* When provided, these replace the default user policies directory.
* @param workspacePoliciesDir Optional path to a directory containing workspace policies.
*/
export function getPolicyDirectories(
defaultPoliciesDir?: string,
policyPaths?: string[],
workspacePoliciesDir?: string,
): string[] {
const dirs: string[] = [];
const dirs = [];
// Default tier (lowest priority)
dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR);
// Admin tier (highest priority)
dirs.push(Storage.getSystemPoliciesDir());
// User tier (middle priority)
// User tier (second highest priority)
if (policyPaths && policyPaths.length > 0) {
dirs.push(...policyPaths);
} else {
dirs.push(Storage.getUserPoliciesDir());
}
// Admin tier (highest priority)
dirs.push(Storage.getSystemPoliciesDir());
// Workspace Tier (third highest)
if (workspacePoliciesDir) {
dirs.push(workspacePoliciesDir);
}
// Reverse so highest priority (Admin) is first
return dirs.reverse();
// Default tier (lowest priority)
dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR);
return dirs;
}
/**
* Determines the policy tier (1=default, 2=user, 3=admin) for a given directory.
* Determines the policy tier (1=default, 2=user, 3=workspace, 4=admin) for a given directory.
* This is used by the TOML loader to assign priority bands.
*/
export function getPolicyTier(
dir: string,
defaultPoliciesDir?: string,
workspacePoliciesDir?: string,
): number {
const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir();
@@ -99,6 +108,12 @@ export function getPolicyTier(
if (normalizedDir === normalizedUser) {
return USER_POLICY_TIER;
}
if (
workspacePoliciesDir &&
normalizedDir === path.resolve(workspacePoliciesDir)
) {
return WORKSPACE_POLICY_TIER;
}
if (normalizedDir === normalizedAdmin) {
return ADMIN_POLICY_TIER;
}
@@ -153,12 +168,17 @@ export async function createPolicyEngineConfig(
settings: PolicySettings,
approvalMode: ApprovalMode,
defaultPoliciesDir?: string,
workspaceDir?: string,
): Promise<PolicyEngineConfig> {
const workspacePoliciesDir = workspaceDir
? path.join(workspaceDir, GEMINI_DIR, 'policies')
: undefined;
const policyDirs = getPolicyDirectories(
defaultPoliciesDir,
settings.policyPaths,
workspacePoliciesDir,
);
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
const normalizedAdminPoliciesDir = path.resolve(
@@ -171,7 +191,11 @@ export async function createPolicyEngineConfig(
checkers: tomlCheckers,
errors,
} = await loadPoliciesFromToml(securePolicyDirs, (p) => {
const tier = getPolicyTier(p, defaultPoliciesDir);
const tier = getPolicyTier(
p,
defaultPoliciesDir,
settings.workspacePoliciesDir,
);
// If it's a user-provided path that isn't already categorized as ADMIN,
// treat it as USER tier.
@@ -207,19 +231,20 @@ export async function createPolicyEngineConfig(
//
// Priority bands (tiers):
// - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
// - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
// - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
// - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
// - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
// - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
//
// This ensures Admin > User > Default hierarchy is always preserved,
// This ensures Admin > User > Workspace > Default hierarchy is always preserved,
// while allowing user-specified priorities to work within each tier.
//
// Settings-based and dynamic rules (all in user tier 2.x):
// 2.95: Tools that the user has selected as "Always Allow" in the interactive UI
// 2.9: MCP servers excluded list (security: persistent server blocks)
// 2.4: Command line flag --exclude-tools (explicit temporary blocks)
// 2.3: Command line flag --allowed-tools (explicit temporary allows)
// 2.2: MCP servers with trust=true (persistent trusted servers)
// 2.1: MCP servers allowed list (persistent general server allows)
// Settings-based and dynamic rules (all in user tier 3.x):
// 3.95: Tools that the user has selected as "Always Allow" in the interactive UI
// 3.9: MCP servers excluded list (security: persistent server blocks)
// 3.4: Command line flag --exclude-tools (explicit temporary blocks)
// 3.3: Command line flag --allowed-tools (explicit temporary allows)
// 3.2: MCP servers with trust=true (persistent trusted servers)
// 3.1: MCP servers allowed list (persistent general server allows)
//
// TOML policy priorities (before transformation):
// 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
@@ -230,33 +255,33 @@ export async function createPolicyEngineConfig(
// 999: YOLO mode allow-all (becomes 1.999 in default tier)
// MCP servers that are explicitly excluded in settings.mcp.excluded
// Priority: 2.9 (highest in user tier for security - persistent server blocks)
// Priority: 3.9 (highest in user tier for security - persistent server blocks)
if (settings.mcp?.excluded) {
for (const serverName of settings.mcp.excluded) {
rules.push({
toolName: `${serverName}__*`,
decision: PolicyDecision.DENY,
priority: 2.9,
priority: 3.9,
source: 'Settings (MCP Excluded)',
});
}
}
// Tools that are explicitly excluded in the settings.
// Priority: 2.4 (user tier - explicit temporary blocks)
// Priority: 3.4 (user tier - explicit temporary blocks)
if (settings.tools?.exclude) {
for (const tool of settings.tools.exclude) {
rules.push({
toolName: tool,
decision: PolicyDecision.DENY,
priority: 2.4,
priority: 3.4,
source: 'Settings (Tools Excluded)',
});
}
}
// Tools that are explicitly allowed in the settings.
// Priority: 2.3 (user tier - explicit temporary allows)
// Priority: 3.3 (user tier - explicit temporary allows)
if (settings.tools?.allowed) {
for (const tool of settings.tools.allowed) {
// Check for legacy format: toolName(args)
@@ -276,7 +301,7 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: 2.3,
priority: 3.3,
argsPattern: new RegExp(pattern),
source: 'Settings (Tools Allowed)',
});
@@ -288,7 +313,7 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: 2.3,
priority: 3.3,
source: 'Settings (Tools Allowed)',
});
}
@@ -300,7 +325,7 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: 2.3,
priority: 3.3,
source: 'Settings (Tools Allowed)',
});
}
@@ -308,7 +333,7 @@ export async function createPolicyEngineConfig(
}
// MCP servers that are trusted in the settings.
// Priority: 2.2 (user tier - persistent trusted servers)
// Priority: 3.2 (user tier - persistent trusted servers)
if (settings.mcpServers) {
for (const [serverName, serverConfig] of Object.entries(
settings.mcpServers,
@@ -319,7 +344,7 @@ export async function createPolicyEngineConfig(
rules.push({
toolName: `${serverName}__*`,
decision: PolicyDecision.ALLOW,
priority: 2.2,
priority: 3.2,
source: 'Settings (MCP Trusted)',
});
}
@@ -327,13 +352,13 @@ export async function createPolicyEngineConfig(
}
// MCP servers that are explicitly allowed in settings.mcp.allowed
// Priority: 2.1 (user tier - persistent general server allows)
// Priority: 3.1 (user tier - persistent general server allows)
if (settings.mcp?.allowed) {
for (const serverName of settings.mcp.allowed) {
rules.push({
toolName: `${serverName}__*`,
decision: PolicyDecision.ALLOW,
priority: 2.1,
priority: 3.1,
source: 'Settings (MCP Allowed)',
});
}
@@ -380,10 +405,10 @@ export function createPolicyUpdater(
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// User tier (3) + high priority (950/1000) = 3.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
// but still lose to admin policies (4.xxx) and settings excludes (300)
priority: 3.95,
argsPattern: new RegExp(pattern),
source: 'Dynamic (Confirmed)',
});
@@ -405,10 +430,10 @@ export function createPolicyUpdater(
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// User tier (3) + high priority (950/1000) = 3.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
// but still lose to admin policies (4.xxx) and settings excludes (300)
priority: 3.95,
argsPattern,
source: 'Dynamic (Confirmed)',
});
+249
View File
@@ -0,0 +1,249 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { Storage } from '../config/storage.js';
describe('PolicyIntegrityManager', () => {
let integrityManager: PolicyIntegrityManager;
let tempDir: string;
let integrityStoragePath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-'));
integrityStoragePath = path.join(tempDir, 'policy_integrity.json');
vi.spyOn(Storage, 'getPolicyIntegrityStoragePath').mockReturnValue(
integrityStoragePath,
);
integrityManager = new PolicyIntegrityManager();
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('checkIntegrity', () => {
it('should return NEW if no stored hash', async () => {
const policyDir = path.join(tempDir, 'policies');
await fs.mkdir(policyDir);
await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA');
const result = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
expect(result.status).toBe(IntegrityStatus.NEW);
expect(result.hash).toBeDefined();
expect(result.hash).toHaveLength(64);
expect(result.fileCount).toBe(1);
});
it('should return MATCH if stored hash matches', async () => {
const policyDir = path.join(tempDir, 'policies');
await fs.mkdir(policyDir);
await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA');
// First run to get the hash
const resultNew = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
const currentHash = resultNew.hash;
// Save the hash to mock storage
await fs.writeFile(
integrityStoragePath,
JSON.stringify({ 'workspace:id': currentHash }),
);
const result = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
expect(result.status).toBe(IntegrityStatus.MATCH);
expect(result.hash).toBe(currentHash);
});
it('should return MISMATCH if stored hash differs', async () => {
const policyDir = path.join(tempDir, 'policies');
await fs.mkdir(policyDir);
await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA');
const resultNew = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
const currentHash = resultNew.hash;
// Save a different hash
await fs.writeFile(
integrityStoragePath,
JSON.stringify({ 'workspace:id': 'different_hash' }),
);
const result = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
expect(result.status).toBe(IntegrityStatus.MISMATCH);
expect(result.hash).toBe(currentHash);
});
it('should result in different hash if filename changes', async () => {
const policyDir1 = path.join(tempDir, 'policies1');
await fs.mkdir(policyDir1);
await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA');
const result1 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir1,
);
const policyDir2 = path.join(tempDir, 'policies2');
await fs.mkdir(policyDir2);
await fs.writeFile(path.join(policyDir2, 'b.toml'), 'contentA');
const result2 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir2,
);
expect(result1.hash).not.toBe(result2.hash);
});
it('should result in different hash if content changes', async () => {
const policyDir = path.join(tempDir, 'policies');
await fs.mkdir(policyDir);
await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA');
const result1 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentB');
const result2 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir,
);
expect(result1.hash).not.toBe(result2.hash);
});
it('should be deterministic (sort order)', async () => {
const policyDir1 = path.join(tempDir, 'policies1');
await fs.mkdir(policyDir1);
await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA');
await fs.writeFile(path.join(policyDir1, 'b.toml'), 'contentB');
const result1 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir1,
);
// Re-read with same files but they might be in different order in readdir
// PolicyIntegrityManager should sort them.
const result2 = await integrityManager.checkIntegrity(
'workspace',
'id',
policyDir1,
);
expect(result1.hash).toBe(result2.hash);
});
it('should handle multiple projects correctly', async () => {
const dirA = path.join(tempDir, 'dirA');
await fs.mkdir(dirA);
await fs.writeFile(path.join(dirA, 'p.toml'), 'contentA');
const dirB = path.join(tempDir, 'dirB');
await fs.mkdir(dirB);
await fs.writeFile(path.join(dirB, 'p.toml'), 'contentB');
const { hash: hashA } = await integrityManager.checkIntegrity(
'workspace',
'idA',
dirA,
);
const { hash: hashB } = await integrityManager.checkIntegrity(
'workspace',
'idB',
dirB,
);
// Save to storage
await fs.writeFile(
integrityStoragePath,
JSON.stringify({
'workspace:idA': hashA,
'workspace:idB': 'oldHashB',
}),
);
// Project A should match
const resultA = await integrityManager.checkIntegrity(
'workspace',
'idA',
dirA,
);
expect(resultA.status).toBe(IntegrityStatus.MATCH);
expect(resultA.hash).toBe(hashA);
// Project B should mismatch
const resultB = await integrityManager.checkIntegrity(
'workspace',
'idB',
dirB,
);
expect(resultB.status).toBe(IntegrityStatus.MISMATCH);
expect(resultB.hash).toBe(hashB);
});
});
describe('acceptIntegrity', () => {
it('should save the hash to storage', async () => {
await integrityManager.acceptIntegrity('workspace', 'id', 'hash123');
const stored = JSON.parse(
await fs.readFile(integrityStoragePath, 'utf-8'),
);
expect(stored['workspace:id']).toBe('hash123');
});
it('should update existing hash', async () => {
await fs.writeFile(
integrityStoragePath,
JSON.stringify({ 'other:id': 'otherhash' }),
);
await integrityManager.acceptIntegrity('workspace', 'id', 'hash123');
const stored = JSON.parse(
await fs.readFile(integrityStoragePath, 'utf-8'),
);
expect(stored['other:id']).toBe('otherhash');
expect(stored['workspace:id']).toBe('hash123');
});
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Storage } from '../config/storage.js';
import { readPolicyFiles } from './toml-loader.js';
import { debugLogger } from '../utils/debugLogger.js';
export enum IntegrityStatus {
MATCH = 'MATCH',
MISMATCH = 'MISMATCH',
NEW = 'NEW',
}
export interface IntegrityResult {
status: IntegrityStatus;
hash: string;
fileCount: number;
}
interface StoredIntegrityData {
[key: string]: string; // key = scope:identifier, value = hash
}
export class PolicyIntegrityManager {
/**
* Checks the integrity of policies in a given directory against the stored hash.
*
* @param scope The scope of the policy (e.g., 'project', 'user').
* @param identifier A unique identifier for the policy scope (e.g., project path).
* @param policyDir The directory containing the policy files.
* @returns IntegrityResult indicating if the current policies match the stored hash.
*/
async checkIntegrity(
scope: string,
identifier: string,
policyDir: string,
): Promise<IntegrityResult> {
const { hash: currentHash, fileCount } =
await PolicyIntegrityManager.calculateIntegrityHash(policyDir);
const storedData = await this.loadIntegrityData();
const key = this.getIntegrityKey(scope, identifier);
const storedHash = storedData[key];
if (!storedHash) {
return { status: IntegrityStatus.NEW, hash: currentHash, fileCount };
}
if (storedHash === currentHash) {
return { status: IntegrityStatus.MATCH, hash: currentHash, fileCount };
}
return { status: IntegrityStatus.MISMATCH, hash: currentHash, fileCount };
}
/**
* Accepts and persists the current integrity hash for a given policy scope.
*
* @param scope The scope of the policy.
* @param identifier A unique identifier for the policy scope (e.g., project path).
* @param hash The hash to persist.
*/
async acceptIntegrity(
scope: string,
identifier: string,
hash: string,
): Promise<void> {
const storedData = await this.loadIntegrityData();
const key = this.getIntegrityKey(scope, identifier);
storedData[key] = hash;
await this.saveIntegrityData(storedData);
}
/**
* Calculates a SHA-256 hash of all policy files in the directory.
* The hash includes the relative file path and content to detect renames and modifications.
*
* @param policyDir The directory containing the policy files.
* @returns The calculated hash and file count
*/
private static async calculateIntegrityHash(
policyDir: string,
): Promise<{ hash: string; fileCount: number }> {
try {
const files = await readPolicyFiles(policyDir);
// Sort files by path to ensure deterministic hashing
files.sort((a, b) => a.path.localeCompare(b.path));
const hash = crypto.createHash('sha256');
for (const file of files) {
const relativePath = path.relative(policyDir, file.path);
// Include relative path and content in the hash
hash.update(relativePath);
hash.update('\0'); // Separator
hash.update(file.content);
hash.update('\0'); // Separator
}
return { hash: hash.digest('hex'), fileCount: files.length };
} catch (error) {
debugLogger.error('Failed to calculate policy integrity hash', error);
// Return a unique hash (random) to force a mismatch if calculation fails?
// Or throw? Throwing is better so we don't accidentally accept/deny corrupted state.
throw error;
}
}
private getIntegrityKey(scope: string, identifier: string): string {
return `${scope}:${identifier}`;
}
private async loadIntegrityData(): Promise<StoredIntegrityData> {
const storagePath = Storage.getPolicyIntegrityStoragePath();
try {
const content = await fs.readFile(storagePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return JSON.parse(content) as StoredIntegrityData;
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as Record<string, unknown>)['code'] === 'ENOENT'
) {
return {};
}
debugLogger.error('Failed to load policy integrity data', error);
return {};
}
}
private async saveIntegrityData(data: StoredIntegrityData): Promise<void> {
const storagePath = Storage.getPolicyIntegrityStoragePath();
try {
await fs.mkdir(path.dirname(storagePath), { recursive: true });
await fs.writeFile(storagePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (error) {
debugLogger.error('Failed to save policy integrity data', error);
throw error;
}
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ describe('createPolicyUpdater', () => {
const rules = policyEngine.getRules();
const addedRule = rules.find((r) => r.toolName === toolName);
expect(addedRule).toBeDefined();
expect(addedRule?.priority).toBe(2.95);
expect(addedRule?.priority).toBe(3.95);
expect(addedRule?.argsPattern).toEqual(
new RegExp(`"command":"git\\ status(?:[\\s"]|\\\\")`),
);
+11 -10
View File
@@ -5,19 +5,20 @@
#
# Priority bands (tiers):
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
#
# This ensures Admin > User > Default hierarchy is always preserved,
# This ensures Admin > User > Workspace > Default hierarchy is always preserved,
# while allowing user-specified priorities to work within each tier.
#
# Settings-based and dynamic rules (all in user tier 2.x):
# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI
# 2.9: MCP servers excluded list (security: persistent server blocks)
# 2.4: Command line flag --exclude-tools (explicit temporary blocks)
# 2.3: Command line flag --allowed-tools (explicit temporary allows)
# 2.2: MCP servers with trust=true (persistent trusted servers)
# 2.1: MCP servers allowed list (persistent general server allows)
# Settings-based and dynamic rules (all in user tier 3.x):
# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI
# 3.9: MCP servers excluded list (security: persistent server blocks)
# 3.4: Command line flag --exclude-tools (explicit temporary blocks)
# 3.3: Command line flag --allowed-tools (explicit temporary allows)
# 3.2: MCP servers with trust=true (persistent trusted servers)
# 3.1: MCP servers allowed list (persistent general server allows)
#
# TOML policy priorities (before transformation):
# 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
@@ -5,19 +5,20 @@
#
# Priority bands (tiers):
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
#
# This ensures Admin > User > Default hierarchy is always preserved,
# This ensures Admin > User > Workspace > Default hierarchy is always preserved,
# while allowing user-specified priorities to work within each tier.
#
# Settings-based and dynamic rules (all in user tier 2.x):
# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI
# 2.9: MCP servers excluded list (security: persistent server blocks)
# 2.4: Command line flag --exclude-tools (explicit temporary blocks)
# 2.3: Command line flag --allowed-tools (explicit temporary allows)
# 2.2: MCP servers with trust=true (persistent trusted servers)
# 2.1: MCP servers allowed list (persistent general server allows)
# Settings-based and dynamic rules (all in user tier 3.x):
# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI
# 3.9: MCP servers excluded list (security: persistent server blocks)
# 3.4: Command line flag --exclude-tools (explicit temporary blocks)
# 3.3: Command line flag --allowed-tools (explicit temporary allows)
# 3.2: MCP servers with trust=true (persistent trusted servers)
# 3.1: MCP servers allowed list (persistent general server allows)
#
# TOML policy priorities (before transformation):
# 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
+11 -10
View File
@@ -5,19 +5,20 @@
#
# Priority bands (tiers):
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
#
# This ensures Admin > User > Default hierarchy is always preserved,
# This ensures Admin > User > Workspace > Default hierarchy is always preserved,
# while allowing user-specified priorities to work within each tier.
#
# Settings-based and dynamic rules (all in user tier 2.x):
# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI
# 2.9: MCP servers excluded list (security: persistent server blocks)
# 2.4: Command line flag --exclude-tools (explicit temporary blocks)
# 2.3: Command line flag --allowed-tools (explicit temporary allows)
# 2.2: MCP servers with trust=true (persistent trusted servers)
# 2.1: MCP servers allowed list (persistent general server allows)
# Settings-based and dynamic rules (all in user tier 3.x):
# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI
# 3.9: MCP servers excluded list (security: persistent server blocks)
# 3.4: Command line flag --exclude-tools (explicit temporary blocks)
# 3.3: Command line flag --allowed-tools (explicit temporary allows)
# 3.2: MCP servers with trust=true (persistent trusted servers)
# 3.1: MCP servers allowed list (persistent general server allows)
#
# TOML policy priorities (before transformation):
# 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
+11 -10
View File
@@ -5,19 +5,20 @@
#
# Priority bands (tiers):
# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100)
# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100)
# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100)
# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100)
#
# This ensures Admin > User > Default hierarchy is always preserved,
# This ensures Admin > User > Workspace > Default hierarchy is always preserved,
# while allowing user-specified priorities to work within each tier.
#
# Settings-based and dynamic rules (all in user tier 2.x):
# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI
# 2.9: MCP servers excluded list (security: persistent server blocks)
# 2.4: Command line flag --exclude-tools (explicit temporary blocks)
# 2.3: Command line flag --allowed-tools (explicit temporary allows)
# 2.2: MCP servers with trust=true (persistent trusted servers)
# 2.1: MCP servers allowed list (persistent general server allows)
# Settings-based and dynamic rules (all in user tier 3.x):
# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI
# 3.9: MCP servers excluded list (security: persistent server blocks)
# 3.4: Command line flag --exclude-tools (explicit temporary blocks)
# 3.3: Command line flag --allowed-tools (explicit temporary allows)
# 3.2: MCP servers with trust=true (persistent trusted servers)
# 3.1: MCP servers allowed list (persistent general server allows)
#
# TOML policy priorities (before transformation):
# 10: Write tools default to ASK_USER (becomes 1.010 in default tier)
@@ -2373,4 +2373,90 @@ describe('PolicyEngine', () => {
);
});
});
describe('Relative vs Absolute Path Regex (Plan Mode Trap)', () => {
const relativePath = 'conductor/product.md';
const absolutePath = '/usr/local/home/user/conductor/product.md';
it('should FAIL with the brittle regex on relative paths', async () => {
// The brittle regex used by the user: .*/conductor/
// This requires a preceding slash or characters before 'conductor'
const brittleRegex = /"(?:file_path|path)":".*\/conductor\/[^"]+"/;
const rules: PolicyRule[] = [
{
toolName: 'write_file',
argsPattern: brittleRegex,
decision: PolicyDecision.ALLOW,
priority: 100,
},
];
// Default behavior (simulating Plan Mode Deny fallback)
const engine = new PolicyEngine({
rules,
defaultDecision: PolicyDecision.DENY,
});
// Relative path fails matching because it starts with 'c', not '/'
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: relativePath } },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
// Absolute path matches
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: absolutePath } },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should PASS with the robust regex on relative paths', async () => {
// The robust regex: (?:.*/)?conductor/
// Makes the prefix optional
const robustRegex = /"(?:file_path|path)":"(?:.*\/)?conductor\/[^"]+"/;
const rules: PolicyRule[] = [
{
toolName: 'write_file',
argsPattern: robustRegex,
decision: PolicyDecision.ALLOW,
priority: 100,
},
];
const engine = new PolicyEngine({
rules,
defaultDecision: PolicyDecision.DENY,
});
// Relative path matches
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: relativePath } },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Absolute path matches
expect(
(
await engine.check(
{ name: 'write_file', args: { file_path: absolutePath } },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
});
});
});
+11 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -319,6 +319,16 @@ export class PolicyEngine {
`[PolicyEngine.check] toolCall.name: ${toolCall.name}, stringifiedArgs: ${stringifiedArgs}`,
);
if (toolCall.name === 'write_file') {
for (const rule of this.rules) {
if (!rule.toolName || rule.toolName === 'write_file') {
debugLogger.debug(
` - [Rule Dump] Tool=${rule.toolName}, P=${rule.priority}, Decision=${rule.decision}, Source=${rule.source}, Pattern=${rule.argsPattern}`,
);
}
}
}
// Check for shell commands upfront to handle splitting
let isShellCommand = false;
let command: string | undefined;
+11 -7
View File
@@ -228,14 +228,18 @@ modes = ["autoEdit"]
`,
);
const getPolicyTier = (_dir: string) => 2; // Tier 2
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
const getPolicyTier2 = (_dir: string) => 2; // Tier 2
const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('tier2-tool');
expect(result.rules[0].modes).toEqual(['autoEdit']);
expect(result.rules[0].source).toBe('User: tier2.toml');
expect(result.errors).toHaveLength(0);
expect(result2.rules).toHaveLength(1);
expect(result2.rules[0].toolName).toBe('tier2-tool');
expect(result2.rules[0].modes).toEqual(['autoEdit']);
expect(result2.rules[0].source).toBe('Workspace: tier2.toml');
const getPolicyTier3 = (_dir: string) => 3; // Tier 3
const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3);
expect(result3.rules[0].source).toBe('User: tier2.toml');
expect(result3.errors).toHaveLength(0);
});
it('should handle TOML parse errors', async () => {
+57 -29
View File
@@ -105,7 +105,7 @@ export type PolicyFileErrorType =
export interface PolicyFileError {
filePath: string;
fileName: string;
tier: 'default' | 'user' | 'admin';
tier: 'default' | 'user' | 'workspace' | 'admin';
ruleIndex?: number;
errorType: PolicyFileErrorType;
message: string;
@@ -122,13 +122,61 @@ export interface PolicyLoadResult {
errors: PolicyFileError[];
}
export interface PolicyFile {
path: string;
content: string;
}
/**
* Reads policy files from a directory or a single file.
*
* @param policyPath Path to a directory or a .toml file.
* @returns Array of PolicyFile objects.
*/
export async function readPolicyFiles(
policyPath: string,
): Promise<PolicyFile[]> {
let filesToLoad: string[] = [];
let baseDir = '';
try {
const stats = await fs.stat(policyPath);
if (stats.isDirectory()) {
baseDir = policyPath;
const dirEntries = await fs.readdir(policyPath, { withFileTypes: true });
filesToLoad = dirEntries
.filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
.map((entry) => entry.name);
} else if (stats.isFile() && policyPath.endsWith('.toml')) {
baseDir = path.dirname(policyPath);
filesToLoad = [path.basename(policyPath)];
}
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
const results: PolicyFile[] = [];
for (const file of filesToLoad) {
const filePath = path.join(baseDir, file);
const content = await fs.readFile(filePath, 'utf-8');
results.push({ path: filePath, content });
}
return results;
}
/**
* Converts a tier number to a human-readable tier name.
*/
function getTierName(tier: number): 'default' | 'user' | 'admin' {
function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' {
if (tier === 1) return 'default';
if (tier === 2) return 'user';
if (tier === 3) return 'admin';
if (tier === 2) return 'workspace';
if (tier === 3) return 'user';
if (tier === 4) return 'admin';
return 'default';
}
@@ -211,7 +259,7 @@ function transformPriority(priority: number, tier: number): number {
* 4. Collects detailed error information for any failures
*
* @param policyPaths Array of paths (directories or files) to scan for policy files
* @param getPolicyTier Function to determine tier (1-3) for a path
* @param getPolicyTier Function to determine tier (1-4) for a path
* @returns Object containing successfully parsed rules and any errors encountered
*/
export async function loadPoliciesFromToml(
@@ -226,30 +274,13 @@ export async function loadPoliciesFromToml(
const tier = getPolicyTier(p);
const tierName = getTierName(tier);
let filesToLoad: string[] = [];
let baseDir = '';
let policyFiles: PolicyFile[] = [];
try {
const stats = await fs.stat(p);
if (stats.isDirectory()) {
baseDir = p;
const dirEntries = await fs.readdir(p, { withFileTypes: true });
filesToLoad = dirEntries
.filter((entry) => entry.isFile() && entry.name.endsWith('.toml'))
.map((entry) => entry.name);
} else if (stats.isFile() && p.endsWith('.toml')) {
baseDir = path.dirname(p);
filesToLoad = [path.basename(p)];
}
// Other file types or non-.toml files are silently ignored
// for consistency with directory scanning behavior.
policyFiles = await readPolicyFiles(p);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
// Path doesn't exist, skip it (not an error)
continue;
}
errors.push({
filePath: p,
fileName: path.basename(p),
@@ -261,13 +292,10 @@ export async function loadPoliciesFromToml(
continue;
}
for (const file of filesToLoad) {
const filePath = path.join(baseDir, file);
for (const { path: filePath, content: fileContent } of policyFiles) {
const file = path.basename(filePath);
try {
// Read file
const fileContent = await fs.readFile(filePath, 'utf-8');
// Parse TOML
let parsed: unknown;
try {
+2
View File
@@ -272,7 +272,9 @@ export interface PolicySettings {
allowed?: string[];
};
mcpServers?: Record<string, { trust?: boolean }>;
// User provided policies that will replace the USER level policies in ~/.gemini/policies
policyPaths?: string[];
workspacePoliciesDir?: string;
}
export interface CheckResult {
@@ -0,0 +1,290 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import nodePath from 'node:path';
import { ApprovalMode } from './types.js';
import { isDirectorySecure } from '../utils/security.js';
// Mock dependencies
vi.mock('../utils/security.js', () => ({
isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }),
}));
describe('Workspace-Level Policies', () => {
beforeEach(async () => {
vi.resetModules();
const { Storage } = await import('../config/storage.js');
vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(
'/mock/user/policies',
);
vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(
'/mock/system/policies',
);
// Ensure security check always returns secure
vi.mocked(isDirectorySecure).mockResolvedValue({ secure: true });
});
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
vi.doUnmock('node:fs/promises');
});
it('should load workspace policies with correct priority (Tier 2)', async () => {
const workspacePoliciesDir = '/mock/workspace/policies';
const defaultPoliciesDir = '/mock/default/policies';
// Mock FS
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockStat = vi.fn(async (path: string) => {
if (typeof path === 'string' && path.startsWith('/mock/')) {
return {
isDirectory: () => true,
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(path);
});
// Mock readdir to return a policy file for each tier
const mockReaddir = vi.fn(async (path: string) => {
const normalizedPath = nodePath.normalize(path);
if (normalizedPath.endsWith('default/policies'))
return [
{
name: 'default.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.endsWith('user/policies'))
return [
{ name: 'user.toml', isFile: () => true, isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.endsWith('workspace/policies'))
return [
{
name: 'workspace.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
if (normalizedPath.endsWith('system/policies'))
return [
{ name: 'admin.toml', isFile: () => true, isDirectory: () => false },
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
// Mock readFile to return content with distinct priorities/decisions
const mockReadFile = vi.fn(async (path: string) => {
if (path.includes('default.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "allow"
priority = 10
`; // Tier 1 -> 1.010
}
if (path.includes('user.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "deny"
priority = 10
`; // Tier 3 -> 3.010
}
if (path.includes('workspace.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "allow"
priority = 10
`; // Tier 2 -> 2.010
}
if (path.includes('admin.toml')) {
return `[[rule]]
toolName = "test_tool"
decision = "deny"
priority = 10
`; // Tier 4 -> 4.010
}
return '';
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
}));
const { createPolicyEngineConfig } = await import('./config.js');
// Test 1: Workspace vs User (User should win)
const config = await createPolicyEngineConfig(
{ workspacePoliciesDir },
ApprovalMode.DEFAULT,
defaultPoliciesDir,
);
const rules = config.rules?.filter((r) => r.toolName === 'test_tool');
expect(rules).toBeDefined();
// Check for all 4 rules
const defaultRule = rules?.find((r) => r.priority === 1.01);
const workspaceRule = rules?.find((r) => r.priority === 2.01);
const userRule = rules?.find((r) => r.priority === 3.01);
const adminRule = rules?.find((r) => r.priority === 4.01);
expect(defaultRule).toBeDefined();
expect(userRule).toBeDefined();
expect(workspaceRule).toBeDefined();
expect(adminRule).toBeDefined();
// Verify Hierarchy: Admin > User > Workspace > Default
expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!);
expect(userRule!.priority).toBeGreaterThan(workspaceRule!.priority!);
expect(workspaceRule!.priority).toBeGreaterThan(defaultRule!.priority!);
});
it('should ignore workspace policies if workspacePoliciesDir is undefined', async () => {
const defaultPoliciesDir = '/mock/default/policies';
// Mock FS (simplified)
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockStat = vi.fn(async (path: string) => {
if (typeof path === 'string' && path.startsWith('/mock/')) {
return {
isDirectory: () => true,
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(path);
});
const mockReaddir = vi.fn(async (path: string) => {
const normalizedPath = nodePath.normalize(path);
if (normalizedPath.endsWith('default/policies'))
return [
{
name: 'default.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
const mockReadFile = vi.fn(
async () => `[[rule]]
toolName="t"
decision="allow"
priority=10`,
);
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
}));
const { createPolicyEngineConfig } = await import('./config.js');
const config = await createPolicyEngineConfig(
{ workspacePoliciesDir: undefined },
ApprovalMode.DEFAULT,
defaultPoliciesDir,
);
// Should only have default tier rule (1.01)
const rules = config.rules;
expect(rules).toHaveLength(1);
expect(rules![0].priority).toBe(1.01);
});
it('should load workspace policies and correctly transform to Tier 2', async () => {
const workspacePoliciesDir = '/mock/workspace/policies';
// Mock FS
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockStat = vi.fn(async (path: string) => {
if (typeof path === 'string' && path.startsWith('/mock/')) {
return {
isDirectory: () => true,
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(path);
});
const mockReaddir = vi.fn(async (path: string) => {
const normalizedPath = nodePath.normalize(path);
if (normalizedPath.endsWith('workspace/policies'))
return [
{
name: 'workspace.toml',
isFile: () => true,
isDirectory: () => false,
},
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
return [];
});
const mockReadFile = vi.fn(
async () => `[[rule]]
toolName="p_tool"
decision="allow"
priority=500`,
);
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
}));
const { createPolicyEngineConfig } = await import('./config.js');
const config = await createPolicyEngineConfig(
{ workspacePoliciesDir },
ApprovalMode.DEFAULT,
);
const rule = config.rules?.find((r) => r.toolName === 'p_tool');
expect(rule).toBeDefined();
// Workspace Tier (2) + 500/1000 = 2.5
expect(rule?.priority).toBe(2.5);
});
});
+24 -4
View File
@@ -64,9 +64,29 @@ export class PromptProvider {
const contextFilenames = getAllGeminiMdFilenames();
// --- Context Gathering ---
let planModeToolsList = PLAN_MODE_TOOLS.filter((t) =>
enabledToolNames.has(t),
)
const allowedTools = new Set<string>(PLAN_MODE_TOOLS);
// Dynamically include tools allowed by policy in Plan Mode
// The default Plan Mode restriction is priority 60 in Tier 1 (approx 1.06).
// Any rule with priority > 1.1 (e.g. Tier 2/User rules) that allows a tool in Plan Mode should make that tool visible.
const policyEngine = config.getPolicyEngine();
const rules = policyEngine.getRules();
for (const rule of rules) {
const appliesToPlan =
!rule.modes || rule.modes.includes(ApprovalMode.PLAN);
// Priority is transformed: Tier 1 (1.xxx), Tier 2 (2.xxx).
// We want to respect any user policy (Tier 2+) or high-priority default policy.
const isHighPriority = (rule.priority ?? 0) > 1.1;
const isAllowOrAsk =
rule.decision === 'allow' || rule.decision === 'ask_user';
if (appliesToPlan && isHighPriority && isAllowOrAsk && rule.toolName) {
allowedTools.add(rule.toolName);
}
}
let planModeToolsList = Array.from(allowedTools)
.filter((t) => enabledToolNames.has(t))
.map((t) => ` <tool>\`${t}\`</tool>`)
.join('\n');
@@ -172,7 +192,7 @@ export class PromptProvider {
'planningWorkflow',
() => ({
planModeToolsList,
plansDir: config.storage.getProjectTempPlansDir(),
plansDir: config.getPlanDirectory(),
approvedPlanPath: config.getApprovedPlanPath(),
}),
isPlanMode,
@@ -23,6 +23,8 @@ describe('EnterPlanModeTool', () => {
mockConfig = {
setApprovalMode: vi.fn(),
getPlanDirectory: vi.fn().mockReturnValue('/mock/plans/dir'),
validatePathAccess: vi.fn().mockReturnValue(null),
storage: {
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
} as unknown as Config['storage'],
@@ -60,7 +62,7 @@ describe('EnterPlanModeTool', () => {
expect(result.title).toBe('Enter Plan Mode');
if (result.type === 'info') {
expect(result.prompt).toBe(
'This will restrict the agent to read-only tools to allow for safe planning.',
'This will switch to Plan Mode. The agent will be primarily restricted to read-only tools, but will have write access to its designated plans directory: /mock/plans/dir',
);
}
});
@@ -101,7 +103,7 @@ describe('EnterPlanModeTool', () => {
});
describe('execute', () => {
it('should set approval mode to PLAN and return message', async () => {
it('should set approval mode to PLAN', async () => {
const invocation = tool.build({});
const result = await invocation.execute(new AbortController().signal);
@@ -113,6 +115,21 @@ describe('EnterPlanModeTool', () => {
expect(result.returnDisplay).toBe('Switching to Plan mode');
});
it('should throw error if plan directory validation fails', async () => {
const invocation = tool.build({});
mockConfig.validatePathAccess = vi
.fn()
.mockReturnValue('Path outside workspace');
await expect(
invocation.execute(new AbortController().signal),
).rejects.toThrow(
'Invalid plan directory configuration: Path outside workspace',
);
expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
});
it('should include optional reason in output display but not in llmContent', async () => {
const reason = 'Design new database schema';
const invocation = tool.build({ reason });
+10 -2
View File
@@ -97,12 +97,13 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
);
}
const plansDir = this.config.getPlanDirectory();
// ASK_USER
return {
type: 'info',
title: 'Enter Plan Mode',
prompt:
'This will restrict the agent to read-only tools to allow for safe planning.',
prompt: `This will switch to Plan Mode. The agent will be primarily restricted to read-only tools, but will have write access to its designated plans directory: ${plansDir}`,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
this.confirmationOutcome = outcome;
await this.publishPolicyUpdate(outcome);
@@ -118,6 +119,13 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
};
}
const plansDir = this.config.getPlanDirectory();
// Validate that the plan directory is safe (within workspace)
const pathError = this.config.validatePathAccess(plansDir, 'write');
if (pathError) {
throw new Error(`Invalid plan directory configuration: ${pathError}`);
}
this.config.setApprovalMode(ApprovalMode.PLAN);
return {
@@ -42,6 +42,7 @@ describe('ExitPlanModeTool', () => {
mockConfig = {
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
getPlanDirectory: vi.fn().mockReturnValue(mockPlansDir),
setApprovalMode: vi.fn(),
setApprovedPlanPath: vi.fn(),
storage: {
+4 -6
View File
@@ -57,7 +57,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
private config: Config,
messageBus: MessageBus,
) {
const plansDir = config.storage.getProjectTempPlansDir();
const plansDir = config.getPlanDirectory();
const definition = getExitPlanModeDefinition(plansDir);
super(
EXIT_PLAN_MODE_TOOL_NAME,
@@ -78,9 +78,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
// Since validateToolParamValues is synchronous, we use a basic synchronous check
// for path traversal safety. High-level async validation is deferred to shouldConfirmExecute.
const plansDir = resolveToRealPath(
this.config.storage.getProjectTempPlansDir(),
);
const plansDir = resolveToRealPath(this.config.getPlanDirectory());
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.plan_path,
@@ -111,7 +109,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
}
override getSchema(modelId?: string) {
const plansDir = this.config.storage.getProjectTempPlansDir();
const plansDir = this.config.getPlanDirectory();
return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId);
}
}
@@ -141,7 +139,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const pathError = await validatePlanPath(
this.params.plan_path,
this.config.storage.getProjectTempPlansDir(),
this.config.getPlanDirectory(),
this.config.getTargetDir(),
);
if (pathError) {
+18
View File
@@ -105,6 +105,13 @@
},
"additionalProperties": false
},
"plan": {
"title": "Plan Configuration",
"description": "Configuration for planning features.",
"markdownDescription": "Configuration for planning features.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `{}`",
"default": {},
"$ref": "#/$defs/PlanConfig"
},
"enablePromptCompletion": {
"title": "Enable Prompt Completion",
"description": "Enable AI-powered prompt completion suggestions while typing.",
@@ -1971,6 +1978,17 @@
}
}
},
"PlanConfig": {
"type": "object",
"description": "Planning features configuration.",
"additionalProperties": false,
"properties": {
"directory": {
"type": "string",
"description": "Custom directory for implementation plans."
}
}
},
"TelemetrySettings": {
"type": "object",
"description": "Telemetry configuration for Gemini CLI.",