Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions packages/core/src/confirmation-bus/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export interface UpdatePolicy {
commandPrefix?: string | string[];
mcpName?: string;
allowRedirection?: boolean;
allowEnv?: boolean;
modes?: ApprovalMode[];
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/policy/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ export function createPolicyUpdater(
modes: message.modes,
source: 'Dynamic (Confirmed)',
allowRedirection: message.allowRedirection,
allowEnv: message.allowEnv,
});
}
}
Expand Down Expand Up @@ -680,6 +681,7 @@ export function createPolicyUpdater(
modes: message.modes,
source: 'Dynamic (Confirmed)',
allowRedirection: message.allowRedirection,
allowEnv: message.allowEnv,
});
}

Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/policy/policy-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,57 @@ describe('PolicyEngine', () => {
expect(result.decision).toBe(PolicyDecision.ALLOW);
});

it('should allow env prefixed shell commands when allowEnv is true', async () => {
const engineWithEnvRule = new PolicyEngine({
rules: [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 1,
allowEnv: true,
},
],
});

const command = 'PAGER=cat git log';

const result = await engineWithEnvRule.check(
{
name: 'run_shell_command',
args: { command },
},
undefined,
undefined,
);

expect(result.decision).toBe(PolicyDecision.ALLOW);
});

it('should NOT allow env prefixed shell commands by default', async () => {
const engineWithRule = new PolicyEngine({
rules: [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 1,
},
],
});

const command = 'PAGER=cat git log';

const result = await engineWithRule.check(
{
name: 'run_shell_command',
args: { command },
},
undefined,
undefined,
);

expect(result.decision).toBe(PolicyDecision.ASK_USER);
});

