@google/gemini-cli 0.12.0-nightly.20251027.cb0947c5 → 0.12.0-preview.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +7 -5
  2. package/dist/package.json +2 -2
  3. package/dist/src/commands/extensions/disable.d.ts +1 -1
  4. package/dist/src/commands/extensions/disable.js +5 -4
  5. package/dist/src/commands/extensions/disable.js.map +1 -1
  6. package/dist/src/commands/extensions/enable.d.ts +1 -1
  7. package/dist/src/commands/extensions/enable.js +3 -2
  8. package/dist/src/commands/extensions/enable.js.map +1 -1
  9. package/dist/src/commands/extensions/install.js +2 -1
  10. package/dist/src/commands/extensions/install.js.map +1 -1
  11. package/dist/src/commands/extensions/install.test.js +1 -0
  12. package/dist/src/commands/extensions/install.test.js.map +1 -1
  13. package/dist/src/commands/extensions/link.js +2 -1
  14. package/dist/src/commands/extensions/link.js.map +1 -1
  15. package/dist/src/commands/extensions/list.js +2 -2
  16. package/dist/src/commands/extensions/list.js.map +1 -1
  17. package/dist/src/commands/extensions/uninstall.js +2 -1
  18. package/dist/src/commands/extensions/uninstall.js.map +1 -1
  19. package/dist/src/commands/extensions/update.js +2 -2
  20. package/dist/src/commands/extensions/update.js.map +1 -1
  21. package/dist/src/commands/mcp/list.js +2 -2
  22. package/dist/src/commands/mcp/list.js.map +1 -1
  23. package/dist/src/config/config.d.ts +5 -3
  24. package/dist/src/config/config.js +43 -10
  25. package/dist/src/config/config.js.map +1 -1
  26. package/dist/src/config/config.test.js +192 -171
  27. package/dist/src/config/config.test.js.map +1 -1
  28. package/dist/src/config/extension-manager.d.ts +23 -10
  29. package/dist/src/config/extension-manager.js +89 -62
  30. package/dist/src/config/extension-manager.js.map +1 -1
  31. package/dist/src/config/extension.test.js +158 -74
  32. package/dist/src/config/extension.test.js.map +1 -1
  33. package/dist/src/config/extensions/extensionSettings.d.ts +3 -3
  34. package/dist/src/config/extensions/extensionSettings.js +74 -24
  35. package/dist/src/config/extensions/extensionSettings.js.map +1 -1
  36. package/dist/src/config/extensions/extensionSettings.test.js +145 -24
  37. package/dist/src/config/extensions/extensionSettings.test.js.map +1 -1
  38. package/dist/src/config/extensions/github.js +3 -3
  39. package/dist/src/config/extensions/github.js.map +1 -1
  40. package/dist/src/config/extensions/github.test.js +1 -1
  41. package/dist/src/config/extensions/github.test.js.map +1 -1
  42. package/dist/src/config/extensions/update.js +7 -6
  43. package/dist/src/config/extensions/update.js.map +1 -1
  44. package/dist/src/config/extensions/update.test.js +54 -31
  45. package/dist/src/config/extensions/update.test.js.map +1 -1
  46. package/dist/src/config/keyBindings.js +1 -1
  47. package/dist/src/config/keyBindings.js.map +1 -1
  48. package/dist/src/config/policies/read-only.toml +56 -0
  49. package/dist/src/config/policies/write.toml +63 -0
  50. package/dist/src/config/policies/yolo.toml +31 -0
  51. package/dist/src/config/policy-engine.integration.test.js +41 -38
  52. package/dist/src/config/policy-engine.integration.test.js.map +1 -1
  53. package/dist/src/config/policy-toml-loader.d.ts +46 -0
  54. package/dist/src/config/policy-toml-loader.js +314 -0
  55. package/dist/src/config/policy-toml-loader.js.map +1 -0
  56. package/dist/src/config/policy-toml-loader.test.d.ts +6 -0
  57. package/dist/src/config/policy-toml-loader.test.js +626 -0
  58. package/dist/src/config/policy-toml-loader.test.js.map +1 -0
  59. package/dist/src/config/policy.d.ts +9 -2
  60. package/dist/src/config/policy.js +139 -110
  61. package/dist/src/config/policy.js.map +1 -1
  62. package/dist/src/config/policy.test.js +780 -82
  63. package/dist/src/config/policy.test.js.map +1 -1
  64. package/dist/src/config/settings.js +1 -1
  65. package/dist/src/config/settings.js.map +1 -1
  66. package/dist/src/config/settings.test.js +17 -57
  67. package/dist/src/config/settings.test.js.map +1 -1
  68. package/dist/src/config/settingsSchema.d.ts +18 -9
  69. package/dist/src/config/settingsSchema.js +17 -8
  70. package/dist/src/config/settingsSchema.js.map +1 -1
  71. package/dist/src/config/settingsSchema.test.js +2 -0
  72. package/dist/src/config/settingsSchema.test.js.map +1 -1
  73. package/dist/src/gemini.js +6 -17
  74. package/dist/src/gemini.js.map +1 -1
  75. package/dist/src/gemini.test.js +1 -0
  76. package/dist/src/gemini.test.js.map +1 -1
  77. package/dist/src/generated/git-commit.d.ts +2 -2
  78. package/dist/src/generated/git-commit.js +2 -2
  79. package/dist/src/generated/git-commit.js.map +1 -1
  80. package/dist/src/test-utils/render.d.ts +12 -0
  81. package/dist/src/test-utils/render.js +28 -1
  82. package/dist/src/test-utils/render.js.map +1 -1
  83. package/dist/src/test-utils/render.test.d.ts +6 -0
  84. package/dist/src/test-utils/render.test.js +54 -0
  85. package/dist/src/test-utils/render.test.js.map +1 -0
  86. package/dist/src/ui/AppContainer.js +33 -22
  87. package/dist/src/ui/AppContainer.js.map +1 -1
  88. package/dist/src/ui/AppContainer.test.js +30 -1
  89. package/dist/src/ui/AppContainer.test.js.map +1 -1
  90. package/dist/src/ui/commands/directoryCommand.js +1 -1
  91. package/dist/src/ui/commands/directoryCommand.js.map +1 -1
  92. package/dist/src/ui/commands/extensionsCommand.js +45 -1
  93. package/dist/src/ui/commands/extensionsCommand.js.map +1 -1
  94. package/dist/src/ui/commands/extensionsCommand.test.js +64 -1
  95. package/dist/src/ui/commands/extensionsCommand.test.js.map +1 -1
  96. package/dist/src/ui/commands/memoryCommand.js +1 -1
  97. package/dist/src/ui/commands/memoryCommand.js.map +1 -1
  98. package/dist/src/ui/commands/memoryCommand.test.js +3 -1
  99. package/dist/src/ui/commands/memoryCommand.test.js.map +1 -1
  100. package/dist/src/ui/components/ConsoleSummaryDisplay.js +1 -1
  101. package/dist/src/ui/components/ConsoleSummaryDisplay.js.map +1 -1
  102. package/dist/src/ui/components/DetailedMessagesDisplay.js +1 -1
  103. package/dist/src/ui/components/DetailedMessagesDisplay.js.map +1 -1
  104. package/dist/src/ui/components/FolderTrustDialog.test.js +4 -5
  105. package/dist/src/ui/components/FolderTrustDialog.test.js.map +1 -1
  106. package/dist/src/ui/components/Footer.js +4 -3
  107. package/dist/src/ui/components/Footer.js.map +1 -1
  108. package/dist/src/ui/components/Footer.test.js +83 -0
  109. package/dist/src/ui/components/Footer.test.js.map +1 -1
  110. package/dist/src/ui/components/Help.test.js +0 -1
  111. package/dist/src/ui/components/Help.test.js.map +1 -1
  112. package/dist/src/ui/components/ModelDialog.test.js +5 -6
  113. package/dist/src/ui/components/ModelDialog.test.js.map +1 -1
  114. package/dist/src/ui/components/Notifications.js +38 -5
  115. package/dist/src/ui/components/Notifications.js.map +1 -1
  116. package/dist/src/ui/components/PermissionsModifyTrustDialog.test.js +11 -13
  117. package/dist/src/ui/components/PermissionsModifyTrustDialog.test.js.map +1 -1
  118. package/dist/src/ui/components/SettingsDialog.test.js +12 -14
  119. package/dist/src/ui/components/SettingsDialog.test.js.map +1 -1
  120. package/dist/src/ui/components/shared/BaseSelectionList.test.js +11 -13
  121. package/dist/src/ui/components/shared/BaseSelectionList.test.js.map +1 -1
  122. package/dist/src/ui/components/shared/text-buffer.test.js +2 -2
  123. package/dist/src/ui/components/shared/text-buffer.test.js.map +1 -1
  124. package/dist/src/ui/contexts/KeypressContext.js +8 -29
  125. package/dist/src/ui/contexts/KeypressContext.js.map +1 -1
  126. package/dist/src/ui/contexts/KeypressContext.test.js +90 -73
  127. package/dist/src/ui/contexts/KeypressContext.test.js.map +1 -1
  128. package/dist/src/ui/contexts/SessionContext.test.js +27 -14
  129. package/dist/src/ui/contexts/SessionContext.test.js.map +1 -1
  130. package/dist/src/ui/hooks/atCommandProcessor.js +2 -2
  131. package/dist/src/ui/hooks/atCommandProcessor.js.map +1 -1
  132. package/dist/src/ui/hooks/useAtCompletion.test.js +32 -23
  133. package/dist/src/ui/hooks/useAtCompletion.test.js.map +1 -1
  134. package/dist/src/ui/hooks/useAutoAcceptIndicator.test.js +2 -2
  135. package/dist/src/ui/hooks/useAutoAcceptIndicator.test.js.map +1 -1
  136. package/dist/src/ui/hooks/useExtensionUpdates.d.ts +1 -2
  137. package/dist/src/ui/hooks/useExtensionUpdates.js +2 -1
  138. package/dist/src/ui/hooks/useExtensionUpdates.js.map +1 -1
  139. package/dist/src/ui/hooks/useExtensionUpdates.test.js +14 -20
  140. package/dist/src/ui/hooks/useExtensionUpdates.test.js.map +1 -1
  141. package/dist/src/ui/hooks/useFlickerDetector.test.js +9 -6
  142. package/dist/src/ui/hooks/useFlickerDetector.test.js.map +1 -1
  143. package/dist/src/ui/hooks/useFolderTrust.test.js +45 -23
  144. package/dist/src/ui/hooks/useFolderTrust.test.js.map +1 -1
  145. package/dist/src/ui/hooks/useGeminiStream.js +7 -5
  146. package/dist/src/ui/hooks/useGeminiStream.js.map +1 -1
  147. package/dist/src/ui/hooks/useGeminiStream.test.js +42 -41
  148. package/dist/src/ui/hooks/useGeminiStream.test.js.map +1 -1
  149. package/dist/src/ui/hooks/useHistoryManager.test.js +2 -2
  150. package/dist/src/ui/hooks/useHistoryManager.test.js.map +1 -1
  151. package/dist/src/ui/hooks/useInputHistory.test.js +2 -2
  152. package/dist/src/ui/hooks/useInputHistory.test.js.map +1 -1
  153. package/dist/src/ui/hooks/useInputHistoryStore.test.js +2 -2
  154. package/dist/src/ui/hooks/useInputHistoryStore.test.js.map +1 -1
  155. package/dist/src/ui/hooks/usePermissionsModifyTrust.test.js +2 -3
  156. package/dist/src/ui/hooks/usePermissionsModifyTrust.test.js.map +1 -1
  157. package/dist/src/ui/hooks/usePhraseCycler.js +1 -1
  158. package/dist/src/ui/hooks/usePhraseCycler.js.map +1 -1
  159. package/dist/src/ui/hooks/usePhraseCycler.test.js +83 -111
  160. package/dist/src/ui/hooks/usePhraseCycler.test.js.map +1 -1
  161. package/dist/src/ui/hooks/useQuotaAndFallback.test.js +2 -2
  162. package/dist/src/ui/hooks/useQuotaAndFallback.test.js.map +1 -1
  163. package/dist/src/ui/hooks/useReactToolScheduler.test.js +1 -2
  164. package/dist/src/ui/hooks/useReactToolScheduler.test.js.map +1 -1
  165. package/dist/src/ui/hooks/useReverseSearchCompletion.test.js +2 -2
  166. package/dist/src/ui/hooks/useReverseSearchCompletion.test.js.map +1 -1
  167. package/dist/src/ui/hooks/useShellHistory.test.js +40 -17
  168. package/dist/src/ui/hooks/useShellHistory.test.js.map +1 -1
  169. package/dist/src/ui/hooks/useSlashCompletion.test.js +54 -49
  170. package/dist/src/ui/hooks/useSlashCompletion.test.js.map +1 -1
  171. package/dist/src/ui/hooks/useToolScheduler.test.js +48 -42
  172. package/dist/src/ui/hooks/useToolScheduler.test.js.map +1 -1
  173. package/dist/src/ui/keyMatchers.test.js +3 -3
  174. package/dist/src/ui/keyMatchers.test.js.map +1 -1
  175. package/dist/src/zed-integration/zedIntegration.d.ts +2 -2
  176. package/dist/src/zed-integration/zedIntegration.js +4 -6
  177. package/dist/src/zed-integration/zedIntegration.js.map +1 -1
  178. package/dist/tsconfig.tsbuildinfo +1 -1
  179. package/package.json +3 -3
