fix(core): properly support allowRedirect in policy engine (#23579)

This commit is contained in:
Tommaso Sciortino
2026-03-23 20:32:50 +00:00
committed by GitHub
parent 42a673a52c
commit 37857ab956
15 changed files with 168 additions and 17 deletions
@@ -136,6 +136,7 @@ export interface UpdatePolicy {
argsPattern?: string;
commandPrefix?: string | string[];
mcpName?: string;
allowRedirection?: boolean;
}
export interface ToolPolicyRejection {
+7
View File
@@ -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."
+4 -4
View File
@@ -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."
+1 -1
View File
@@ -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',
);
});
});
+29 -1
View File
@@ -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);
+7 -4
View File
@@ -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
+4 -2
View File
@@ -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;
}
+1
View File
@@ -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');
+4 -1
View File
@@ -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);
}
/**