mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(policy): implement project-level policy support (#18682)
This commit is contained in:
@@ -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';
|
||||
@@ -692,9 +695,17 @@ export async function loadCliConfig(
|
||||
policyPaths: argv.policy,
|
||||
};
|
||||
|
||||
const { workspacePoliciesDir, policyUpdateConfirmationRequest } =
|
||||
await resolveWorkspacePolicyState({
|
||||
cwd,
|
||||
trustedFolder,
|
||||
interactive,
|
||||
});
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
effectiveSettings,
|
||||
approvalMode,
|
||||
workspacePoliciesDir,
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
@@ -758,6 +769,7 @@ export async function loadCliConfig(
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
policyEngineConfig,
|
||||
policyUpdateConfirmationRequest,
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
|
||||
@@ -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,145 @@
|
||||
/**
|
||||
* @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 { writeToStderr } 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(),
|
||||
},
|
||||
writeToStderr: 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,
|
||||
});
|
||||
|
||||
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 (interactive 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,
|
||||
);
|
||||
|
||||
// Second call should match
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: workspaceDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
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 = []');
|
||||
|
||||
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),
|
||||
});
|
||||
});
|
||||
|
||||
it('should warn and auto-accept 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,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBe(policiesDir);
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
expect(writeToStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Automatically accepting and loading'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,18 @@ import {
|
||||
type PolicySettings,
|
||||
createPolicyEngineConfig as createCorePolicyEngineConfig,
|
||||
createPolicyUpdater as createCorePolicyUpdater,
|
||||
PolicyIntegrityManager,
|
||||
IntegrityStatus,
|
||||
Storage,
|
||||
type PolicyUpdateConfirmationRequest,
|
||||
writeToStderr,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Settings } from './settings.js';
|
||||
|
||||
export async function createPolicyEngineConfig(
|
||||
settings: Settings,
|
||||
approvalMode: ApprovalMode,
|
||||
workspacePoliciesDir?: string,
|
||||
): Promise<PolicyEngineConfig> {
|
||||
// Explicitly construct PolicySettings from Settings to ensure type safety
|
||||
// and avoid accidental leakage of other settings properties.
|
||||
@@ -26,6 +32,7 @@ export async function createPolicyEngineConfig(
|
||||
tools: settings.tools,
|
||||
mcpServers: settings.mcpServers,
|
||||
policyPaths: settings.policyPaths,
|
||||
workspacePoliciesDir,
|
||||
};
|
||||
|
||||
return createCorePolicyEngineConfig(policySettings, approvalMode);
|
||||
@@ -37,3 +44,68 @@ 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;
|
||||
}): Promise<WorkspacePolicyState> {
|
||||
const { cwd, trustedFolder, interactive } = 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 if (interactive) {
|
||||
// Policies changed or are new, and we are in interactive mode
|
||||
policyUpdateConfirmationRequest = {
|
||||
scope: 'workspace',
|
||||
identifier: cwd,
|
||||
policyDir: potentialWorkspacePoliciesDir,
|
||||
newHash: integrityResult.hash,
|
||||
};
|
||||
} else {
|
||||
// Non-interactive mode: warn and 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { workspacePoliciesDir, policyUpdateConfirmationRequest };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
// 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 automatically accept and load 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(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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -506,6 +506,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,
|
||||
@@ -1910,6 +1917,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
(shouldShowRetentionWarning && retentionCheckComplete) ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
@@ -2137,6 +2145,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
@@ -2259,6 +2269,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
@@ -2356,6 +2368,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
vimHandleInput,
|
||||
handleIdePromptComplete,
|
||||
handleFolderTrustSelect,
|
||||
setIsPolicyUpdateDialogOpen,
|
||||
setConstrainHeight,
|
||||
onEscapePromptChange: handleEscapePromptChange,
|
||||
refreshStatic,
|
||||
@@ -2440,6 +2453,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
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
const { mockAcceptIntegrity } = vi.hoisted(() => ({
|
||||
mockAcceptIntegrity: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock PolicyIntegrityManager
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
PolicyIntegrityManager: vi.fn().mockImplementation(() => ({
|
||||
acceptIntegrity: mockAcceptIntegrity,
|
||||
checkIntegrity: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
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', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderWithProviders(
|
||||
<PolicyUpdateDialog
|
||||
config={mockConfig}
|
||||
request={mockRequest}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
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,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useCallback, useRef } 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 isProcessing = useRef(false);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (choice: PolicyUpdateChoice) => {
|
||||
if (isProcessing.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.current = true;
|
||||
try {
|
||||
if (choice === PolicyUpdateChoice.ACCEPT) {
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
await integrityManager.acceptIntegrity(
|
||||
request.scope,
|
||||
request.identifier,
|
||||
request.newHash,
|
||||
);
|
||||
await config.loadWorkspacePolicies(request.policyDir);
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
isProcessing.current = false;
|
||||
}
|
||||
},
|
||||
[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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// 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;
|
||||
|
||||
@@ -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 {
|
||||
/** @deprecated Use ui.loadingPhrases instead. */
|
||||
@@ -379,6 +381,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;
|
||||
@@ -459,6 +468,7 @@ export interface ConfigParameters {
|
||||
eventEmitter?: EventEmitter;
|
||||
useWriteTodos?: boolean;
|
||||
policyEngineConfig?: PolicyEngineConfig;
|
||||
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
|
||||
output?: OutputSettings;
|
||||
disableModelRouterForAuth?: AuthType[];
|
||||
continueOnFailedApiCall?: boolean;
|
||||
@@ -637,6 +647,9 @@ export class Config {
|
||||
private readonly useWriteTodos: boolean;
|
||||
private readonly messageBus: MessageBus;
|
||||
private readonly policyEngine: PolicyEngine;
|
||||
private policyUpdateConfirmationRequest:
|
||||
| PolicyUpdateConfirmationRequest
|
||||
| undefined;
|
||||
private readonly outputSettings: OutputSettings;
|
||||
private readonly continueOnFailedApiCall: boolean;
|
||||
private readonly retryFetchErrors: boolean;
|
||||
@@ -853,6 +866,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();
|
||||
@@ -1721,6 +1736,41 @@ 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,
|
||||
);
|
||||
|
||||
// Clear existing workspace policies to prevent duplicates/stale rules
|
||||
this.policyEngine.removeRulesByTier(WORKSPACE_POLICY_TIER);
|
||||
this.policyEngine.removeCheckersByTier(WORKSPACE_POLICY_TIER);
|
||||
|
||||
for (const rule of rules) {
|
||||
this.policyEngine.addRule(rule);
|
||||
}
|
||||
|
||||
for (const checker of checkers) {
|
||||
this.policyEngine.addChecker(checker);
|
||||
}
|
||||
|
||||
this.policyUpdateConfirmationRequest = undefined;
|
||||
|
||||
debugLogger.debug(`Workspace policies loaded from: ${policyDir}`);
|
||||
}
|
||||
|
||||
setApprovalMode(mode: ApprovalMode): void {
|
||||
if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) {
|
||||
throw new Error(
|
||||
|
||||
@@ -103,6 +103,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';
|
||||
@@ -146,6 +150,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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,7 @@ import { coreEvents } from '../utils/events.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
|
||||
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
|
||||
import { isDirectorySecure } from '../utils/security.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -157,8 +172,8 @@ export async function createPolicyEngineConfig(
|
||||
const policyDirs = getPolicyDirectories(
|
||||
defaultPoliciesDir,
|
||||
settings.policyPaths,
|
||||
settings.workspacePoliciesDir,
|
||||
);
|
||||
|
||||
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
|
||||
|
||||
const normalizedAdminPoliciesDir = path.resolve(
|
||||
@@ -171,7 +186,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 +226,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 +250,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 +296,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 +308,7 @@ export async function createPolicyEngineConfig(
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 2.3,
|
||||
priority: 3.3,
|
||||
source: 'Settings (Tools Allowed)',
|
||||
});
|
||||
}
|
||||
@@ -300,7 +320,7 @@ export async function createPolicyEngineConfig(
|
||||
rules.push({
|
||||
toolName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 2.3,
|
||||
priority: 3.3,
|
||||
source: 'Settings (Tools Allowed)',
|
||||
});
|
||||
}
|
||||
@@ -308,7 +328,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 +339,7 @@ export async function createPolicyEngineConfig(
|
||||
rules.push({
|
||||
toolName: `${serverName}__*`,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 2.2,
|
||||
priority: 3.2,
|
||||
source: 'Settings (MCP Trusted)',
|
||||
});
|
||||
}
|
||||
@@ -327,13 +347,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 +400,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 +425,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)',
|
||||
});
|
||||
@@ -425,10 +445,16 @@ export function createPolicyUpdater(
|
||||
let existingData: { rule?: TomlRule[] } = {};
|
||||
try {
|
||||
const fileContent = await fs.readFile(policyFile, 'utf-8');
|
||||
existingData = toml.parse(fileContent) as { rule?: TomlRule[] };
|
||||
const parsed = toml.parse(fileContent);
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
(!('rule' in parsed) || Array.isArray(parsed['rule']))
|
||||
) {
|
||||
existingData = parsed as { rule?: TomlRule[] };
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
if (!isNodeError(error) || error.code !== 'ENOENT') {
|
||||
debugLogger.warn(
|
||||
`Failed to parse ${policyFile}, overwriting with new policy.`,
|
||||
error,
|
||||
|
||||
@@ -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,154 @@
|
||||
/**
|
||||
* @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';
|
||||
import { isNodeError } from '../utils/errors.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');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
Object.values(parsed).every((v) => typeof v === 'string')
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return parsed as StoredIntegrityData;
|
||||
}
|
||||
debugLogger.warn('Invalid policy integrity data format');
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.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,89 @@ describe('PolicyEngine', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRulesByTier', () => {
|
||||
it('should remove rules matching a specific tier', () => {
|
||||
engine.addRule({
|
||||
toolName: 'rule1',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 1.1,
|
||||
});
|
||||
engine.addRule({
|
||||
toolName: 'rule2',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 1.5,
|
||||
});
|
||||
engine.addRule({
|
||||
toolName: 'rule3',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 2.1,
|
||||
});
|
||||
engine.addRule({
|
||||
toolName: 'rule4',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 0.5,
|
||||
});
|
||||
engine.addRule({ toolName: 'rule5', decision: PolicyDecision.ALLOW }); // priority undefined -> 0
|
||||
|
||||
expect(engine.getRules()).toHaveLength(5);
|
||||
|
||||
engine.removeRulesByTier(1);
|
||||
|
||||
const rules = engine.getRules();
|
||||
expect(rules).toHaveLength(3);
|
||||
expect(rules.some((r) => r.toolName === 'rule1')).toBe(false);
|
||||
expect(rules.some((r) => r.toolName === 'rule2')).toBe(false);
|
||||
expect(rules.some((r) => r.toolName === 'rule3')).toBe(true);
|
||||
expect(rules.some((r) => r.toolName === 'rule4')).toBe(true);
|
||||
expect(rules.some((r) => r.toolName === 'rule5')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle removing tier 0 rules (including undefined priority)', () => {
|
||||
engine.addRule({
|
||||
toolName: 'rule1',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 0.5,
|
||||
});
|
||||
engine.addRule({ toolName: 'rule2', decision: PolicyDecision.ALLOW }); // defaults to 0
|
||||
engine.addRule({
|
||||
toolName: 'rule3',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 1.5,
|
||||
});
|
||||
|
||||
expect(engine.getRules()).toHaveLength(3);
|
||||
|
||||
engine.removeRulesByTier(0);
|
||||
|
||||
const rules = engine.getRules();
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].toolName).toBe('rule3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCheckersByTier', () => {
|
||||
it('should remove checkers matching a specific tier', () => {
|
||||
engine.addChecker({
|
||||
checker: { type: 'external', name: 'c1' },
|
||||
priority: 1.1,
|
||||
});
|
||||
engine.addChecker({
|
||||
checker: { type: 'external', name: 'c2' },
|
||||
priority: 1.9,
|
||||
});
|
||||
engine.addChecker({
|
||||
checker: { type: 'external', name: 'c3' },
|
||||
priority: 2.5,
|
||||
});
|
||||
|
||||
expect(engine.getCheckers()).toHaveLength(3);
|
||||
|
||||
engine.removeCheckersByTier(1);
|
||||
|
||||
const checkers = engine.getCheckers();
|
||||
expect(checkers).toHaveLength(1);
|
||||
expect(checkers[0].priority).toBe(2.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -475,6 +475,24 @@ export class PolicyEngine {
|
||||
this.checkers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove rules matching a specific tier (priority band).
|
||||
*/
|
||||
removeRulesByTier(tier: number): void {
|
||||
this.rules = this.rules.filter(
|
||||
(rule) => Math.floor(rule.priority ?? 0) !== tier,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove checkers matching a specific tier (priority band).
|
||||
*/
|
||||
removeCheckersByTier(tier: number): void {
|
||||
this.checkers = this.checkers.filter(
|
||||
(checker) => Math.floor(checker.priority ?? 0) !== tier,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove rules for a specific tool.
|
||||
* If source is provided, only rules matching that source are removed.
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -359,6 +363,21 @@ priority = -1
|
||||
expect(result.errors[0].fileName).toBe('invalid.toml');
|
||||
expect(result.errors[0].errorType).toBe('schema_validation');
|
||||
});
|
||||
|
||||
it('should transform safety checker priorities based on tier', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
[[safety_checker]]
|
||||
toolName = "write_file"
|
||||
priority = 100
|
||||
[safety_checker.checker]
|
||||
type = "in-process"
|
||||
name = "allowed-path"
|
||||
`);
|
||||
|
||||
expect(result.checkers).toHaveLength(1);
|
||||
expect(result.checkers[0].priority).toBe(1.1); // tier 1 + 100/1000
|
||||
expect(result.checkers[0].source).toBe('Default: test.toml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Negative Tests', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import toml from '@iarna/toml';
|
||||
import { z, type ZodError } from 'zod';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Schema for a single policy rule in the TOML file (before transformation).
|
||||
@@ -105,7 +106,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 +123,59 @@ 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) {
|
||||
if (isNodeError(e) && e.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
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 +258,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,48 +273,26 @@ 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),
|
||||
tier: tierName,
|
||||
errorType: 'file_read',
|
||||
message: `Failed to read policy path`,
|
||||
details: error.message,
|
||||
details: isNodeError(e) ? e.message : String(e),
|
||||
});
|
||||
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 {
|
||||
@@ -438,10 +463,11 @@ export async function loadPoliciesFromToml(
|
||||
|
||||
const safetyCheckerRule: SafetyCheckerRule = {
|
||||
toolName: effectiveToolName,
|
||||
priority: checker.priority,
|
||||
priority: transformPriority(checker.priority, tier),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
checker: checker.checker as SafetyCheckerConfig,
|
||||
modes: checker.modes,
|
||||
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
|
||||
};
|
||||
|
||||
if (argsPattern) {
|
||||
@@ -485,17 +511,15 @@ export async function loadPoliciesFromToml(
|
||||
|
||||
checkers.push(...parsedCheckers);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const error = e as NodeJS.ErrnoException;
|
||||
// Catch-all for unexpected errors
|
||||
if (error.code !== 'ENOENT') {
|
||||
if (!isNodeError(e) || e.code !== 'ENOENT') {
|
||||
errors.push({
|
||||
filePath,
|
||||
fileName: file,
|
||||
tier: tierName,
|
||||
errorType: 'file_read',
|
||||
message: 'Failed to read policy file',
|
||||
details: error.message,
|
||||
details: isNodeError(e) ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,12 @@ export interface SafetyCheckerRule {
|
||||
* If undefined or empty, it applies to all modes.
|
||||
*/
|
||||
modes?: ApprovalMode[];
|
||||
|
||||
/**
|
||||
* Source of the rule.
|
||||
* e.g. "my-policies.toml", "Workspace: project.toml", etc.
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface HookExecutionContext {
|
||||
@@ -272,7 +278,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user