Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 89 additions & 29 deletions packages/cli/src/config/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ 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 {
resolveWorkspacePolicyState,
autoAcceptWorkspacePolicies,
setAutoAcceptWorkspacePolicies,
disableWorkspacePolicies,
setDisableWorkspacePolicies,
} from './policy.js';
import { writeToStderr } from '@google/gemini-cli-core';

// Mock debugLogger to avoid noise in test output
Expand Down Expand Up @@ -41,6 +47,9 @@ describe('resolveWorkspacePolicyState', () => {
fs.mkdirSync(workspaceDir);
policiesDir = path.join(workspaceDir, '.gemini', 'policies');

// Enable policies for these tests to verify loading logic
setDisableWorkspacePolicies(false);

vi.clearAllMocks();
});

Expand All @@ -63,29 +72,30 @@ describe('resolveWorkspacePolicyState', () => {
});
});

it('should have disableWorkspacePolicies set to true by default', () => {
// We explicitly set it to false in beforeEach for other tests,
// so here we test that setting it to true works.
setDisableWorkspacePolicies(true);
expect(disableWorkspacePolicies).toBe(true);
});

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 (interactive accept)
// First call to establish integrity (interactive auto-accept)
const firstResult = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
expect(firstResult.policyUpdateConfirmationRequest).toBeDefined();

// Establish integrity manually as if accepted
const { PolicyIntegrityManager } = await import('@google/gemini-cli-core');
const integrityManager = new PolicyIntegrityManager();
await integrityManager.acceptIntegrity(
'workspace',
workspaceDir,
firstResult.policyUpdateConfirmationRequest!.newHash,
);
expect(firstResult.workspacePoliciesDir).toBe(policiesDir);
expect(firstResult.policyUpdateConfirmationRequest).toBeUndefined();
expect(writeToStderr).not.toHaveBeenCalled();

// Second call should match

const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
Expand All @@ -107,26 +117,33 @@ describe('resolveWorkspacePolicyState', () => {
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});

it('should return confirmation request if changed in interactive mode', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
it('should return confirmation request if changed in interactive mode when AUTO_ACCEPT is false', async () => {
const originalValue = autoAcceptWorkspacePolicies;
setAutoAcceptWorkspacePolicies(false);

const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
try {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');

expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toEqual({
scope: 'workspace',
identifier: workspaceDir,
policyDir: policiesDir,
newHash: expect.any(String),
});
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});

expect(result.workspacePoliciesDir).toBeUndefined();
expect(result.policyUpdateConfirmationRequest).toEqual({
scope: 'workspace',
identifier: workspaceDir,
policyDir: policiesDir,
newHash: expect.any(String),
});
} finally {
setAutoAcceptWorkspacePolicies(originalValue);
}
});

it('should warn and auto-accept if changed in non-interactive mode', async () => {
it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is true', async () => {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');

Expand All @@ -143,6 +160,30 @@ describe('resolveWorkspacePolicyState', () => {
);
});

it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is false', async () => {
const originalValue = autoAcceptWorkspacePolicies;
setAutoAcceptWorkspacePolicies(false);

try {
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');

const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: false,
});

expect(result.workspacePoliciesDir).toBe(policiesDir);
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
expect(writeToStderr).toHaveBeenCalledWith(
expect.stringContaining('Automatically accepting and loading'),
);
} finally {
setAutoAcceptWorkspacePolicies(originalValue);
}
});

it('should not return workspace policies if cwd is the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
Expand All @@ -159,7 +200,26 @@ describe('resolveWorkspacePolicyState', () => {
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});

it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => {
setDisableWorkspacePolicies(true);

// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');

const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});

expect(result).toEqual({
workspacePoliciesDir: undefined,
policyUpdateConfirmationRequest: undefined,
});
});

