feat: redesign Auto-Approve settings to support granular permission rules

Redesign the Auto-Approve tab in VS Code extension settings to display
and edit granular pattern-based permission rules (object syntax).

- Add PermissionRule type to support nested objects (e.g. { bash: { "*": "deny", "git *": "allow" } })
- Granular tools (external_directory, bash, read, edit) show wildcard row, exceptions list with per-pattern dropdowns, delete buttons, and inline add input
- Simple tools show single Ask/Allow/Deny dropdown
- Group todoread/todowrite and websearch/codesearch into combined rows
- Remove "Set all permissions" card, add description subtitle
- Truncate long patterns with ellipsis and title tooltip
- Two-phase config update for exception deletion to work with patchJsonc
This commit is contained in:
Josh Lambert
2026-03-09 20:42:25 -03:00
committed by Catriel Müller
parent 1a2c2cc825
commit 25b2c5ae7e
3 changed files with 479 additions and 118 deletions
@@ -1,28 +1,9 @@
import { Component, For, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Select } from "@kilocode/kilo-ui/select"
import { Card } from "@kilocode/kilo-ui/card"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import type { PermissionLevel } from "../../types/messages"
const TOOLS = [
"read",
"edit",
"glob",
"grep",
"list",
"bash",
"task",
"skill",
"lsp",
"todoread",
"todowrite",
"webfetch",
"websearch",
"codesearch",
"external_directory",
"doom_loop",
] as const
import type { PermissionLevel, PermissionRule } from "../../types/messages"
interface LevelOption {
value: PermissionLevel
@@ -35,104 +16,470 @@ const LEVEL_OPTIONS: LevelOption[] = [
{ value: "deny", labelKey: "settings.autoApprove.level.deny" },
]
interface ToolDef {
id: string
descriptionKey: string
granular?: {
wildcardKey: string
addKey: string
placeholderKey: string
}
}
/** Grouped tool: maps a single UI row to multiple config keys */
interface GroupedToolDef {
ids: string[]
label: string
descriptionKey: string
}
const GRANULAR_TOOLS: ToolDef[] = [
{
id: "external_directory",
descriptionKey: "settings.autoApprove.tool.external_directory",
granular: {
wildcardKey: "settings.autoApprove.wildcardLabel.paths",
addKey: "settings.autoApprove.addPath",
placeholderKey: "settings.autoApprove.placeholder.path",
},
},
{
id: "bash",
descriptionKey: "settings.autoApprove.tool.bash",
granular: {
wildcardKey: "settings.autoApprove.wildcardLabel.commands",
addKey: "settings.autoApprove.addCommand",
placeholderKey: "settings.autoApprove.placeholder.command",
},
},
{
id: "read",
descriptionKey: "settings.autoApprove.tool.read",
granular: {
wildcardKey: "settings.autoApprove.wildcardLabel.paths",
addKey: "settings.autoApprove.addPath",
placeholderKey: "settings.autoApprove.placeholder.path",
},
},
{
id: "edit",
descriptionKey: "settings.autoApprove.tool.edit",
granular: {
wildcardKey: "settings.autoApprove.wildcardLabel.paths",
addKey: "settings.autoApprove.addPath",
placeholderKey: "settings.autoApprove.placeholder.path",
},
},
]
const SIMPLE_TOOLS: ToolDef[] = [
{ id: "glob", descriptionKey: "settings.autoApprove.tool.glob" },
{ id: "grep", descriptionKey: "settings.autoApprove.tool.grep" },
{ id: "list", descriptionKey: "settings.autoApprove.tool.list" },
{ id: "task", descriptionKey: "settings.autoApprove.tool.task" },
{ id: "skill", descriptionKey: "settings.autoApprove.tool.skill" },
{ id: "lsp", descriptionKey: "settings.autoApprove.tool.lsp" },
]
const GROUPED_TOOLS: GroupedToolDef[] = [
{
ids: ["todoread", "todowrite"],
label: "todoread / todowrite",
descriptionKey: "settings.autoApprove.tool.todoreadwrite",
},
{
ids: ["websearch", "codesearch"],
label: "websearch / codesearch",
descriptionKey: "settings.autoApprove.tool.websearchcodesearch",
},
]
const TRAILING_TOOLS: ToolDef[] = [
{ id: "webfetch", descriptionKey: "settings.autoApprove.tool.webfetch" },
{ id: "doom_loop", descriptionKey: "settings.autoApprove.tool.doom_loop" },
]
function wildcardAction(rule: PermissionRule | undefined, fallback: PermissionLevel): PermissionLevel {
if (!rule) return fallback
if (typeof rule === "string") return rule
return rule["*"] ?? fallback
}
function exceptions(rule: PermissionRule | undefined): Array<{ pattern: string; action: PermissionLevel }> {
if (!rule || typeof rule === "string") return []
return Object.entries(rule)
.filter(([key]) => key !== "*")
.map(([pattern, action]) => ({ pattern, action }))
}
const AutoApproveTab: Component = () => {
const { config, updateConfig } = useConfig()
const language = useLanguage()
const permissions = createMemo(() => config().permission ?? {})
const getLevel = (tool: string): PermissionLevel => {
return permissions()[tool] ?? permissions()["*"] ?? "ask"
const globalFallback = createMemo((): PermissionLevel => {
const star = permissions()["*"]
if (typeof star === "string") return star
return "ask"
})
const levelFor = (tool: string): PermissionLevel => wildcardAction(permissions()[tool], globalFallback())
const ruleFor = (tool: string): PermissionRule | undefined => permissions()[tool]
const setSimple = (tool: string, level: PermissionLevel) => {
updateConfig({ permission: { [tool]: level } })
}
const setPermission = (tool: string, level: PermissionLevel) => {
updateConfig({
permission: { ...permissions(), [tool]: level },
})
const setGrouped = (ids: string[], level: PermissionLevel) => {
const patch: Record<string, PermissionLevel> = {}
for (const id of ids) patch[id] = level
updateConfig({ permission: patch })
}
const setAll = (level: PermissionLevel) => {
const updated: Record<string, PermissionLevel> = {}
for (const tool of TOOLS) {
updated[tool] = level
const setWildcard = (tool: string, level: PermissionLevel) => {
const current = ruleFor(tool)
const excs = exceptions(current)
if (excs.length === 0) {
updateConfig({ permission: { [tool]: level } })
return
}
const obj: Record<string, PermissionLevel> = { "*": level }
for (const exc of excs) obj[exc.pattern] = exc.action
updateConfig({ permission: { [tool]: obj } })
}
const setException = (tool: string, pattern: string, level: PermissionLevel) => {
const current = ruleFor(tool)
const base: Record<string, PermissionLevel> =
typeof current === "string" ? { "*": current } : { ...(current ?? {}) }
base[pattern] = level
updateConfig({ permission: { [tool]: base } })
}
const addException = (tool: string, pattern: string) => {
const current = ruleFor(tool)
const base: Record<string, PermissionLevel> =
typeof current === "string" ? { "*": current } : { ...(current ?? {}) }
base[pattern] = "allow"
updateConfig({ permission: { [tool]: base } })
}
const removeException = (tool: string, pattern: string) => {
const current = ruleFor(tool)
if (!current || typeof current === "string") return
const rebuilt: Record<string, PermissionLevel> = {}
for (const [k, v] of Object.entries(current)) {
if (k !== pattern) rebuilt[k] = v
}
const keys = Object.keys(rebuilt)
const value: PermissionRule =
keys.length === 0 ? "ask" : keys.length === 1 && keys[0] === "*" ? rebuilt["*"]! : rebuilt
// patchJsonc only sets keys present in the patch — it won't remove the deleted key
// from the JSONC file. To work around this, first set the tool to a string (which
// replaces the entire JSONC node), then set the rebuilt object if needed.
const wildcard = rebuilt["*"] ?? "ask"
updateConfig({ permission: { [tool]: wildcard } })
if (typeof value === "object") {
updateConfig({ permission: { [tool]: value } })
}
updateConfig({ permission: updated })
}
return (
<div data-component="auto-approve-settings">
{/* Set All control */}
<Card>
<div
data-slot="settings-row"
style={{ display: "flex", "align-items": "center", "justify-content": "space-between", padding: "8px 0" }}
>
<span style={{ "font-weight": "600" }}>{language.t("settings.autoApprove.setAll")}</span>
<Select
options={LEVEL_OPTIONS}
value={(o) => o.value}
label={(o) => language.t(o.labelKey)}
onSelect={(option) => option && setAll(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
placeholder={language.t("common.choose")}
<div
style={{
"font-size": "12px",
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
"padding-bottom": "12px",
"border-bottom": "1px solid var(--border-weak-base)",
}}
>
{language.t("settings.autoApprove.description")}
</div>
<For each={GRANULAR_TOOLS}>
{(tool) => (
<GranularToolRow
tool={tool}
rule={ruleFor(tool.id)}
fallback={globalFallback()}
onWildcardChange={(level) => setWildcard(tool.id, level)}
onExceptionChange={(pattern, level) => setException(tool.id, pattern, level)}
onExceptionAdd={(pattern) => addException(tool.id, pattern)}
onExceptionRemove={(pattern) => removeException(tool.id, pattern)}
/>
</div>
</Card>
)}
</For>
<div style={{ "margin-top": "12px" }} />
<For each={SIMPLE_TOOLS}>
{(tool) => (
<SimpleToolRow
id={tool.id}
descriptionKey={tool.descriptionKey}
level={levelFor(tool.id)}
onChange={(level) => setSimple(tool.id, level)}
/>
)}
</For>
{/* Tool permission list */}
<Card>
<For each={[...TOOLS]}>
{(tool, index) => (
<div
data-slot="settings-row"
style={{
display: "flex",
"align-items": "center",
"justify-content": "space-between",
padding: "8px 0",
"border-bottom": index() < TOOLS.length - 1 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<div style={{ flex: 1, "min-width": 0 }}>
<div
style={{
"font-family": "var(--vscode-editor-font-family, monospace)",
"font-size": "12px",
"text-transform": "capitalize",
}}
>
{tool}
</div>
<div
style={{
"font-size": "12px",
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
"margin-top": "2px",
}}
>
{language.t(`settings.autoApprove.tool.${tool}`)}
</div>
</div>
<Select
options={LEVEL_OPTIONS}
current={LEVEL_OPTIONS.find((o) => o.value === getLevel(tool))}
value={(o) => o.value}
label={(o) => language.t(o.labelKey)}
onSelect={(option) => option && setPermission(tool, option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</div>
)}
</For>
</Card>
<For each={GROUPED_TOOLS}>
{(group) => (
<SimpleToolRow
id={group.label}
descriptionKey={group.descriptionKey}
level={levelFor(group.ids[0])}
onChange={(level) => setGrouped(group.ids, level)}
/>
)}
</For>
<For each={TRAILING_TOOLS}>
{(tool) => (
<SimpleToolRow
id={tool.id}
descriptionKey={tool.descriptionKey}
level={levelFor(tool.id)}
onChange={(level) => setSimple(tool.id, level)}
/>
)}
</For>
</div>
)
}
const SimpleToolRow: Component<{
id: string
descriptionKey: string
level: PermissionLevel
onChange: (level: PermissionLevel) => void
}> = (props) => {
const language = useLanguage()
return (
<div
style={{
display: "flex",
gap: "24px",
"align-items": "flex-start",
"justify-content": "space-between",
padding: "12px 0",
"border-bottom": "1px solid var(--border-weak-base)",
}}
>
<div style={{ flex: 1, "min-width": 0 }}>
<div style={{ "font-size": "13px", color: "var(--text-strong-base, white)" }}>{props.id}</div>
<div
style={{
"font-size": "12px",
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
"margin-top": "6px",
}}
>
{language.t(props.descriptionKey)}
</div>
</div>
<ActionSelect level={props.level} onChange={props.onChange} />
</div>
)
}
const GranularToolRow: Component<{
tool: ToolDef
rule: PermissionRule | undefined
fallback: PermissionLevel
onWildcardChange: (level: PermissionLevel) => void
onExceptionChange: (pattern: string, level: PermissionLevel) => void
onExceptionAdd: (pattern: string) => void
onExceptionRemove: (pattern: string) => void
}> = (props) => {
const language = useLanguage()
const [adding, setAdding] = createSignal(false)
const [input, setInput] = createSignal("")
const excs = createMemo(() => exceptions(props.rule))
const level = createMemo(() => wildcardAction(props.rule, props.fallback))
const submit = () => {
const val = input().trim()
if (val) {
props.onExceptionAdd(val)
setInput("")
}
setAdding(false)
}
const cancel = () => {
setInput("")
setAdding(false)
}
return (
<div style={{ padding: "12px 0", "border-bottom": "1px solid var(--border-weak-base)" }}>
{/* Tool header with name and description */}
<div style={{ display: "flex", gap: "24px", "align-items": "flex-start", "justify-content": "space-between" }}>
<div style={{ flex: 1, "min-width": 0 }}>
<div style={{ "font-size": "13px", color: "var(--text-strong-base, white)" }}>{props.tool.id}</div>
<div
style={{
"font-size": "12px",
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
"margin-top": "6px",
}}
>
{language.t(props.tool.descriptionKey)}
</div>
</div>
</div>
{/* Wildcard row */}
<div
style={{
display: "flex",
gap: "24px",
"align-items": "center",
"justify-content": "space-between",
padding: "8px 0",
}}
>
<div style={{ flex: 1, "min-width": 0 }}>
<div style={{ "font-size": "12px", color: "var(--text-base, #ccc)" }}>
{language.t(props.tool.granular!.wildcardKey)}
</div>
</div>
<ActionSelect level={level()} onChange={props.onWildcardChange} />
</div>
{/* Exceptions */}
<Show when={excs().length > 0}>
<div style={{ "margin-top": "4px" }}>
<div
style={{
"font-size": "12px",
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
"margin-bottom": "4px",
}}
>
{language.t("settings.autoApprove.exceptions")}
</div>
<For each={excs()}>
{(exc) => (
<div
style={{
display: "flex",
gap: "8px",
"align-items": "center",
padding: "4px 0",
"padding-left": "12px",
"border-top": "1px solid var(--border-weak-base)",
}}
>
<div
style={{
flex: "1 1 0%",
"min-width": 0,
"font-size": "13px",
"font-family": "var(--vscode-editor-font-family, monospace)",
color: "var(--text-base, #ccc)",
overflow: "hidden",
"text-overflow": "ellipsis",
"white-space": "nowrap",
}}
title={exc.pattern}
>
{exc.pattern}
</div>
<div style={{ display: "flex", gap: "4px", "align-items": "center", "flex-shrink": 0 }}>
<ActionSelect level={exc.action} onChange={(level) => props.onExceptionChange(exc.pattern, level)} />
<IconButton
variant="ghost"
size="small"
icon="close"
onClick={() => props.onExceptionRemove(exc.pattern)}
/>
</div>
</div>
)}
</For>
</div>
</Show>
{/* Add button / inline input */}
<Show
when={adding()}
fallback={
<button
style={{
display: "flex",
gap: "4px",
"align-items": "center",
padding: "4px 0",
background: "none",
border: "none",
cursor: "pointer",
"font-size": "12px",
color: "var(--text-link-base, #3794ff)",
"font-family": "inherit",
"margin-top": "4px",
}}
onClick={() => setAdding(true)}
>
<span style={{ "font-size": "14px" }}>+</span>
{language.t(props.tool.granular!.addKey)}
</button>
}
>
<div style={{ display: "flex", gap: "8px", "align-items": "center", "margin-top": "4px" }}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
type="text"
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit()
if (e.key === "Escape") cancel()
}}
onBlur={() => {
if (!input().trim()) cancel()
}}
placeholder={language.t(props.tool.granular!.placeholderKey)}
style={{
flex: 1,
"min-width": 0,
background: "var(--surface-strong-base, #252526)",
border: "1px solid var(--border-base, #434443)",
"border-radius": "2px",
color: "var(--text-base, #ccc)",
"font-size": "13px",
"font-family": "var(--vscode-editor-font-family, monospace)",
padding: "4px 8px",
outline: "none",
}}
/>
<IconButton variant="ghost" size="small" icon="close" onClick={cancel} />
</div>
</Show>
</div>
)
}
const ActionSelect: Component<{
level: PermissionLevel
onChange: (level: PermissionLevel) => void
}> = (props) => {
const language = useLanguage()
return (
<Select
options={LEVEL_OPTIONS}
current={LEVEL_OPTIONS.find((o) => o.value === props.level)}
value={(o) => o.value}
label={(o) => language.t(o.labelKey)}
onSelect={(option) => option && props.onChange(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
)
}
export default AutoApproveTab
+29 -17
View File
@@ -957,26 +957,38 @@ export const dict = {
"settings.agentBehaviour.workflowsPlaceholder": "Workflows are managed via workflow files in your workspace.",
"settings.agentBehaviour.notImplemented": "Not yet implemented.",
"settings.autoApprove.setAll": "Set all permissions",
"settings.autoApprove.description":
"Define how tools are allowed to run. Most tools default to Allow. doom_loop and external_directory default to Ask.",
"settings.autoApprove.level.allow": "Allow",
"settings.autoApprove.level.ask": "Ask",
"settings.autoApprove.level.deny": "Deny",
"settings.autoApprove.tool.read": "Read file contents",
"settings.autoApprove.tool.edit": "Edit or create files",
"settings.autoApprove.tool.glob": "Find files by pattern",
"settings.autoApprove.tool.grep": "Search file contents",
"settings.autoApprove.tool.list": "List directory contents",
"settings.autoApprove.tool.bash": "Execute shell commands",
"settings.autoApprove.tool.task": "Create sub-agent tasks",
"settings.autoApprove.tool.skill": "Execute skills",
"settings.autoApprove.tool.lsp": "Language server operations",
"settings.autoApprove.tool.todoread": "Read todo lists",
"settings.autoApprove.tool.todowrite": "Write todo lists",
"settings.autoApprove.tool.webfetch": "Fetch web pages",
"settings.autoApprove.tool.websearch": "Search the web",
"settings.autoApprove.tool.codesearch": "Search codebase",
"settings.autoApprove.tool.external_directory": "Access files outside workspace",
"settings.autoApprove.tool.doom_loop": "Continue after repeated failures",
"settings.autoApprove.wildcardLabel.commands": "All commands (*)",
"settings.autoApprove.wildcardLabel.paths": "All paths (*)",
"settings.autoApprove.exceptions": "Exceptions",
"settings.autoApprove.addCommand": "Add command",
"settings.autoApprove.addPath": "Add path",
"settings.autoApprove.placeholder.command": "e.g. git *",
"settings.autoApprove.placeholder.path": "e.g. *.env",
"settings.autoApprove.tool.external_directory":
"Access files outside workspace. Triggered when accessing files outside the current project directory.",
"settings.autoApprove.tool.bash": "Run terminal commands. Allows execution of shell commands (e.g., git status).",
"settings.autoApprove.tool.read": "Read files. Allows the agent to read files matching the specified path.",
"settings.autoApprove.tool.edit":
"Modify files. Allows the agent to create or edit files, including patches and multi-file updates.",
"settings.autoApprove.tool.glob":
"Match files by pattern. Allows file matching using glob patterns (e.g., src/**/*.ts).",
"settings.autoApprove.tool.grep": "Search file contents. Allows regex-based search inside files.",
"settings.autoApprove.tool.list": "List directory contents. Allows viewing files and folders within a directory.",
"settings.autoApprove.tool.task": "Launch sub-agents. Allows starting specialized sub-agents for specific tasks.",
"settings.autoApprove.tool.skill": "Load skills. Allows loading predefined skills by name.",
"settings.autoApprove.tool.lsp":
"Query language server. Allows running language server queries for code intelligence.",
"settings.autoApprove.tool.todoreadwrite": "Manage task list. Allows reading and updating the internal task list.",
"settings.autoApprove.tool.webfetch": "Fetch a URL. Allows retrieving content from a specific URL.",
"settings.autoApprove.tool.websearchcodesearch":
"Search web or code. Allows performing external web or code searches.",
"settings.autoApprove.tool.doom_loop":
"Prevent repeated identical actions. Triggered when the same tool call repeats with identical input.",
"settings.checkpoints.enable.title": "Enable Snapshots",
"settings.checkpoints.enable.description": "Create checkpoints before file edits so you can restore previous states",
@@ -254,7 +254,9 @@ export interface ModelSelection {
export type PermissionLevel = "allow" | "ask" | "deny"
export type PermissionConfig = Partial<Record<string, PermissionLevel>>
export type PermissionRule = PermissionLevel | Record<string, PermissionLevel>
export type PermissionConfig = Partial<Record<string, PermissionRule>>
export interface AgentConfig {
model?: string | null