2025-12-12 13:45:39 -08:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
2025-12-12 13:45:39 -08:00
|
|
|
import * as path from 'node:path';
|
2026-03-10 13:01:41 -04:00
|
|
|
import {
|
|
|
|
|
createPolicyUpdater,
|
|
|
|
|
getAlwaysAllowPriorityFraction,
|
|
|
|
|
} from './config.js';
|
2025-12-12 13:45:39 -08:00
|
|
|
import { PolicyEngine } from './policy-engine.js';
|
|
|
|
|
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
|
|
|
|
import { MessageBusType } from '../confirmation-bus/types.js';
|
2026-02-20 14:07:20 -08:00
|
|
|
import { Storage, AUTO_SAVED_POLICY_FILENAME } from '../config/storage.js';
|
2025-12-22 15:25:07 -05:00
|
|
|
import { ApprovalMode } from './types.js';
|
2026-03-10 13:01:41 -04:00
|
|
|
import { vol, fs as memfs } from 'memfs';
|
|
|
|
|
|
|
|
|
|
// Use memfs for all fs operations in this test
|
|
|
|
|
vi.mock('node:fs/promises', () => import('memfs').then((m) => m.fs.promises));
|
2025-12-12 13:45:39 -08:00
|
|
|
|
|
|
|
|
vi.mock('../config/storage.js');
|
|
|
|
|
|
|
|
|
|
describe('createPolicyUpdater', () => {
|
|
|
|
|
let policyEngine: PolicyEngine;
|
|
|
|
|
let messageBus: MessageBus;
|
2026-02-20 14:07:20 -08:00
|
|
|
let mockStorage: Storage;
|
2025-12-12 13:45:39 -08:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2026-03-10 13:01:41 -04:00
|
|
|
vi.useFakeTimers();
|
|
|
|
|
vol.reset();
|
2025-12-22 15:25:07 -05:00
|
|
|
policyEngine = new PolicyEngine({
|
|
|
|
|
rules: [],
|
|
|
|
|
checkers: [],
|
|
|
|
|
approvalMode: ApprovalMode.DEFAULT,
|
|
|
|
|
});
|
2025-12-12 13:45:39 -08:00
|
|
|
messageBus = new MessageBus(policyEngine);
|
2026-02-20 14:07:20 -08:00
|
|
|
mockStorage = new Storage('/mock/project');
|
2025-12-12 13:45:39 -08:00
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks();
|
2026-03-10 13:01:41 -04:00
|
|
|
vi.useRealTimers();
|
2025-12-12 13:45:39 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should persist policy when persist flag is true', async () => {
|
2026-02-20 14:07:20 -08:00
|
|
|
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
|
2026-02-20 14:07:20 -08:00
|
|
|
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
2026-03-10 13:01:41 -04:00
|
|
|
|
2025-12-12 13:45:39 -08:00
|
|
|
await messageBus.publish({
|
|
|
|
|
type: MessageBusType.UPDATE_POLICY,
|
2026-03-10 13:01:41 -04:00
|
|
|
toolName: 'test_tool',
|
2025-12-12 13:45:39 -08:00
|
|
|
persist: true,
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
// Policy updater handles persistence asynchronously in a promise queue.
|
|
|
|
|
// We use advanceTimersByTimeAsync to yield to the microtask queue.
|
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const fileExists = memfs.existsSync(policyFile);
|
|
|
|
|
expect(fileExists).toBe(true);
|
2026-02-10 15:35:09 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
|
|
|
|
|
expect(content).toContain('toolName = "test_tool"');
|
|
|
|
|
expect(content).toContain('decision = "allow"');
|
|
|
|
|
const expectedPriority = getAlwaysAllowPriorityFraction();
|
|
|
|
|
expect(content).toContain(`priority = ${expectedPriority}`);
|
2025-12-12 13:45:39 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should not persist policy when persist flag is false or undefined', async () => {
|
2026-02-20 14:07:20 -08:00
|
|
|
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
|
|
|
|
|
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
|
|
|
|
|
2025-12-12 13:45:39 -08:00
|
|
|
await messageBus.publish({
|
|
|
|
|
type: MessageBusType.UPDATE_POLICY,
|
|
|
|
|
toolName: 'test_tool',
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
expect(memfs.existsSync(policyFile)).toBe(false);
|
2025-12-12 13:45:39 -08:00
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
it('should append to existing policy file', async () => {
|
2026-02-20 14:07:20 -08:00
|
|
|
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
|
2026-02-20 14:07:20 -08:00
|
|
|
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const existingContent =
|
|
|
|
|
'[[rule]]\ntoolName = "existing_tool"\ndecision = "allow"\n';
|
|
|
|
|
const dir = path.dirname(policyFile);
|
|
|
|
|
memfs.mkdirSync(dir, { recursive: true });
|
|
|
|
|
memfs.writeFileSync(policyFile, existingContent);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
|
|
|
|
await messageBus.publish({
|
|
|
|
|
type: MessageBusType.UPDATE_POLICY,
|
2026-03-10 13:01:41 -04:00
|
|
|
toolName: 'new_tool',
|
2025-12-12 13:45:39 -08:00
|
|
|
persist: true,
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
|
|
|
|
|
expect(content).toContain('toolName = "existing_tool"');
|
|
|
|
|
expect(content).toContain('toolName = "new_tool"');
|
2025-12-12 13:45:39 -08:00
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
it('should handle toml with multiple rules correctly', async () => {
|
2026-02-20 14:07:20 -08:00
|
|
|
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
|
2026-02-20 14:07:20 -08:00
|
|
|
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
2026-02-10 15:35:09 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const existingContent = `
|
|
|
|
|
[[rule]]
|
|
|
|
|
toolName = "tool1"
|
|
|
|
|
decision = "allow"
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
[[rule]]
|
|
|
|
|
toolName = "tool2"
|
|
|
|
|
decision = "deny"
|
|
|
|
|
`;
|
|
|
|
|
const dir = path.dirname(policyFile);
|
|
|
|
|
memfs.mkdirSync(dir, { recursive: true });
|
|
|
|
|
memfs.writeFileSync(policyFile, existingContent);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
|
|
|
|
await messageBus.publish({
|
|
|
|
|
type: MessageBusType.UPDATE_POLICY,
|
2026-03-10 13:01:41 -04:00
|
|
|
toolName: 'tool3',
|
2025-12-12 13:45:39 -08:00
|
|
|
persist: true,
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
|
|
|
|
|
expect(content).toContain('toolName = "tool1"');
|
|
|
|
|
expect(content).toContain('toolName = "tool2"');
|
|
|
|
|
expect(content).toContain('toolName = "tool3"');
|
2025-12-12 13:45:39 -08:00
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
it('should include argsPattern if provided', async () => {
|
2026-02-20 14:07:20 -08:00
|
|
|
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
|
2026-02-20 14:07:20 -08:00
|
|
|
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
2026-02-10 15:35:09 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
await messageBus.publish({
|
|
|
|
|
type: MessageBusType.UPDATE_POLICY,
|
|
|
|
|
toolName: 'test_tool',
|
|
|
|
|
persist: true,
|
|
|
|
|
argsPattern: '^foo.*$',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
|
|
|
|
|
|
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
|
|
|
|
|
expect(content).toContain('argsPattern = "^foo.*$"');
|
|
|
|
|
});
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
it('should include mcpName if provided', async () => {
|
|
|
|
|
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
|
|
|
|
|
|
|
|
|
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
|
|
|
|
|
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
|
|
|
|
await messageBus.publish({
|
|
|
|
|
type: MessageBusType.UPDATE_POLICY,
|
2026-03-10 13:01:41 -04:00
|
|
|
toolName: 'search"tool"',
|
2025-12-12 13:45:39 -08:00
|
|
|
persist: true,
|
2026-03-10 13:01:41 -04:00
|
|
|
mcpName: 'my"jira"server',
|
2025-12-12 13:45:39 -08:00
|
|
|
});
|
|
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
const writtenContent = memfs.readFileSync(policyFile, 'utf-8') as string;
|
2025-12-12 13:45:39 -08:00
|
|
|
|
2026-03-10 13:01:41 -04:00
|
|
|
// Verify escaping - should be valid TOML and contain the values
|
2025-12-12 13:45:39 -08:00
|
|
|
// Note: @iarna/toml optimizes for shortest representation, so it may use single quotes 'foo"bar'
|
|
|
|
|
// instead of "foo\"bar\"" if there are no single quotes in the string.
|
|
|
|
|
try {
|
2026-03-10 13:01:41 -04:00
|
|
|
expect(writtenContent).toContain('mcpName = "my\\"jira\\"server"');
|
2025-12-12 13:45:39 -08:00
|
|
|
} catch {
|
2026-03-10 13:01:41 -04:00
|
|
|
expect(writtenContent).toContain('mcpName = \'my"jira"server\'');
|
2025-12-12 13:45:39 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-10 13:01:41 -04:00
|
|
|
expect(writtenContent).toContain('toolName = "search\\"tool\\""');
|
2025-12-12 13:45:39 -08:00
|
|
|
} catch {
|
2026-03-10 13:01:41 -04:00
|
|
|
expect(writtenContent).toContain('toolName = \'search"tool"\'');
|
2025-12-12 13:45:39 -08:00
|
|
|
}
|
|
|
|
|
});
|
2026-03-10 13:01:41 -04:00
|
|
|
|
|
|
|
|
it('should persist to workspace when persistScope is workspace', async () => {
|
|
|
|
|
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
|
|
|
|
|
|
|
|
|
const workspacePoliciesDir = '/mock/project/.gemini/policies';
|
|
|
|
|
const policyFile = path.join(
|
|
|
|
|
workspacePoliciesDir,
|
|
|
|
|
AUTO_SAVED_POLICY_FILENAME,
|
|
|
|
|
);
|
|
|
|
|
vi.spyOn(mockStorage, 'getWorkspaceAutoSavedPolicyPath').mockReturnValue(
|
|
|
|
|
policyFile,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await messageBus.publish({
|
|
|
|
|
type: MessageBusType.UPDATE_POLICY,
|
|
|
|
|
toolName: 'test_tool',
|
|
|
|
|
persist: true,
|
|
|
|
|
persistScope: 'workspace',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
|
|
|
|
|
|
expect(memfs.existsSync(policyFile)).toBe(true);
|
|
|
|
|
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
|
|
|
|
|
expect(content).toContain('toolName = "test_tool"');
|
|
|
|
|
});
|
2025-12-12 13:45:39 -08:00
|
|
|
});
|