Comment thread
rmedranollamas marked this conversation as resolved.
it('should handle tools with no args', async () => {
const rules: PolicyRule[] = [
{
Expand Down
54 changes: 54 additions & 0 deletions packages/core/src/policy/policy-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
initializeShellParsers,
splitCommands,
hasRedirection,
hasEnvPrefix,
extractStringFromParseEntry,
} from '../utils/shell-utils.js';
import { parse as shellParse } from 'shell-quote';
Expand Down Expand Up @@ -299,6 +300,26 @@ export class PolicyEngine {
return true;
}

private shouldDowngradeForEnvPrefix(
command: string,
allowEnv?: boolean,
): boolean {
if (allowEnv) return false;
if (!hasEnvPrefix(command)) return false;

// Do not downgrade (do not ask user) if sandboxing is enabled and in AUTO_EDIT or YOLO
const sandboxEnabled = !(this.sandboxManager instanceof NoopSandboxManager);
if (
sandboxEnabled &&
(this.approvalMode === ApprovalMode.AUTO_EDIT ||
this.approvalMode === ApprovalMode.YOLO)
) {
return false;
}

return true;
}

/**
* Check if a shell command is allowed.
*/
Expand Down Expand Up @@ -340,6 +361,7 @@ export class PolicyEngine {
serverName: string | undefined,
dir_path: string | undefined,
allowRedirection?: boolean,
allowEnv?: boolean,
rule?: PolicyRule,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
Expand Down Expand Up @@ -404,6 +426,14 @@ export class PolicyEngine {
responsibleRule = undefined; // Inherent policy
}

if (this.shouldDowngradeForEnvPrefix(command, allowEnv)) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for command with env prefix: ${command}`,
);
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined; // Inherent policy
}

for (const rawSubCmd of subCommands) {
const subCmd = rawSubCmd.trim();
// Prevent infinite recursion for the root command
Expand All @@ -417,6 +447,14 @@ export class PolicyEngine {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined; // Inherent policy
}
} else if (this.shouldDowngradeForEnvPrefix(subCmd, allowEnv)) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for command with env prefix: ${subCmd}`,
);
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined; // Inherent policy
}
} else {
// Atomic command matching the rule.
if (
Expand Down Expand Up @@ -469,6 +507,20 @@ export class PolicyEngine {
responsibleRule = undefined;
}
}

// Check for env prefix in allowed sub-commands
if (
subDecision === PolicyDecision.ALLOW &&
this.shouldDowngradeForEnvPrefix(subCmd, allowEnv)
) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for command with env prefix: ${subCmd}`,
);
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined;
}
}
}

return {
Expand Down Expand Up @@ -603,6 +655,7 @@ export class PolicyEngine {
serverName,
shellDirPath,
rule.allowRedirection,
rule.allowEnv,
rule,
toolAnnotations,
subagent,
Expand Down Expand Up @@ -650,6 +703,7 @@ export class PolicyEngine {
serverName,
shellDirPath,
false,
false,
undefined,
toolAnnotations,
subagent,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/policy/policy-updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
getCommandRoots: vi.fn(),
stripShellWrapper: vi.fn(),
hasRedirection: vi.fn(),
hasEnvPrefix: vi.fn(),
}));
interface ParsedPolicy {
rule?: Array<{
Expand Down Expand Up @@ -99,6 +100,25 @@
);
});

it('should pass allowEnv to policyEngine.addRule', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);

await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'run_shell_command',
commandPrefix: 'ls',
persist: false,
allowEnv: true,
});

expect(policyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'run_shell_command',
allowEnv: true,
}),
);
});

it('should pass mcpName to policyEngine.addRule for argsPattern updates', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);

Expand Down Expand Up @@ -259,6 +279,7 @@
(c: string) => c,
);
vi.mocked(shellUtils.hasRedirection).mockReturnValue(false);
vi.mocked(shellUtils.hasEnvPrefix).mockReturnValue(false);
});

it('should extract multiple root commands for chained commands', () => {
Expand Down Expand Up @@ -322,4 +343,31 @@
'echo "hello" > file.txt',
);
});

it('should include allowEnv if command has environment prefix', () => {
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['ls']);
vi.mocked(shellUtils.hasEnvPrefix).mockReturnValue(true);

const invocation = new ShellToolInvocation(
mockConfig,
{ command: 'PAGER=cat ls -la' },
mockMessageBus,
'run_shell_command',
'Shell',
);

const options = (
invocation as unknown as {
getPolicyUpdateOptions: (o: ToolConfirmationOutcome) => {
commandPrefix: string[];
allowEnv?: boolean;
};
}
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
expect(options!.commandPrefix).toEqual(['ls']);

Check failure on line 367 in packages/core/src/policy/policy-updater.test.ts

View workflow job for this annotation

GitHub Actions / Lint

This assertion is unnecessary since it does not change the type of the expression
Comment thread
rmedranollamas marked this conversation as resolved.
expect(options!.allowEnv).toBe(true);

Check failure on line 368 in packages/core/src/policy/policy-updater.test.ts

View workflow job for this annotation

GitHub Actions / Lint

This assertion is unnecessary since it does not change the type of the expression
expect(shellUtils.hasEnvPrefix).toHaveBeenCalledWith(
'PAGER=cat ls -la',
);
});
});
7 changes: 7 additions & 0 deletions packages/core/src/policy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ export interface PolicyRule {
*/
allowRedirection?: boolean;

/**
* If true, allows environment variable prefixes even if the policy engine would normally
* downgrade ALLOW to ASK_USER for commands with env prefixes.
* Only applies when decision is ALLOW.
*/
allowEnv?: boolean;

/**
* Effect of the rule's source.
* e.g. "my-policies.toml", "Settings (MCP Trusted)", etc.
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/tools/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
stripShellWrapper,
parseCommandDetails,
hasRedirection,
hasEnvPrefix,
normalizeCommand,
} from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
Expand Down Expand Up @@ -236,11 +237,12 @@ export class ShellToolInvocation extends BaseToolInvocation<
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
const allowRedirection = hasRedirection(command) ? true : undefined;
const allowEnv = hasEnvPrefix(command) ? true : undefined;

if (rootCommands.length > 0) {
return { commandPrefix: rootCommands, allowRedirection };
return { commandPrefix: rootCommands, allowRedirection, allowEnv };
}
return { commandPrefix: this.params.command, allowRedirection };
return { commandPrefix: this.params.command, allowRedirection, allowEnv };
}
return undefined;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export interface PolicyUpdateOptions {
mcpName?: string;
toolName?: string;
allowRedirection?: boolean;
allowEnv?: boolean;
}

/**
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/utils/shell-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
stripShellWrapper,
normalizeCommand,
hasRedirection,
hasEnvPrefix,
resolveExecutable,
} from './shell-utils.js';
import path from 'node:path';
Expand Down Expand Up @@ -288,6 +289,12 @@ describe('hasRedirection', () => {
mockPlatform.mockReturnValue('linux');
expect(hasRedirection('echo "a > b"')).toBe(false);
});

it('should not detect redirection in chained commands', () => {
expect(hasRedirection('cmd1 && echo hello > world')).toBe(false);
expect(hasRedirection('echo hello > world || cmd2')).toBe(false);
expect(hasRedirection('cmd1 ; echo hello > world')).toBe(false);
});
});

describeWindowsOnly('PowerShell integration', () => {
Expand Down Expand Up @@ -621,3 +628,34 @@ describe('resolveExecutable', () => {
expect(await resolveExecutable('unknown')).toBeUndefined();
});
});

describe('hasEnvPrefix', () => {
it('should detect simple environment variable assignments', () => {
expect(hasEnvPrefix('FOO=bar cmd')).toBe(true);
expect(hasEnvPrefix('FOO=bar')).toBe(true);
expect(hasEnvPrefix('FOO=bar baz=qux cmd')).toBe(true);
});

it('should detect quoted environment variable assignments', () => {
expect(hasEnvPrefix('FOO="bar baz" cmd')).toBe(true);
expect(hasEnvPrefix("FOO='bar baz' cmd")).toBe(true);
});

it('should detect environment variable assignments using env', () => {
expect(hasEnvPrefix('env FOO=bar cmd')).toBe(true);
expect(hasEnvPrefix('FOO=bar env cmd')).toBe(true);
expect(hasEnvPrefix('env FOO="bar baz" cmd')).toBe(true);
expect(hasEnvPrefix('env cmd')).toBe(true);
});

it('should not detect environment variables used in the command', () => {
expect(hasEnvPrefix('echo $FOO')).toBe(false);
expect(hasEnvPrefix('echo "${FOO}"')).toBe(false);
});

it('should not detect environment variables in chained commands', () => {
expect(hasEnvPrefix('FOO=bar cmd && cmd2')).toBe(false);
expect(hasEnvPrefix('cmd1 || FOO=bar cmd2')).toBe(false);
expect(hasEnvPrefix('cmd1 ; FOO=bar cmd2')).toBe(false);
});
});
Loading
Loading