mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -07:00
fix(core): properly support allowRedirect in policy engine (#23579)
This commit is contained in:
committed by
GitHub
parent
42a673a52c
commit
37857ab956
@@ -136,6 +136,7 @@ export interface UpdatePolicy {
|
||||
argsPattern?: string;
|
||||
commandPrefix?: string | string[];
|
||||
mcpName?: string;
|
||||
allowRedirection?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolPolicyRejection {
|
||||
|
||||
@@ -537,6 +537,7 @@ interface TomlRule {
|
||||
priority?: number;
|
||||
commandPrefix?: string | string[];
|
||||
argsPattern?: string;
|
||||
allowRedirection?: boolean;
|
||||
// Index signature to satisfy Record type if needed for toml.stringify
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -581,6 +582,7 @@ export function createPolicyUpdater(
|
||||
argsPattern: new RegExp(pattern),
|
||||
mcpName: message.mcpName,
|
||||
source: 'Dynamic (Confirmed)',
|
||||
allowRedirection: message.allowRedirection,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -617,6 +619,7 @@ export function createPolicyUpdater(
|
||||
argsPattern,
|
||||
mcpName: message.mcpName,
|
||||
source: 'Dynamic (Confirmed)',
|
||||
allowRedirection: message.allowRedirection,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -681,6 +684,10 @@ export function createPolicyUpdater(
|
||||
newRule.argsPattern = message.argsPattern;
|
||||
}
|
||||
|
||||
if (message.allowRedirection !== undefined) {
|
||||
newRule.allowRedirection = message.allowRedirection;
|
||||
}
|
||||
|
||||
// Add to rules
|
||||
existingData.rule.push(newRule);
|
||||
|
||||
|
||||
@@ -71,6 +71,26 @@ describe('createPolicyUpdater', () => {
|
||||
expect(content).toContain(`priority = ${expectedPriority}`);
|
||||
});
|
||||
|
||||
it('should include allowRedirection when persisting policy', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
const policyFile = '/mock/user/.gemini/policies/auto-saved.toml';
|
||||
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'test_tool',
|
||||
persist: true,
|
||||
allowRedirection: true,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const content = memfs.readFileSync(policyFile, 'utf-8') as string;
|
||||
expect(content).toContain('toolName = "test_tool"');
|
||||
expect(content).toContain('allowRedirection = true');
|
||||
});
|
||||
|
||||
it('should not persist policy when persist flag is false or undefined', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ toolName = ["read_file", "write_file", "replace", "list_directory", "glob", "gre
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
argsPattern = "(^|.*/)\\.gemini/.*"
|
||||
deny_message = "Memory Manager is only allowed to access the .gemini folder."
|
||||
denyMessage = "Memory Manager is only allowed to access the .gemini folder."
|
||||
|
||||
@@ -46,7 +46,7 @@ toolName = "enter_plan_mode"
|
||||
decision = "deny"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
deny_message = "You are already in Plan Mode."
|
||||
denyMessage = "You are already in Plan Mode."
|
||||
|
||||
[[rule]]
|
||||
toolName = "exit_plan_mode"
|
||||
@@ -65,7 +65,7 @@ interactive = false
|
||||
toolName = "exit_plan_mode"
|
||||
decision = "deny"
|
||||
priority = 50
|
||||
deny_message = "You are not currently in Plan Mode. Use enter_plan_mode first to design a plan."
|
||||
denyMessage = "You are not currently in Plan Mode. Use enter_plan_mode first to design a plan."
|
||||
|
||||
|
||||
# Catch-All: Deny everything by default in Plan mode.
|
||||
@@ -74,7 +74,7 @@ deny_message = "You are not currently in Plan Mode. Use enter_plan_mode first to
|
||||
decision = "deny"
|
||||
priority = 60
|
||||
modes = ["plan"]
|
||||
deny_message = "You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked."
|
||||
denyMessage = "You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked."
|
||||
|
||||
# Explicitly Allow Read-Only Tools in Plan mode.
|
||||
|
||||
@@ -121,4 +121,4 @@ toolName = ["write_file", "replace"]
|
||||
decision = "deny"
|
||||
priority = 65
|
||||
modes = ["plan"]
|
||||
deny_message = "You are in Plan Mode and cannot modify source code. You may ONLY use write_file or replace to save plans to the designated plans directory as .md files."
|
||||
denyMessage = "You are in Plan Mode and cannot modify source code. You may ONLY use write_file or replace to save plans to the designated plans directory as .md files."
|
||||
|
||||
@@ -52,4 +52,4 @@ interactive = true
|
||||
decision = "allow"
|
||||
priority = 998
|
||||
modes = ["yolo"]
|
||||
allow_redirection = true
|
||||
allowRedirection = true
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('../config/storage.js');
|
||||
vi.mock('../utils/shell-utils.js', () => ({
|
||||
getCommandRoots: vi.fn(),
|
||||
stripShellWrapper: vi.fn(),
|
||||
hasRedirection: vi.fn(),
|
||||
}));
|
||||
interface ParsedPolicy {
|
||||
rule?: Array<{
|
||||
@@ -177,6 +178,25 @@ describe('createPolicyUpdater', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass allowRedirection to policyEngine.addRule', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.UPDATE_POLICY,
|
||||
toolName: 'run_shell_command',
|
||||
commandPrefix: 'ls',
|
||||
persist: false,
|
||||
allowRedirection: true,
|
||||
});
|
||||
|
||||
expect(policyEngine.addRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'run_shell_command',
|
||||
allowRedirection: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist multiple rules correctly to TOML', async () => {
|
||||
createPolicyUpdater(policyEngine, messageBus, mockStorage);
|
||||
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
|
||||
@@ -238,6 +258,7 @@ describe('ShellToolInvocation Policy Update', () => {
|
||||
vi.mocked(shellUtils.stripShellWrapper).mockImplementation(
|
||||
(c: string) => c,
|
||||
);
|
||||
vi.mocked(shellUtils.hasRedirection).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should extract multiple root commands for chained commands', () => {
|
||||
@@ -279,4 +300,26 @@ describe('ShellToolInvocation Policy Update', () => {
|
||||
expect(options!.commandPrefix).toEqual(['ls']);
|
||||
expect(shellUtils.getCommandRoots).toHaveBeenCalledWith('ls -la /tmp');
|
||||
});
|
||||
|
||||
it('should include allowRedirection if command has redirection', () => {
|
||||
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['echo']);
|
||||
vi.mocked(shellUtils.hasRedirection).mockReturnValue(true);
|
||||
|
||||
const invocation = new ShellToolInvocation(
|
||||
mockConfig,
|
||||
{ command: 'echo "hello" > file.txt' },
|
||||
mockMessageBus,
|
||||
'run_shell_command',
|
||||
'Shell',
|
||||
);
|
||||
|
||||
const options = (
|
||||
invocation as unknown as TestableShellToolInvocation
|
||||
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
|
||||
expect(options!.commandPrefix).toEqual(['echo']);
|
||||
expect(options!.allowRedirection).toBe(true);
|
||||
expect(shellUtils.hasRedirection).toHaveBeenCalledWith(
|
||||
'echo "hello" > file.txt',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,6 +263,20 @@ allow_redirection = true
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should parse and transform allowRedirection property (camelCase)', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = "echo"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
allowRedirection = true
|
||||
`);
|
||||
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].allowRedirection).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
it('should parse deny_message property', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
[[rule]]
|
||||
@@ -273,7 +287,21 @@ deny_message = "Deletion is permanent"
|
||||
`);
|
||||
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].toolName).toBe('rm');
|
||||
expect(result.rules[0].decision).toBe(PolicyDecision.DENY);
|
||||
expect(result.rules[0].denyMessage).toBe('Deletion is permanent');
|
||||
expect(getErrors(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should parse denyMessage property (camelCase)', async () => {
|
||||
const result = await runLoadPoliciesFromToml(`
|
||||
[[rule]]
|
||||
toolName = "rm"
|
||||
decision = "deny"
|
||||
priority = 100
|
||||
denyMessage = "Deletion is permanent"
|
||||
`);
|
||||
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].decision).toBe(PolicyDecision.DENY);
|
||||
expect(result.rules[0].denyMessage).toBe('Deletion is permanent');
|
||||
expect(getErrors(result)).toHaveLength(0);
|
||||
|
||||
@@ -63,8 +63,10 @@ const PolicyRuleSchema = z.object({
|
||||
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
|
||||
interactive: z.boolean().optional(),
|
||||
toolAnnotations: z.record(z.any()).optional(),
|
||||
allow_redirection: z.boolean().optional(),
|
||||
deny_message: z.string().optional(),
|
||||
allowRedirection: z.boolean().optional(),
|
||||
allow_redirection: z.boolean().optional(), // deprecated snake_case for backward compatibility
|
||||
denyMessage: z.string().optional(),
|
||||
deny_message: z.string().optional(), // deprecated snake_case for backward compatibility
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -478,9 +480,10 @@ export async function loadPoliciesFromToml(
|
||||
modes: rule.modes,
|
||||
interactive: rule.interactive,
|
||||
toolAnnotations: rule.toolAnnotations,
|
||||
allowRedirection: rule.allow_redirection,
|
||||
allowRedirection:
|
||||
rule.allowRedirection ?? rule.allow_redirection,
|
||||
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,
|
||||
denyMessage: rule.deny_message,
|
||||
denyMessage: rule.denyMessage ?? rule.deny_message,
|
||||
};
|
||||
|
||||
// Compile regex pattern
|
||||
|
||||
@@ -100,10 +100,12 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
) {
|
||||
const command = stripShellWrapper(this.params.command);
|
||||
const rootCommands = [...new Set(getCommandRoots(command))];
|
||||
const allowRedirection = hasRedirection(command) ? true : undefined;
|
||||
|
||||
if (rootCommands.length > 0) {
|
||||
return { commandPrefix: rootCommands };
|
||||
return { commandPrefix: rootCommands, allowRedirection };
|
||||
}
|
||||
return { commandPrefix: this.params.command };
|
||||
return { commandPrefix: this.params.command, allowRedirection };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ export interface PolicyUpdateOptions {
|
||||
commandPrefix?: string | string[];
|
||||
mcpName?: string;
|
||||
toolName?: string;
|
||||
allowRedirection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getShellConfiguration,
|
||||
initializeShellParsers,
|
||||
parseCommandDetails,
|
||||
splitCommands,
|
||||
stripShellWrapper,
|
||||
hasRedirection,
|
||||
resolveExecutable,
|
||||
@@ -304,6 +305,40 @@ describeWindowsOnly('PowerShell integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitCommands', () => {
|
||||
it('should split chained commands', () => {
|
||||
expect(splitCommands('ls -l && git status')).toEqual([
|
||||
'ls -l',
|
||||
'git status',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out redirection tokens but keep command parts', () => {
|
||||
// Standard redirection
|
||||
expect(splitCommands('echo "hello" > file.txt')).toEqual(['echo "hello"']);
|
||||
expect(splitCommands('printf "test" >> log.txt')).toEqual([
|
||||
'printf "test"',
|
||||
]);
|
||||
expect(splitCommands('cat < input.txt')).toEqual(['cat']);
|
||||
|
||||
// Heredoc/Herestring
|
||||
expect(splitCommands('cat << EOF\nhello\nEOF')).toEqual(['cat']);
|
||||
// Note: The Tree-sitter bash parser includes the herestring in the main
|
||||
// command node's text, unlike standard redirections which are siblings.
|
||||
expect(splitCommands('grep "foo" <<< "foobar"')).toEqual([
|
||||
'grep "foo" <<< "foobar"',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract nested commands from process substitution while filtering the redirection operator', () => {
|
||||
// This is the key security test: we want cat to be checked, but not the > >(...) wrapper part
|
||||
const parts = splitCommands('echo "foo" > >(cat)');
|
||||
expect(parts).toContain('echo "foo"');
|
||||
expect(parts).toContain('cat');
|
||||
expect(parts.some((p) => p.includes('>'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripShellWrapper', () => {
|
||||
it('should strip sh -c with quotes', () => {
|
||||
expect(stripShellWrapper('sh -c "ls -l"')).toEqual('ls -l');
|
||||
|
||||
@@ -663,7 +663,10 @@ export function splitCommands(command: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.details.map((detail) => detail.text).filter(Boolean);
|
||||
return parsed.details
|
||||
.filter((detail) => !REDIRECTION_NAMES.has(detail.name))
|
||||
.map((detail) => detail.text)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user