Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -670,6 +686,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
150 changes: 144 additions & 6 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 Expand Up @@ -964,7 +1063,7 @@ name = "invalid-name"
options?: Parameters<typeof actualFs.readdir>[1],
) => {
const normalizedPath = nodePath.normalize(path.toString());
if (normalizedPath.includes(nodePath.normalize('.gemini/policies'))) {
if (normalizedPath.includes('gemini-cli-test/user/policies')) {
return [
{
name: 'user-plan.toml',
Expand All @@ -980,6 +1079,22 @@ name = "invalid-name"
},
);

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

const mockReadFile = vi.fn(
async (
path: Parameters<typeof actualFs.readFile>[0],
Expand Down Expand Up @@ -1008,12 +1123,35 @@ modes = ["plan"]

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();

// Robustly mock Storage using doMock to ensure it persists through imports in config.js
vi.doMock('../config/storage.js', async () => {
const actual = await vi.importActual<
typeof import('../config/storage.js')
>('../config/storage.js');
class MockStorage extends actual.Storage {
static override getUserPoliciesDir() {
return '/tmp/gemini-cli-test/user/policies';
}
static override getSystemPoliciesDir() {
return '/tmp/gemini-cli-test/system/policies';
}
}
return { ...actual, Storage: MockStorage };
});

const { createPolicyEngineConfig } = await import('./config.js');

const settings: PolicySettings = {};
Expand Down
Loading
Loading