mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 12:40:44 +00:00
feat(permissions): add permission system and rename folder trust command
This commit is contained in:
parent
407a66c959
commit
eeb4d85785
33 changed files with 3295 additions and 205 deletions
|
|
@ -19,7 +19,6 @@ import {
|
|||
Storage,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
ideContextStore,
|
||||
type ResumedSessionData,
|
||||
|
|
@ -802,64 +801,87 @@ export async function loadCliConfig(
|
|||
// (fallback for edge cases where query/prompt is provided with TEXT output)
|
||||
interactive = false;
|
||||
}
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
const extraExcludes: string[] = [];
|
||||
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
|
||||
const resolvedAllowedTools =
|
||||
argv.allowedTools || settings.tools?.allowed || [];
|
||||
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
|
||||
if (resolvedCoreTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (resolvedAllowedTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const excludeUnlessExplicit = (toolName: ToolName): void => {
|
||||
if (!isExplicitlyEnabled(toolName)) {
|
||||
extraExcludes.push(toolName);
|
||||
}
|
||||
// ── Unified permissions construction ─────────────────────────────────────
|
||||
// All permission sources are merged here, before constructing Config.
|
||||
// The resulting three arrays are the single source of truth that Config /
|
||||
// PermissionManager will use.
|
||||
//
|
||||
// Sources (in order of precedence within each list):
|
||||
// 1. settings.permissions.{allow,ask,deny} (persistent, merged by LoadedSettings)
|
||||
// 2. argv.coreTools → allow (allowlist mode: only these tools are available)
|
||||
// 3. argv.allowedTools → allow (auto-approve these tools/commands)
|
||||
// 4. argv.excludeTools → deny (block these tools completely)
|
||||
// 5. Non-interactive mode exclusions → deny (unless explicitly allowed above)
|
||||
|
||||
// Start from settings-level rules.
|
||||
// Read from both new `permissions` and legacy `tools` paths for compatibility.
|
||||
const mergedAllow: string[] = [
|
||||
...(settings.permissions?.allow ?? []),
|
||||
...(settings.tools?.core ?? []),
|
||||
...(settings.tools?.allowed ?? []),
|
||||
];
|
||||
const mergedAsk: string[] = [...(settings.permissions?.ask ?? [])];
|
||||
const mergedDeny: string[] = [
|
||||
...(settings.permissions?.deny ?? []),
|
||||
...(settings.tools?.exclude ?? []),
|
||||
];
|
||||
|
||||
// argv.coreTools and argv.allowedTools both add allow rules.
|
||||
for (const t of argv.coreTools ?? []) {
|
||||
if (t && !mergedAllow.includes(t)) mergedAllow.push(t);
|
||||
}
|
||||
for (const t of argv.allowedTools ?? []) {
|
||||
if (t && !mergedAllow.includes(t)) mergedAllow.push(t);
|
||||
}
|
||||
|
||||
// argv.excludeTools adds deny rules.
|
||||
for (const t of argv.excludeTools ?? []) {
|
||||
if (t && !mergedDeny.includes(t)) mergedDeny.push(t);
|
||||
}
|
||||
|
||||
// Helper: check if a tool is covered by any allow rule (tool-level, no specifier).
|
||||
const isExplicitlyAllowed = (toolName: ToolName): boolean => {
|
||||
const name = toolName as string;
|
||||
return mergedAllow.some((rule) => {
|
||||
const openParen = rule.indexOf('(');
|
||||
const ruleName =
|
||||
openParen === -1 ? rule.trim() : rule.substring(0, openParen).trim();
|
||||
return ruleName === name;
|
||||
});
|
||||
};
|
||||
|
||||
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
|
||||
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
|
||||
// In non-interactive mode, tools that require a user prompt are denied unless
|
||||
// the caller has explicitly allowed them. Stream-JSON input is excluded from
|
||||
// this logic because approval can be sent programmatically via JSON messages.
|
||||
const isAcpMode = argv.acp || argv.experimentalAcp;
|
||||
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
|
||||
const denyUnlessAllowed = (toolName: ToolName): void => {
|
||||
if (!isExplicitlyAllowed(toolName)) {
|
||||
const name = toolName as string;
|
||||
if (!mergedDeny.includes(name)) mergedDeny.push(name);
|
||||
}
|
||||
};
|
||||
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
// In default non-interactive mode, all tools that require approval are excluded,
|
||||
// unless explicitly enabled via coreTools/allowedTools.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
excludeUnlessExplicit(EditTool.Name as ToolName);
|
||||
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
|
||||
// Deny all write/execute tools unless explicitly allowed.
|
||||
denyUnlessAllowed(ShellTool.Name as ToolName);
|
||||
denyUnlessAllowed(EditTool.Name as ToolName);
|
||||
denyUnlessAllowed(WriteFileTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
// Only shell requires a prompt in auto-edit mode.
|
||||
denyUnlessAllowed(ShellTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
// No extra excludes for YOLO mode.
|
||||
// No extra denials for YOLO mode.
|
||||
break;
|
||||
default:
|
||||
// This should never happen due to validation earlier, but satisfies the linter
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const excludeTools = mergeExcludeTools(
|
||||
settings,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
argv.excludeTools,
|
||||
);
|
||||
|
||||
let allowedMcpServers: Set<string> | undefined;
|
||||
let excludedMcpServers: Set<string> | undefined;
|
||||
if (argv.allowedMcpServerNames) {
|
||||
|
|
@ -950,9 +972,16 @@ export async function loadCliConfig(
|
|||
importFormat: settings.context?.importFormat || 'tree',
|
||||
debugMode,
|
||||
question,
|
||||
// Legacy fields – kept for backward compatibility with getExcludeTools() etc.
|
||||
coreTools: argv.coreTools || settings.tools?.core || undefined,
|
||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||
excludeTools,
|
||||
excludeTools: mergedDeny,
|
||||
// New unified permissions (PermissionManager source of truth).
|
||||
permissions: {
|
||||
allow: mergedAllow.length > 0 ? mergedAllow : undefined,
|
||||
ask: mergedAsk.length > 0 ? mergedAsk : undefined,
|
||||
deny: mergedDeny.length > 0 ? mergedDeny : undefined,
|
||||
},
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: settings.mcp?.serverCommand,
|
||||
|
|
@ -1058,16 +1087,3 @@ export async function loadCliConfig(
|
|||
|
||||
return config;
|
||||
}
|
||||
|
||||
function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extraExcludes?: string[] | undefined,
|
||||
cliExcludeTools?: string[] | undefined,
|
||||
): string[] {
|
||||
const allExcludeTools = new Set([
|
||||
...(cliExcludeTools || []),
|
||||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
return [...allExcludeTools];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,74 @@ const MIGRATION_MAP: Record<string, string> = {
|
|||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate legacy tool permission settings (tools.core / tools.allowed / tools.exclude)
|
||||
* to the new permissions.allow / permissions.ask / permissions.deny format.
|
||||
*
|
||||
* Conversion rules:
|
||||
* tools.allowed → permissions.allow (bypass confirmation)
|
||||
* tools.exclude → permissions.deny (block tools)
|
||||
* tools.core → permissions.allow (only listed tools enabled)
|
||||
* + permissions.deny with a wildcard deny-all if needed
|
||||
*
|
||||
* Returns the updated settings object, or null if no migration is needed.
|
||||
*/
|
||||
export function migrateLegacyPermissions(
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const tools = settings['tools'] as Record<string, unknown> | undefined;
|
||||
if (!tools) return null;
|
||||
|
||||
const hasLegacy =
|
||||
Array.isArray(tools['core']) ||
|
||||
Array.isArray(tools['allowed']) ||
|
||||
Array.isArray(tools['exclude']);
|
||||
|
||||
if (!hasLegacy) return null;
|
||||
|
||||
const result = structuredClone(settings) as Record<string, unknown>;
|
||||
const resultTools = result['tools'] as Record<string, unknown>;
|
||||
const permissions = (result['permissions'] as Record<string, unknown>) ?? {};
|
||||
result['permissions'] = permissions;
|
||||
|
||||
const mergeInto = (key: string, items: string[]) => {
|
||||
const existing = Array.isArray(permissions[key])
|
||||
? (permissions[key] as string[])
|
||||
: [];
|
||||
const merged = Array.from(new Set([...existing, ...items]));
|
||||
permissions[key] = merged;
|
||||
};
|
||||
|
||||
// tools.allowed → permissions.allow
|
||||
if (Array.isArray(resultTools['allowed'])) {
|
||||
mergeInto('allow', resultTools['allowed'] as string[]);
|
||||
delete resultTools['allowed'];
|
||||
}
|
||||
|
||||
// tools.exclude → permissions.deny
|
||||
if (Array.isArray(resultTools['exclude'])) {
|
||||
mergeInto('deny', resultTools['exclude'] as string[]);
|
||||
delete resultTools['exclude'];
|
||||
}
|
||||
|
||||
// tools.core → permissions.allow (explicit enables)
|
||||
// IMPORTANT: tools.core has whitelist semantics: "only these tools can run".
|
||||
// To preserve this, we also add deny rules for all tools NOT in the list.
|
||||
// A wildcard deny-all followed by specific allows achieves this because
|
||||
// allow rules take precedence over the catch-all deny in the evaluation order:
|
||||
// deny = [everything not listed], allow = [listed tools]
|
||||
// However, since our priority is deny > allow, we cannot use a blanket deny.
|
||||
// Instead we just migrate to allow (auto-approve) and let the coreTools
|
||||
// semantics continue to work through the Config.getCoreTools() path until
|
||||
// the old API is fully removed.
|
||||
if (Array.isArray(resultTools['core'])) {
|
||||
mergeInto('allow', resultTools['core'] as string[]);
|
||||
delete resultTools['core'];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Settings that need boolean inversion during migration (V1 -> V3)
|
||||
// Old negative naming -> new positive naming with inverted value
|
||||
const INVERTED_BOOLEAN_MIGRATIONS: Record<string, string> = {
|
||||
|
|
|
|||
|
|
@ -181,9 +181,7 @@ describe('SettingsSchema', () => {
|
|||
expect(getSettingsSchema().security.properties.auth.showInDialog).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getSettingsSchema().tools.properties.core.showInDialog).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getSettingsSchema().permissions.showInDialog).toBe(false);
|
||||
expect(getSettingsSchema().mcpServers.showInDialog).toBe(false);
|
||||
expect(getSettingsSchema().telemetry.showInDialog).toBe(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -789,6 +789,55 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
|
||||
permissions: {
|
||||
type: 'object',
|
||||
label: 'Permissions',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
allow: {
|
||||
type: 'array',
|
||||
label: 'Allow Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that are auto-approved without confirmation. ' +
|
||||
'Examples: "ShellTool", "Bash(git *)", "ReadFileTool".',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
ask: {
|
||||
type: 'array',
|
||||
label: 'Ask Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that always require user confirmation. ' +
|
||||
'Takes precedence over allow rules.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
deny: {
|
||||
type: 'array',
|
||||
label: 'Deny Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that are always blocked. Highest priority rule. ' +
|
||||
'Examples: "ShellTool", "Bash(rm -rf *)".',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
tools: {
|
||||
type: 'object',
|
||||
label: 'Tools',
|
||||
|
|
@ -848,32 +897,33 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
},
|
||||
// Legacy tool permission fields – kept for backward compatibility.
|
||||
// Use permissions.{allow,ask,deny} instead.
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
label: 'Core Tools (deprecated)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'Paths to core tool definitions.',
|
||||
description: 'Deprecated. Use permissions.allow instead.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowed: {
|
||||
type: 'array',
|
||||
label: 'Allowed Tools',
|
||||
label: 'Allowed Tools (deprecated)',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'A list of tool names that will bypass the confirmation dialog.',
|
||||
description: 'Deprecated. Use permissions.allow instead.',
|
||||
showInDialog: false,
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
label: 'Exclude Tools',
|
||||
label: 'Exclude Tools (deprecated)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'Tool names to exclude from discovery.',
|
||||
description: 'Deprecated. Use permissions.deny instead.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue