mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
feat(policy): add --admin-policy flag for supplemental admin policies (#20360)
This commit is contained in:
@@ -76,6 +76,7 @@ export interface CliArgs {
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
policy: string[] | undefined;
|
||||
adminPolicy: string[] | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
allowedTools: string[] | undefined;
|
||||
acp?: boolean;
|
||||
@@ -97,6 +98,21 @@ export interface CliArgs {
|
||||
isCommand: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to coerce comma-separated or multiple flag values into a flat array.
|
||||
*/
|
||||
const coerceCommaSeparated = (values: string[]): string[] => {
|
||||
if (values.length === 1 && values[0] === '') {
|
||||
return [''];
|
||||
}
|
||||
return values.flatMap((v) =>
|
||||
v
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
};
|
||||
|
||||
export async function parseArguments(
|
||||
settings: MergedSettings,
|
||||
): Promise<CliArgs> {
|
||||
@@ -166,14 +182,15 @@ export async function parseArguments(
|
||||
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),
|
||||
),
|
||||
coerce: coerceCommaSeparated,
|
||||
})
|
||||
.option('admin-policy', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
nargs: 1,
|
||||
description:
|
||||
'Additional admin policy files or directories to load (comma-separated or multiple --admin-policy)',
|
||||
coerce: coerceCommaSeparated,
|
||||
})
|
||||
.option('acp', {
|
||||
type: 'boolean',
|
||||
@@ -189,11 +206,7 @@ export async function parseArguments(
|
||||
string: true,
|
||||
nargs: 1,
|
||||
description: 'Allowed MCP server names',
|
||||
coerce: (mcpServerNames: string[]) =>
|
||||
// Handle comma-separated values
|
||||
mcpServerNames.flatMap((mcpServerName) =>
|
||||
mcpServerName.split(',').map((m) => m.trim()),
|
||||
),
|
||||
coerce: coerceCommaSeparated,
|
||||
})
|
||||
.option('allowed-tools', {
|
||||
type: 'array',
|
||||
@@ -201,9 +214,7 @@ export async function parseArguments(
|
||||
nargs: 1,
|
||||
description:
|
||||
'[DEPRECATED: Use Policy Engine instead See https://geminicli.com/docs/core/policy-engine] Tools that are allowed to run without confirmation',
|
||||
coerce: (tools: string[]) =>
|
||||
// Handle comma-separated values
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
coerce: coerceCommaSeparated,
|
||||
})
|
||||
.option('extensions', {
|
||||
alias: 'e',
|
||||
@@ -212,11 +223,7 @@ export async function parseArguments(
|
||||
nargs: 1,
|
||||
description:
|
||||
'A list of extensions to use. If not provided, all extensions are used.',
|
||||
coerce: (extensions: string[]) =>
|
||||
// Handle comma-separated values
|
||||
extensions.flatMap((extension) =>
|
||||
extension.split(',').map((e) => e.trim()),
|
||||
),
|
||||
coerce: coerceCommaSeparated,
|
||||
})
|
||||
.option('list-extensions', {
|
||||
alias: 'l',
|
||||
@@ -258,9 +265,7 @@ export async function parseArguments(
|
||||
nargs: 1,
|
||||
description:
|
||||
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
|
||||
coerce: (dirs: string[]) =>
|
||||
// Handle comma-separated values
|
||||
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
|
||||
coerce: coerceCommaSeparated,
|
||||
})
|
||||
.option('screen-reader', {
|
||||
type: 'boolean',
|
||||
@@ -643,7 +648,8 @@ export async function loadCliConfig(
|
||||
...settings.mcp,
|
||||
allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
|
||||
},
|
||||
policyPaths: argv.policy,
|
||||
policyPaths: argv.policy ?? settings.policyPaths,
|
||||
adminPolicyPaths: argv.adminPolicy ?? settings.adminPolicyPaths,
|
||||
};
|
||||
|
||||
const { workspacePoliciesDir, policyUpdateConfirmationRequest } =
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function createPolicyEngineConfig(
|
||||
tools: settings.tools,
|
||||
mcpServers: settings.mcpServers,
|
||||
policyPaths: settings.policyPaths,
|
||||
adminPolicyPaths: settings.adminPolicyPaths,
|
||||
workspacePoliciesDir,
|
||||
};
|
||||
|
||||
|
||||
@@ -134,6 +134,18 @@ export interface SettingsSchema {
|
||||
export type MemoryImportFormat = 'tree' | 'flat';
|
||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||
|
||||
const pathArraySetting = (label: string, description: string) => ({
|
||||
type: 'array' as const,
|
||||
label,
|
||||
category: 'Advanced' as const,
|
||||
requiresRestart: true as const,
|
||||
default: [] as string[],
|
||||
description,
|
||||
showInDialog: false as const,
|
||||
items: { type: 'string' as const },
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
});
|
||||
|
||||
/**
|
||||
* The canonical schema for all settings.
|
||||
* The structure of this object defines the structure of the `Settings` type.
|
||||
@@ -156,17 +168,15 @@ 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,
|
||||
},
|
||||
policyPaths: pathArraySetting(
|
||||
'Policy Paths',
|
||||
'Additional policy files or directories to load.',
|
||||
),
|
||||
|
||||
adminPolicyPaths: pathArraySetting(
|
||||
'Admin Policy Paths',
|
||||
'Additional admin policy files or directories to load.',
|
||||
),
|
||||
|
||||
general: {
|
||||
type: 'object',
|
||||
@@ -2677,7 +2687,9 @@ type InferSettings<T extends SettingsSchema> = {
|
||||
? boolean
|
||||
: T[K]['default'] extends string
|
||||
? string
|
||||
: T[K]['default'];
|
||||
: T[K]['default'] extends ReadonlyArray<infer U>
|
||||
? U[]
|
||||
: T[K]['default'];
|
||||
};
|
||||
|
||||
type InferMergedSettings<T extends SettingsSchema> = {
|
||||
@@ -2691,7 +2703,9 @@ type InferMergedSettings<T extends SettingsSchema> = {
|
||||
? boolean
|
||||
: T[K]['default'] extends string
|
||||
? string
|
||||
: T[K]['default'];
|
||||
: T[K]['default'] extends ReadonlyArray<infer U>
|
||||
? U[]
|
||||
: T[K]['default'];
|
||||
};
|
||||
|
||||
export type Settings = InferSettings<SettingsSchemaType>;
|
||||
|
||||
@@ -481,6 +481,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
experimentalAcp: undefined,
|
||||
|
||||
@@ -29,3 +29,9 @@ exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,26 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
|
||||
|
||||
// Cache to prevent duplicate warnings in the same process
|
||||
const emittedWarnings = new Set<string>();
|
||||
|
||||
/**
|
||||
* Emits a warning feedback event only once per process.
|
||||
*/
|
||||
function emitWarningOnce(message: string): void {
|
||||
if (!emittedWarnings.has(message)) {
|
||||
coreEvents.emitFeedback('warning', message);
|
||||
emittedWarnings.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the emitted warnings cache. Used primarily for tests.
|
||||
*/
|
||||
export function clearEmittedPolicyWarnings(): void {
|
||||
emittedWarnings.clear();
|
||||
}
|
||||
|
||||
// Policy tier constants for priority calculation
|
||||
export const DEFAULT_POLICY_TIER = 1;
|
||||
export const EXTENSION_POLICY_TIER = 2;
|
||||
@@ -89,33 +109,29 @@ export function getAlwaysAllowPriorityFraction(): number {
|
||||
* @param policyPaths Optional user-provided policy paths (from --policy flag).
|
||||
* When provided, these replace the default user policies directory.
|
||||
* @param workspacePoliciesDir Optional path to a directory containing workspace policies.
|
||||
* @param adminPolicyPaths Optional admin-provided policy paths (from --admin-policy flag).
|
||||
* When provided, these supplement the default system policies directory.
|
||||
*/
|
||||
export function getPolicyDirectories(
|
||||
defaultPoliciesDir?: string,
|
||||
policyPaths?: string[],
|
||||
workspacePoliciesDir?: string,
|
||||
adminPolicyPaths?: string[],
|
||||
): string[] {
|
||||
const dirs = [];
|
||||
return [
|
||||
// Admin tier (highest priority)
|
||||
Storage.getSystemPoliciesDir(),
|
||||
...(adminPolicyPaths ?? []),
|
||||
|
||||
// Admin tier (highest priority)
|
||||
dirs.push(Storage.getSystemPoliciesDir());
|
||||
// User tier (second highest priority)
|
||||
...(policyPaths ?? [Storage.getUserPoliciesDir()]),
|
||||
|
||||
// User tier (second highest priority)
|
||||
if (policyPaths && policyPaths.length > 0) {
|
||||
dirs.push(...policyPaths);
|
||||
} else {
|
||||
dirs.push(Storage.getUserPoliciesDir());
|
||||
}
|
||||
// Workspace Tier (third highest)
|
||||
workspacePoliciesDir,
|
||||
|
||||
// Workspace Tier (third highest)
|
||||
if (workspacePoliciesDir) {
|
||||
dirs.push(workspacePoliciesDir);
|
||||
}
|
||||
|
||||
// Default tier (lowest priority)
|
||||
dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR);
|
||||
|
||||
return dirs;
|
||||
// Default tier (lowest priority)
|
||||
defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR,
|
||||
].filter((dir): dir is string => !!dir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,37 +140,40 @@ export function getPolicyDirectories(
|
||||
*/
|
||||
export function getPolicyTier(
|
||||
dir: string,
|
||||
defaultPoliciesDir?: string,
|
||||
workspacePoliciesDir?: string,
|
||||
context: {
|
||||
defaultPoliciesDir?: string;
|
||||
workspacePoliciesDir?: string;
|
||||
adminPolicyPaths?: Set<string>;
|
||||
systemPoliciesDir: string;
|
||||
userPoliciesDir: string;
|
||||
},
|
||||
): number {
|
||||
const USER_POLICIES_DIR = Storage.getUserPoliciesDir();
|
||||
const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir();
|
||||
|
||||
const normalizedDir = path.resolve(dir);
|
||||
const normalizedUser = path.resolve(USER_POLICIES_DIR);
|
||||
const normalizedAdmin = path.resolve(ADMIN_POLICIES_DIR);
|
||||
|
||||
if (normalizedDir === context.systemPoliciesDir) {
|
||||
return ADMIN_POLICY_TIER;
|
||||
}
|
||||
if (context.adminPolicyPaths?.has(normalizedDir)) {
|
||||
return ADMIN_POLICY_TIER;
|
||||
}
|
||||
if (normalizedDir === context.userPoliciesDir) {
|
||||
return USER_POLICY_TIER;
|
||||
}
|
||||
if (
|
||||
defaultPoliciesDir &&
|
||||
normalizedDir === path.resolve(defaultPoliciesDir)
|
||||
context.workspacePoliciesDir &&
|
||||
normalizedDir === path.resolve(context.workspacePoliciesDir)
|
||||
) {
|
||||
return WORKSPACE_POLICY_TIER;
|
||||
}
|
||||
if (
|
||||
context.defaultPoliciesDir &&
|
||||
normalizedDir === path.resolve(context.defaultPoliciesDir)
|
||||
) {
|
||||
return DEFAULT_POLICY_TIER;
|
||||
}
|
||||
if (normalizedDir === path.resolve(DEFAULT_CORE_POLICIES_DIR)) {
|
||||
return DEFAULT_POLICY_TIER;
|
||||
}
|
||||
if (normalizedDir === normalizedUser) {
|
||||
return USER_POLICY_TIER;
|
||||
}
|
||||
if (
|
||||
workspacePoliciesDir &&
|
||||
normalizedDir === path.resolve(workspacePoliciesDir)
|
||||
) {
|
||||
return WORKSPACE_POLICY_TIER;
|
||||
}
|
||||
if (normalizedDir === normalizedAdmin) {
|
||||
return ADMIN_POLICY_TIER;
|
||||
}
|
||||
|
||||
return DEFAULT_POLICY_TIER;
|
||||
}
|
||||
@@ -178,21 +197,24 @@ export function formatPolicyError(error: PolicyFileError): string {
|
||||
|
||||
/**
|
||||
* Filters out insecure policy directories (specifically the system policy directory).
|
||||
* Supplemental admin policy paths are NOT subject to strict security checks as they
|
||||
* are explicitly provided by the user/administrator via flags or settings.
|
||||
* Emits warnings if insecure directories are found.
|
||||
*/
|
||||
async function filterSecurePolicyDirectories(
|
||||
dirs: string[],
|
||||
systemPoliciesDir: string,
|
||||
): Promise<string[]> {
|
||||
const systemPoliciesDir = path.resolve(Storage.getSystemPoliciesDir());
|
||||
|
||||
const results = await Promise.all(
|
||||
dirs.map(async (dir) => {
|
||||
// Only check security for system policies
|
||||
if (path.resolve(dir) === systemPoliciesDir) {
|
||||
const normalizedDir = path.resolve(dir);
|
||||
const isSystemPolicy = normalizedDir === systemPoliciesDir;
|
||||
|
||||
if (isSystemPolicy) {
|
||||
const { secure, reason } = await isDirectorySecure(dir);
|
||||
if (!secure) {
|
||||
const msg = `Security Warning: Skipping system policies from ${dir}: ${reason}`;
|
||||
coreEvents.emitFeedback('warning', msg);
|
||||
emitWarningOnce(msg);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -271,40 +293,70 @@ export async function createPolicyEngineConfig(
|
||||
approvalMode: ApprovalMode,
|
||||
defaultPoliciesDir?: string,
|
||||
): Promise<PolicyEngineConfig> {
|
||||
const systemPoliciesDir = path.resolve(Storage.getSystemPoliciesDir());
|
||||
const userPoliciesDir = path.resolve(Storage.getUserPoliciesDir());
|
||||
let adminPolicyPaths = settings.adminPolicyPaths;
|
||||
|
||||
// Security: Ignore supplemental admin policies if the system directory already contains policies.
|
||||
// This prevents flag-based overrides when a central system policy is established.
|
||||
if (adminPolicyPaths?.length) {
|
||||
try {
|
||||
const files = await fs.readdir(systemPoliciesDir);
|
||||
if (files.some((f) => f.endsWith('.toml'))) {
|
||||
const msg = `Security Warning: Ignoring --admin-policy because system policies are already defined in ${systemPoliciesDir}`;
|
||||
emitWarningOnce(msg);
|
||||
adminPolicyPaths = undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isNodeError(e) || e.code !== 'ENOENT') {
|
||||
debugLogger.warn(
|
||||
`Failed to check system policies in ${systemPoliciesDir}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const policyDirs = getPolicyDirectories(
|
||||
defaultPoliciesDir,
|
||||
settings.policyPaths,
|
||||
settings.workspacePoliciesDir,
|
||||
adminPolicyPaths,
|
||||
);
|
||||
const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs);
|
||||
|
||||
const normalizedAdminPoliciesDir = path.resolve(
|
||||
Storage.getSystemPoliciesDir(),
|
||||
const adminPolicyPathsSet = adminPolicyPaths
|
||||
? new Set(adminPolicyPaths.map((p) => path.resolve(p)))
|
||||
: undefined;
|
||||
|
||||
const securePolicyDirs = await filterSecurePolicyDirectories(
|
||||
policyDirs,
|
||||
systemPoliciesDir,
|
||||
);
|
||||
|
||||
const tierContext = {
|
||||
defaultPoliciesDir,
|
||||
workspacePoliciesDir: settings.workspacePoliciesDir,
|
||||
adminPolicyPaths: adminPolicyPathsSet,
|
||||
systemPoliciesDir,
|
||||
userPoliciesDir,
|
||||
};
|
||||
|
||||
const userProvidedPaths = settings.policyPaths
|
||||
? new Set(settings.policyPaths.map((p) => path.resolve(p)))
|
||||
: new Set<string>();
|
||||
|
||||
// Load policies from TOML files
|
||||
const {
|
||||
rules: tomlRules,
|
||||
checkers: tomlCheckers,
|
||||
errors,
|
||||
} = await loadPoliciesFromToml(securePolicyDirs, (p) => {
|
||||
const tier = getPolicyTier(
|
||||
p,
|
||||
defaultPoliciesDir,
|
||||
settings.workspacePoliciesDir,
|
||||
);
|
||||
const normalizedPath = path.resolve(p);
|
||||
const tier = getPolicyTier(normalizedPath, tierContext);
|
||||
|
||||
// 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;
|
||||
}
|
||||
// If it's a user-provided path that isn't already categorized as ADMIN, treat it as USER tier.
|
||||
if (userProvidedPaths.has(normalizedPath) && tier !== ADMIN_POLICY_TIER) {
|
||||
return USER_POLICY_TIER;
|
||||
}
|
||||
|
||||
return tier;
|
||||
|
||||
@@ -311,6 +311,8 @@ export interface PolicySettings {
|
||||
mcpServers?: Record<string, { trust?: boolean }>;
|
||||
// User provided policies that will replace the USER level policies in ~/.gemini/policies
|
||||
policyPaths?: string[];
|
||||
// Admin provided policies that will supplement the ADMIN level policies
|
||||
adminPolicyPaths?: string[];
|
||||
workspacePoliciesDir?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user