Granular bash permission rules (#7091)

* fix: renamed pattern arrays used for permission rules

* fix: rename savePatternRules to saveAlwaysRules

* feat: generate hierarchical always patterns in bash tool

* feat: validate saveAlwaysRules against always array only

* feat: pass always field through extension bridge to webview

* feat: show hierarchical always patterns in PermissionDock dropdown

* fix: rename pattern to rule in PermissionDock translations, styles, and data-slots

* fix(vscode): correct auto-run comment in PermissionDock

* feat(kilo-vscode): strip trailing wildcard from permission rule labels

* fix: deduplicate permission rules and strip wildcards from display labels

* refactor: move hierarchy to metadata.rules, restore always to arity prefix

* feat: pass always field to permission dock for non-bash tool rules

* fix: correct bash test assertion to check metadata.rules and fix indentation

* fix: hide permission rules dropdown for non-bash tools

* fix: align always-rules route descriptions, whitespace, and restore multi-command tests
This commit is contained in:
Imanol Maiztegui
2026-03-16 17:43:16 +01:00
committed by GitHub
parent 4c5bd13687
commit 0484318191
34 changed files with 477 additions and 523 deletions
+9 -9
View File
@@ -354,8 +354,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
message.permissionId,
message.sessionID,
message.response,
message.approvedPatterns,
message.deniedPatterns,
message.approvedAlways,
message.deniedAlways,
)
break
case "createSession":
@@ -1654,14 +1654,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/**
* Handle permission response from the webview.
* Calls savePatternRules first (if any), then reply — sequentially to avoid races.
* Calls saveAlwaysRules first (if any), then reply — sequentially to avoid races.
*/
private async handlePermissionResponse(
permissionId: string,
sessionID: string,
response: "once" | "always" | "reject",
approvedPatterns: string[],
deniedPatterns: string[],
approvedAlways: string[],
deniedAlways: string[],
): Promise<void> {
if (!this.client) {
this.postMessage({ type: "permissionError", permissionID: permissionId })
@@ -1679,13 +1679,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const workspaceDir = this.getWorkspaceDirectory(targetSessionID)
// Save per-pattern rules before replying (reply deletes the pending request)
if (approvedPatterns.length > 0 || deniedPatterns.length > 0) {
await this.client.permission.savePatternRules(
if (approvedAlways.length > 0 || deniedAlways.length > 0) {
await this.client.permission.saveAlwaysRules(
{
requestID: permissionId,
directory: workspaceDir,
approvedPatterns,
deniedPatterns,
approvedAlways,
deniedAlways,
},
{ throwOnError: true },
)
@@ -76,10 +76,10 @@ export const ChatView: Component<ChatViewProps> = (props) => {
onCleanup(() => document.removeEventListener("keydown", handler))
})
const decide = (response: "once" | "always" | "reject", approvedPatterns: string[], deniedPatterns: string[]) => {
const decide = (response: "once" | "always" | "reject", approvedAlways: string[], deniedAlways: string[]) => {
const perm = permissionRequest()
if (!perm || session.respondingPermissions().has(perm.id)) return
session.respondToPermission(perm.id, response, approvedPatterns, deniedPatterns)
session.respondToPermission(perm.id, response, approvedAlways, deniedAlways)
}
return (
@@ -3,9 +3,9 @@
* Displays permission requests from the AI assistant in the dock above the prompt input.
* Uses kilo-ui's DockPrompt component for proper surface styling.
*
* Per-pattern toggles allow users to approve/deny individual patterns for future requests.
* Per-rule toggles allow users to approve/deny individual permission rules for future requests.
* The command buttons (Deny / Allow Always / Allow Once) control the current command.
* When all patterns are toggled ✓, the command auto-runs.
* When all rules are toggled ✓, the command auto-runs.
*/
import { Component, For, Show, createSignal } from "solid-js"
@@ -17,74 +17,78 @@ import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import type { PermissionRequest } from "../../types/messages"
type PatternDecision = "approved" | "denied" | "pending"
type RuleDecision = "approved" | "denied" | "pending"
let permissionPatternsExpandedPreference = false
let rulesExpandedPreference = false
export const PermissionDock: Component<{
request: PermissionRequest
responding: boolean
onDecide: (response: "once" | "always" | "reject", approvedPatterns: string[], deniedPatterns: string[]) => void
onDecide: (response: "once" | "always" | "reject", approvedAlways: string[], deniedAlways: string[]) => void
}> = (props) => {
const session = useSession()
const language = useLanguage()
const fromChild = () => props.request.sessionID !== session.currentSessionID()
const patterns = () => props.request.patterns ?? []
// Bash sends fine-grained rules via metadata.rules; other tools have no dropdown.
const rules = () => props.request.args?.rules ?? []
// Rules like "git *" or "git log *" — strip the trailing wildcard for display.
// A bare "*" (global wildcard) becomes empty so only the tool name shows.
const label = (rule: string) => (rule === "*" ? "" : rule.replace(/ \*$/, ""))
const command = () => {
const cmd = props.request.args?.command
return typeof cmd === "string" ? cmd : undefined
}
const [decisions, setDecisions] = createSignal<Record<number, PatternDecision>>({})
const [patternsExpanded, setPatternsExpanded] = createSignal(permissionPatternsExpandedPreference)
const [decisions, setDecisions] = createSignal<Record<number, RuleDecision>>({})
const [expanded, setExpanded] = createSignal(rulesExpandedPreference)
const hasDeniedPatterns = () => Object.values(decisions()).some((d) => d === "denied")
const hasPatterns = () => patterns().length > 0
const hasDenied = () => Object.values(decisions()).some((d) => d === "denied")
const hasRules = () => rules().length > 0
const togglePatternsExpanded = () => {
const next = !patternsExpanded()
permissionPatternsExpandedPreference = next
setPatternsExpanded(next)
const toggleExpanded = () => {
const next = !expanded()
rulesExpandedPreference = next
setExpanded(next)
}
const collectPatterns = () => {
const all = patterns()
const collectRules = () => {
const all = rules()
const approved: string[] = []
const denied: string[] = []
for (const [i, d] of Object.entries(decisions())) {
const pattern = all[Number(i)]
if (!pattern) continue
if (d === "approved") approved.push(pattern)
else if (d === "denied") denied.push(pattern)
const rule = all[Number(i)]
if (!rule) continue
if (d === "approved") approved.push(rule)
else if (d === "denied") denied.push(rule)
}
return { approved, denied }
}
const togglePattern = (index: number, decision: PatternDecision) => {
const toggleRule = (index: number, decision: RuleDecision) => {
const current = decisions()[index]
const next = current === decision ? "pending" : decision
const updated = { ...decisions(), [index]: next }
setDecisions(updated)
const total = patterns().length
const total = rules().length
const count = Object.values(updated).filter((d) => d === "approved").length
if (count === total && total > 0) {
props.onDecide("once", [...patterns()], [])
props.onDecide("once", [...rules()], [])
}
}
const decision = (index: number): PatternDecision => decisions()[index] ?? "pending"
const decision = (index: number): RuleDecision => decisions()[index] ?? "pending"
const approveTooltip = (index: number) =>
decision(index) === "approved"
? language.t("ui.permission.pattern.removeFromAllowed")
: language.t("ui.permission.pattern.addToAllowed")
? language.t("ui.permission.rule.removeFromAllowed")
: language.t("ui.permission.rule.addToAllowed")
const denyTooltip = (index: number) =>
decision(index) === "denied"
? language.t("ui.permission.pattern.removeFromDenied")
: language.t("ui.permission.pattern.addToDenied")
? language.t("ui.permission.rule.removeFromDenied")
: language.t("ui.permission.rule.addToDenied")
const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.toolName}.description`
@@ -110,35 +114,35 @@ export const PermissionDock: Component<{
</div>
}
footer={
<Show when={hasPatterns()}>
<div data-slot="permission-patterns-section">
<Show when={hasRules()}>
<div data-slot="permission-rules-section">
<button
type="button"
data-slot="permission-patterns-header"
data-open={patternsExpanded() ? "" : undefined}
onClick={togglePatternsExpanded}
aria-expanded={patternsExpanded()}
data-slot="permission-rules-header"
data-open={expanded() ? "" : undefined}
onClick={toggleExpanded}
aria-expanded={expanded()}
>
<span data-slot="permission-patterns-header-chevron" data-open={patternsExpanded() ? "" : undefined}>
<span data-slot="permission-rules-header-chevron" data-open={expanded() ? "" : undefined}>
<Icon name="chevron-down" size="small" />
</span>
<span data-slot="permission-patterns-header-title">{language.t("ui.permission.permissionRules")}</span>
<span data-slot="permission-rules-header-title">{language.t("ui.permission.permissionRules")}</span>
</button>
<div data-slot="permission-patterns-collapse" data-open={patternsExpanded() ? "" : undefined}>
<div data-slot="permission-patterns-collapse-inner">
<div data-slot="permission-patterns">
<For each={patterns()}>
{(pattern, index) => (
<div data-slot="permission-pattern-row" data-decision={decision(index())}>
<div data-slot="permission-pattern-actions">
<div data-slot="permission-rules-collapse" data-open={expanded() ? "" : undefined}>
<div data-slot="permission-rules-collapse-inner">
<div data-slot="permission-rules">
<For each={rules()}>
{(rule, index) => (
<div data-slot="permission-rule-row" data-decision={decision(index())}>
<div data-slot="permission-rule-actions">
<Tooltip value={approveTooltip(index())} placement="top">
<button
data-slot="permission-pattern-toggle"
data-slot="permission-rule-toggle"
data-variant="approve"
data-active={decision(index()) === "approved" ? "" : undefined}
disabled={props.responding}
onClick={() => togglePattern(index(), "approved")}
onClick={() => toggleRule(index(), "approved")}
aria-label={approveTooltip(index())}
>
<Icon name="check-small" size="small" />
@@ -146,19 +150,19 @@ export const PermissionDock: Component<{
</Tooltip>
<Tooltip value={denyTooltip(index())} placement="top">
<button
data-slot="permission-pattern-toggle"
data-slot="permission-rule-toggle"
data-variant="deny"
data-active={decision(index()) === "denied" ? "" : undefined}
disabled={props.responding}
onClick={() => togglePattern(index(), "denied")}
onClick={() => toggleRule(index(), "denied")}
aria-label={denyTooltip(index())}
>
<Icon name="close-small" size="small" />
</button>
</Tooltip>
</div>
<span data-slot="permission-pattern-type">{props.request.toolName}</span>
<code data-slot="permission-pattern">{pattern}</code>
<span data-slot="permission-rule-type">{props.request.toolName}</span>
<code data-slot="permission-rule">{label(rule)}</code>
</div>
)}
</For>
@@ -181,7 +185,7 @@ export const PermissionDock: Component<{
variant="primary"
size="small"
onClick={() => {
const { approved, denied } = collectPatterns()
const { approved, denied } = collectRules()
props.onDecide("once", approved, denied)
}}
disabled={props.responding}
@@ -192,10 +196,10 @@ export const PermissionDock: Component<{
variant="secondary"
size="small"
onClick={() => {
const { approved, denied } = collectPatterns()
const { approved, denied } = collectRules()
props.onDecide("always", approved, denied)
}}
disabled={props.responding || hasDeniedPatterns()}
disabled={props.responding || hasDenied()}
>
{language.t("ui.permission.allowAlways")}
</Button>
@@ -203,7 +207,7 @@ export const PermissionDock: Component<{
variant="ghost"
size="small"
onClick={() => {
const { approved, denied } = collectPatterns()
const { approved, denied } = collectRules()
props.onDecide("reject", approved, denied)
}}
disabled={props.responding}
@@ -142,8 +142,8 @@ interface SessionContextValue {
respondToPermission: (
permissionId: string,
response: "once" | "always" | "reject",
approvedPatterns: string[],
deniedPatterns: string[],
approvedAlways: string[],
deniedAlways: string[],
) => void
replyToQuestion: (requestID: string, answers: string[][]) => void
rejectQuestion: (requestID: string) => void
@@ -1116,8 +1116,8 @@ export const SessionProvider: ParentComponent = (props) => {
function respondToPermission(
permissionId: string,
response: "once" | "always" | "reject",
approvedPatterns: string[],
deniedPatterns: string[],
approvedAlways: string[],
deniedAlways: string[],
) {
// Resolve sessionID from the stored permission request
const permission = permissions().find((p) => p.id === permissionId)
@@ -1132,8 +1132,8 @@ export const SessionProvider: ParentComponent = (props) => {
permissionId,
sessionID,
response,
approvedPatterns,
deniedPatterns,
approvedAlways,
deniedAlways,
})
}
@@ -441,10 +441,10 @@ export const dict = {
"notification.permission.title": "مطلوب إذن",
"notification.permission.description": "{{sessionTitle}} في {{projectName}} يحتاج إلى إذن",
"ui.permission.permissionRules": "قواعد الأذونات",
"ui.permission.pattern.addToAllowed": "أضف إلى قائمة المسموح",
"ui.permission.pattern.removeFromAllowed": "أزل من قائمة المسموح",
"ui.permission.pattern.addToDenied": "أضف إلى قائمة المرفوض",
"ui.permission.pattern.removeFromDenied": "أزل من قائمة المرفوض",
"ui.permission.rule.addToAllowed": "أضف إلى قائمة المسموح",
"ui.permission.rule.removeFromAllowed": "أزل من قائمة المسموح",
"ui.permission.rule.addToDenied": "أضف إلى قائمة المرفوض",
"ui.permission.rule.removeFromDenied": "أزل من قائمة المرفوض",
"notification.question.title": "سؤال",
"notification.question.description": "{{sessionTitle}} في {{projectName}} لديه سؤال",
"notification.action.goToSession": "انتقل إلى الجلسة",
@@ -443,10 +443,10 @@ export const dict = {
"notification.permission.title": "Permissão necessária",
"notification.permission.description": "{{sessionTitle}} em {{projectName}} precisa de permissão",
"ui.permission.permissionRules": "Regras de permissão",
"ui.permission.pattern.addToAllowed": "Adicionar à lista de permitidos",
"ui.permission.pattern.removeFromAllowed": "Remover da lista de permitidos",
"ui.permission.pattern.addToDenied": "Adicionar à lista de negados",
"ui.permission.pattern.removeFromDenied": "Remover da lista de negados",
"ui.permission.rule.addToAllowed": "Adicionar à lista de permitidos",
"ui.permission.rule.removeFromAllowed": "Remover da lista de permitidos",
"ui.permission.rule.addToDenied": "Adicionar à lista de negados",
"ui.permission.rule.removeFromDenied": "Remover da lista de negados",
"notification.question.title": "Pergunta",
"notification.question.description": "{{sessionTitle}} em {{projectName}} tem uma pergunta",
"notification.action.goToSession": "Ir para sessão",
@@ -447,10 +447,10 @@ export const dict = {
"notification.permission.title": "Potrebna dozvola",
"notification.permission.description": "{{sessionTitle}} u {{projectName}} traži dozvolu",
"ui.permission.permissionRules": "Pravila dozvola",
"ui.permission.pattern.addToAllowed": "Dodaj na listu dozvoljenih",
"ui.permission.pattern.removeFromAllowed": "Ukloni sa liste dozvoljenih",
"ui.permission.pattern.addToDenied": "Dodaj na listu odbijenih",
"ui.permission.pattern.removeFromDenied": "Ukloni sa liste odbijenih",
"ui.permission.rule.addToAllowed": "Dodaj na listu dozvoljenih",
"ui.permission.rule.removeFromAllowed": "Ukloni sa liste dozvoljenih",
"ui.permission.rule.addToDenied": "Dodaj na listu odbijenih",
"ui.permission.rule.removeFromDenied": "Ukloni sa liste odbijenih",
"notification.question.title": "Pitanje",
"notification.question.description": "{{sessionTitle}} u {{projectName}} ima pitanje",
"notification.action.goToSession": "Idi na sesiju",
@@ -444,10 +444,10 @@ export const dict = {
"notification.permission.title": "Tilladelse påkrævet",
"notification.permission.description": "{{sessionTitle}} i {{projectName}} kræver tilladelse",
"ui.permission.permissionRules": "Tilladelsesregler",
"ui.permission.pattern.addToAllowed": "Tilføj til tilladelseslisten",
"ui.permission.pattern.removeFromAllowed": "Fjern fra tilladelseslisten",
"ui.permission.pattern.addToDenied": "Tilføj til afvisningslisten",
"ui.permission.pattern.removeFromDenied": "Fjern fra afvisningslisten",
"ui.permission.rule.addToAllowed": "Tilføj til tilladelseslisten",
"ui.permission.rule.removeFromAllowed": "Fjern fra tilladelseslisten",
"ui.permission.rule.addToDenied": "Tilføj til afvisningslisten",
"ui.permission.rule.removeFromDenied": "Fjern fra afvisningslisten",
"notification.question.title": "Spørgsmål",
"notification.question.description": "{{sessionTitle}} i {{projectName}} har et spørgsmål",
"notification.action.goToSession": "Gå til session",
@@ -451,10 +451,10 @@ export const dict = {
"notification.permission.title": "Berechtigung erforderlich",
"notification.permission.description": "{{sessionTitle}} in {{projectName}} benötigt Berechtigung",
"ui.permission.permissionRules": "Berechtigungsregeln",
"ui.permission.pattern.addToAllowed": "Zur Erlaubt-Liste hinzufügen",
"ui.permission.pattern.removeFromAllowed": "Aus der Erlaubt-Liste entfernen",
"ui.permission.pattern.addToDenied": "Zur Verweigert-Liste hinzufügen",
"ui.permission.pattern.removeFromDenied": "Aus der Verweigert-Liste entfernen",
"ui.permission.rule.addToAllowed": "Zur Erlaubt-Liste hinzufügen",
"ui.permission.rule.removeFromAllowed": "Aus der Erlaubt-Liste entfernen",
"ui.permission.rule.addToDenied": "Zur Verweigert-Liste hinzufügen",
"ui.permission.rule.removeFromDenied": "Aus der Verweigert-Liste entfernen",
"notification.question.title": "Frage",
"notification.question.description": "{{sessionTitle}} in {{projectName}} hat eine Frage",
"notification.action.goToSession": "Zur Sitzung gehen",
@@ -446,10 +446,10 @@ export const dict = {
"notification.permission.title": "Permission required",
"notification.permission.description": "{{sessionTitle}} in {{projectName}} needs permission",
"ui.permission.permissionRules": "Permission rules",
"ui.permission.pattern.addToAllowed": "Add to allowed list",
"ui.permission.pattern.removeFromAllowed": "Remove from allowed list",
"ui.permission.pattern.addToDenied": "Add to denied list",
"ui.permission.pattern.removeFromDenied": "Remove from denied list",
"ui.permission.rule.addToAllowed": "Add to allowed list",
"ui.permission.rule.removeFromAllowed": "Remove from allowed list",
"ui.permission.rule.addToDenied": "Add to denied list",
"ui.permission.rule.removeFromDenied": "Remove from denied list",
"notification.question.title": "Question",
"notification.question.description": "{{sessionTitle}} in {{projectName}} has a question",
"notification.action.goToSession": "Go to session",
@@ -446,10 +446,10 @@ export const dict = {
"notification.permission.title": "Permiso requerido",
"notification.permission.description": "{{sessionTitle}} en {{projectName}} necesita permiso",
"ui.permission.permissionRules": "Reglas de permisos",
"ui.permission.pattern.addToAllowed": "Añadir a la lista de permitidos",
"ui.permission.pattern.removeFromAllowed": "Quitar de la lista de permitidos",
"ui.permission.pattern.addToDenied": "Añadir a la lista de denegados",
"ui.permission.pattern.removeFromDenied": "Quitar de la lista de denegados",
"ui.permission.rule.addToAllowed": "Añadir a la lista de permitidos",
"ui.permission.rule.removeFromAllowed": "Quitar de la lista de permitidos",
"ui.permission.rule.addToDenied": "Añadir a la lista de denegados",
"ui.permission.rule.removeFromDenied": "Quitar de la lista de denegados",
"notification.question.title": "Pregunta",
"notification.question.description": "{{sessionTitle}} en {{projectName}} tiene una pregunta",
"notification.action.goToSession": "Ir a sesión",
@@ -451,10 +451,10 @@ export const dict = {
"notification.permission.title": "Permission requise",
"notification.permission.description": "{{sessionTitle}} dans {{projectName}} a besoin d'une permission",
"ui.permission.permissionRules": "Règles de permission",
"ui.permission.pattern.addToAllowed": "Ajouter à la liste des autorisés",
"ui.permission.pattern.removeFromAllowed": "Retirer de la liste des autorisés",
"ui.permission.pattern.addToDenied": "Ajouter à la liste des refusés",
"ui.permission.pattern.removeFromDenied": "Retirer de la liste des refusés",
"ui.permission.rule.addToAllowed": "Ajouter à la liste des autorisés",
"ui.permission.rule.removeFromAllowed": "Retirer de la liste des autorisés",
"ui.permission.rule.addToDenied": "Ajouter à la liste des refusés",
"ui.permission.rule.removeFromDenied": "Retirer de la liste des refusés",
"notification.question.title": "Question",
"notification.question.description": "{{sessionTitle}} dans {{projectName}} a une question",
"notification.action.goToSession": "Aller à la session",
@@ -442,10 +442,10 @@ export const dict = {
"notification.permission.title": "権限が必要です",
"notification.permission.description": "{{projectName}} の {{sessionTitle}} が権限を必要としています",
"ui.permission.permissionRules": "権限ルール",
"ui.permission.pattern.addToAllowed": "許可リストに追加",
"ui.permission.pattern.removeFromAllowed": "許可リストから削除",
"ui.permission.pattern.addToDenied": "拒否リストに追加",
"ui.permission.pattern.removeFromDenied": "拒否リストから削除",
"ui.permission.rule.addToAllowed": "許可リストに追加",
"ui.permission.rule.removeFromAllowed": "許可リストから削除",
"ui.permission.rule.addToDenied": "拒否リストに追加",
"ui.permission.rule.removeFromDenied": "拒否リストから削除",
"notification.question.title": "質問",
"notification.question.description": "{{projectName}} の {{sessionTitle}} から質問があります",
"notification.action.goToSession": "セッションへ移動",
@@ -445,10 +445,10 @@ export const dict = {
"notification.permission.title": "권한 필요",
"notification.permission.description": "{{projectName}}의 {{sessionTitle}}에서 권한이 필요합니다",
"ui.permission.permissionRules": "권한 규칙",
"ui.permission.pattern.addToAllowed": "허용 목록에 추가",
"ui.permission.pattern.removeFromAllowed": "허용 목록에서 제거",
"ui.permission.pattern.addToDenied": "거부 목록에 추가",
"ui.permission.pattern.removeFromDenied": "거부 목록에서 제거",
"ui.permission.rule.addToAllowed": "허용 목록에 추가",
"ui.permission.rule.removeFromAllowed": "허용 목록에서 제거",
"ui.permission.rule.addToDenied": "거부 목록에 추가",
"ui.permission.rule.removeFromDenied": "거부 목록에서 제거",
"notification.question.title": "질문",
"notification.question.description": "{{projectName}}의 {{sessionTitle}}에서 질문이 있습니다",
"notification.action.goToSession": "세션으로 이동",
@@ -446,10 +446,10 @@ export const dict = {
"notification.permission.title": "Tillatelse påkrevd",
"notification.permission.description": "{{sessionTitle}} i {{projectName}} trenger tillatelse",
"ui.permission.permissionRules": "Tillatelsesregler",
"ui.permission.pattern.addToAllowed": "Legg til i tillatt-listen",
"ui.permission.pattern.removeFromAllowed": "Fjern fra tillatt-listen",
"ui.permission.pattern.addToDenied": "Legg til i blokkert-listen",
"ui.permission.pattern.removeFromDenied": "Fjern fra blokkert-listen",
"ui.permission.rule.addToAllowed": "Legg til i tillatt-listen",
"ui.permission.rule.removeFromAllowed": "Fjern fra tillatt-listen",
"ui.permission.rule.addToDenied": "Legg til i blokkert-listen",
"ui.permission.rule.removeFromDenied": "Fjern fra blokkert-listen",
"notification.question.title": "Spørsmål",
"notification.question.description": "{{sessionTitle}} i {{projectName}} har et spørsmål",
"notification.action.goToSession": "Gå til sesjon",
@@ -445,10 +445,10 @@ export const dict = {
"notification.permission.title": "Wymagane uprawnienie",
"notification.permission.description": "{{sessionTitle}} w {{projectName}} potrzebuje uprawnienia",
"ui.permission.permissionRules": "Reguły uprawnień",
"ui.permission.pattern.addToAllowed": "Dodaj do listy dozwolonych",
"ui.permission.pattern.removeFromAllowed": "Usuń z listy dozwolonych",
"ui.permission.pattern.addToDenied": "Dodaj do listy zablokowanych",
"ui.permission.pattern.removeFromDenied": "Usuń z listy zablokowanych",
"ui.permission.rule.addToAllowed": "Dodaj do listy dozwolonych",
"ui.permission.rule.removeFromAllowed": "Usuń z listy dozwolonych",
"ui.permission.rule.addToDenied": "Dodaj do listy zablokowanych",
"ui.permission.rule.removeFromDenied": "Usuń z listy zablokowanych",
"notification.question.title": "Pytanie",
"notification.question.description": "{{sessionTitle}} w {{projectName}} ma pytanie",
"notification.action.goToSession": "Przejdź do sesji",
@@ -447,10 +447,10 @@ export const dict = {
"notification.permission.title": "Требуется разрешение",
"notification.permission.description": "{{sessionTitle}} в {{projectName}} требуется разрешение",
"ui.permission.permissionRules": "Правила разрешений",
"ui.permission.pattern.addToAllowed": "Добавить в список разрешённых",
"ui.permission.pattern.removeFromAllowed": "Убрать из списка разрешённых",
"ui.permission.pattern.addToDenied": "Добавить в список запрещённых",
"ui.permission.pattern.removeFromDenied": "Убрать из списка запрещённых",
"ui.permission.rule.addToAllowed": "Добавить в список разрешённых",
"ui.permission.rule.removeFromAllowed": "Убрать из списка разрешённых",
"ui.permission.rule.addToDenied": "Добавить в список запрещённых",
"ui.permission.rule.removeFromDenied": "Убрать из списка запрещённых",
"notification.question.title": "Вопрос",
"notification.question.description": "У {{sessionTitle}} в {{projectName}} есть вопрос",
"notification.action.goToSession": "Перейти к сессии",
@@ -442,10 +442,10 @@ export const dict = {
"notification.permission.title": "ต้องการสิทธิ์",
"notification.permission.description": "{{sessionTitle}} ใน {{projectName}} ต้องการสิทธิ์",
"ui.permission.permissionRules": "กฎการอนุญาต",
"ui.permission.pattern.addToAllowed": "เพิ่มไปยังรายการที่อนุญาต",
"ui.permission.pattern.removeFromAllowed": "นำออกจากรายการที่อนุญาต",
"ui.permission.pattern.addToDenied": "เพิ่มไปยังรายการที่ถูกปฏิเสธ",
"ui.permission.pattern.removeFromDenied": "นำออกจากรายการที่ถูกปฏิเสธ",
"ui.permission.rule.addToAllowed": "เพิ่มไปยังรายการที่อนุญาต",
"ui.permission.rule.removeFromAllowed": "นำออกจากรายการที่อนุญาต",
"ui.permission.rule.addToDenied": "เพิ่มไปยังรายการที่ถูกปฏิเสธ",
"ui.permission.rule.removeFromDenied": "นำออกจากรายการที่ถูกปฏิเสธ",
"notification.question.title": "คำถาม",
"notification.question.description": "{{sessionTitle}} ใน {{projectName}} มีคำถาม",
"notification.action.goToSession": "ไปที่เซสชัน",
@@ -438,10 +438,10 @@ export const dict = {
"notification.permission.title": "需要权限",
"notification.permission.description": "{{sessionTitle}}({{projectName}})需要权限",
"ui.permission.permissionRules": "权限规则",
"ui.permission.pattern.addToAllowed": "添加到允许列表",
"ui.permission.pattern.removeFromAllowed": "从允许列表移除",
"ui.permission.pattern.addToDenied": "添加到拒绝列表",
"ui.permission.pattern.removeFromDenied": "从拒绝列表移除",
"ui.permission.rule.addToAllowed": "添加到允许列表",
"ui.permission.rule.removeFromAllowed": "从允许列表移除",
"ui.permission.rule.addToDenied": "添加到拒绝列表",
"ui.permission.rule.removeFromDenied": "从拒绝列表移除",
"notification.question.title": "问题",
"notification.question.description": "{{sessionTitle}}({{projectName}})有一个问题",
"notification.action.goToSession": "前往会话",
@@ -439,10 +439,10 @@ export const dict = {
"notification.permission.title": "需要權限",
"notification.permission.description": "{{sessionTitle}}({{projectName}})需要權限",
"ui.permission.permissionRules": "權限規則",
"ui.permission.pattern.addToAllowed": "加入允許清單",
"ui.permission.pattern.removeFromAllowed": "從允許清單移除",
"ui.permission.pattern.addToDenied": "加入拒絕清單",
"ui.permission.pattern.removeFromDenied": "從拒絕清單移除",
"ui.permission.rule.addToAllowed": "加入允許清單",
"ui.permission.rule.removeFromAllowed": "從允許清單移除",
"ui.permission.rule.addToDenied": "加入拒絕清單",
"ui.permission.rule.removeFromDenied": "從拒絕清單移除",
"notification.question.title": "問題",
"notification.question.description": "{{sessionTitle}}({{projectName}})有一個問題",
"notification.action.goToSession": "前往工作階段",
@@ -161,7 +161,7 @@ const globPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "glob",
patterns: ["**/*.md"],
args: { pattern: "**/*.md" },
args: { pattern: "**/*.md", rules: ["**/*.md"] },
tool: { messageID: ASST_MSG_ID, callID: "call-glob-001" },
}
@@ -170,7 +170,7 @@ const bashPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "bash",
patterns: ["bun test"],
args: { command: "bun test" },
args: { command: "bun test", rules: ["bun *", "bun test"] },
tool: { messageID: ASST_MSG_ID, callID: "call-bash-001" },
}
@@ -179,7 +179,7 @@ const dockPermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "write",
patterns: ["src/main.tsx", "src/utils.ts"],
args: {},
args: { rules: ["src/main.tsx", "src/utils.ts"] },
// No `tool` field — this is a non-tool (dock) permission
}
@@ -291,7 +291,7 @@ const todoWritePermission: PermissionRequest = {
sessionID: SESSION_ID,
toolName: "todowrite",
patterns: ["*"],
args: {},
args: { rules: ["*"] },
tool: { messageID: ASST_MSG_ID, callID: "call-todo-001" },
}
@@ -1218,7 +1218,7 @@
font-size: 12px;
}
[data-slot="permission-patterns"] {
[data-slot="permission-rules"] {
margin: 0;
max-height: 160px;
overflow-y: auto;
@@ -1232,12 +1232,12 @@
}
}
[data-slot="permission-patterns-section"] {
[data-slot="permission-rules-section"] {
display: flex;
flex-direction: column;
}
[data-slot="permission-patterns-header"] {
[data-slot="permission-rules-header"] {
all: unset;
display: flex;
align-items: center;
@@ -1254,13 +1254,13 @@
}
}
[data-slot="permission-patterns-header-title"] {
[data-slot="permission-rules-header-title"] {
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
[data-slot="permission-patterns-header-chevron"] {
[data-slot="permission-rules-header-chevron"] {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1272,7 +1272,7 @@
}
}
[data-slot="permission-patterns-collapse"] {
[data-slot="permission-rules-collapse"] {
display: grid;
grid-template-rows: 0fr;
transition:
@@ -1286,36 +1286,36 @@
}
}
[data-slot="permission-patterns-collapse-inner"] {
[data-slot="permission-rules-collapse-inner"] {
min-height: 0;
overflow: hidden;
}
[data-slot="permission-pattern-row"] {
[data-slot="permission-rule-row"] {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0 3px 8px;
min-width: 0;
&[data-decision="approved"] [data-slot="permission-pattern"] {
&[data-decision="approved"] [data-slot="permission-rule"] {
color: var(--text-success, var(--vscode-testing-iconPassed, #5cb85c));
}
&[data-decision="denied"] [data-slot="permission-pattern"] {
&[data-decision="denied"] [data-slot="permission-rule"] {
color: var(--text-error, var(--vscode-errorForeground, #f44));
text-decoration: line-through;
}
}
[data-slot="permission-pattern-type"] {
[data-slot="permission-rule-type"] {
font-size: 11px;
font-weight: 600;
color: var(--text-base, var(--vscode-foreground));
flex-shrink: 0;
}
[data-slot="permission-pattern"] {
[data-slot="permission-rule"] {
font-size: 12px;
font-family: var(--vscode-editor-font-family, monospace);
color: var(--text-weak, var(--vscode-descriptionForeground));
@@ -1326,7 +1326,7 @@
flex: 1;
}
[data-slot="permission-pattern-actions"] {
[data-slot="permission-rule-actions"] {
display: flex;
align-items: center;
gap: 2px;
@@ -1336,7 +1336,7 @@
margin-left: -3px;
}
[data-slot="permission-pattern-toggle"] {
[data-slot="permission-rule-toggle"] {
all: unset;
display: flex;
align-items: center;
@@ -135,7 +135,7 @@ export interface PermissionRequest {
sessionID: string
toolName: string
patterns: string[]
args: Record<string, unknown>
args: Record<string, unknown> & { rules?: string[] }
message?: string
tool?: { messageID: string; callID: string }
}
@@ -1143,8 +1143,8 @@ export interface PermissionResponseRequest {
permissionId: string
sessionID: string
response: "once" | "always" | "reject"
approvedPatterns: string[]
deniedPatterns: string[]
approvedAlways: string[]
deniedAlways: string[]
}
export interface CreateSessionRequest {
@@ -0,0 +1,29 @@
// kilocode_change - new file
import { BashArity } from "@/permission/arity"
/**
* Generates hierarchical always-patterns for a bash command and adds them
* directly to the provided Set.
*
* Given `["npm", "install", "lodash"]` with text `"npm install lodash"`,
* adds: `"npm *"`, `"npm install *"`, `"npm install lodash"`.
*
* When the exact text matches the arity prefix (e.g. `"git branch"` with
* prefix `["git", "branch"]`), the exact text is skipped because the
* wildcard `"git branch *"` already covers it.
*/
export namespace BashHierarchy {
export function addAll(target: Set<string>, command: string[], text: string) {
const prefix = BashArity.prefix(command)
// Add wildcard at each arity level: "git *", "git branch *", etc.
for (let i = 1; i <= prefix.length; i++) {
target.add(prefix.slice(0, i).join(" ") + " *")
}
// Add exact text only when it adds specificity beyond the arity prefix.
// e.g. if text is "git log --oneline", add it; if it's only "git log",
// no need to add it because arity already generates "git log *" as a prefix.
if (text !== prefix.join(" ")) target.add(text)
}
}
+8 -8
View File
@@ -161,25 +161,25 @@ export namespace PermissionNext {
)
// kilocode_change start
export const savePatternRules = fn(
export const saveAlwaysRules = fn(
z.object({
requestID: Identifier.schema("permission"),
approvedPatterns: z.string().array().optional(),
deniedPatterns: z.string().array().optional(),
approvedAlways: z.string().array().optional(),
deniedAlways: z.string().array().optional(),
}),
async (input) => {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) throw new NotFoundError({ message: `Permission request ${input.requestID} not found` })
const validPatterns = new Set(existing.info.patterns)
const validRules = new Set(existing.info.metadata?.rules ?? [])
const permission = existing.info.permission
for (const pattern of input.approvedPatterns ?? []) {
if (validPatterns.has(pattern)) s.approved.push({ permission, pattern, action: "allow" })
for (const pattern of input.approvedAlways ?? []) {
if (validRules.has(pattern)) s.approved.push({ permission, pattern, action: "allow" })
}
for (const pattern of input.deniedPatterns ?? []) {
if (validPatterns.has(pattern)) s.approved.push({ permission, pattern, action: "deny" })
for (const pattern of input.deniedAlways ?? []) {
if (validRules.has(pattern)) s.approved.push({ permission, pattern, action: "deny" })
}
},
)
@@ -51,14 +51,14 @@ export const PermissionRoutes = lazy(() =>
)
// kilocode_change start
.post(
"/:requestID/pattern-rules",
"/:requestID/always-rules",
describeRoute({
summary: "Save per-pattern permission rules",
description: "Save approved/denied patterns for a pending permission request.",
operationId: "permission.savePatternRules",
summary: "Save always-allow/deny permission rules",
description: "Save approved/denied always-rules for a pending permission request.",
operationId: "permission.saveAlwaysRules",
responses: {
200: {
description: "Pattern rules saved successfully",
description: "Always rules saved successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
@@ -77,17 +77,17 @@ export const PermissionRoutes = lazy(() =>
validator(
"json",
z.object({
approvedPatterns: z.string().array().optional(),
deniedPatterns: z.string().array().optional(),
approvedAlways: z.string().array().optional(),
deniedAlways: z.string().array().optional(),
}),
),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await PermissionNext.savePatternRules({
await PermissionNext.saveAlwaysRules({
requestID: params.requestID,
approvedPatterns: json.approvedPatterns,
deniedPatterns: json.deniedPatterns,
approvedAlways: json.approvedAlways,
deniedAlways: json.deniedAlways,
})
return c.json(true)
},
+4 -1
View File
@@ -16,6 +16,7 @@ import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { BashHierarchy } from "@/kilocode/bash-hierarchy" // kilocode_change
import { Truncate } from "./truncation"
import { Plugin } from "@/plugin"
@@ -90,6 +91,7 @@ export const BashTool = Tool.define("bash", async () => {
if (!Instance.containsPath(cwd)) directories.add(cwd)
const patterns = new Set<string>()
const always = new Set<string>()
const rules = new Set<string>() // kilocode_change — hierarchy rules for permissions "npm", "npm install", "npm install lodash"
for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
@@ -139,6 +141,7 @@ export const BashTool = Tool.define("bash", async () => {
if (command.length && command[0] !== "cd") {
patterns.add(commandText)
always.add(BashArity.prefix(command).join(" ") + " *")
BashHierarchy.addAll(rules, command, commandText) // kilocode_change
}
}
@@ -161,7 +164,7 @@ export const BashTool = Tool.define("bash", async () => {
permission: "bash",
patterns: Array.from(patterns),
always: Array.from(always),
metadata: { command: params.command }, // kilocode_change
metadata: { command: params.command, rules: Array.from(rules) }, // kilocode_change
})
}
@@ -0,0 +1,64 @@
import { test, expect, describe } from "bun:test"
import { BashHierarchy } from "../../src/kilocode/bash-hierarchy"
function collect(command: string[], text: string): string[] {
const set = new Set<string>()
BashHierarchy.addAll(set, command, text)
return [...set]
}
describe("BashHierarchy.addAll", () => {
test("arity-1 command with args produces base wildcard + exact", () => {
// "ls" has arity 1, prefix = ["ls"], text "ls -la" !== "ls" → exact is added
const result = collect(["ls", "-la"], "ls -la")
expect(result).toContain("ls *")
expect(result).toContain("ls -la")
})
test("arity-2 command without extra args skips redundant exact text", () => {
// "git status" has arity 2, prefix = ["git", "status"], text === prefix → no exact
const result = collect(["git", "status"], "git status")
expect(result).toEqual(["git *", "git status *"])
})
test("arity-2 command with extra args includes exact text", () => {
// "npm install lodash" has arity 2, prefix = ["npm", "install"], text !== prefix → exact added
const result = collect(["npm", "install", "lodash"], "npm install lodash")
expect(result).toEqual(["npm *", "npm install *", "npm install lodash"])
})
test("arity-3 command without extra args skips redundant exact text", () => {
// "npm run dev" has arity 3, prefix = ["npm", "run", "dev"], text === prefix → no exact
const result = collect(["npm", "run", "dev"], "npm run dev")
expect(result).toEqual(["npm *", "npm run *", "npm run dev *"])
})
test("arity-3 command with extra args includes exact text", () => {
const result = collect(["docker", "compose", "up", "-d"], "docker compose up -d")
expect(result).toEqual(["docker *", "docker compose *", "docker compose up *", "docker compose up -d"])
})
test("single token command without args skips redundant exact text", () => {
// "pwd" has arity 1, prefix = ["pwd"], text === prefix → no exact
const result = collect(["pwd"], "pwd")
expect(result).toEqual(["pwd *"])
})
test("empty command returns empty", () => {
const result = collect([], "")
expect(result).toEqual([])
})
test("unknown command with args includes exact text", () => {
const result = collect(["mycustomtool", "arg1", "arg2"], "mycustomtool arg1 arg2")
expect(result).toEqual(["mycustomtool *", "mycustomtool arg1 arg2"])
})
test("duplicates are deduplicated by Set", () => {
const set = new Set<string>()
BashHierarchy.addAll(set, ["git", "status"], "git status")
BashHierarchy.addAll(set, ["git", "diff"], "git diff")
// "git *" appears in both but Set deduplicates
expect([...set].filter((p) => p === "git *")).toHaveLength(1)
})
})
@@ -0,0 +1,169 @@
import { test, expect, describe } from "bun:test"
import { PermissionNext } from "../../src/permission/next"
import { Instance } from "../../src/project/instance"
import { NotFoundError } from "../../src/storage/db"
import { tmpdir } from "../fixture/fixture"
describe("saveAlwaysRules", () => {
test("approved rules auto-allow future requests", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_1",
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: { rules: ["npm *", "npm install"] },
always: ["npm install *"],
ruleset: [],
})
await PermissionNext.saveAlwaysRules({ requestID: "permission_1", approvedAlways: ["npm install"] })
await PermissionNext.reply({ requestID: "permission_1", reply: "once" })
await expect(askPromise).resolves.toBeUndefined()
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
},
})
})
test("denied rules auto-deny future requests", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_2",
sessionID: "session_test",
permission: "bash",
patterns: ["rm -rf /"],
metadata: { rules: ["rm *", "rm -rf /"] },
always: ["rm *"],
ruleset: [],
})
await PermissionNext.saveAlwaysRules({ requestID: "permission_2", deniedAlways: ["rm -rf /"] })
await PermissionNext.reply({ requestID: "permission_2", reply: "once" })
await expect(askPromise).resolves.toBeUndefined()
await expect(
PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["rm -rf /"],
metadata: {},
always: [],
ruleset: [],
}),
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
},
})
})
test("throws for unknown request ID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
PermissionNext.saveAlwaysRules({ requestID: "permission_nonexistent", approvedAlways: ["npm install"] }),
).rejects.toBeInstanceOf(NotFoundError)
},
})
})
test("ignores patterns not in metadata.rules", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_3",
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: { rules: ["npm *", "npm install"] },
always: ["npm install *"],
ruleset: [],
})
// "curl" is not in metadata.rules — should be silently ignored
await PermissionNext.saveAlwaysRules({
requestID: "permission_3",
approvedAlways: ["npm install", "curl http://evil.com"],
})
await PermissionNext.reply({ requestID: "permission_3", reply: "once" })
await expect(askPromise).resolves.toBeUndefined()
// npm install was in rules — auto-allowed
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
// curl was NOT in rules — still requires permission
const curlPromise = PermissionNext.ask({
id: "permission_curl",
sessionID: "session_test",
permission: "bash",
patterns: ["curl http://evil.com"],
metadata: {},
always: [],
ruleset: [],
})
await PermissionNext.reply({ requestID: "permission_curl", reply: "reject" })
await expect(curlPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
},
})
})
test("accepts hierarchy patterns from metadata.rules", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_4",
sessionID: "session_test",
permission: "bash",
patterns: ["npm install lodash"],
metadata: { rules: ["npm *", "npm install *", "npm install lodash"] },
always: ["npm install *"],
ruleset: [],
})
// Approve the broadest hierarchy level
await PermissionNext.saveAlwaysRules({ requestID: "permission_4", approvedAlways: ["npm *"] })
await PermissionNext.reply({ requestID: "permission_4", reply: "once" })
await expect(askPromise).resolves.toBeUndefined()
// "npm *" wildcard should auto-allow any npm command
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["npm test"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
},
})
})
})
@@ -1,317 +0,0 @@
import { test, expect } from "bun:test"
import { PermissionNext } from "../../src/permission/next"
import { Instance } from "../../src/project/instance"
import { NotFoundError } from "../../src/storage/db"
import { tmpdir } from "../fixture/fixture"
test("savePatternRules - approvedPatterns saves allow rules for future requests", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_approved1",
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: {},
always: [],
ruleset: [],
})
// Save pattern rules before replying
await PermissionNext.savePatternRules({
requestID: "permission_approved1",
approvedPatterns: ["npm install"],
})
await PermissionNext.reply({
requestID: "permission_approved1",
reply: "once",
})
await expect(askPromise).resolves.toBeUndefined()
// The approved pattern should now auto-allow future requests
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
},
})
})
test("savePatternRules - deniedPatterns saves deny rules for future requests", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_denied1",
sessionID: "session_test",
permission: "bash",
patterns: ["rm -rf /"],
metadata: {},
always: [],
ruleset: [],
})
// Save pattern rules before replying
await PermissionNext.savePatternRules({
requestID: "permission_denied1",
deniedPatterns: ["rm -rf /"],
})
await PermissionNext.reply({
requestID: "permission_denied1",
reply: "once",
})
await expect(askPromise).resolves.toBeUndefined()
// The denied pattern should now auto-deny future requests
await expect(
PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["rm -rf /"],
metadata: {},
always: [],
ruleset: [],
}),
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
},
})
})
test("savePatternRules - multiple bash commands: approve some, deny others", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_multi1",
sessionID: "session_test",
permission: "bash",
patterns: ["npm install", "npm test", "rm -rf /tmp/cache"],
metadata: {},
always: [],
ruleset: [],
})
// Approve npm commands, deny the rm command
await PermissionNext.savePatternRules({
requestID: "permission_multi1",
approvedPatterns: ["npm install", "npm test"],
deniedPatterns: ["rm -rf /tmp/cache"],
})
await PermissionNext.reply({
requestID: "permission_multi1",
reply: "once",
})
await expect(askPromise).resolves.toBeUndefined()
// Approved patterns should auto-allow
const result1 = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: {},
always: [],
ruleset: [],
})
expect(result1).toBeUndefined()
const result2 = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["npm test"],
metadata: {},
always: [],
ruleset: [],
})
expect(result2).toBeUndefined()
// Denied pattern should auto-deny
await expect(
PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["rm -rf /tmp/cache"],
metadata: {},
always: [],
ruleset: [],
}),
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
},
})
})
test("savePatternRules - multiple bash commands: approve all patterns", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_multi2",
sessionID: "session_test",
permission: "bash",
patterns: ["git status", "git diff", "git log --oneline"],
metadata: {},
always: [],
ruleset: [],
})
await PermissionNext.savePatternRules({
requestID: "permission_multi2",
approvedPatterns: ["git status", "git diff", "git log --oneline"],
})
await PermissionNext.reply({
requestID: "permission_multi2",
reply: "once",
})
await expect(askPromise).resolves.toBeUndefined()
// All three should auto-allow in a single request with multiple patterns
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["git status", "git diff", "git log --oneline"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
},
})
})
test("savePatternRules - ignores patterns not in the original request", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_multi3",
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: {},
always: [],
ruleset: [],
})
// Try to sneak in an unrelated pattern — should be ignored
await PermissionNext.savePatternRules({
requestID: "permission_multi3",
approvedPatterns: ["npm install", "curl http://evil.com"],
})
await PermissionNext.reply({
requestID: "permission_multi3",
reply: "once",
})
await expect(askPromise).resolves.toBeUndefined()
// npm install was in the original request — should be auto-allowed
const result = await PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: ["npm install"],
metadata: {},
always: [],
ruleset: [],
})
expect(result).toBeUndefined()
// curl was NOT in the original request — should still require permission (ask)
const curlPromise = PermissionNext.ask({
id: "permission_curl_check",
sessionID: "session_test",
permission: "bash",
patterns: ["curl http://evil.com"],
metadata: {},
always: [],
ruleset: [],
})
// Should be pending (not auto-resolved), meaning it returned a Promise
expect(curlPromise).toBeInstanceOf(Promise)
// Clean up the pending request — reject throws RejectedError on the promise
await PermissionNext.reply({
requestID: "permission_curl_check",
reply: "reject",
})
await expect(curlPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
},
})
})
test("savePatternRules - throws error for stale/unknown request ID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
PermissionNext.savePatternRules({
requestID: "permission_nonexistent",
approvedPatterns: ["npm install"],
}),
).rejects.toBeInstanceOf(NotFoundError)
},
})
})
test("savePatternRules - multiple bash commands: deny all patterns", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
id: "permission_multi4",
sessionID: "session_test",
permission: "bash",
patterns: ["rm -rf /", "sudo shutdown", "dd if=/dev/zero of=/dev/sda"],
metadata: {},
always: [],
ruleset: [],
})
await PermissionNext.savePatternRules({
requestID: "permission_multi4",
deniedPatterns: ["rm -rf /", "sudo shutdown", "dd if=/dev/zero of=/dev/sda"],
})
await PermissionNext.reply({
requestID: "permission_multi4",
reply: "once",
})
await expect(askPromise).resolves.toBeUndefined()
// Each denied pattern should auto-deny individually
for (const pattern of ["rm -rf /", "sudo shutdown", "dd if=/dev/zero of=/dev/sda"]) {
await expect(
PermissionNext.ask({
sessionID: "session_test",
permission: "bash",
patterns: [pattern],
metadata: {},
always: [],
ruleset: [],
}),
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
}
},
})
})
+4 -2
View File
@@ -306,8 +306,10 @@ describe("tool.bash permissions", () => {
await bash.execute({ command: "ls -la", description: "List" }, testCtx)
const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined()
const pattern = bashReq!.always[0]
expect(pattern).toBe("ls *")
// kilocode_change start — hierarchy adds base wildcard + exact
expect(bashReq!.always).toContain("ls *")
expect(bashReq!.metadata.rules).toContain("ls -la")
// kilocode_change end
},
})
})
+12 -12
View File
@@ -98,8 +98,8 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
PermissionSavePatternRulesErrors,
PermissionSavePatternRulesResponses,
PermissionSaveAlwaysRulesErrors,
PermissionSaveAlwaysRulesResponses,
ProjectCurrentResponses,
ProjectListResponses,
ProjectUpdateErrors,
@@ -2464,17 +2464,17 @@ export class Permission extends HeyApiClient {
}
/**
* Save per-pattern permission rules
* Save always-allow/deny permission rules
*
* Save approved/denied patterns for a pending permission request.
* Save approved/denied always-rules for a pending permission request.
*/
public savePatternRules<ThrowOnError extends boolean = false>(
public saveAlwaysRules<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
workspace?: string
approvedPatterns?: Array<string>
deniedPatterns?: Array<string>
approvedAlways?: Array<string>
deniedAlways?: Array<string>
},
options?: Options<never, ThrowOnError>,
) {
@@ -2486,18 +2486,18 @@ export class Permission extends HeyApiClient {
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "approvedPatterns" },
{ in: "body", key: "deniedPatterns" },
{ in: "body", key: "approvedAlways" },
{ in: "body", key: "deniedAlways" },
],
},
],
)
return (options?.client ?? this.client).post<
PermissionSavePatternRulesResponses,
PermissionSavePatternRulesErrors,
PermissionSaveAlwaysRulesResponses,
PermissionSaveAlwaysRulesErrors,
ThrowOnError
>({
url: "/permission/{requestID}/pattern-rules",
url: "/permission/{requestID}/always-rules",
...options,
...params,
headers: {
+10 -10
View File
@@ -3947,10 +3947,10 @@ export type PermissionReplyResponses = {
export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses]
export type PermissionSavePatternRulesData = {
export type PermissionSaveAlwaysRulesData = {
body?: {
approvedPatterns?: Array<string>
deniedPatterns?: Array<string>
approvedAlways?: Array<string>
deniedAlways?: Array<string>
}
path: {
requestID: string
@@ -3959,10 +3959,10 @@ export type PermissionSavePatternRulesData = {
directory?: string
workspace?: string
}
url: "/permission/{requestID}/pattern-rules"
url: "/permission/{requestID}/always-rules"
}
export type PermissionSavePatternRulesErrors = {
export type PermissionSaveAlwaysRulesErrors = {
/**
* Bad request
*/
@@ -3973,17 +3973,17 @@ export type PermissionSavePatternRulesErrors = {
404: NotFoundError
}
export type PermissionSavePatternRulesError = PermissionSavePatternRulesErrors[keyof PermissionSavePatternRulesErrors]
export type PermissionSaveAlwaysRulesError = PermissionSaveAlwaysRulesErrors[keyof PermissionSaveAlwaysRulesErrors]
export type PermissionSavePatternRulesResponses = {
export type PermissionSaveAlwaysRulesResponses = {
/**
* Pattern rules saved successfully
* Always rules saved successfully
*/
200: boolean
}
export type PermissionSavePatternRulesResponse =
PermissionSavePatternRulesResponses[keyof PermissionSavePatternRulesResponses]
export type PermissionSaveAlwaysRulesResponse =
PermissionSaveAlwaysRulesResponses[keyof PermissionSaveAlwaysRulesResponses]
export type PermissionListData = {
body?: never
+8 -8
View File
@@ -4320,9 +4320,9 @@
]
}
},
"/permission/{requestID}/pattern-rules": {
"/permission/{requestID}/always-rules": {
"post": {
"operationId": "permission.savePatternRules",
"operationId": "permission.saveAlwaysRules",
"parameters": [
{
"in": "query",
@@ -4347,11 +4347,11 @@
"required": true
}
],
"summary": "Save per-pattern permission rules",
"description": "Save approved/denied patterns for a pending permission request.",
"summary": "Save always-allow/deny permission rules",
"description": "Save approved/denied always-rules for a pending permission request.",
"responses": {
"200": {
"description": "Pattern rules saved successfully",
"description": "Always rules saved successfully",
"content": {
"application/json": {
"schema": {
@@ -4387,13 +4387,13 @@
"schema": {
"type": "object",
"properties": {
"approvedPatterns": {
"approvedAlways": {
"type": "array",
"items": {
"type": "string"
}
},
"deniedPatterns": {
"deniedAlways": {
"type": "array",
"items": {
"type": "string"
@@ -4407,7 +4407,7 @@
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.permission.savePatternRules({\n ...\n})"
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.permission.saveAlwaysRules({\n ...\n})"
}
]
}