Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/cli/src/i18n/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,11 @@ export default {
"Ausführung erlauben von: '{{command}}'?",
'Yes, allow always ...': 'Ja, immer erlauben ...',
'Always allow in this project': 'In diesem Projekt immer erlauben',
'Always allow {{action}} in this project':
'{{action}} in diesem Projekt immer erlauben',
'Always allow for this user': 'Für diesen Benutzer immer erlauben',
'Always allow {{action}} for this user':
'{{action}} für diesen Benutzer immer erlauben',
'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren',
'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen',
'No, keep planning (esc)': 'Nein, weiter planen (Esc)',
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,11 @@ export default {
"Allow execution of: '{{command}}'?": "Allow execution of: '{{command}}'?",
'Yes, allow always ...': 'Yes, allow always ...',
'Always allow in this project': 'Always allow in this project',
'Always allow {{action}} in this project':
'Always allow {{action}} in this project',
'Always allow for this user': 'Always allow for this user',
'Always allow {{action}} for this user':
'Always allow {{action}} for this user',
'Yes, and auto-accept edits': 'Yes, and auto-accept edits',
'Yes, and manually approve edits': 'Yes, and manually approve edits',
'No, keep planning (esc)': 'No, keep planning (esc)',
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/i18n/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,10 @@ export default {
"Allow execution of: '{{command}}'?": "'{{command}}' の実行を許可しますか?",
'Yes, allow always ...': 'はい、常に許可...',
'Always allow in this project': 'このプロジェクトで常に許可',
'Always allow {{action}} in this project':
'このプロジェクトで{{action}}を常に許可',
'Always allow for this user': 'このユーザーに常に許可',
'Always allow {{action}} for this user': 'このユーザーに{{action}}を常に許可',
'Yes, and auto-accept edits': 'はい、編集を自動承認',
'Yes, and manually approve edits': 'はい、編集を手動承認',
'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)',
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/i18n/locales/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -1054,7 +1054,11 @@ export default {
"Permitir a execução de: '{{command}}'?",
'Yes, allow always ...': 'Sim, permitir sempre ...',
'Always allow in this project': 'Sempre permitir neste projeto',
'Always allow {{action}} in this project':
'Sempre permitir {{action}} neste projeto',
'Always allow for this user': 'Sempre permitir para este usuário',
'Always allow {{action}} for this user':
'Sempre permitir {{action}} para este usuário',
'Yes, and auto-accept edits': 'Sim, e aceitar edições automaticamente',
'Yes, and manually approve edits': 'Sim, e aprovar edições manualmente',
'No, keep planning (esc)': 'Não, continuar planejando (esc)',
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/i18n/locales/ru.js
Original file line number Diff line number Diff line change
Expand Up @@ -979,7 +979,11 @@ export default {
"Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?",
'Yes, allow always ...': 'Да, всегда разрешать ...',
'Always allow in this project': 'Всегда разрешать в этом проекте',
'Always allow {{action}} in this project':
'Всегда разрешать {{action}} в этом проекте',
'Always allow for this user': 'Всегда разрешать для этого пользователя',
'Always allow {{action}} for this user':
'Всегда разрешать {{action}} для этого пользователя',
'Yes, and auto-accept edits': 'Да, и автоматически принимать правки',
'Yes, and manually approve edits': 'Да, и вручную подтверждать правки',
'No, keep planning (esc)': 'Нет, продолжить планирование (esc)',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,9 @@ export default {
"Allow execution of: '{{command}}'?": "允许执行:'{{command}}'?",
'Yes, allow always ...': '是,总是允许 ...',
'Always allow in this project': '在本项目中总是允许',
'Always allow {{action}} in this project': '在本项目中总是允许{{action}}',
'Always allow for this user': '对该用户总是允许',
'Always allow {{action}} for this user': '对该用户总是允许{{action}}',
'Yes, and auto-accept edits': '是,并自动接受编辑',
'Yes, and manually approve edits': '是,并手动批准编辑',
'No, keep planning (esc)': '否,继续规划 (esc)',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import type {
Config,
EditorType,
} from '@qwen-code/qwen-code-core';
import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import {
IdeClient,
ToolConfirmationOutcome,
buildHumanReadableRuleLabel,
} from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
Expand Down Expand Up @@ -243,16 +247,24 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = executionProps.permissionRules?.length
? ` [${executionProps.permissionRules.join(', ')}]`
const friendlyLabel = executionProps.permissionRules?.length
? ` ${buildHumanReadableRuleLabel(executionProps.permissionRules)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
Expand Down Expand Up @@ -324,18 +336,26 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel =
const friendlyLabel =
'permissionRules' in infoProps &&
(infoProps as { permissionRules?: string[] }).permissionRules?.length
? ` [${(infoProps as { permissionRules?: string[] }).permissionRules!.join(', ')}]`
? ` ${buildHumanReadableRuleLabel((infoProps as { permissionRules?: string[] }).permissionRules!)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
Expand Down Expand Up @@ -401,16 +421,24 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = mcpProps.permissionRules?.length
? ` [${mcpProps.permissionRules.join(', ')}]`
const friendlyLabel = mcpProps.permissionRules?.length
? ` ${buildHumanReadableRuleLabel(mcpProps.permissionRules)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
Expand Down
24 changes: 18 additions & 6 deletions packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,13 @@ export class CoreToolScheduler {
// This check should happen before registry lookup to provide a clear permission error
const pm = this.config.getPermissionManager?.();
if (pm && !pm.isToolEnabled(reqInfo.name)) {
const permissionErrorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`;
const matchingRule = pm.findMatchingDenyRule({
toolName: reqInfo.name,
});
const ruleInfo = matchingRule
? ` Matching deny rule: "${matchingRule}".`
: '';
const permissionErrorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.${ruleInfo}`;
return {
status: 'error',
request: reqInfo,
Expand Down Expand Up @@ -914,10 +920,16 @@ export class CoreToolScheduler {

if (finalPermission === 'deny') {
// Hard deny: security violation or PM explicit deny
const denyMessage =
defaultPermission === 'deny'
? `Tool "${reqInfo.name}" is denied: command substitution is not allowed for security reasons.`
: `Tool "${reqInfo.name}" is denied by permission rules.`;
let denyMessage: string;
if (defaultPermission === 'deny') {
denyMessage = `Tool "${reqInfo.name}" is denied: command substitution is not allowed for security reasons.`;
} else {
const matchingRule = pm?.findMatchingDenyRule(pmCtx);
const ruleInfo = matchingRule
? ` Matching deny rule: "${matchingRule}".`
: '';
denyMessage = `Tool "${reqInfo.name}" is denied by permission rules.${ruleInfo}`;
}
this.setStatusInternal(
reqInfo.callId,
'error',
Expand Down Expand Up @@ -1002,7 +1014,7 @@ export class CoreToolScheduler {
this.config.getInputFormat() !== InputFormat.STREAM_JSON;

if (shouldAutoDeny) {
const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`;
const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined (non-interactive mode cannot prompt for confirmation).`;
this.setStatusInternal(
reqInfo.callId,
'error',
Expand Down
172 changes: 172 additions & 0 deletions packages/core/src/permissions/permission-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
splitCompoundCommand,
buildPermissionRules,
getRuleDisplayName,
buildHumanReadableRuleLabel,
} from './rule-parser.js';
import { PermissionManager } from './permission-manager.js';
import type { PermissionManagerConfig } from './permission-manager.js';
Expand Down Expand Up @@ -1519,3 +1520,174 @@ describe('buildPermissionRules', () => {
});
});
});

// ─── buildHumanReadableRuleLabel ─────────────────────────────────────────────

describe('buildHumanReadableRuleLabel', () => {
it('returns empty string for empty rules array', () => {
expect(buildHumanReadableRuleLabel([])).toBe('');
});

it('converts bare Read rule to "read files"', () => {
expect(buildHumanReadableRuleLabel(['Read'])).toBe('read files');
});

it('converts bare Bash rule to "run commands"', () => {
expect(buildHumanReadableRuleLabel(['Bash'])).toBe('run commands');
});

it('converts bare WebSearch rule to "search the web"', () => {
expect(buildHumanReadableRuleLabel(['WebSearch'])).toBe('search the web');
});

it('converts Read with absolute path specifier', () => {
const label = buildHumanReadableRuleLabel(['Read(//Users/mochi/.qwen/**)']);
expect(label).toBe('read files in /Users/mochi/.qwen/');
});

it('converts Read with relative path specifier', () => {
const label = buildHumanReadableRuleLabel(['Read(/src/**)']);
expect(label).toBe('read files in /src/');
});

it('converts Edit with path specifier', () => {
const label = buildHumanReadableRuleLabel(['Edit(//tmp/**)']);
expect(label).toBe('edit files in /tmp/');
});

it('converts Bash with command specifier', () => {
const label = buildHumanReadableRuleLabel(['Bash(git *)']);
expect(label).toBe("run 'git *' commands");
});

it('converts WebFetch with domain specifier', () => {
const label = buildHumanReadableRuleLabel(['WebFetch(github.com)']);
expect(label).toBe('fetch from github.com');
});

it('converts Skill with literal specifier', () => {
const label = buildHumanReadableRuleLabel(['Skill(Explore)']);
expect(label).toBe('use skill "Explore"');
});

it('converts Agent with literal specifier', () => {
const label = buildHumanReadableRuleLabel(['Agent(research)']);
expect(label).toBe('use agent "research"');
});

it('joins multiple rules with commas', () => {
const label = buildHumanReadableRuleLabel([
'Read(//Users/alice/**)',
'Bash(npm *)',
]);
expect(label).toBe("read files in /Users/alice/, run 'npm *' commands");
});

it('handles unknown display names gracefully', () => {
const label = buildHumanReadableRuleLabel(['mcp__server__tool']);
expect(label).toBe('mcp__server__tool');
});

it('handles unknown display name with specifier', () => {
const label = buildHumanReadableRuleLabel(['UnknownCategory(someValue)']);
expect(label).toBe('unknowncategory "someValue"');
});

it('cleans path with /* suffix', () => {
const label = buildHumanReadableRuleLabel(['Read(//home/user/docs/*)']);
expect(label).toBe('read files in /home/user/docs/');
});

it('round-trips from buildPermissionRules for file tool', () => {
const rules = buildPermissionRules({
toolName: 'read_file',
filePath: '/Users/alice/.secrets',
});
const label = buildHumanReadableRuleLabel(rules);
expect(label).toBe('read files in /Users/alice/');
});

it('round-trips from buildPermissionRules for shell command', () => {
const rules = buildPermissionRules({
toolName: 'run_shell_command',
command: 'git status',
});
const label = buildHumanReadableRuleLabel(rules);
expect(label).toBe("run 'git status' commands");
});

it('round-trips from buildPermissionRules for web fetch', () => {
const rules = buildPermissionRules({
toolName: 'web_fetch',
domain: 'example.com',
});
const label = buildHumanReadableRuleLabel(rules);
expect(label).toBe('fetch from example.com');
});
});

// ─── PermissionManager.findMatchingDenyRule ──────────────────────────────────

describe('PermissionManager.findMatchingDenyRule', () => {
it('returns the raw deny rule string when context matches', () => {
const pm = new PermissionManager(
makeConfig({ permissionsDeny: ['Bash(rm *)'] }),
);
pm.initialize();

const result = pm.findMatchingDenyRule({
toolName: 'run_shell_command',
command: 'rm -rf /tmp/foo',
});
expect(result).toBe('Bash(rm *)');
});

it('returns undefined when no deny rule matches', () => {
const pm = new PermissionManager(
makeConfig({ permissionsDeny: ['Bash(rm *)'] }),
);
pm.initialize();

const result = pm.findMatchingDenyRule({
toolName: 'run_shell_command',
command: 'git status',
});
expect(result).toBeUndefined();
});

it('matches session deny rules', () => {
const pm = new PermissionManager(makeConfig());
pm.initialize();
pm.addSessionDenyRule('Read(//secret/**)');

const result = pm.findMatchingDenyRule({
toolName: 'read_file',
filePath: '/secret/key.pem',
});
expect(result).toBe('Read(//secret/**)');
});

it('returns undefined for non-denied tool', () => {
const pm = new PermissionManager(
makeConfig({ permissionsDeny: ['ShellTool'] }),
);
pm.initialize();

const result = pm.findMatchingDenyRule({ toolName: 'read_file' });
expect(result).toBeUndefined();
});

it('matches bare tool deny rule', () => {
const pm = new PermissionManager(
makeConfig({ permissionsDeny: ['ShellTool'] }),
);
pm.initialize();

const result = pm.findMatchingDenyRule({
toolName: 'run_shell_command',
command: 'echo hello',
});
// rule.raw preserves the original rule string as written in config
expect(result).toBe('ShellTool');
});
});
Loading
Loading