it('should return empty state if cwd is a symlink to the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
Expand Down
51 changes: 43 additions & 8 deletions packages/cli/src/config/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,38 @@ import {
Storage,
type PolicyUpdateConfirmationRequest,
writeToStderr,
debugLogger,
} from '@google/gemini-cli-core';
import { type Settings } from './settings.js';

/**
* Temporary flag to automatically accept workspace policies to reduce friction.
* Exported as 'let' to allow monkey patching in tests via the setter.
*/
export let autoAcceptWorkspacePolicies = true;

/**
* Sets the autoAcceptWorkspacePolicies flag.
* Used primarily for testing purposes.
*/
export function setAutoAcceptWorkspacePolicies(value: boolean) {
autoAcceptWorkspacePolicies = value;
}

/**
* Temporary flag to disable workspace level policies altogether.
* Exported as 'let' to allow monkey patching in tests via the setter.
*/
export let disableWorkspacePolicies = true;

/**
* Sets the disableWorkspacePolicies flag.
* Used primarily for testing purposes.
*/
export function setDisableWorkspacePolicies(value: boolean) {
disableWorkspacePolicies = value;
}

export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
Expand Down Expand Up @@ -66,7 +95,7 @@ export async function resolveWorkspacePolicyState(options: {
| PolicyUpdateConfirmationRequest
| undefined;

if (trustedFolder) {
if (trustedFolder && !disableWorkspacePolicies) {
const storage = new Storage(cwd);

// If we are in the home directory (or rather, our target Gemini dir is the global one),
Expand All @@ -91,26 +120,32 @@ export async function resolveWorkspacePolicyState(options: {
) {
// No workspace policies found
workspacePoliciesDir = undefined;
} else if (interactive) {
// Policies changed or are new, and we are in interactive mode
} else if (interactive && !autoAcceptWorkspacePolicies) {
// Policies changed or are new, and we are in interactive mode and auto-accept is disabled
policyUpdateConfirmationRequest = {
scope: 'workspace',
identifier: cwd,
policyDir: potentialWorkspacePoliciesDir,
newHash: integrityResult.hash,
};
} else {
// Non-interactive mode: warn and automatically accept/load
// Non-interactive mode or auto-accept is enabled: automatically accept/load
await integrityManager.acceptIntegrity(
'workspace',
cwd,
integrityResult.hash,
);
workspacePoliciesDir = potentialWorkspacePoliciesDir;
// debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console
writeToStderr(
'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n',
);

if (!interactive) {
writeToStderr(
'WARNING: Workspace policies changed or are new. Automatically accepting and loading them.\n',
);
} else {
debugLogger.warn(
'Workspace policies changed or are new. Automatically accepting and loading them.',
);
}
}
}

Expand Down
86 changes: 67 additions & 19 deletions packages/cli/src/config/workspace-policy-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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 * as Policy from './policy.js';

// Mock dependencies
vi.mock('./trustedFolders.js', () => ({
Expand Down Expand Up @@ -53,6 +54,7 @@ describe('Workspace-Level Policy CLI Integration', () => {

beforeEach(() => {
vi.clearAllMocks();
Policy.setDisableWorkspacePolicies(false);
// Default to MATCH for existing tests
mockCheckIntegrity.mockResolvedValue({
status: 'match',
Expand Down Expand Up @@ -164,7 +166,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
);
});

it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => {
it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in interactive mode when AUTO_ACCEPT is true', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
Expand All @@ -186,24 +188,23 @@ describe('Workspace-Level Policy CLI Integration', () => {
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(config.getPolicyUpdateConfirmationRequest()).toBeUndefined();
expect(mockAcceptIntegrity).toHaveBeenCalledWith(
'workspace',
MOCK_CWD,
'new-hash',
);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
}),
expect.anything(),
);
});

it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => {
it('should automatically accept and load workspacePoliciesDir if integrity is NEW in interactive mode when AUTO_ACCEPT is true', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
Expand All @@ -222,18 +223,65 @@ describe('Workspace-Level Policy CLI Integration', () => {
cwd: MOCK_CWD,
});

expect(config.getPolicyUpdateConfirmationRequest()).toEqual({
scope: 'workspace',
identifier: MOCK_CWD,
policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash',
});
expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined();
expect(mockAcceptIntegrity).toHaveBeenCalledWith(
'workspace',
MOCK_CWD,
'new-hash',
);

expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'),
),
}),
expect.anything(),
);
});

it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode when AUTO_ACCEPT is false', async () => {
// Monkey patch autoAcceptWorkspacePolicies using setter
const originalValue = Policy.autoAcceptWorkspacePolicies;
Policy.setAutoAcceptWorkspacePolicies(false);

try {
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',
});
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
workspacePoliciesDir: undefined,
}),
expect.anything(),
);
} finally {
// Restore for other tests
Policy.setAutoAcceptWorkspacePolicies(originalValue);
}
});
});
Loading
Loading