Download packages/core/src/policy/persistence.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 17.4 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/policy/persistence.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/policy/persistence.test.ts
-
curl -L -o persistence.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/policy/persistence.test.ts
17.4 kB
| /** | |
| * @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/promises'; | |
| import * as path from 'node:path'; | |
| import { | |
| createPolicyUpdater, | |
| getAlwaysAllowPriorityFraction, | |
| } from './config.js'; | |
| import { PolicyEngine } from './policy-engine.js'; | |
| import { MessageBus } from '../confirmation-bus/message-bus.js'; | |
| import { MessageBusType } from '../confirmation-bus/types.js'; | |
| import { Storage, AUTO_SAVED_POLICY_FILENAME } from '../config/storage.js'; | |
| import { ApprovalMode } from './types.js'; | |
| import { vol, fs as memfs } from 'memfs'; | |
| import { coreEvents } from '../utils/events.js'; | |
| // Use memfs for all fs operations in this test | |
| vi.mock('node:fs/promises', () => import('memfs').then((m) => m.fs.promises)); | |
| /** | |
| * Creates a Node.js-style error with a `code` property. | |
| */ | |
| function makeNodeError(message: string, code: string): NodeJS.ErrnoException { | |
| const err = new Error(message) as NodeJS.ErrnoException; | |
| err.code = code; | |
| return err; | |
| } | |
| vi.mock('../config/storage.js'); | |
| describe('createPolicyUpdater', () => { | |
| let policyEngine: PolicyEngine; | |
| let messageBus: MessageBus; | |
| let mockStorage: Storage; | |
| beforeEach(() => { | |
| vi.useFakeTimers(); | |
| vol.reset(); | |
| policyEngine = new PolicyEngine({ | |
| rules: [], | |
| checkers: [], | |
| approvalMode: ApprovalMode.DEFAULT, | |
| }); | |
| messageBus = new MessageBus(policyEngine); | |
| mockStorage = new Storage('/mock/project'); | |
| vi.clearAllMocks(); | |
| }); | |
| afterEach(() => { | |
| vi.restoreAllMocks(); | |
| vi.useRealTimers(); | |
| }); | |
| it('should persist policy when persist flag is true', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| const fileExists = memfs.existsSync(policyFile); | |
| expect(fileExists).toBe(true); | |
| 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}`); | |
| }); | |
| it('should include allowRedirection when persisting policy', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| allowRedirection: true, | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| const content = memfs.readFileSync(policyFile, 'utf-8') as string; | |
| expect(content).toContain('toolName = "test_tool"'); | |
| expect(content).toContain('allowRedirection = true'); | |
| }); | |
| it('should not persist policy when persist flag is false or undefined', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| expect(memfs.existsSync(policyFile)).toBe(false); | |
| }); | |
| it('should append to existing policy file', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| const existingContent = | |
| '[[rule]]\ntoolName = "existing_tool"\ndecision = "allow"\n'; | |
| const dir = path.dirname(policyFile); | |
| memfs.mkdirSync(dir, { recursive: true }); | |
| memfs.writeFileSync(policyFile, existingContent); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'new_tool', | |
| persist: true, | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| const content = memfs.readFileSync(policyFile, 'utf-8') as string; | |
| expect(content).toContain('toolName = "existing_tool"'); | |
| expect(content).toContain('toolName = "new_tool"'); | |
| }); | |
| it('should handle toml with multiple rules correctly', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| const existingContent = ` | |
| [[rule]] | |
| toolName = "tool1" | |
| decision = "allow" | |
| [[rule]] | |
| toolName = "tool2" | |
| decision = "deny" | |
| `; | |
| const dir = path.dirname(policyFile); | |
| memfs.mkdirSync(dir, { recursive: true }); | |
| memfs.writeFileSync(policyFile, existingContent); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'tool3', | |
| persist: true, | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| const content = memfs.readFileSync(policyFile, 'utf-8') as string; | |
| expect(content).toContain('toolName = "tool1"'); | |
| expect(content).toContain('toolName = "tool2"'); | |
| expect(content).toContain('toolName = "tool3"'); | |
| }); | |
| it('should include argsPattern if provided', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| 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.*$"'); | |
| }); | |
| 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); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'search"tool"', | |
| persist: true, | |
| mcpName: 'my"jira"server', | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| const writtenContent = memfs.readFileSync(policyFile, 'utf-8') as string; | |
| // Verify escaping - should be valid TOML and contain the values | |
| // 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 { | |
| expect(writtenContent).toContain('mcpName = "my\\"jira\\"server"'); | |
| } catch { | |
| expect(writtenContent).toContain('mcpName = \'my"jira"server\''); | |
| } | |
| try { | |
| expect(writtenContent).toContain('toolName = "search\\"tool\\""'); | |
| } catch { | |
| expect(writtenContent).toContain('toolName = \'search"tool"\''); | |
| } | |
| }); | |
| 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"'); | |
| }); | |
| it('should include error details in feedback message on persistence failure', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const workspacePoliciesDir = '/mock/project/.gemini/policies'; | |
| const policyFile = path.join( | |
| workspacePoliciesDir, | |
| AUTO_SAVED_POLICY_FILENAME, | |
| ); | |
| vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( | |
| workspacePoliciesDir, | |
| ); | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| vi.spyOn(fs, 'mkdir').mockRejectedValue(new Error('Permission denied')); | |
| const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| }); | |
| await vi.runAllTimersAsync(); | |
| expect(feedbackSpy).toHaveBeenCalledWith( | |
| 'error', | |
| expect.stringContaining('Permission denied'), | |
| expect.any(Error), | |
| ); | |
| }); | |
| it('should clean up tmp file on write failure', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const workspacePoliciesDir = '/mock/project/.gemini/policies'; | |
| const policyFile = path.join( | |
| workspacePoliciesDir, | |
| AUTO_SAVED_POLICY_FILENAME, | |
| ); | |
| vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( | |
| workspacePoliciesDir, | |
| ); | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as never); | |
| vi.spyOn(fs, 'readFile').mockRejectedValue( | |
| makeNodeError('ENOENT: no such file or directory', 'ENOENT'), | |
| ); | |
| const mockFileHandle = { | |
| writeFile: vi.fn().mockRejectedValue(new Error('Disk full')), | |
| close: vi.fn().mockResolvedValue(undefined), | |
| }; | |
| vi.spyOn(fs, 'open').mockResolvedValue(mockFileHandle as never); | |
| vi.spyOn(fs, 'unlink').mockResolvedValue(undefined as never); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| }); | |
| await vi.runAllTimersAsync(); | |
| expect(fs.unlink).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/)); | |
| }); | |
| it('should abort persistence on non-ENOENT read errors', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const workspacePoliciesDir = '/mock/project/.gemini/policies'; | |
| const policyFile = path.join( | |
| workspacePoliciesDir, | |
| AUTO_SAVED_POLICY_FILENAME, | |
| ); | |
| vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( | |
| workspacePoliciesDir, | |
| ); | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as never); | |
| vi.spyOn(fs, 'readFile').mockRejectedValue( | |
| makeNodeError('Permission denied', 'EACCES'), | |
| ); | |
| const openSpy = vi.spyOn(fs, 'open'); | |
| const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| }); | |
| await vi.runAllTimersAsync(); | |
| expect(openSpy).not.toHaveBeenCalled(); | |
| expect(feedbackSpy).toHaveBeenCalledWith( | |
| 'error', | |
| expect.stringContaining('Permission denied'), | |
| expect.any(Error), | |
| ); | |
| }); | |
| it('should fall back to copy+unlink when rename fails with EXDEV', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const workspacePoliciesDir = '/mock/project/.gemini/policies'; | |
| const policyFile = path.join( | |
| workspacePoliciesDir, | |
| AUTO_SAVED_POLICY_FILENAME, | |
| ); | |
| vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( | |
| workspacePoliciesDir, | |
| ); | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as never); | |
| vi.spyOn(fs, 'readFile').mockRejectedValue( | |
| makeNodeError('ENOENT: no such file or directory', 'ENOENT'), | |
| ); | |
| const mockFileHandle = { | |
| writeFile: vi.fn().mockResolvedValue(undefined), | |
| close: vi.fn().mockResolvedValue(undefined), | |
| }; | |
| vi.spyOn(fs, 'open').mockResolvedValue(mockFileHandle as never); | |
| vi.spyOn(fs, 'rename').mockRejectedValue( | |
| makeNodeError('EXDEV: cross-device link not permitted', 'EXDEV'), | |
| ); | |
| vi.spyOn(fs, 'copyFile').mockResolvedValue(undefined as never); | |
| vi.spyOn(fs, 'unlink').mockResolvedValue(undefined as never); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| }); | |
| await vi.runAllTimersAsync(); | |
| expect(fs.copyFile).toHaveBeenCalledWith( | |
| expect.stringMatching(/\.tmp$/), | |
| policyFile, | |
| ); | |
| expect(fs.unlink).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/)); | |
| }); | |
| it('should include modes if provided', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| modes: [ApprovalMode.DEFAULT, ApprovalMode.YOLO], | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| const content = memfs.readFileSync(policyFile, 'utf-8') as string; | |
| expect(content).toContain('modes = [ "default", "yolo" ]'); | |
| }); | |
| it('should update existing rule modes instead of appending redundant rule', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| const existingContent = ` | |
| [[rule]] | |
| decision = "allow" | |
| priority = 950 | |
| toolName = "test_tool" | |
| modes = [ "autoEdit", "yolo" ] | |
| `; | |
| const dir = path.dirname(policyFile); | |
| memfs.mkdirSync(dir, { recursive: true }); | |
| memfs.writeFileSync(policyFile, existingContent); | |
| // Now grant in DEFAULT mode, which should include [default, autoEdit, yolo] | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| modes: [ApprovalMode.DEFAULT, ApprovalMode.AUTO_EDIT, ApprovalMode.YOLO], | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| const content = memfs.readFileSync(policyFile, 'utf-8') as string; | |
| // Should NOT have two [[rule]] entries for test_tool | |
| const ruleCount = (content.match(/\[\[rule\]\]/g) || []).length; | |
| expect(ruleCount).toBe(1); | |
| expect(content).toContain('modes = [ "default", "autoEdit", "yolo" ]'); | |
| }); | |
| it('should fall back to copy+unlink when rename fails with EBUSY', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const workspacePoliciesDir = '/mock/project/.gemini/policies'; | |
| const policyFile = path.join( | |
| workspacePoliciesDir, | |
| AUTO_SAVED_POLICY_FILENAME, | |
| ); | |
| vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( | |
| workspacePoliciesDir, | |
| ); | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined); | |
| vi.spyOn(fs, 'readFile').mockRejectedValue( | |
| makeNodeError('ENOENT: no such file or directory', 'ENOENT'), | |
| ); | |
| const mockFileHandle = { | |
| writeFile: vi.fn().mockResolvedValue(undefined), | |
| close: vi.fn().mockResolvedValue(undefined), | |
| }; | |
| vi.spyOn(fs, 'open').mockResolvedValue( | |
| mockFileHandle as unknown as fs.FileHandle, | |
| ); | |
| vi.spyOn(fs, 'rename').mockRejectedValue( | |
| makeNodeError('EBUSY: resource busy or locked', 'EBUSY'), | |
| ); | |
| vi.spyOn(fs, 'copyFile').mockResolvedValue(undefined); | |
| vi.spyOn(fs, 'unlink').mockResolvedValue(undefined); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| }); | |
| await vi.runAllTimersAsync(); | |
| expect(fs.copyFile).toHaveBeenCalledWith( | |
| expect.stringMatching(/\.tmp$/), | |
| policyFile, | |
| ); | |
| expect(fs.unlink).toHaveBeenCalledWith(expect.stringMatching(/\.tmp$/)); | |
| }); | |
| it('should back up corrupted TOML file and recover', async () => { | |
| createPolicyUpdater(policyEngine, messageBus, mockStorage); | |
| const policyFile = '/mock/user/.gemini/policies/auto-saved.toml'; | |
| vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); | |
| const dir = path.dirname(policyFile); | |
| memfs.mkdirSync(dir, { recursive: true }); | |
| memfs.writeFileSync(policyFile, 'this is not valid toml ][[['); | |
| const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); | |
| await messageBus.publish({ | |
| type: MessageBusType.UPDATE_POLICY, | |
| toolName: 'test_tool', | |
| persist: true, | |
| }); | |
| await vi.advanceTimersByTimeAsync(100); | |
| expect(feedbackSpy).toHaveBeenCalledWith( | |
| 'warning', | |
| expect.stringContaining('.bak'), | |
| ); | |
| expect(memfs.existsSync(policyFile)).toBe(true); | |
| const content = memfs.readFileSync(policyFile, 'utf-8') as string; | |
| expect(content).toContain('toolName = "test_tool"'); | |
| }); | |
| }); | |