mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-03 16:34:31 -07:00
fix(core): improve shell command with redirection detection (#15683)
This commit is contained in:
@@ -864,6 +864,116 @@ describe('PolicyEngine', () => {
|
||||
(await engine.check({ name: 'test', args }, undefined)).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
it('should downgrade ALLOW to ASK_USER for redirected shell commands', async () => {
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
// Matches "echo" prefix
|
||||
argsPattern: /"command":"echo/,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({ rules });
|
||||
|
||||
// Safe command should be allowed
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'echo "hello"' } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Redirected command should be downgraded to ASK_USER
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'echo "hello" > file.txt' },
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ASK_USER);
|
||||
});
|
||||
|
||||
it('should allow redirected shell commands when allowRedirection is true', async () => {
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
// Matches "echo" prefix
|
||||
argsPattern: /"command":"echo/,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
allowRedirection: true,
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({ rules });
|
||||
|
||||
// Redirected command should stay ALLOW
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'echo "hello" > file.txt' },
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should NOT downgrade ALLOW to ASK_USER for quoted redirection chars', async () => {
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
argsPattern: /"command":"echo/,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({ rules });
|
||||
|
||||
// Should remain ALLOW because it's not a real redirection
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'echo "-> arrow"' },
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should avoid infinite recursion for commands with substitution', async () => {
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: 'run_shell_command',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({ rules });
|
||||
|
||||
// Command with substitution triggers splitCommands returning the same command as its first element.
|
||||
// This verifies the fix for the infinite recursion bug.
|
||||
const result = await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'echo $(ls)' },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safety checker integration', () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SHELL_TOOL_NAMES,
|
||||
initializeShellParsers,
|
||||
splitCommands,
|
||||
hasRedirection,
|
||||
} from '../utils/shell-utils.js';
|
||||
|
||||
function ruleMatches(
|
||||
@@ -140,6 +141,92 @@ export class PolicyEngine {
|
||||
return this.approvalMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a shell command is allowed.
|
||||
*/
|
||||
private async checkShellCommand(
|
||||
toolName: string,
|
||||
command: string | undefined,
|
||||
ruleDecision: PolicyDecision,
|
||||
serverName: string | undefined,
|
||||
dir_path: string | undefined,
|
||||
allowRedirection?: boolean,
|
||||
): Promise<PolicyDecision> {
|
||||
if (!command) {
|
||||
return this.applyNonInteractiveMode(ruleDecision);
|
||||
}
|
||||
|
||||
await initializeShellParsers();
|
||||
const subCommands = splitCommands(command);
|
||||
|
||||
if (subCommands.length === 0) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ASK_USER.`,
|
||||
);
|
||||
return this.applyNonInteractiveMode(PolicyDecision.ASK_USER);
|
||||
}
|
||||
|
||||
// If there are multiple parts, or if we just want to validate the single part against DENY rules
|
||||
if (subCommands.length > 0) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
|
||||
);
|
||||
|
||||
// Start with the decision for the full command string
|
||||
let aggregateDecision = ruleDecision;
|
||||
|
||||
for (const subCmd of subCommands) {
|
||||
// Prevent infinite recursion for the root command
|
||||
if (subCmd === command) {
|
||||
if (!allowRedirection && hasRedirection(subCmd)) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
|
||||
);
|
||||
// Redirection always downgrades ALLOW to ASK_USER
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const subResult = await this.check(
|
||||
{ name: toolName, args: { command: subCmd, dir_path } },
|
||||
serverName,
|
||||
);
|
||||
|
||||
// If any part is DENIED, the whole command is DENIED
|
||||
if (subResult.decision === PolicyDecision.DENY) {
|
||||
return PolicyDecision.DENY;
|
||||
}
|
||||
|
||||
// If any part requires ASK_USER, the whole command requires ASK_USER (unless already DENY)
|
||||
if (subResult.decision === PolicyDecision.ASK_USER) {
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for redirection in allowed sub-commands
|
||||
if (
|
||||
subResult.decision === PolicyDecision.ALLOW &&
|
||||
!allowRedirection &&
|
||||
hasRedirection(subCmd)
|
||||
) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
|
||||
);
|
||||
if (aggregateDecision === PolicyDecision.ALLOW) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.applyNonInteractiveMode(aggregateDecision);
|
||||
}
|
||||
|
||||
return this.applyNonInteractiveMode(ruleDecision);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool call is allowed based on the configured policies.
|
||||
* Returns the decision and the matching rule (if any).
|
||||
@@ -183,62 +270,16 @@ export class PolicyEngine {
|
||||
`[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`,
|
||||
);
|
||||
|
||||
// Special handling for shell commands: check sub-commands if present
|
||||
if (
|
||||
toolCall.name &&
|
||||
SHELL_TOOL_NAMES.includes(toolCall.name) &&
|
||||
rule.decision === PolicyDecision.ALLOW
|
||||
) {
|
||||
const command = (toolCall.args as { command?: string })?.command;
|
||||
if (command) {
|
||||
await initializeShellParsers();
|
||||
const subCommands = splitCommands(command);
|
||||
|
||||
// If there are multiple sub-commands, we must verify EACH of them matches an ALLOW rule.
|
||||
// If any sub-command results in DENY -> the whole thing is DENY.
|
||||
// If any sub-command results in ASK_USER -> the whole thing is ASK_USER (unless one is DENY).
|
||||
// Only if ALL sub-commands are ALLOW do we proceed with ALLOW.
|
||||
if (subCommands.length === 0) {
|
||||
// This case occurs if the command is non-empty but parsing fails.
|
||||
// An ALLOW rule for a prefix might have matched, but since the rest of
|
||||
// the command is un-parseable, it's unsafe to proceed.
|
||||
// Fall back to a safe decision.
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to safe decision because implicit ALLOW is unsafe.`,
|
||||
);
|
||||
decision = this.applyNonInteractiveMode(PolicyDecision.ASK_USER);
|
||||
} else if (subCommands.length > 1) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Compound command detected: ${subCommands.length} parts`,
|
||||
);
|
||||
let aggregateDecision = PolicyDecision.ALLOW;
|
||||
|
||||
for (const subCmd of subCommands) {
|
||||
// Recursively check each sub-command
|
||||
const subCall = {
|
||||
name: toolCall.name,
|
||||
args: { command: subCmd },
|
||||
};
|
||||
const subResult = await this.check(subCall, serverName);
|
||||
|
||||
if (subResult.decision === PolicyDecision.DENY) {
|
||||
aggregateDecision = PolicyDecision.DENY;
|
||||
break; // Fail fast
|
||||
} else if (subResult.decision === PolicyDecision.ASK_USER) {
|
||||
aggregateDecision = PolicyDecision.ASK_USER;
|
||||
// efficient: we can only strictly downgrade from ALLOW to ASK_USER,
|
||||
// but we must continue looking for DENY.
|
||||
}
|
||||
}
|
||||
|
||||
decision = aggregateDecision;
|
||||
} else {
|
||||
// Single command, rule match is valid
|
||||
decision = this.applyNonInteractiveMode(rule.decision);
|
||||
}
|
||||
} else {
|
||||
decision = this.applyNonInteractiveMode(rule.decision);
|
||||
}
|
||||
if (toolCall.name && SHELL_TOOL_NAMES.includes(toolCall.name)) {
|
||||
const args = toolCall.args as { command?: string; dir_path?: string };
|
||||
decision = await this.checkShellCommand(
|
||||
toolCall.name,
|
||||
args?.command,
|
||||
rule.decision,
|
||||
serverName,
|
||||
args?.dir_path,
|
||||
rule.allowRedirection,
|
||||
);
|
||||
} else {
|
||||
decision = this.applyNonInteractiveMode(rule.decision);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,6 @@ describe('ShellToolInvocation Policy Update', () => {
|
||||
const invocation = new ShellToolInvocation(
|
||||
mockConfig,
|
||||
{ command: 'git status && npm test' },
|
||||
new Set(),
|
||||
mockMessageBus,
|
||||
'run_shell_command',
|
||||
'Shell',
|
||||
@@ -174,7 +173,6 @@ describe('ShellToolInvocation Policy Update', () => {
|
||||
const invocation = new ShellToolInvocation(
|
||||
mockConfig,
|
||||
{ command: 'ls -la /tmp' },
|
||||
new Set(),
|
||||
mockMessageBus,
|
||||
'run_shell_command',
|
||||
'Shell',
|
||||
|
||||
@@ -123,6 +123,13 @@ export interface PolicyRule {
|
||||
* If undefined or empty, it applies to all modes.
|
||||
*/
|
||||
modes?: ApprovalMode[];
|
||||
|
||||
/**
|
||||
* If true, allows command redirection even if the policy engine would normally
|
||||
* downgrade ALLOW to ASK_USER for redirected commands.
|
||||
* Only applies when decision is ALLOW.
|
||||
*/
|
||||
allowRedirection?: boolean;
|
||||
}
|
||||
|
||||
export interface SafetyCheckerRule {
|
||||
|
||||
Reference in New Issue
Block a user