diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index 1785faba71..0161bf6af1 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -8,6 +8,57 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ApprovalMode, PolicyDecision } from './types.js'; import type { Dirent } from 'node:fs'; import nodePath from 'node:path'; +import type { PolicyLoadResult } from './toml-loader.js'; + +async function runLoadPoliciesFromToml( + tomlContent: string, + fileName = 'test.toml', +): Promise { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: fileName, + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', fileName)) + ) { + return tomlContent; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); + + const getPolicyTier = (_dir: string) => 1; + return load(ApprovalMode.DEFAULT, ['/policies'], getPolicyTier); +} describe('policy-toml-loader', () => { beforeEach(() => { @@ -21,59 +72,12 @@ describe('policy-toml-loader', () => { describe('loadPoliciesFromToml', () => { it('should load and parse a simple policy file', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'test.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'test.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "glob" decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(1); expect(result.rules[0]).toEqual({ @@ -85,60 +89,13 @@ priority = 100 }); it('should expand commandPrefix array to multiple rules', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'shell.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'shell.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "run_shell_command" commandPrefix = ["git status", "git log"] decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 2; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(2); expect(result.rules[0].toolName).toBe('run_shell_command'); @@ -153,60 +110,13 @@ priority = 100 }); it('should transform commandRegex to argsPattern', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'shell.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'shell.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "run_shell_command" commandRegex = "git (status|log).*" decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 2; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(1); expect( @@ -222,59 +132,12 @@ priority = 100 }); it('should expand toolName array', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'tools.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'tools.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = ["glob", "grep", "read"] decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(3); expect(result.rules.map((r) => r.toolName)).toEqual([ @@ -286,60 +149,13 @@ priority = 100 }); it('should transform mcpName to composite toolName', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'mcp.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'mcp.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] mcpName = "google-workspace" toolName = ["calendar.list", "calendar.get"] decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 2; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(2); expect(result.rules[0].toolName).toBe('google-workspace__calendar.list'); @@ -348,35 +164,7 @@ priority = 100 }); it('should filter rules by mode', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'modes.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'modes.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "glob" decision = "allow" @@ -388,26 +176,7 @@ toolName = "grep" decision = "allow" priority = 100 modes = ["yolo"] -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); // Only the first rule should be included (modes includes "default") expect(result.rules).toHaveLength(1); @@ -416,119 +185,25 @@ modes = ["yolo"] }); it('should handle TOML parse errors', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'invalid.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule] toolName = "glob" decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(0); expect(result.errors).toHaveLength(1); expect(result.errors[0].errorType).toBe('toml_parse'); - expect(result.errors[0].fileName).toBe('invalid.toml'); + expect(result.errors[0].fileName).toBe('test.toml'); }); it('should handle schema validation errors', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'invalid.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "glob" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(0); expect(result.errors).toHaveLength(1); @@ -537,60 +212,13 @@ priority = 100 }); it('should reject commandPrefix without run_shell_command', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'invalid.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "glob" commandPrefix = "git status" decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.errors).toHaveLength(1); expect(result.errors[0].errorType).toBe('rule_validation'); @@ -598,61 +226,14 @@ priority = 100 }); it('should reject commandPrefix + argsPattern combination', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'invalid.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "run_shell_command" commandPrefix = "git status" argsPattern = "test" decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.errors).toHaveLength(1); expect(result.errors[0].errorType).toBe('rule_validation'); @@ -660,60 +241,13 @@ priority = 100 }); it('should handle invalid regex patterns', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'invalid.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "run_shell_command" commandRegex = "git (status|branch" decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(0); expect(result.errors).toHaveLength(1); @@ -722,60 +256,13 @@ priority = 100 }); it('should escape regex special characters in commandPrefix', async () => { - const actualFs = - await vi.importActual( - 'node:fs/promises', - ); - - const mockReaddir = vi.fn( - async ( - path: string, - _options?: { withFileTypes: boolean }, - ): Promise => { - if (nodePath.normalize(path) === nodePath.normalize('/policies')) { - return [ - { - name: 'shell.toml', - isFile: () => true, - isDirectory: () => false, - } as Dirent, - ]; - } - return []; - }, - ); - - const mockReadFile = vi.fn(async (path: string): Promise => { - if ( - nodePath.normalize(path) === - nodePath.normalize(nodePath.join('/policies', 'shell.toml')) - ) { - return ` + const result = await runLoadPoliciesFromToml(` [[rule]] toolName = "run_shell_command" commandPrefix = "git log *.txt" decision = "allow" priority = 100 -`; - } - throw new Error('File not found'); - }); - - vi.doMock('node:fs/promises', () => ({ - ...actualFs, - default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, - readFile: mockReadFile, - readdir: mockReaddir, - })); - - const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); - - const getPolicyTier = (_dir: string) => 1; - const result = await load( - ApprovalMode.DEFAULT, - ['/policies'], - getPolicyTier, - ); +`); expect(result.rules).toHaveLength(1); // The regex should have escaped the * and . @@ -787,5 +274,237 @@ priority = 100 ).toBe(true); expect(result.errors).toHaveLength(0); }); + + it('should handle a mix of valid and invalid policy files', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'valid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'valid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = 100 +`; + } + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "grep" +decision = "allow" +priority = -1 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(1); + expect(result.rules[0].toolName).toBe('glob'); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].fileName).toBe('invalid.toml'); + expect(result.errors[0].errorType).toBe('schema_validation'); + }); + }); + describe('Negative Tests', () => { + it('should return a schema_validation error if priority is missing', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "test" +decision = "allow" +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('schema_validation'); + expect(error.details).toContain('priority'); + }); + + it('should return a schema_validation error if priority is a float', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "test" +decision = "allow" +priority = 1.5 +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('schema_validation'); + expect(error.details).toContain('priority'); + expect(error.details).toContain('integer'); + }); + + it('should return a schema_validation error if priority is negative', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "test" +decision = "allow" +priority = -1 +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('schema_validation'); + expect(error.details).toContain('priority'); + expect(error.details).toContain('>= 0'); + }); + + it('should return a schema_validation error if priority is >= 1000', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "test" +decision = "allow" +priority = 1000 +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('schema_validation'); + expect(error.details).toContain('priority'); + expect(error.details).toContain('<= 999'); + }); + + it('should return a schema_validation error if decision is invalid', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "test" +decision = "maybe" +priority = 100 +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('schema_validation'); + expect(error.details).toContain('decision'); + }); + + it('should return a schema_validation error if toolName is not a string or array', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = 123 +decision = "allow" +priority = 100 +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('schema_validation'); + expect(error.details).toContain('toolName'); + }); + + it('should return a rule_validation error if commandRegex is used with wrong toolName', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "not_shell" +commandRegex = ".*" +decision = "allow" +priority = 100 +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('rule_validation'); + expect(error.details).toContain('run_shell_command'); + }); + + it('should return a rule_validation error if commandPrefix and commandRegex are combined', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git" +commandRegex = ".*" +decision = "allow" +priority = 100 +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('rule_validation'); + expect(error.details).toContain('mutually exclusive'); + }); + + it('should return a regex_compilation error for invalid argsPattern', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "test" +argsPattern = "([a-z)" +decision = "allow" +priority = 100 +`); + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('regex_compilation'); + expect(error.message).toBe('Invalid regex pattern'); + }); + + it('should return a file_read error if readdir fails', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn(async () => { + throw new Error('Permission denied'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readdir: mockReaddir }, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import('./toml-loader.js'); + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.errors).toHaveLength(1); + const error = result.errors[0]; + expect(error.errorType).toBe('file_read'); + expect(error.message).toContain('Failed to read policy directory'); + }); }); });