mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-12 14:10:08 +00:00
feat: human-readable permission labels, deny rule feedback, and multi-dir search tests
- Add buildHumanReadableRuleLabel() to convert raw permission rules into natural-language descriptions for the 'Always Allow' UI options - Add PermissionManager.findMatchingDenyRule() to surface which deny rule caused a tool to be blocked, improving error messages in coreToolScheduler - Update ToolConfirmationMessage to use friendly labels with i18n support - Add comprehensive tests for new permission features and multi-directory search in glob, grep, and ripGrep tools - Fix integration test for tool-control allowedTools configuration
This commit is contained in:
parent
8c31775573
commit
a5a8ec5d67
14 changed files with 622 additions and 19 deletions
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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',
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -365,6 +365,43 @@ export class PermissionManager {
|
|||
return decision !== 'deny';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first deny rule that matches the given context.
|
||||
* Returns the raw rule string if found, or undefined if no deny rule matches.
|
||||
*
|
||||
* Useful for providing user-visible feedback about which rule caused a denial.
|
||||
*/
|
||||
findMatchingDenyRule(ctx: PermissionCheckContext): string | undefined {
|
||||
const { toolName, command, filePath, domain, specifier } = ctx;
|
||||
|
||||
const pathCtx: PathMatchContext | undefined =
|
||||
this.config.getProjectRoot && this.config.getCwd
|
||||
? {
|
||||
projectRoot: this.config.getProjectRoot(),
|
||||
cwd: this.config.getCwd(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const matchArgs = [
|
||||
toolName,
|
||||
command,
|
||||
filePath,
|
||||
domain,
|
||||
pathCtx,
|
||||
specifier,
|
||||
] as const;
|
||||
|
||||
for (const rule of [
|
||||
...this.sessionRules.deny,
|
||||
...this.persistentRules.deny,
|
||||
]) {
|
||||
if (matchesRule(rule, ...matchArgs)) {
|
||||
return rule.raw;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell command helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -405,6 +405,106 @@ export function buildPermissionRules(ctx: PermissionCheckContext): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable display names for permission rule categories.
|
||||
* Maps display name → verb phrase for use in "Always allow [verb phrase] in this project".
|
||||
*/
|
||||
const DISPLAY_NAME_TO_VERB: Readonly<Record<string, string>> = {
|
||||
Read: 'read files',
|
||||
Edit: 'edit files',
|
||||
Bash: 'run commands',
|
||||
WebFetch: 'fetch from',
|
||||
WebSearch: 'search the web',
|
||||
Agent: 'use agent',
|
||||
Skill: 'use skill',
|
||||
SaveMemory: 'save memory',
|
||||
TodoWrite: 'write todos',
|
||||
Lsp: 'use LSP',
|
||||
ExitPlanMode: 'exit plan mode',
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the glob suffix (e.g. `/**`) and the leading `//` from an absolute
|
||||
* path specifier so it reads cleanly in a UI label.
|
||||
*
|
||||
* `//Users/mochi/.qwen/**` → `/Users/mochi/.qwen/`
|
||||
* `/src/**` → `src/`
|
||||
*/
|
||||
function cleanPathSpecifier(specifier: string): string {
|
||||
let cleaned = specifier;
|
||||
// Remove trailing glob patterns like /** or /*
|
||||
cleaned = cleaned.replace(/\/\*\*$/, '/').replace(/\/\*$/, '/');
|
||||
// Convert rule grammar `//absolute` → `/absolute`
|
||||
if (cleaned.startsWith('//')) {
|
||||
cleaned = cleaned.substring(1);
|
||||
}
|
||||
// Ensure trailing slash for directories
|
||||
if (!cleaned.endsWith('/')) {
|
||||
cleaned += '/';
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable label describing what a set of permission rules allow.
|
||||
*
|
||||
* Used in "Always Allow" UI options to give users a clear, natural-language
|
||||
* description instead of raw rule syntax.
|
||||
*
|
||||
* Examples:
|
||||
* `["Read(//Users/mochi/.qwen/**)"]` → `"read files in /Users/mochi/.qwen/"`
|
||||
* `["Bash(git *)"]` → `"run 'git *' commands"`
|
||||
* `["WebFetch(github.com)"]` → `"fetch from github.com"`
|
||||
* `["Read"]` → `"read files"`
|
||||
*
|
||||
* @param rules - Array of rule strings from buildPermissionRules()
|
||||
* @returns A human-readable description string
|
||||
*/
|
||||
export function buildHumanReadableRuleLabel(rules: string[]): string {
|
||||
if (!rules.length) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const rule of rules) {
|
||||
// Parse "DisplayName(specifier)" or bare "DisplayName"
|
||||
const parenIdx = rule.indexOf('(');
|
||||
if (parenIdx === -1) {
|
||||
// Bare rule like "Read" or "Bash"
|
||||
const verb = DISPLAY_NAME_TO_VERB[rule] ?? rule.toLowerCase();
|
||||
parts.push(verb);
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayName = rule.substring(0, parenIdx);
|
||||
const specifier = rule.substring(parenIdx + 1, rule.length - 1); // strip parens
|
||||
const verb = DISPLAY_NAME_TO_VERB[displayName] ?? displayName.toLowerCase();
|
||||
|
||||
const canonicalName = Object.entries(CANONICAL_TO_RULE_DISPLAY).find(
|
||||
([, v]) => v === displayName,
|
||||
)?.[0];
|
||||
const kind = canonicalName ? getSpecifierKind(canonicalName) : 'literal';
|
||||
|
||||
switch (kind) {
|
||||
case 'path': {
|
||||
const cleanPath = cleanPathSpecifier(specifier);
|
||||
parts.push(`${verb} in ${cleanPath}`);
|
||||
break;
|
||||
}
|
||||
case 'command':
|
||||
parts.push(`run '${specifier}' commands`);
|
||||
break;
|
||||
case 'domain':
|
||||
parts.push(`${verb} ${specifier}`);
|
||||
break;
|
||||
case 'literal':
|
||||
default:
|
||||
parts.push(`${verb} "${specifier}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shell command matching
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -366,6 +366,87 @@ describe('GlobTool', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('multi-directory workspace', () => {
|
||||
it('should search across all workspace directories when no path is specified', async () => {
|
||||
// Create a second workspace directory
|
||||
const secondDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'glob-tool-second-'),
|
||||
);
|
||||
await fs.writeFile(path.join(secondDir, '.git'), ''); // Fake git repo
|
||||
await fs.writeFile(path.join(secondDir, 'extra.txt'), 'extra content');
|
||||
await fs.writeFile(path.join(secondDir, 'bonus.txt'), 'bonus content');
|
||||
|
||||
const multiDirConfig = {
|
||||
...mockConfig,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGlobTool = new GlobTool(multiDirConfig);
|
||||
const params: GlobToolParams = { pattern: '*.txt' };
|
||||
const invocation = multiDirGlobTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should find files from both directories
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
|
||||
expect(result.llmContent).toContain(path.join(secondDir, 'extra.txt'));
|
||||
expect(result.llmContent).toContain(path.join(secondDir, 'bonus.txt'));
|
||||
expect(result.llmContent).toContain('across 2 workspace directories');
|
||||
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should deduplicate entries across overlapping directories', async () => {
|
||||
// Use the same directory twice to test deduplication
|
||||
const multiDirConfig = {
|
||||
...mockConfig,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [tempRootDir]),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGlobTool = new GlobTool(multiDirConfig);
|
||||
const params: GlobToolParams = { pattern: '*.txt' };
|
||||
const invocation = multiDirGlobTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should still only have 2 txt files (fileA.txt, FileB.TXT), not doubled
|
||||
expect(result.llmContent).toContain('Found 2 file(s)');
|
||||
});
|
||||
|
||||
it('should use single directory description when only one workspace dir', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.txt' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('in the workspace directory');
|
||||
expect(result.llmContent).not.toContain('across');
|
||||
});
|
||||
|
||||
it('should search only the specified path when path is provided (ignoring multi-dir)', async () => {
|
||||
const secondDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'glob-tool-second-'),
|
||||
);
|
||||
await fs.writeFile(path.join(secondDir, '.git'), '');
|
||||
await fs.writeFile(path.join(secondDir, 'other.txt'), 'other');
|
||||
|
||||
const multiDirConfig = {
|
||||
...mockConfig,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGlobTool = new GlobTool(multiDirConfig);
|
||||
const params: GlobToolParams = { pattern: '*.txt', path: 'sub' };
|
||||
const invocation = multiDirGlobTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should NOT find files from secondDir
|
||||
expect(result.llmContent).not.toContain('other.txt');
|
||||
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ignore file handling', () => {
|
||||
it('should respect .gitignore files by default', async () => {
|
||||
await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.ignored.txt');
|
||||
|
|
|
|||
|
|
@ -357,6 +357,48 @@ describe('GrepTool', () => {
|
|||
// Clean up
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should convert relative paths to absolute when searching multiple directories', async () => {
|
||||
const secondDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'grep-tool-second-'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(secondDir, 'extra.txt'),
|
||||
'world content in second dir',
|
||||
);
|
||||
|
||||
const multiDirConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 25000,
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
|
||||
const params: GrepToolParams = { pattern: 'world' };
|
||||
const invocation = multiDirGrepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should show "across N workspace directories"
|
||||
expect(result.llmContent).toContain('across 2 workspace directories');
|
||||
|
||||
// File paths from the second directory should be absolute
|
||||
expect(result.llmContent).toContain(
|
||||
`File: ${path.resolve(secondDir, 'extra.txt')}`,
|
||||
);
|
||||
|
||||
// File paths from the first directory should also be absolute
|
||||
expect(result.llmContent).toContain(
|
||||
`File: ${path.resolve(tempRootDir, 'fileA.txt')}`,
|
||||
);
|
||||
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
|
|
|
|||
|
|
@ -436,6 +436,116 @@ describe('RipGrepTool', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('multi-directory workspace', () => {
|
||||
it('should search across all workspace directories when no path is specified', async () => {
|
||||
const secondDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'grep-tool-second-'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(secondDir, 'extra.txt'),
|
||||
'hello from second dir',
|
||||
);
|
||||
|
||||
const multiDirConfig = {
|
||||
...mockConfig,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
|
||||
|
||||
(runRipgrep as Mock).mockResolvedValue({
|
||||
stdout: `fileA.txt:1:hello world${EOL}${secondDir}/extra.txt:1:hello from second dir${EOL}`,
|
||||
truncated: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const params: RipGrepToolParams = { pattern: 'hello' };
|
||||
const invocation = multiDirGrepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('across 2 workspace directories');
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
|
||||
// Verify both paths were passed to runRipgrep
|
||||
expect(runRipgrep).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([tempRootDir, secondDir]),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should search only specified path when path is given (ignoring multi-dir)', async () => {
|
||||
const secondDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'grep-tool-second-'),
|
||||
);
|
||||
await fs.writeFile(path.join(secondDir, 'other.txt'), 'other content');
|
||||
|
||||
const multiDirConfig = {
|
||||
...mockConfig,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
|
||||
|
||||
(runRipgrep as Mock).mockResolvedValue({
|
||||
stdout: `fileC.txt:1:another world in sub dir${EOL}`,
|
||||
truncated: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const params: RipGrepToolParams = { pattern: 'world', path: 'sub' };
|
||||
const invocation = multiDirGrepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('in path "sub"');
|
||||
expect(result.llmContent).not.toContain('across');
|
||||
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load .qwenignore from each workspace directory', async () => {
|
||||
const secondDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'grep-tool-second-'),
|
||||
);
|
||||
await fs.writeFile(path.join(secondDir, '.qwenignore'), 'ignored.txt\n');
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, '.qwenignore'),
|
||||
'other-ignored.txt\n',
|
||||
);
|
||||
|
||||
const multiDirConfig = {
|
||||
...mockConfig,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
|
||||
|
||||
(runRipgrep as Mock).mockResolvedValue({
|
||||
stdout: '',
|
||||
truncated: false,
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const params: RipGrepToolParams = { pattern: 'test' };
|
||||
const invocation = multiDirGrepTool.build(params);
|
||||
await invocation.execute(abortSignal);
|
||||
|
||||
// Verify both .qwenignore files were passed
|
||||
const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];
|
||||
const ignoreFileArgs = rgArgs.filter(
|
||||
(a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',
|
||||
);
|
||||
expect(ignoreFileArgs).toContain(path.join(tempRootDir, '.qwenignore'));
|
||||
expect(ignoreFileArgs).toContain(path.join(secondDir, '.qwenignore'));
|
||||
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal handling', () => {
|
||||
it('should handle AbortSignal during search', async () => {
|
||||
const controller = new AbortController();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue