From 0484318191ccc252cee866aa5dd0f2c6b8481ea7 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Mon, 16 Mar 2026 17:43:16 +0100 Subject: [PATCH] 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 --- packages/kilo-vscode/src/KiloProvider.ts | 18 +- .../src/components/chat/ChatView.tsx | 4 +- .../src/components/chat/PermissionDock.tsx | 110 +++--- .../webview-ui/src/context/session.tsx | 12 +- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/br.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/da.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/de.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/en.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/es.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/no.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/th.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 8 +- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 8 +- .../src/stories/composite.stories.tsx | 8 +- .../webview-ui/src/styles/chat.css | 28 +- .../webview-ui/src/types/messages.ts | 6 +- .../opencode/src/kilocode/bash-hierarchy.ts | 29 ++ packages/opencode/src/permission/next.ts | 16 +- .../opencode/src/server/routes/permission.ts | 20 +- packages/opencode/src/tool/bash.ts | 5 +- .../test/kilocode/bash-hierarchy.test.ts | 64 ++++ .../test/permission/next.always-rules.test.ts | 169 ++++++++++ .../permission/next.pattern-rules.test.ts | 317 ------------------ packages/opencode/test/tool/bash.test.ts | 6 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 24 +- packages/sdk/js/src/v2/gen/types.gen.ts | 20 +- packages/sdk/openapi.json | 16 +- 34 files changed, 477 insertions(+), 523 deletions(-) create mode 100644 packages/opencode/src/kilocode/bash-hierarchy.ts create mode 100644 packages/opencode/test/kilocode/bash-hierarchy.test.ts create mode 100644 packages/opencode/test/permission/next.always-rules.test.ts delete mode 100644 packages/opencode/test/permission/next.pattern-rules.test.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index ff714143e42..d6894e4f4c4 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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 { 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 }, ) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index cf5737311a0..448f9e39d3a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -76,10 +76,10 @@ export const ChatView: Component = (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 ( diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 5f18c78b3cf..fd041287bbb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -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>({}) - const [patternsExpanded, setPatternsExpanded] = createSignal(permissionPatternsExpandedPreference) + const [decisions, setDecisions] = createSignal>({}) + 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<{ } footer={ - -
+ +
-
-
-
- - {(pattern, index) => ( -
-
+
+
+
+ + {(rule, index) => ( +
+
- {props.request.toolName} - {pattern} + {props.request.toolName} + {label(rule)}
)}
@@ -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")} @@ -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} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 69934ccf753..20c30d06890 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -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, }) } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index e4518b3a0bd..d644057a638 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -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": "انتقل إلى الجلسة", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 25245fd392f..8a39eda7d7d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index f2c3badcc87..cf17847909d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index ccd3b763f26..2b31cadffdd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 4168c4757b2..932b1e7d8ee 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index b954d4488d4..a9ec76dd5a1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index d23bb917e99..3b7cebdeaf0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 40aa5c629b0..04106daac73 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index e749cf94fc2..b3347ead6ed 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -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": "セッションへ移動", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 5e050a64613..4995168a000 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -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": "세션으로 이동", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index dbaabf4c5cc..c1374561d27 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index ea2e4192677..0962853e9e7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 224ece5b1f9..284d8ac7258 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -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": "Перейти к сессии", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index c8e25f4aef0..72c1c95a4c1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -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": "ไปที่เซสชัน", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 233bc28223e..95fda9b5abc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -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": "前往会话", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 95552807499..52801d327df 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -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": "前往工作階段", diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 3aad5a607bb..81475355146 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -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" }, } diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index 044af7fadfe..8ca1a8ab36d 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -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; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 191043f81ea..998e523ff01 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -135,7 +135,7 @@ export interface PermissionRequest { sessionID: string toolName: string patterns: string[] - args: Record + args: Record & { 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 { diff --git a/packages/opencode/src/kilocode/bash-hierarchy.ts b/packages/opencode/src/kilocode/bash-hierarchy.ts new file mode 100644 index 00000000000..1fb57973fdd --- /dev/null +++ b/packages/opencode/src/kilocode/bash-hierarchy.ts @@ -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, 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) + } +} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 7f848474dc8..86de010dbc5 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -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" }) } }, ) diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/routes/permission.ts index 6168b5288af..701de5a098e 100644 --- a/packages/opencode/src/server/routes/permission.ts +++ b/packages/opencode/src/server/routes/permission.ts @@ -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) }, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0e55225bdb9..9871d307243 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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() const always = new Set() + const rules = new Set() // 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 }) } diff --git a/packages/opencode/test/kilocode/bash-hierarchy.test.ts b/packages/opencode/test/kilocode/bash-hierarchy.test.ts new file mode 100644 index 00000000000..4d9a551152c --- /dev/null +++ b/packages/opencode/test/kilocode/bash-hierarchy.test.ts @@ -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() + 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() + 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) + }) +}) diff --git a/packages/opencode/test/permission/next.always-rules.test.ts b/packages/opencode/test/permission/next.always-rules.test.ts new file mode 100644 index 00000000000..f6a3d8e494a --- /dev/null +++ b/packages/opencode/test/permission/next.always-rules.test.ts @@ -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() + }, + }) + }) +}) diff --git a/packages/opencode/test/permission/next.pattern-rules.test.ts b/packages/opencode/test/permission/next.pattern-rules.test.ts deleted file mode 100644 index f47c00a1196..00000000000 --- a/packages/opencode/test/permission/next.pattern-rules.test.ts +++ /dev/null @@ -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) - } - }, - }) -}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index f75af4db1a3..77ba3aba4fe 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -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 }, }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index adb6602bade..aa2c32aed96 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + public saveAlwaysRules( parameters: { requestID: string directory?: string workspace?: string - approvedPatterns?: Array - deniedPatterns?: Array + approvedAlways?: Array + deniedAlways?: Array }, options?: Options, ) { @@ -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: { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 06f61fb976f..ec6f56bce7d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3947,10 +3947,10 @@ export type PermissionReplyResponses = { export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] -export type PermissionSavePatternRulesData = { +export type PermissionSaveAlwaysRulesData = { body?: { - approvedPatterns?: Array - deniedPatterns?: Array + approvedAlways?: Array + deniedAlways?: Array } 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 diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1a53f6fef8f..5fe9c101531 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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})" } ] }