Skip to content

Commit 7708009

Browse files
yunaseoulYuna Seol
andauthored
fix(security): enforce strict policy directory permissions (#17353)
Co-authored-by: Yuna Seol <yunaseol@google.com>
1 parent 00f60ef commit 7708009

File tree

7 files changed

+472
-10
lines changed

7 files changed

+472
-10
lines changed

docs/core/policy-engine.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,38 @@ A rule matches a tool call if all of its conditions are met:
146146
Policies are defined in `.toml` files. The CLI loads these files from Default,
147147
User, and (if configured) Admin directories.
148148

149+
### Policy locations
150+
151+
| Tier | Type | Location |
152+
| :-------- | :----- | :-------------------------- |
153+
| **User** | Custom | `~/.gemini/policies/*.toml` |
154+
| **Admin** | System | _See below (OS specific)_ |
155+
156+
#### System-wide policies (Admin)
157+
158+
Administrators can enforce system-wide policies (Tier 3) that override all user
159+
and default settings. These policies must be placed in specific, secure
160+
directories:
161+
162+
| OS | Policy Directory Path |
163+
| :---------- | :------------------------------------------------ |
164+
| **Linux** | `/etc/gemini-cli/policies` |
165+
| **macOS** | `/Library/Application Support/GeminiCli/policies` |
166+
| **Windows** | `C:\ProgramData\gemini-cli\policies` |
167+
168+
**Security Requirements:**
169+
170+
To prevent privilege escalation, the CLI enforces strict security checks on
171+
admin directories. If checks fail, system policies are **ignored**.
172+
173+
- **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group
174+
or others (e.g., `chmod 755`).
175+
- **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`)
176+
must NOT have `Write`, `Modify`, or `Full Control` permissions. _Tip: If you
177+
see a security warning, use the folder properties to remove write permissions
178+
for non-admin groups. You may need to "Disable inheritance" in Advanced
179+
Security Settings._
180+
149181
### TOML rule schema
150182

151183
Here is a breakdown of the fields available in a TOML policy rule:

packages/core/src/config/storage.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { describe, it, expect, vi } from 'vitest';
7+
import { describe, it, expect, vi, afterEach } from 'vitest';
88
import * as os from 'node:os';
99
import * as path from 'node:path';
1010

@@ -85,3 +85,55 @@ describe('Storage – additional helpers', () => {
8585
expect(storage.getProjectTempPlansDir()).toBe(expected);
8686
});
8787
});
88+
89+
describe('Storage - System Paths', () => {
90+
const originalEnv = process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
91+
92+
afterEach(() => {
93+
if (originalEnv !== undefined) {
94+
process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = originalEnv;
95+
} else {
96+
delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
97+
}
98+
});
99+
100+
it('getSystemSettingsPath returns correct path based on platform (default)', () => {
101+
delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
102+
103+
const platform = os.platform();
104+
const result = Storage.getSystemSettingsPath();
105+
106+
if (platform === 'darwin') {
107+
expect(result).toBe(
108+
'/Library/Application Support/GeminiCli/settings.json',
109+
);
110+
} else if (platform === 'win32') {
111+
expect(result).toBe('C:\\ProgramData\\gemini-cli\\settings.json');
112+
} else {
113+
expect(result).toBe('/etc/gemini-cli/settings.json');
114+
}
115+
});
116+
117+
it('getSystemSettingsPath follows GEMINI_CLI_SYSTEM_SETTINGS_PATH if set', () => {
118+
const customPath = '/custom/path/settings.json';
119+
process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = customPath;
120+
expect(Storage.getSystemSettingsPath()).toBe(customPath);
121+
});
122+
123+
it('getSystemPoliciesDir returns correct path based on platform and ignores env var', () => {
124+
process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] =
125+
'/custom/path/settings.json';
126+
const platform = os.platform();
127+
const result = Storage.getSystemPoliciesDir();
128+
129+
expect(result).not.toContain('/custom/path');
130+
131+
if (platform === 'darwin') {
132+
expect(result).toBe('/Library/Application Support/GeminiCli/policies');
133+
} else if (platform === 'win32') {
134+
expect(result).toBe('C:\\ProgramData\\gemini-cli\\policies');
135+
} else {
136+
expect(result).toBe('/etc/gemini-cli/policies');
137+
}
138+
});
139+
});

packages/core/src/config/storage.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,25 @@ export class Storage {
7474
);
7575
}
7676

77-
static getSystemSettingsPath(): string {
78-
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
79-
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
80-
}
77+
private static getSystemConfigDir(): string {
8178
if (os.platform() === 'darwin') {
82-
return '/Library/Application Support/GeminiCli/settings.json';
79+
return '/Library/Application Support/GeminiCli';
8380
} else if (os.platform() === 'win32') {
84-
return 'C:\\ProgramData\\gemini-cli\\settings.json';
81+
return 'C:\\ProgramData\\gemini-cli';
8582
} else {
86-
return '/etc/gemini-cli/settings.json';
83+
return '/etc/gemini-cli';
84+
}
85+
}
86+
87+
static getSystemSettingsPath(): string {
88+
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
89+
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
8790
}
91+
return path.join(Storage.getSystemConfigDir(), 'settings.json');
8892
}
8993

9094
static getSystemPoliciesDir(): string {
91-
return path.join(path.dirname(Storage.getSystemSettingsPath()), 'policies');
95+
return path.join(Storage.getSystemConfigDir(), 'policies');
9296
}
9397

9498
static getGlobalTempDir(): string {

packages/core/src/policy/config.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import nodePath from 'node:path';
1010

1111
import type { PolicySettings } from './types.js';
1212
import { ApprovalMode, PolicyDecision, InProcessCheckerType } from './types.js';
13+
import { isDirectorySecure } from '../utils/security.js';
14+
15+
vi.mock('../utils/security.js', () => ({
16+
isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }),
17+
}));
1318

1419
afterEach(() => {
1520
vi.clearAllMocks();
@@ -28,7 +33,53 @@ describe('createPolicyEngineConfig', () => {
2833
vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(
2934
'/non/existent/system/policies',
3035
);
36+
// Reset security check to default secure
37+
vi.mocked(isDirectorySecure).mockResolvedValue({ secure: true });
38+
});
39+
40+
it('should filter out insecure system policy directories', async () => {
41+
const { Storage } = await import('../config/storage.js');
42+
const systemPolicyDir = '/insecure/system/policies';
43+
vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue(systemPolicyDir);
44+
45+
vi.mocked(isDirectorySecure).mockImplementation(async (path: string) => {
46+
if (nodePath.resolve(path) === nodePath.resolve(systemPolicyDir)) {
47+
return { secure: false, reason: 'Insecure directory' };
48+
}
49+
return { secure: true };
50+
});
51+
52+
// We need to spy on loadPoliciesFromToml to verify which directories were passed
53+
// But it is not exported from config.js, it is imported.
54+
// We can spy on the module it comes from.
55+
const tomlLoader = await import('./toml-loader.js');
56+
const loadPoliciesSpy = vi.spyOn(tomlLoader, 'loadPoliciesFromToml');
57+
loadPoliciesSpy.mockResolvedValue({
58+
rules: [],
59+
checkers: [],
60+
errors: [],
61+
});
62+
63+
const { createPolicyEngineConfig } = await import('./config.js');
64+
const settings: PolicySettings = {};
65+
66+
await createPolicyEngineConfig(
67+
settings,
68+
ApprovalMode.DEFAULT,
69+
'/tmp/mock/default/policies',
70+
);
71+
72+
// Verify loadPoliciesFromToml was called
73+
expect(loadPoliciesSpy).toHaveBeenCalled();
74+
const calledDirs = loadPoliciesSpy.mock.calls[0][0];
75+
76+
// The system directory should NOT be in the list
77+
expect(calledDirs).not.toContain(systemPolicyDir);
78+
// But other directories (user, default) should be there
79+
expect(calledDirs).toContain('/non/existent/user/policies');
80+
expect(calledDirs).toContain('/tmp/mock/default/policies');
3181
});
82+
3283
it('should return ASK_USER for write tools and ALLOW for read-only tools by default', async () => {
3384
const actualFs =
3485
await vi.importActual<typeof import('node:fs/promises')>(

packages/core/src/policy/config.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { debugLogger } from '../utils/debugLogger.js';
2929
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
3030
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
3131

32+
import { isDirectorySecure } from '../utils/security.js';
33+
3234
const __filename = fileURLToPath(import.meta.url);
3335
const __dirname = path.dirname(__filename);
3436
export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
@@ -112,19 +114,47 @@ export function formatPolicyError(error: PolicyFileError): string {
112114
return message;
113115
}
114116

117+
/**
118+
* Filters out insecure policy directories (specifically the system policy directory).
119+
* Emits warnings if insecure directories are found.
120+
*/
121+
async function filterSecurePolicyDirectories(
122+
dirs: string[],
123+
): Promise<string[]> {
124+
const systemPoliciesDir = path.resolve(Storage.getSystemPoliciesDir());
125+
126+
const results = await Promise.all(
127+
dirs.map(async (dir) => {
128+
// Only check security for system policies
129+
if (path.resolve(dir) === systemPoliciesDir) {
130+
const { secure, reason } = await isDirectorySecure(dir);
131+
if (!secure) {
132+
const msg = `Security Warning: Skipping system policies from ${dir}: ${reason}`;
133+
coreEvents.emitFeedback('warning', msg);
134+
return null;
135+
}
136+
}
137+
return dir;
138+
}),
139+
);
140+
141+
return results.filter((dir): dir is string => dir !== null);
142+
}
143+
115144
export async function createPolicyEngineConfig(
116145
settings: PolicySettings,
117146
approvalMode: ApprovalMode,
118147
defaultPoliciesDir?: string,
119148
): Promise<PolicyEngineConfig> {
120149
const policyDirs = getPolicyDirectories(defaultPoliciesDir);
150+
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
121151

122152
// Load policies from TOML files
123153
const {
124154
rules: tomlRules,
125155
checkers: tomlCheckers,
126156
errors,
127-
} = await loadPoliciesFromToml(policyDirs, (dir) =>
157+
} = await loadPoliciesFromToml(securePolicyDirs, (dir) =>
128158
getPolicyTier(dir, defaultPoliciesDir),
129159
);
130160

0 commit comments

Comments
 (0)