feat(permissions): add permission system and rename folder trust command

This commit is contained in:
LaZzyMan 2026-03-04 19:24:43 +08:00
parent 407a66c959
commit eeb4d85785
33 changed files with 3295 additions and 205 deletions

View file

@ -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];
}

View file

@ -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> = {

View file

@ -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);

View file

@ -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,
},