Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import { PermissionManager } from './permission-manager.js';
import type { PermissionManagerConfig } from './permission-manager.js';
import { matchesCommandPattern } from './rule-parser.js';

function makeConfig(allow: string[]): PermissionManagerConfig {
return {
getPermissionsAllow: () => allow,
getPermissionsAsk: () => [],
getPermissionsDeny: () => [],
getProjectRoot: () => '/repo',
getCwd: () => '/repo',
getApprovalMode: () => 'default',
};
}

describe('leading env assignment substitution permissions (#10192)', () => {
it('keeps static env-prefix compatibility', () => {
expect(
matchesCommandPattern('npm --version', 'FOO=bar npm --version'),
).toBe(true);
expect(
matchesCommandPattern('npm --version', "FOO='$(literal)' npm --version"),
).toBe(true);
});

it.each([
'X=$(printf hidden) npm --version',
'X=`printf hidden` npm --version',
'X="$(printf hidden)" npm --version',
])('does not strip an env prefix containing substitution: %s', (command) => {
expect(matchesCommandPattern('npm --version', command)).toBe(false);
});

it('does not let a saved Bash allow downgrade substitution to allow', async () => {
const pm = new PermissionManager(makeConfig(['Bash(npm --version)']));
pm.initialize();

await expect(
pm.evaluate({
toolName: 'run_shell_command',
command: 'X=$(printf hidden) npm --version',
cwd: '/repo',
}),
).resolves.toBe('ask');
});
});
10 changes: 9 additions & 1 deletion packages/core/src/permissions/rule-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
sanitizeToolNameForProvider,
} from '../utils/tool-name-utils.js';
import { isNodeError } from '../utils/errors.js';
import { hasShellSubstitution } from '../utils/shell-utils.js';

const debugLogger = createDebugLogger('PERMISSIONS');

Expand Down Expand Up @@ -970,7 +971,14 @@ export function matchesCommandPattern(
): boolean {
// This function matches a single pattern against a single simple command.
// Compound command splitting is handled by the caller (PermissionManager).
const normalizedCommand = stripLeadingVariableAssignments(command);
// Leading environment assignments are compatibility-normalized so a
// static `FOO=bar npm --version` can match `Bash(npm --version)`.
// Never apply that widening when the raw command carries shell
// substitution: stripping the assignment would hide execution that
// happens before the trusted main command (#10192).
const normalizedCommand = hasShellSubstitution(command)
? command.trim()
: stripLeadingVariableAssignments(command);

// Special case: lone `*` matches any single command
if (pattern === '*') {
Expand Down
Loading