@@ -3,113 +3,141 @@
3
3
  * Copyright 2025 Google LLC
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
- import { describe, it, expect } from 'vitest';
7
- import { createPolicyEngineConfig } from './policy.js';
6
+ import { describe, it, expect, vi, afterEach } from 'vitest';
7
+ import nodePath from 'node:path';
8
8
  import { ApprovalMode, PolicyDecision, WEB_FETCH_TOOL_NAME, } from '@google/gemini-cli-core';
9
+ afterEach(() => {
10
+ vi.clearAllMocks();
11
+ });
9
12
  describe('createPolicyEngineConfig', () => {
10
- it('should return ASK_USER for write tools and ALLOW for read-only tools by default', () => {
13
+ it('should return ASK_USER for write tools and ALLOW for read-only tools by default', async () => {
14
+ const actualFs = await vi.importActual('node:fs/promises');
15
+ const mockReaddir = vi.fn(async (path, options) => {
16
+ if (typeof path === 'string' &&
17
+ nodePath
18
+ .normalize(path)
19
+ .includes(nodePath.normalize('.gemini/policies'))) {
20
+ // Return empty array for user policies
21
+ return [];
22
+ }
23
+ return actualFs.readdir(path, options);
24
+ });
25
+ vi.doMock('node:fs/promises', () => ({
26
+ ...actualFs,
27
+ default: { ...actualFs, readdir: mockReaddir },
28
+ readdir: mockReaddir,
29
+ }));
30
+ vi.resetModules();
31
+ const { createPolicyEngineConfig } = await import('./policy.js');
11
32
  const settings = {};
12
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
33
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
13
34
  expect(config.defaultDecision).toBe(PolicyDecision.ASK_USER);
14
35
  // The order of the rules is not guaranteed, so we sort them by tool name.
15
36
  config.rules?.sort((a, b) => (a.toolName ?? '').localeCompare(b.toolName ?? ''));
37
+ // Default policies are transformed to tier 1: 1 + priority/1000
16
38
  expect(config.rules).toEqual([
17
39
  {
18
40
  toolName: 'glob',
19
41
  decision: PolicyDecision.ALLOW,
20
- priority: 50,
42
+ priority: 1.05, // 1 + 50/1000
21
43
  },
22
44
  {
23
45
  toolName: 'google_web_search',
24
46
  decision: PolicyDecision.ALLOW,
25
- priority: 50,
47
+ priority: 1.05,
26
48
  },
27
49
  {
28
50
  toolName: 'list_directory',
29
51
  decision: PolicyDecision.ALLOW,
30
- priority: 50,
52
+ priority: 1.05,
31
53
  },
32
54
  {
33
55
  toolName: 'read_file',
34
56
  decision: PolicyDecision.ALLOW,
35
- priority: 50,
57
+ priority: 1.05,
36
58
  },
37
59
  {
38
60
  toolName: 'read_many_files',
39
61
  decision: PolicyDecision.ALLOW,
40
- priority: 50,
62
+ priority: 1.05,
41
63
  },
42
64
  {
43
65
  toolName: 'replace',
44
66
  decision: PolicyDecision.ASK_USER,
45
- priority: 10,
67
+ priority: 1.01, // 1 + 10/1000
46
68
  },
47
69
  {
48
70
  toolName: 'run_shell_command',
49
71
  decision: PolicyDecision.ASK_USER,
50
- priority: 10,
72
+ priority: 1.01,
51
73
  },
52
74
  {
53
75
  toolName: 'save_memory',
54
76
  decision: PolicyDecision.ASK_USER,
55
- priority: 10,
77
+ priority: 1.01,
56
78
  },
57
79
  {
58
80
  toolName: 'search_file_content',
59
81
  decision: PolicyDecision.ALLOW,
60
- priority: 50,
82
+ priority: 1.05,
61
83
  },
62
84
  {
63
85
  toolName: 'web_fetch',
64
86
  decision: PolicyDecision.ASK_USER,
65
- priority: 10,
87
+ priority: 1.01,
66
88
  },
67
89
  {
68
90
  toolName: 'write_file',
69
91
  decision: PolicyDecision.ASK_USER,
70
- priority: 10,
92
+ priority: 1.01,
71
93
  },
72
94
  ]);
95
+ vi.doUnmock('node:fs/promises');
73
96
  });
74
- it('should allow tools in tools.allowed', () => {
97
+ it('should allow tools in tools.allowed', async () => {
98
+ const { createPolicyEngineConfig } = await import('./policy.js');
75
99
  const settings = {
76
100
  tools: { allowed: ['run_shell_command'] },
77
101
  };
78
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
102
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
79
103
  const rule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
80
104
  r.decision === PolicyDecision.ALLOW);
81
105
  expect(rule).toBeDefined();
82
- expect(rule?.priority).toBe(100);
106
+ expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow
83
107
  });
84
- it('should deny tools in tools.exclude', () => {
108
+ it('should deny tools in tools.exclude', async () => {
109
+ const { createPolicyEngineConfig } = await import('./policy.js');
85
110
  const settings = {
86
111
  tools: { exclude: ['run_shell_command'] },
87
112
  };
88
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
113
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
89
114
  const rule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
90
115
  r.decision === PolicyDecision.DENY);
91
116
  expect(rule).toBeDefined();
92
- expect(rule?.priority).toBe(200);
117
+ expect(rule?.priority).toBeCloseTo(2.4, 5); // Command line exclude
93
118
  });
94
- it('should allow tools from allowed MCP servers', () => {
119
+ it('should allow tools from allowed MCP servers', async () => {
120
+ const { createPolicyEngineConfig } = await import('./policy.js');
95
121
  const settings = {
96
122
  mcp: { allowed: ['my-server'] },
97
123
  };
98
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
124
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
99
125
  const rule = config.rules?.find((r) => r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW);
100
126
  expect(rule).toBeDefined();
101
- expect(rule?.priority).toBe(85);
127
+ expect(rule?.priority).toBe(2.1); // MCP allowed server
102
128
  });
103
- it('should deny tools from excluded MCP servers', () => {
129
+ it('should deny tools from excluded MCP servers', async () => {
130
+ const { createPolicyEngineConfig } = await import('./policy.js');
104
131
  const settings = {
105
132
  mcp: { excluded: ['my-server'] },
106
133
  };
107
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
134
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
108
135
  const rule = config.rules?.find((r) => r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY);
109
136
  expect(rule).toBeDefined();
110
- expect(rule?.priority).toBe(195);
137
+ expect(rule?.priority).toBe(2.9); // MCP excluded server
111
138
  });
112
- it('should allow tools from trusted MCP servers', () => {
139
+ it('should allow tools from trusted MCP servers', async () => {
140
+ const { createPolicyEngineConfig } = await import('./policy.js');
113
141
  const settings = {
114
142
  mcpServers: {
115
143
  'trusted-server': {
@@ -124,17 +152,18 @@ describe('createPolicyEngineConfig', () => {
124
152
  },
125
153
  },
126
154
  };
127
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
155
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
128
156
  const trustedRule = config.rules?.find((r) => r.toolName === 'trusted-server__*' &&
129
157
  r.decision === PolicyDecision.ALLOW);
130
158
  expect(trustedRule).toBeDefined();
131
- expect(trustedRule?.priority).toBe(90);
159
+ expect(trustedRule?.priority).toBe(2.2); // MCP trusted server
132
160
  // Untrusted server should not have an allow rule
133
161
  const untrustedRule = config.rules?.find((r) => r.toolName === 'untrusted-server__*' &&
134
162
  r.decision === PolicyDecision.ALLOW);
135
163
  expect(untrustedRule).toBeUndefined();
136
164
  });
137
- it('should handle multiple MCP server configurations together', () => {
165
+ it('should handle multiple MCP server configurations together', async () => {
166
+ const { createPolicyEngineConfig } = await import('./policy.js');
138
167
  const settings = {
139
168
  mcp: {
140
169
  allowed: ['allowed-server'],
@@ -148,41 +177,47 @@ describe('createPolicyEngineConfig', () => {
148
177
  },
149
178
  },
150
179
  };
151
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
180
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
152
181
  // Check allowed server
153
182
  const allowedRule = config.rules?.find((r) => r.toolName === 'allowed-server__*' &&
154
183
  r.decision === PolicyDecision.ALLOW);
155
184
  expect(allowedRule).toBeDefined();
156
- expect(allowedRule?.priority).toBe(85);
185
+ expect(allowedRule?.priority).toBe(2.1); // MCP allowed server
157
186
  // Check trusted server
158
187
  const trustedRule = config.rules?.find((r) => r.toolName === 'trusted-server__*' &&
159
188
  r.decision === PolicyDecision.ALLOW);
160
189
  expect(trustedRule).toBeDefined();
161
- expect(trustedRule?.priority).toBe(90);
190
+ expect(trustedRule?.priority).toBe(2.2); // MCP trusted server
162
191
  // Check excluded server
163
192
  const excludedRule = config.rules?.find((r) => r.toolName === 'excluded-server__*' &&
164
193
  r.decision === PolicyDecision.DENY);
165
194
  expect(excludedRule).toBeDefined();
166
- expect(excludedRule?.priority).toBe(195);
195
+ expect(excludedRule?.priority).toBe(2.9); // MCP excluded server
167
196
  });
168
- it('should allow all tools in YOLO mode', () => {
197
+ it('should allow all tools in YOLO mode', async () => {
198
+ const { createPolicyEngineConfig } = await import('./policy.js');
169
199
  const settings = {};
170
- const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
171
- const rule = config.rules?.find((r) => r.decision === PolicyDecision.ALLOW && r.priority === 0);
200
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.YOLO);
201
+ const rule = config.rules?.find((r) => r.decision === PolicyDecision.ALLOW && !r.toolName);
172
202
  expect(rule).toBeDefined();
203
+ // Priority 999 in default tier → 1.999
204
+ expect(rule?.priority).toBeCloseTo(1.999, 5);
173
205
  });
174
- it('should allow edit tool in AUTO_EDIT mode', () => {
206
+ it('should allow edit tool in AUTO_EDIT mode', async () => {
207
+ const { createPolicyEngineConfig } = await import('./policy.js');
175
208
  const settings = {};
176
- const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT);
209
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT);
177
210
  const rule = config.rules?.find((r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW);
178
211
  expect(rule).toBeDefined();
179
- expect(rule?.priority).toBe(15);
212
+ // Priority 15 in default tier → 1.015
213
+ expect(rule?.priority).toBeCloseTo(1.015, 5);
180
214
  });
181
- it('should prioritize exclude over allow', () => {
215
+ it('should prioritize exclude over allow', async () => {
216
+ const { createPolicyEngineConfig } = await import('./policy.js');
182
217
  const settings = {
183
218
  tools: { allowed: ['run_shell_command'], exclude: ['run_shell_command'] },
184
219
  };
185
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
220
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
186
221
  const denyRule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
187
222
  r.decision === PolicyDecision.DENY);
188
223
  const allowRule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
@@ -191,23 +226,25 @@ describe('createPolicyEngineConfig', () => {
191
226
  expect(allowRule).toBeDefined();
192
227
  expect(denyRule.priority).toBeGreaterThan(allowRule.priority);
193
228
  });
194
- it('should prioritize specific tool allows over MCP server excludes', () => {
229
+ it('should prioritize specific tool allows over MCP server excludes', async () => {
230
+ const { createPolicyEngineConfig } = await import('./policy.js');
195
231
  const settings = {
196
232
  mcp: { excluded: ['my-server'] },
197
233
  tools: { allowed: ['my-server__specific-tool'] },
198
234
  };
199
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
235
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
200
236
  const serverDenyRule = config.rules?.find((r) => r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY);
201
237
  const toolAllowRule = config.rules?.find((r) => r.toolName === 'my-server__specific-tool' &&
202
238
  r.decision === PolicyDecision.ALLOW);
203
239
  expect(serverDenyRule).toBeDefined();
204
- expect(serverDenyRule?.priority).toBe(195);
240
+ expect(serverDenyRule?.priority).toBe(2.9); // MCP excluded server
205
241
  expect(toolAllowRule).toBeDefined();
206
- expect(toolAllowRule?.priority).toBe(100);
207
- // Tool allow (100) has lower priority than server deny (195),
208
- // so server deny wins - this might be counterintuitive
242
+ expect(toolAllowRule?.priority).toBeCloseTo(2.3, 5); // Command line allow
243
+ // Server deny (2.9) has higher priority than tool allow (2.3),
244
+ // so server deny wins (this is expected behavior - server-level blocks are security critical)
209
245
  });
210
- it('should prioritize specific tool excludes over MCP server allows', () => {
246
+ it('should handle MCP server allows and tool excludes', async () => {
247
+ const { createPolicyEngineConfig } = await import('./policy.js');
211
248
  const settings = {
212
249
  mcp: { allowed: ['my-server'] },
213
250
  mcpServers: {
@@ -219,24 +256,27 @@ describe('createPolicyEngineConfig', () => {
219
256
  },
220
257
  tools: { exclude: ['my-server__dangerous-tool'] },
221
258
  };
222
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
259
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
223
260
  const serverAllowRule = config.rules?.find((r) => r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW);
224
261
  const toolDenyRule = config.rules?.find((r) => r.toolName === 'my-server__dangerous-tool' &&
225
262
  r.decision === PolicyDecision.DENY);
226
263
  expect(serverAllowRule).toBeDefined();
227
264
  expect(toolDenyRule).toBeDefined();
265
+ // Command line exclude (2.4) has higher priority than MCP server trust (2.2)
266
+ // This is the correct behavior - specific exclusions should beat general server trust
228
267
  expect(toolDenyRule.priority).toBeGreaterThan(serverAllowRule.priority);
229
268
  });
230
- it('should handle complex priority scenarios correctly', () => {
269
+ it('should handle complex priority scenarios correctly', async () => {
270
+ const { createPolicyEngineConfig } = await import('./policy.js');
231
271
  const settings = {
232
272
  tools: {
233
- autoAccept: true, // Priority 50 for read-only tools
234
- allowed: ['my-server__tool1', 'other-tool'], // Priority 100
235
- exclude: ['my-server__tool2', 'glob'], // Priority 200
273
+ autoAccept: true, // Not used in policy system (modes handle this)
274
+ allowed: ['my-server__tool1', 'other-tool'], // Priority 2.3
275
+ exclude: ['my-server__tool2', 'glob'], // Priority 2.4
236
276
  },
237
277
  mcp: {
238
- allowed: ['allowed-server'], // Priority 85
239
- excluded: ['excluded-server'], // Priority 195
278
+ allowed: ['allowed-server'], // Priority 2.1
279
+ excluded: ['excluded-server'], // Priority 2.9
240
280
  },
241
281
  mcpServers: {
242
282
  'trusted-server': {
@@ -246,14 +286,16 @@ describe('createPolicyEngineConfig', () => {
246
286
  },
247
287
  },
248
288
  };
249
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
289
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
250
290
  // Verify glob is denied even though autoAccept would allow it
251
291
  const globDenyRule = config.rules?.find((r) => r.toolName === 'glob' && r.decision === PolicyDecision.DENY);
252
292
  const globAllowRule = config.rules?.find((r) => r.toolName === 'glob' && r.decision === PolicyDecision.ALLOW);
253
293
  expect(globDenyRule).toBeDefined();
254
294
  expect(globAllowRule).toBeDefined();
255
- expect(globDenyRule.priority).toBe(200);
256
- expect(globAllowRule.priority).toBe(50);
295
+ // Deny from settings (user tier)
296
+ expect(globDenyRule.priority).toBeCloseTo(2.4, 5); // Command line exclude
297
+ // Allow from default TOML: 1 + 50/1000 = 1.05
298
+ expect(globAllowRule.priority).toBeCloseTo(1.05, 5);
257
299
  // Verify all priority levels are correct
258
300
  const priorities = config.rules
259
301
  ?.map((r) => ({
@@ -262,11 +304,12 @@ describe('createPolicyEngineConfig', () => {
262
304
  priority: r.priority,
263
305
  }))
264
306
  .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
265
- // Check that the highest priority items are the excludes
266
- const highestPriorityExcludes = priorities?.filter((p) => p.priority === 200);
307
+ // Check that the highest priority items are the excludes (user tier: 2.4)
308
+ const highestPriorityExcludes = priorities?.filter((p) => Math.abs(p.priority - 2.4) < 0.01);
267
309
  expect(highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY)).toBe(true);
268
310
  });
269
- it('should handle MCP servers with undefined trust property', () => {
311
+ it('should handle MCP servers with undefined trust property', async () => {
312
+ const { createPolicyEngineConfig } = await import('./policy.js');
270
313
  const settings = {
271
314
  mcpServers: {
272
315
  'no-trust-property': {
@@ -281,7 +324,7 @@ describe('createPolicyEngineConfig', () => {
281
324
  },
282
325
  },
283
326
  };
284
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
327
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
285
328
  // Neither server should have an allow rule
286
329
  const noTrustRule = config.rules?.find((r) => r.toolName === 'no-trust-property__*' &&
287
330
  r.decision === PolicyDecision.ALLOW);
@@ -290,15 +333,18 @@ describe('createPolicyEngineConfig', () => {
290
333
  expect(noTrustRule).toBeUndefined();
291
334
  expect(explicitFalseRule).toBeUndefined();
292
335
  });
293
- it('should not add write tool rules in YOLO mode', () => {
336
+ it('should have YOLO allow-all rule beat write tool rules in YOLO mode', async () => {
337
+ const { createPolicyEngineConfig } = await import('./policy.js');
294
338
  const settings = {
295
339
  tools: { exclude: ['dangerous-tool'] },
296
340
  };
297
- const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
298
- // Should have the wildcard allow rule with priority 0
299
- const wildcardRule = config.rules?.find((r) => !r.toolName && r.decision === PolicyDecision.ALLOW && r.priority === 0);
341
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.YOLO);
342
+ // Should have the wildcard allow rule
343
+ const wildcardRule = config.rules?.find((r) => !r.toolName && r.decision === PolicyDecision.ALLOW);
300
344
  expect(wildcardRule).toBeDefined();
301
- // Should NOT have any write tool rules (which would have priority 10)
345
+ // Priority 999 in default tier 1.999
346
+ expect(wildcardRule?.priority).toBeCloseTo(1.999, 5);
347
+ // Write tool ASK_USER rules are present (no modes restriction now)
302
348
  const writeToolRules = config.rules?.filter((r) => [
303
349
  'replace',
304
350
  'save_memory',
@@ -306,13 +352,18 @@ describe('createPolicyEngineConfig', () => {
306
352
  'write_file',
307
353
  WEB_FETCH_TOOL_NAME,
308
354
  ].includes(r.toolName || '') && r.decision === PolicyDecision.ASK_USER);
309
- expect(writeToolRules).toHaveLength(0);
310
- // Should still have the exclude rule
355
+ expect(writeToolRules).toBeDefined();
356
+ // But YOLO allow-all rule has higher priority than all write tool rules
357
+ writeToolRules?.forEach((writeRule) => {
358
+ expect(wildcardRule.priority).toBeGreaterThan(writeRule.priority);
359
+ });
360
+ // Should still have the exclude rule (from settings, user tier)
311
361
  const excludeRule = config.rules?.find((r) => r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY);
312
362
  expect(excludeRule).toBeDefined();
313
- expect(excludeRule?.priority).toBe(200);
363
+ expect(excludeRule?.priority).toBeCloseTo(2.4, 5); // Command line exclude
314
364
  });
315
- it('should handle combination of trusted server and excluded server for same name', () => {
365
+ it('should handle combination of trusted server and excluded server for same name', async () => {
366
+ const { createPolicyEngineConfig } = await import('./policy.js');
316
367
  const settings = {
317
368
  mcpServers: {
318
369
  'conflicted-server': {
@@ -325,36 +376,683 @@ describe('createPolicyEngineConfig', () => {
325
376
  excluded: ['conflicted-server'], // Priority 195
326
377
  },
327
378
  };
328
- const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
379
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
329
380
  // Both rules should exist
330
381
  const trustRule = config.rules?.find((r) => r.toolName === 'conflicted-server__*' &&
331
382
  r.decision === PolicyDecision.ALLOW);
332
383
  const excludeRule = config.rules?.find((r) => r.toolName === 'conflicted-server__*' &&
333
384
  r.decision === PolicyDecision.DENY);
334
385
  expect(trustRule).toBeDefined();
335
- expect(trustRule?.priority).toBe(90);
386
+ expect(trustRule?.priority).toBe(2.2); // MCP trusted server
336
387
  expect(excludeRule).toBeDefined();
337
- expect(excludeRule?.priority).toBe(195);
388
+ expect(excludeRule?.priority).toBe(2.9); // MCP excluded server
338
389
  // Exclude (195) should win over trust (90) when evaluated
339
390
  });
340
- it('should handle all approval modes correctly', () => {
391
+ it('should handle all approval modes correctly', async () => {
392
+ const { createPolicyEngineConfig } = await import('./policy.js');
341
393
  const settings = {};
342
394
  // Test DEFAULT mode
343
- const defaultConfig = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
395
+ const defaultConfig = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
344
396
  expect(defaultConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
345
397
  expect(defaultConfig.rules?.find((r) => !r.toolName && r.decision === PolicyDecision.ALLOW)).toBeUndefined();
346
398
  // Test YOLO mode
347
- const yoloConfig = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
399
+ const yoloConfig = await createPolicyEngineConfig(settings, ApprovalMode.YOLO);
348
400
  expect(yoloConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
349
401
  const yoloWildcard = yoloConfig.rules?.find((r) => !r.toolName && r.decision === PolicyDecision.ALLOW);
350
402
  expect(yoloWildcard).toBeDefined();
351
- expect(yoloWildcard?.priority).toBe(0);
403
+ // Priority 999 in default tier → 1.999
404
+ expect(yoloWildcard?.priority).toBeCloseTo(1.999, 5);
352
405
  // Test AUTO_EDIT mode
353
- const autoEditConfig = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT);
406
+ const autoEditConfig = await createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT);
354
407
  expect(autoEditConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
355
408
  const editRule = autoEditConfig.rules?.find((r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW);
356
409
  expect(editRule).toBeDefined();
357
- expect(editRule?.priority).toBe(15);
410
+ // Priority 15 in default tier → 1.015
411
+ expect(editRule?.priority).toBeCloseTo(1.015, 5);
412
+ });
413
+ it('should support argsPattern in policy rules', async () => {
414
+ const actualFs = await vi.importActual('node:fs/promises');
415
+ const mockReaddir = vi.fn(async (path, options) => {
416
+ if (typeof path === 'string' &&
417
+ nodePath
418
+ .normalize(path)
419
+ .includes(nodePath.normalize('.gemini/policies'))) {
420
+ return [
421
+ {
422
+ name: 'write.toml',
423
+ isFile: () => true,
424
+ isDirectory: () => false,
425
+ },
426
+ ];
427
+ }
428
+ return actualFs.readdir(path, options);
429
+ });
430
+ const mockReadFile = vi.fn(async (path, options) => {
431
+ if (typeof path === 'string' &&
432
+ nodePath
433
+ .normalize(path)
434
+ .includes(nodePath.normalize('.gemini/policies/write.toml'))) {
435
+ return `
436
+ [[rule]]
437
+ toolName = "run_shell_command"
438
+ argsPattern = "\\"command\\":\\"git (status|diff|log)\\""
439
+ decision = "allow"
440
+ priority = 150
441
+ `;
442
+ }
443
+ return actualFs.readFile(path, options);
444
+ });
445
+ vi.doMock('node:fs/promises', () => ({
446
+ ...actualFs,
447
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
448
+ readFile: mockReadFile,
449
+ readdir: mockReaddir,
450
+ }));
451
+ vi.resetModules();
452
+ const { createPolicyEngineConfig } = await import('./policy.js');
453
+ const settings = {};
454
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
455
+ const rule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
456
+ r.decision === PolicyDecision.ALLOW);
457
+ expect(rule).toBeDefined();
458
+ // Priority 150 in user tier → 2.150
459
+ expect(rule?.priority).toBeCloseTo(2.15, 5);
460
+ expect(rule?.argsPattern).toBeInstanceOf(RegExp);
461
+ expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true);
462
+ expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true);
463
+ expect(rule?.argsPattern?.test('{"command":"git log"}')).toBe(true);
464
+ expect(rule?.argsPattern?.test('{"command":"git commit"}')).toBe(false);
465
+ expect(rule?.argsPattern?.test('{"command":"git push"}')).toBe(false);
466
+ vi.doUnmock('node:fs/promises');
467
+ });
468
+ it('should load and apply user-defined policies', async () => {
469
+ const actualFs = await vi.importActual('node:fs/promises');
470
+ const mockReaddir = vi.fn(async (path, options) => {
471
+ if (typeof path === 'string' &&
472
+ nodePath
473
+ .normalize(path)
474
+ .includes(nodePath.normalize('.gemini/policies'))) {
475
+ return [
476
+ {
477
+ name: 'write.toml',
478
+ isFile: () => true,
479
+ isDirectory: () => false,
480
+ },
481
+ ];
482
+ }
483
+ return actualFs.readdir(path, options);
484
+ });
485
+ const mockReadFile = vi.fn(async (path, options) => {
486
+ if (typeof path === 'string' &&
487
+ nodePath
488
+ .normalize(path)
489
+ .includes(nodePath.normalize('.gemini/policies/write.toml'))) {
490
+ return `
491
+ [[rule]]
492
+ toolName = "run_shell_command"
493
+ decision = "allow"
494
+ priority = 150
495
+ `;
496
+ }
497
+ return actualFs.readFile(path, options);
498
+ });
499
+ vi.doMock('node:fs/promises', () => ({
500
+ ...actualFs,
501
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
502
+ readFile: mockReadFile,
503
+ readdir: mockReaddir,
504
+ }));
505
+ vi.resetModules();
506
+ const { createPolicyEngineConfig } = await import('./policy.js');
507
+ const settings = {};
508
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
509
+ const rule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
510
+ r.decision === PolicyDecision.ALLOW);
511
+ expect(rule).toBeDefined();
512
+ // Priority 150 in user tier → 2.150
513
+ expect(rule?.priority).toBeCloseTo(2.15, 5);
514
+ vi.doUnmock('node:fs/promises');
515
+ });
516
+ it('should load and apply admin policies over user and default policies', async () => {
517
+ process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json';
518
+ const actualFs = await vi.importActual('node:fs/promises');
519
+ const mockReaddir = vi.fn(async (path, options) => {
520
+ if (typeof path === 'string') {
521
+ if (nodePath
522
+ .normalize(path)
523
+ .includes(nodePath.normalize('/tmp/admin/policies'))) {
524
+ return [
525
+ {
526
+ name: 'write.toml',
527
+ isFile: () => true,
528
+ isDirectory: () => false,
529
+ },
530
+ ];
531
+ }
532
+ if (nodePath
533
+ .normalize(path)
534
+ .includes(nodePath.normalize('.gemini/policies'))) {
535
+ return [
536
+ {
537
+ name: 'write.toml',
538
+ isFile: () => true,
539
+ isDirectory: () => false,
540
+ },
541
+ ];
542
+ }
543
+ }
544
+ return actualFs.readdir(path, options);
545
+ });
546
+ const mockReadFile = vi.fn(async (path, options) => {
547
+ if (typeof path === 'string' &&
548
+ (nodePath
549
+ .normalize(path)
550
+ .includes(nodePath.normalize('/tmp/admin/policies/write.toml')) ||
551
+ path.endsWith('tmp/admin/policies/write.toml'))) {
552
+ return `
553
+ [[rule]]
554
+ toolName = "run_shell_command"
555
+ decision = "deny"
556
+ priority = 200
557
+ `;
558
+ }
559
+ if (typeof path === 'string' &&
560
+ nodePath
561
+ .normalize(path)
562
+ .includes(nodePath.normalize('.gemini/policies/write.toml'))) {
563
+ return `
564
+ [[rule]]
565
+ toolName = "run_shell_command"
566
+ decision = "allow"
567
+ priority = 150
568
+ `;
569
+ }
570
+ return actualFs.readFile(path, options);
571
+ });
572
+ vi.doMock('node:fs/promises', () => ({
573
+ ...actualFs,
574
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
575
+ readFile: mockReadFile,
576
+ readdir: mockReaddir,
577
+ }));
578
+ vi.resetModules();
579
+ const { createPolicyEngineConfig } = await import('./policy.js');
580
+ const settings = {};
581
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
582
+ const denyRule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
583
+ r.decision === PolicyDecision.DENY);
584
+ const allowRule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
585
+ r.decision === PolicyDecision.ALLOW);
586
+ expect(denyRule).toBeDefined();
587
+ // Priority 200 in admin tier → 3.200
588
+ expect(denyRule?.priority).toBeCloseTo(3.2, 5);
589
+ expect(allowRule).toBeDefined();
590
+ // Priority 150 in user tier → 2.150
591
+ expect(allowRule?.priority).toBeCloseTo(2.15, 5);
592
+ expect(denyRule.priority).toBeGreaterThan(allowRule.priority);
593
+ delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
594
+ vi.doUnmock('node:fs/promises');
595
+ });
596
+ it('should apply priority bands to ensure Admin > User > Default hierarchy', async () => {
597
+ process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json';
598
+ const actualFs = await vi.importActual('node:fs/promises');
599
+ const mockReaddir = vi.fn(async (path, options) => {
600
+ if (typeof path === 'string') {
601
+ if (nodePath
602
+ .normalize(path)
603
+ .includes(nodePath.normalize('/tmp/admin/policies'))) {
604
+ return [
605
+ {
606
+ name: 'admin-policy.toml',
607
+ isFile: () => true,
608
+ isDirectory: () => false,
609
+ },
610
+ ];
611
+ }
612
+ if (nodePath
613
+ .normalize(path)
614
+ .includes(nodePath.normalize('.gemini/policies'))) {
615
+ return [
616
+ {
617
+ name: 'user-policy.toml',
618
+ isFile: () => true,
619
+ isDirectory: () => false,
620
+ },
621
+ ];
622
+ }
623
+ }
624
+ return actualFs.readdir(path, options);
625
+ });
626
+ const mockReadFile = vi.fn(async (path, options) => {
627
+ if (typeof path === 'string') {
628
+ // Admin policy with low priority (100)
629
+ if (nodePath
630
+ .normalize(path)
631
+ .includes(nodePath.normalize('/tmp/admin/policies/admin-policy.toml'))) {
632
+ return `
633
+ [[rule]]
634
+ toolName = "run_shell_command"
635
+ decision = "deny"
636
+ priority = 100
637
+ `;
638
+ }
639
+ // User policy with high priority (900)
640
+ if (nodePath
641
+ .normalize(path)
642
+ .includes(nodePath.normalize('.gemini/policies/user-policy.toml'))) {
643
+ return `
644
+ [[rule]]
645
+ toolName = "run_shell_command"
646
+ decision = "allow"
647
+ priority = 900
648
+ `;
649
+ }
650
+ }
651
+ return actualFs.readFile(path, options);
652
+ });
653
+ vi.doMock('node:fs/promises', () => ({
654
+ ...actualFs,
655
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
656
+ readFile: mockReadFile,
657
+ readdir: mockReaddir,
658
+ }));
659
+ vi.resetModules();
660
+ const { createPolicyEngineConfig } = await import('./policy.js');
661
+ const settings = {};
662
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
663
+ const adminRule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
664
+ r.decision === PolicyDecision.DENY);
665
+ const userRule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
666
+ r.decision === PolicyDecision.ALLOW);
667
+ expect(adminRule).toBeDefined();
668
+ expect(userRule).toBeDefined();
669
+ // Admin priority should be 3.100 (tier 3 + 100/1000)
670
+ expect(adminRule?.priority).toBeCloseTo(3.1, 5);
671
+ // User priority should be 2.900 (tier 2 + 900/1000)
672
+ expect(userRule?.priority).toBeCloseTo(2.9, 5);
673
+ // Admin rule with low priority should still beat user rule with high priority
674
+ expect(adminRule.priority).toBeGreaterThan(userRule.priority);
675
+ delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
676
+ vi.doUnmock('node:fs/promises');
677
+ });
678
+ it('should apply correct priority transformations for each tier', async () => {
679
+ process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json';
680
+ const actualFs = await vi.importActual('node:fs/promises');
681
+ const mockReaddir = vi.fn(async (path, options) => {
682
+ if (typeof path === 'string') {
683
+ if (nodePath
684
+ .normalize(path)
685
+ .includes(nodePath.normalize('/tmp/admin/policies'))) {
686
+ return [
687
+ {
688
+ name: 'admin.toml',
689
+ isFile: () => true,
690
+ isDirectory: () => false,
691
+ },
692
+ ];
693
+ }
694
+ if (nodePath
695
+ .normalize(path)
696
+ .includes(nodePath.normalize('.gemini/policies'))) {
697
+ return [
698
+ {
699
+ name: 'user.toml',
700
+ isFile: () => true,
701
+ isDirectory: () => false,
702
+ },
703
+ ];
704
+ }
705
+ }
706
+ return actualFs.readdir(path, options);
707
+ });
708
+ const mockReadFile = vi.fn(async (path, options) => {
709
+ if (typeof path === 'string') {
710
+ if (nodePath
711
+ .normalize(path)
712
+ .includes(nodePath.normalize('/tmp/admin/policies/admin.toml'))) {
713
+ return `
714
+ [[rule]]
715
+ toolName = "admin-tool"
716
+ decision = "allow"
717
+ priority = 500
718
+ `;
719
+ }
720
+ if (nodePath
721
+ .normalize(path)
722
+ .includes(nodePath.normalize('.gemini/policies/user.toml'))) {
723
+ return `
724
+ [[rule]]
725
+ toolName = "user-tool"
726
+ decision = "allow"
727
+ priority = 500
728
+ `;
729
+ }
730
+ }
731
+ return actualFs.readFile(path, options);
732
+ });
733
+ vi.doMock('node:fs/promises', () => ({
734
+ ...actualFs,
735
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
736
+ readFile: mockReadFile,
737
+ readdir: mockReaddir,
738
+ }));
739
+ vi.resetModules();
740
+ const { createPolicyEngineConfig } = await import('./policy.js');
741
+ const settings = {};
742
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
743
+ const adminRule = config.rules?.find((r) => r.toolName === 'admin-tool');
744
+ const userRule = config.rules?.find((r) => r.toolName === 'user-tool');
745
+ expect(adminRule).toBeDefined();
746
+ expect(userRule).toBeDefined();
747
+ // Priority 500 in admin tier → 3.500
748
+ expect(adminRule?.priority).toBeCloseTo(3.5, 5);
749
+ // Priority 500 in user tier → 2.500
750
+ expect(userRule?.priority).toBeCloseTo(2.5, 5);
751
+ delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
752
+ vi.doUnmock('node:fs/promises');
753
+ });
754
+ it('should support array syntax for toolName in TOML policies', async () => {
755
+ const actualFs = await vi.importActual('node:fs/promises');
756
+ const mockReaddir = vi.fn(async (path, options) => {
757
+ if (typeof path === 'string' &&
758
+ nodePath
759
+ .normalize(path)
760
+ .includes(nodePath.normalize('.gemini/policies'))) {
761
+ return [
762
+ {
763
+ name: 'array-test.toml',
764
+ isFile: () => true,
765
+ isDirectory: () => false,
766
+ },
767
+ ];
768
+ }
769
+ return actualFs.readdir(path, options);
770
+ });
771
+ const mockReadFile = vi.fn(async (path, options) => {
772
+ if (typeof path === 'string' &&
773
+ nodePath
774
+ .normalize(path)
775
+ .includes(nodePath.normalize('.gemini/policies/array-test.toml'))) {
776
+ return `
777
+ # Test array syntax for toolName
778
+ [[rule]]
779
+ toolName = ["tool1", "tool2", "tool3"]
780
+ decision = "allow"
781
+ priority = 100
782
+
783
+ # Test array syntax with mcpName
784
+ [[rule]]
785
+ mcpName = "google-workspace"
786
+ toolName = ["calendar.findFreeTime", "calendar.getEvent", "calendar.list"]
787
+ decision = "allow"
788
+ priority = 150
789
+ `;
790
+ }
791
+ return actualFs.readFile(path, options);
792
+ });
793
+ vi.doMock('node:fs/promises', () => ({
794
+ ...actualFs,
795
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
796
+ readFile: mockReadFile,
797
+ readdir: mockReaddir,
798
+ }));
799
+ vi.resetModules();
800
+ const { createPolicyEngineConfig } = await import('./policy.js');
801
+ const settings = {};
802
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
803
+ // Should create separate rules for each tool in the array
804
+ const tool1Rule = config.rules?.find((r) => r.toolName === 'tool1');
805
+ const tool2Rule = config.rules?.find((r) => r.toolName === 'tool2');
806
+ const tool3Rule = config.rules?.find((r) => r.toolName === 'tool3');
807
+ expect(tool1Rule).toBeDefined();
808
+ expect(tool2Rule).toBeDefined();
809
+ expect(tool3Rule).toBeDefined();
810
+ // All should have the same decision and priority
811
+ expect(tool1Rule?.decision).toBe(PolicyDecision.ALLOW);
812
+ expect(tool2Rule?.decision).toBe(PolicyDecision.ALLOW);
813
+ expect(tool3Rule?.decision).toBe(PolicyDecision.ALLOW);
814
+ // Priority 100 in user tier → 2.100
815
+ expect(tool1Rule?.priority).toBeCloseTo(2.1, 5);
816
+ expect(tool2Rule?.priority).toBeCloseTo(2.1, 5);
817
+ expect(tool3Rule?.priority).toBeCloseTo(2.1, 5);
818
+ // MCP tools should have composite names
819
+ const calendarFreeTime = config.rules?.find((r) => r.toolName === 'google-workspace__calendar.findFreeTime');
820
+ const calendarGetEvent = config.rules?.find((r) => r.toolName === 'google-workspace__calendar.getEvent');
821
+ const calendarList = config.rules?.find((r) => r.toolName === 'google-workspace__calendar.list');
822
+ expect(calendarFreeTime).toBeDefined();
823
+ expect(calendarGetEvent).toBeDefined();
824
+ expect(calendarList).toBeDefined();
825
+ // All should have the same decision and priority
826
+ expect(calendarFreeTime?.decision).toBe(PolicyDecision.ALLOW);
827
+ expect(calendarGetEvent?.decision).toBe(PolicyDecision.ALLOW);
828
+ expect(calendarList?.decision).toBe(PolicyDecision.ALLOW);
829
+ // Priority 150 in user tier → 2.150
830
+ expect(calendarFreeTime?.priority).toBeCloseTo(2.15, 5);
831
+ expect(calendarGetEvent?.priority).toBeCloseTo(2.15, 5);
832
+ expect(calendarList?.priority).toBeCloseTo(2.15, 5);
833
+ vi.doUnmock('node:fs/promises');
834
+ });
835
+ it('should support commandPrefix syntax for shell commands', async () => {
836
+ const actualFs = await vi.importActual('node:fs/promises');
837
+ const mockReaddir = vi.fn(async (path, options) => {
838
+ if (typeof path === 'string' &&
839
+ nodePath
840
+ .normalize(path)
841
+ .includes(nodePath.normalize('.gemini/policies'))) {
842
+ return [
843
+ {
844
+ name: 'shell.toml',
845
+ isFile: () => true,
846
+ isDirectory: () => false,
847
+ },
848
+ ];
849
+ }
850
+ return actualFs.readdir(path, options);
851
+ });
852
+ const mockReadFile = vi.fn(async (path, options) => {
853
+ if (typeof path === 'string' &&
854
+ nodePath
855
+ .normalize(path)
856
+ .includes(nodePath.normalize('.gemini/policies/shell.toml'))) {
857
+ return `
858
+ [[rule]]
859
+ toolName = "run_shell_command"
860
+ commandPrefix = "git status"
861
+ decision = "allow"
862
+ priority = 100
863
+ `;
864
+ }
865
+ return actualFs.readFile(path, options);
866
+ });
867
+ vi.doMock('node:fs/promises', () => ({
868
+ ...actualFs,
869
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
870
+ readFile: mockReadFile,
871
+ readdir: mockReaddir,
872
+ }));
873
+ vi.resetModules();
874
+ const { createPolicyEngineConfig } = await import('./policy.js');
875
+ const settings = {};
876
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
877
+ const rule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
878
+ r.decision === PolicyDecision.ALLOW);
879
+ expect(rule).toBeDefined();
880
+ expect(rule?.priority).toBeCloseTo(2.1, 5);
881
+ expect(rule?.argsPattern).toBeInstanceOf(RegExp);
882
+ // Should match commands starting with "git status"
883
+ expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true);
884
+ expect(rule?.argsPattern?.test('{"command":"git status --short"}')).toBe(true);
885
+ // Should not match other commands
886
+ expect(rule?.argsPattern?.test('{"command":"git branch"}')).toBe(false);
887
+ vi.doUnmock('node:fs/promises');
888
+ });
889
+ it('should support array syntax for commandPrefix', async () => {
890
+ const actualFs = await vi.importActual('node:fs/promises');
891
+ const mockReaddir = vi.fn(async (path, options) => {
892
+ if (typeof path === 'string' &&
893
+ nodePath
894
+ .normalize(path)
895
+ .includes(nodePath.normalize('.gemini/policies'))) {
896
+ return [
897
+ {
898
+ name: 'shell.toml',
899
+ isFile: () => true,
900
+ isDirectory: () => false,
901
+ },
902
+ ];
903
+ }
904
+ return actualFs.readdir(path, options);
905
+ });
906
+ const mockReadFile = vi.fn(async (path, options) => {
907
+ if (typeof path === 'string' &&
908
+ nodePath
909
+ .normalize(path)
910
+ .includes(nodePath.normalize('.gemini/policies/shell.toml'))) {
911
+ return `
912
+ [[rule]]
913
+ toolName = "run_shell_command"
914
+ commandPrefix = ["git status", "git branch", "git log"]
915
+ decision = "allow"
916
+ priority = 100
917
+ `;
918
+ }
919
+ return actualFs.readFile(path, options);
920
+ });
921
+ vi.doMock('node:fs/promises', () => ({
922
+ ...actualFs,
923
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
924
+ readFile: mockReadFile,
925
+ readdir: mockReaddir,
926
+ }));
927
+ vi.resetModules();
928
+ const { createPolicyEngineConfig } = await import('./policy.js');
929
+ const settings = {};
930
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
931
+ const rules = config.rules?.filter((r) => r.toolName === 'run_shell_command' &&
932
+ r.decision === PolicyDecision.ALLOW);
933
+ // Should create 3 rules (one for each prefix)
934
+ expect(rules?.length).toBe(3);
935
+ // All rules should have the same priority and decision
936
+ rules?.forEach((rule) => {
937
+ expect(rule.priority).toBeCloseTo(2.1, 5);
938
+ expect(rule.decision).toBe(PolicyDecision.ALLOW);
939
+ });
940
+ // Test that each prefix pattern works
941
+ const patterns = rules?.map((r) => r.argsPattern);
942
+ expect(patterns?.some((p) => p?.test('{"command":"git status"}'))).toBe(true);
943
+ expect(patterns?.some((p) => p?.test('{"command":"git branch"}'))).toBe(true);
944
+ expect(patterns?.some((p) => p?.test('{"command":"git log"}'))).toBe(true);
945
+ // Should not match other commands
946
+ expect(patterns?.some((p) => p?.test('{"command":"git commit"}'))).toBe(false);
947
+ vi.doUnmock('node:fs/promises');
948
+ });
949
+ it('should support commandRegex syntax for shell commands', async () => {
950
+ const actualFs = await vi.importActual('node:fs/promises');
951
+ const mockReaddir = vi.fn(async (path, options) => {
952
+ if (typeof path === 'string' &&
953
+ nodePath
954
+ .normalize(path)
955
+ .includes(nodePath.normalize('.gemini/policies'))) {
956
+ return [
957
+ {
958
+ name: 'shell.toml',
959
+ isFile: () => true,
960
+ isDirectory: () => false,
961
+ },
962
+ ];
963
+ }
964
+ return actualFs.readdir(path, options);
965
+ });
966
+ const mockReadFile = vi.fn(async (path, options) => {
967
+ if (typeof path === 'string' &&
968
+ nodePath
969
+ .normalize(path)
970
+ .includes(nodePath.normalize('.gemini/policies/shell.toml'))) {
971
+ return `
972
+ [[rule]]
973
+ toolName = "run_shell_command"
974
+ commandRegex = "git (status|branch|log).*"
975
+ decision = "allow"
976
+ priority = 100
977
+ `;
978
+ }
979
+ return actualFs.readFile(path, options);
980
+ });
981
+ vi.doMock('node:fs/promises', () => ({
982
+ ...actualFs,
983
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
984
+ readFile: mockReadFile,
985
+ readdir: mockReaddir,
986
+ }));
987
+ vi.resetModules();
988
+ const { createPolicyEngineConfig } = await import('./policy.js');
989
+ const settings = {};
990
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
991
+ const rule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
992
+ r.decision === PolicyDecision.ALLOW);
993
+ expect(rule).toBeDefined();
994
+ expect(rule?.priority).toBeCloseTo(2.1, 5);
995
+ expect(rule?.argsPattern).toBeInstanceOf(RegExp);
996
+ // Should match commands matching the regex
997
+ expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true);
998
+ expect(rule?.argsPattern?.test('{"command":"git status --short"}')).toBe(true);
999
+ expect(rule?.argsPattern?.test('{"command":"git branch"}')).toBe(true);
1000
+ expect(rule?.argsPattern?.test('{"command":"git log --all"}')).toBe(true);
1001
+ // Should not match commands not in the regex
1002
+ expect(rule?.argsPattern?.test('{"command":"git commit"}')).toBe(false);
1003
+ expect(rule?.argsPattern?.test('{"command":"git push"}')).toBe(false);
1004
+ vi.doUnmock('node:fs/promises');
1005
+ });
1006
+ it('should escape regex special characters in commandPrefix', async () => {
1007
+ const actualFs = await vi.importActual('node:fs/promises');
1008
+ const mockReaddir = vi.fn(async (path, options) => {
1009
+ if (typeof path === 'string' &&
1010
+ nodePath
1011
+ .normalize(path)
1012
+ .includes(nodePath.normalize('.gemini/policies'))) {
1013
+ return [
1014
+ {
1015
+ name: 'shell.toml',
1016
+ isFile: () => true,
1017
+ isDirectory: () => false,
1018
+ },
1019
+ ];
1020
+ }
1021
+ return actualFs.readdir(path, options);
1022
+ });
1023
+ const mockReadFile = vi.fn(async (path, options) => {
1024
+ if (typeof path === 'string' &&
1025
+ nodePath
1026
+ .normalize(path)
1027
+ .includes(nodePath.normalize('.gemini/policies/shell.toml'))) {
1028
+ return `
1029
+ [[rule]]
1030
+ toolName = "run_shell_command"
1031
+ commandPrefix = "git log *.txt"
1032
+ decision = "allow"
1033
+ priority = 100
1034
+ `;
1035
+ }
1036
+ return actualFs.readFile(path, options);
1037
+ });
1038
+ vi.doMock('node:fs/promises', () => ({
1039
+ ...actualFs,
1040
+ default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
1041
+ readFile: mockReadFile,
1042
+ readdir: mockReaddir,
1043
+ }));
1044
+ vi.resetModules();
1045
+ const { createPolicyEngineConfig } = await import('./policy.js');
1046
+ const settings = {};
1047
+ const config = await createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
1048
+ const rule = config.rules?.find((r) => r.toolName === 'run_shell_command' &&
1049
+ r.decision === PolicyDecision.ALLOW);
1050
+ expect(rule).toBeDefined();
1051
+ // Should match the literal string "git log *.txt" (asterisk is escaped)
1052
+ expect(rule?.argsPattern?.test('{"command":"git log *.txt"}')).toBe(true);
1053
+ // Should not match "git log a.txt" because * is escaped to literal asterisk
1054
+ expect(rule?.argsPattern?.test('{"command":"git log a.txt"}')).toBe(false);
1055
+ vi.doUnmock('node:fs/promises');
358
1056
  });
359
1057
  });
360
1058
  //# sourceMappingURL=policy.test.js.map