mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 05:01:04 -07:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f0d7b8adb | |||
| 12ee556f3b | |||
| c1dfcd9a2d | |||
| e51876b108 | |||
| 527161d7ef | |||
| d08bc96fab | |||
| c95e05454c | |||
| 662654c5d2 | |||
| 8feff1cc9b | |||
| d8f1db6161 | |||
| 73b3cb86eb | |||
| c73e47bbbe | |||
| 53511d6ed4 | |||
| 3a24b0c975 | |||
| f34a3ee1df | |||
| 322de4309d |
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
-17698
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
acceptChangedPolicies: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]|\\\\")`),
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user