Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
08a0e8d
feat(policy): add --policy flag for user defined policies
allenhutchison Feb 5, 2026
82c3c0a
Merge branch 'main' into adh/feature/add-policy-flag
allenhutchison Feb 7, 2026
e790636
chore: update package-lock.json
allenhutchison Feb 9, 2026
5b10511
Merge branch 'main' into adh/feature/add-policy-flag
allenhutchison Feb 9, 2026
76752bf
Merge branch 'main' into adh/feature/add-policy-flag
allenhutchison Feb 9, 2026
b78f8a3
chore: update Vitest configuration to use `testTimeout` instead of `t…
allenhutchison Feb 9, 2026
e1ff38a
Merge branch 'adh/feature/add-policy-flag' of github.com:google-gemin…
allenhutchison Feb 9, 2026
561f54c
test: add `fs.promises.stat` mock to simulate policy directories in `…
allenhutchison Feb 9, 2026
b70e0a0
Merge branch 'main' into adh/feature/add-policy-flag
allenhutchison Feb 9, 2026
dd2bd7c
refactor: initialize TOML loader variables and explicitly check for `…
allenhutchison Feb 9, 2026
c979ce2
Merge remote-tracking branch 'origin/main' into adh/feature/add-polic…
allenhutchison Feb 10, 2026
f17aff6
Update packages/cli/src/config/config.ts
allenhutchison Feb 10, 2026
1815ef3
Merge branch 'main' into adh/feature/add-policy-flag
allenhutchison Feb 11, 2026
8a772dc
fix formatting
allenhutchison Feb 11, 2026
70a7fd8
Merge branch 'main' into adh/feature/add-policy-flag
allenhutchison Feb 11, 2026
2fa9527
fix(core): mock fs.stat in policy config tests
allenhutchison Feb 12, 2026
4a4ad3c
Merge branch 'main' into adh/feature/add-policy-flag
allenhutchison Feb 12, 2026
062f1ec
refactor(test): add `override` keyword to static methods in MockStora…
allenhutchison Feb 12, 2026
0449c51
refactor(policy): consolidate duplicate directory logic into getPolic…
allenhutchison Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/get-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ their corresponding top-level category object in your `settings.json` file.

<!-- SETTINGS-AUTOGEN:START -->

#### `policyPaths`

- **`policyPaths`** (array):
- **Description:** Additional policy files or directories to load.
- **Default:** `[]`
- **Requires restart:** Yes

#### `general`

- **`general.preferredEditor`** (string):
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ vi.mock('@google/gemini-cli-core', async () => {
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
getAdminErrorMessage: vi.fn(
(_feature) =>
`YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`,
),
isHeadlessMode: vi.fn((opts) => {
if (process.env['VITEST'] === 'true') {
return (
Expand Down Expand Up @@ -3192,6 +3196,26 @@ describe('Policy Engine Integration in loadCliConfig', () => {
expect.anything(),
);
});

it('should pass user-provided policy paths from --policy flag to createPolicyEngineConfig', async () => {
process.argv = [
'node',
'script.js',
'--policy',
'/path/to/policy1.toml,/path/to/policy2.toml',
];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);

await loadCliConfig(settings, 'test-session', argv);

expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'],
}),
expect.anything(),
);
});
});

describe('loadCliConfig disableYoloMode', () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface CliArgs {

yolo: boolean | undefined;
approvalMode: string | undefined;
policy: string[] | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
experimentalAcp: boolean | undefined;
Expand Down Expand Up @@ -158,6 +159,21 @@ export async function parseArguments(
description:
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)',
})
.option('policy', {
type: 'array',
string: true,
nargs: 1,
description:
'Additional policy files or directories to load (comma-separated or multiple --policy)',
coerce: (policies: string[]) =>
// Handle comma-separated values
policies.flatMap((p) =>
p
.split(',')
.map((s) => s.trim())
.filter(Boolean),
),
})
.option('experimental-acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
Expand Down Expand Up @@ -666,6 +682,7 @@ export async function loadCliConfig(
...settings.mcp,
allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
},
policyPaths: argv.policy,
};

const policyEngineConfig = await createPolicyEngineConfig(
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export async function createPolicyEngineConfig(
mcp: settings.mcp,
tools: settings.tools,
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
};

return createCorePolicyEngineConfig(policySettings, approvalMode);
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ const SETTINGS_SCHEMA = {
},
},

policyPaths: {
type: 'array',
label: 'Policy Paths',
category: 'Advanced',
requiresRestart: true,
default: [] as string[],
description: 'Additional policy files or directories to load.',
showInDialog: false,
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},

general: {
type: 'object',
label: 'General',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ describe('gemini.tsx main function kitty protocol', () => {
query: undefined,
yolo: undefined,
approvalMode: undefined,
policy: undefined,
allowedMcpServerNames: undefined,
allowedTools: undefined,
experimentalAcp: undefined,
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ import type { ExtensionUpdateAction } from '../state/extensions.js';
*/
export function createNonInteractiveUI(): CommandContext['ui'] {
return {
addItem: (_item, _timestamp) => 0,
addItem: (item, _timestamp) => {
if ('text' in item && item.text) {
if (item.type === 'error') {
process.stderr.write(`Error: ${item.text}\n`);
} else if (item.type === 'warning') {
process.stderr.write(`Warning: ${item.text}\n`);
} else if (item.type === 'info') {
process.stdout.write(`${item.text}\n`);
}
}
return 0;
},
clear: () => {},
setDebugMessage: (_message) => {},
loadHistory: (_newHistory) => {},
Expand Down
107 changes: 103 additions & 4 deletions packages/core/src/policy/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,21 @@ describe('createPolicyEngineConfig', () => {
}
return [];
});
const mockStat = vi.fn(async (p) => {
if (typeof p === 'string' && p.includes('/tmp/mock/default/policies')) {
return {
isDirectory: () => true,
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
if (typeof p === 'string' && p.includes('default.toml')) {
return {
isDirectory: () => false,
isFile: () => true,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(p);
});
const mockReadFile = vi.fn(async (p, _o) => {
if (typeof p === 'string' && p.includes('default.toml')) {
return '[[rule]]\ntoolName = "glob"\ndecision = "allow"\npriority = 50\n';
Expand All @@ -471,9 +486,15 @@ describe('createPolicyEngineConfig', () => {
});
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readdir: mockReaddir, readFile: mockReadFile },
default: {
...actualFs,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
},
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
}));
vi.resetModules();
const { createPolicyEngineConfig: createConfig } = await import(
Expand Down Expand Up @@ -663,11 +684,37 @@ priority = 150
},
);

const mockStat = vi.fn(
async (
path: Parameters<typeof actualFs.stat>[0],
options?: Parameters<typeof actualFs.stat>[1],
) => {
if (
typeof path === 'string' &&
nodePath
.normalize(path)
.includes(nodePath.normalize('.gemini/policies'))
) {
return {
isDirectory: () => true,
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(path, options);
},
);

vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
default: {
...actualFs,
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
},
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
}));

vi.resetModules();
Expand Down Expand Up @@ -766,11 +813,37 @@ required_context = ["environment"]
},
);

const mockStat = vi.fn(
async (
path: Parameters<typeof actualFs.stat>[0],
options?: Parameters<typeof actualFs.stat>[1],
) => {
if (
typeof path === 'string' &&
nodePath
.normalize(path)
.includes(nodePath.normalize('.gemini/policies'))
) {
return {
isDirectory: () => true,
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(path, options);
},
);

vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
default: {
...actualFs,
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
},
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
}));

vi.resetModules();
Expand Down Expand Up @@ -862,11 +935,37 @@ name = "invalid-name"
},
);

const mockStat = vi.fn(
async (
path: Parameters<typeof actualFs.stat>[0],
options?: Parameters<typeof actualFs.stat>[1],
) => {
if (
typeof path === 'string' &&
nodePath
.normalize(path)
.includes(nodePath.normalize('.gemini/policies'))
) {
return {
isDirectory: () => true,
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof actualFs.stat>>;
}
return actualFs.stat(path, options);
},
);

vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
default: {
...actualFs,
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
},
readFile: mockReadFile,
readdir: mockReaddir,
stat: mockStat,
}));

vi.resetModules();
Expand Down
46 changes: 42 additions & 4 deletions packages/core/src/policy/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,17 +147,55 @@ export async function createPolicyEngineConfig(
approvalMode: ApprovalMode,
defaultPoliciesDir?: string,
): Promise<PolicyEngineConfig> {
const policyDirs = getPolicyDirectories(defaultPoliciesDir);
const policyDirs: string[] = [];

// 1. Admin Policies (Highest Priority)
policyDirs.push(Storage.getSystemPoliciesDir());

// 2. User Policies (Middle Priority)
// If policyPaths are provided via CLI/Settings, use them INSTEAD of the default user directory.
if (settings.policyPaths && settings.policyPaths.length > 0) {
policyDirs.push(...settings.policyPaths);
} else {
policyDirs.push(Storage.getUserPoliciesDir());
}

// 3. Default Policies (Lowest Priority)
if (defaultPoliciesDir) {
policyDirs.push(defaultPoliciesDir);
} else {
policyDirs.push(DEFAULT_CORE_POLICIES_DIR);
}

Comment thread
allenhutchison marked this conversation as resolved.
Outdated
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);

const normalizedAdminPoliciesDir = path.resolve(
Storage.getSystemPoliciesDir(),
);

// Load policies from TOML files
const {
rules: tomlRules,
checkers: tomlCheckers,
errors,
} = await loadPoliciesFromToml(securePolicyDirs, (dir) =>
getPolicyTier(dir, defaultPoliciesDir),
);
} = await loadPoliciesFromToml(securePolicyDirs, (p) => {
const tier = getPolicyTier(p, defaultPoliciesDir);

// If it's a user-provided path that isn't already categorized as ADMIN,
// treat it as USER tier.
if (
settings.policyPaths?.some(
(userPath) => path.resolve(userPath) === path.resolve(p),
)
) {
const normalizedPath = path.resolve(p);
if (normalizedPath !== normalizedAdminPoliciesDir) {
return USER_POLICY_TIER;
}
}

return tier;
});

// Emit any errors encountered during TOML loading to the UI
// coreEvents has a buffer that will display these once the UI is ready
Expand Down
Loading
Loading