fix(vscode): align skill-shell permission prompt with CLI backend

This commit is contained in:
Bruno Agatao
2026-07-29 18:21:59 +02:00
parent 97dd0450fc
commit ea161e0932
6 changed files with 70 additions and 9 deletions
@@ -1,5 +1,24 @@
import { describe, it, expect } from "bun:test"
import { savedRuleStates } from "../../webview-ui/src/components/chat/permission-dock-utils"
import { displaySkillCommand, savedRuleStates } from "../../webview-ui/src/components/chat/permission-dock-utils"
describe("displaySkillCommand", () => {
it("leaves ordinary commands, including astral characters, untouched", () => {
expect(displaySkillCommand("git status && printf 'hi 😀'")).toBe("git status && printf 'hi 😀'")
})
it("escapes whitespace controls with readable shorthands", () => {
expect(displaySkillCommand("a\nb\rc\td")).toBe("a\\nb\\rc\\td")
})
it("escapes bidi/format controls so the visible text can't be reordered", () => {
// U+202E (RLO) would otherwise reverse the trailing text in the prompt.
expect(displaySkillCommand("rm \u202Etxt.exe")).toBe("rm \\u202etxt.exe")
})
it("uses \\x for C0/C1 controls and \\u for higher code points", () => {
expect(displaySkillCommand("\u0000\u009f\u2066")).toBe("\\x00\\x9f\\u2066")
})
})
describe("savedRuleStates", () => {
it("returns empty map when rule is undefined", () => {
@@ -17,7 +17,13 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import { useConfig } from "../../context/config"
import { describePatterns, describeRule, savedRuleStates, type RuleDecision } from "./permission-dock-utils"
import {
describePatterns,
describeRule,
displaySkillCommand,
savedRuleStates,
type RuleDecision,
} from "./permission-dock-utils"
import { PermissionCommand } from "./PermissionCommand"
import { PermissionDiff } from "./PermissionDiff"
import { permissionDiffs } from "./permission-diff-utils"
@@ -37,9 +43,12 @@ export const PermissionDock: Component<{
const { config } = useConfig()
const fromChild = () => props.request.sessionID !== session.currentSessionID()
// Skill shell batches list every command and are never persisted, so they show
// the command list and no auto-approve rules (matching the CLI TUI).
// Skill shell batches are never persisted, so they show no auto-approve rules. The command
// list is only shown for the bash ask; the sibling external_directory ask (same skillShell
// metadata) keeps its normal directory rendering.
const skillShell = () => props.request.args?.skillShell === true
const skillShellCommands = () =>
skillShell() && props.request.toolName === "bash" ? (props.request.args?.commands ?? []) : []
// Bash sends fine-grained rules via metadata.rules; other tools use the always array.
const rules = () => props.request.args?.rules ?? props.request.always ?? []
// Rules like "git *" or "git log *" — strip the trailing wildcard for display.
@@ -119,8 +128,15 @@ export const PermissionDock: Component<{
return value
}
const title = () =>
fromChild() ? language.t("notification.permission.titleSubagent") : language.t("notification.permission.title")
const title = () => {
const skill = props.request.args?.skill
if (skillShell() && typeof skill === "string" && skill.length > 0)
// Escape the untrusted skill name so bidi/control chars can't reorder the header text.
return language.t("notification.permission.titleSkillShell", { skill: displaySkillCommand(skill) })
return fromChild()
? language.t("notification.permission.titleSubagent")
: language.t("notification.permission.title")
}
const focusPrompt = () => requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt")))
@@ -272,7 +288,7 @@ export const PermissionDock: Component<{
}
>
<Show
when={skillShell()}
when={skillShellCommands().length > 0}
fallback={
<>
<Show when={cmdDescription()}>{(desc) => <div data-slot="permission-hint">{desc()}</div>}</Show>
@@ -306,7 +322,8 @@ export const PermissionDock: Component<{
</>
}
>
<For each={props.request.patterns}>{(cmd) => <PermissionCommand command={cmd} />}</For>
{/* Verbatim commands (args.commands), control-char/bidi-escaped so the displayed command matches execution. */}
<For each={skillShellCommands()}>{(cmd) => <PermissionCommand command={displaySkillCommand(cmd)} />}</For>
</Show>
<Show when={diffs().length > 0}>
@@ -2,6 +2,22 @@ import type { PermissionRule } from "../../types/messages"
export type RuleDecision = "approved" | "denied" | "pending"
// Escape control and bidi/format characters when displaying a skill-shell command, so a
// command can't repaint the prompt or use Trojan-Source reordering to make the visible text
// differ from what executes. The webview can't import from @kilocode/cli, so this mirrors
// displayCommand in packages/opencode/src/kilocode/skills/display.ts; keep them in sync.
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g
export function displaySkillCommand(command: string) {
return command.replace(CONTROL, (ch) => {
if (ch === "\n") return "\\n"
if (ch === "\r") return "\\r"
if (ch === "\t") return "\\t"
const code = ch.charCodeAt(0)
return code <= 0xff ? "\\x" + code.toString(16).padStart(2, "0") : "\\u" + code.toString(16).padStart(4, "0")
})
}
/**
* Check which rules are already saved in the user's config and return
* their initial toggle states (approved/denied). Rules not found in
@@ -263,6 +263,7 @@ export const dict = {
"notification.permission.title": "Permission required",
"notification.permission.titleSubagent": "Permission required (subagent)",
"notification.permission.titleSkillShell": 'Run shell commands from skill "{{skill}}"?',
"ui.permission.manageAutoApprove": "Manage Auto-Approve Rules",
"ui.permission.doomLoop.prompt": "Potential loop detected for the {{tool}} tool. Continue running?",
"ui.permission.doomLoop.rule": "Continue {{tool}} calls",
@@ -304,9 +304,15 @@ const skillShellPermission: PermissionRequest = {
id: "perm-skill-shell-001",
sessionID: SESSION_ID,
toolName: "bash",
// patterns are the decomposed sub-commands (for authorization); the prompt displays the
// verbatim per-placeholder commands from args.commands, and names the skill via args.skill.
patterns: ["git rev-parse --abbrev-ref HEAD", "printf INJECTED_OK"],
always: [],
args: { skillShell: true },
args: {
skillShell: true,
skill: "git-status",
commands: ["git rev-parse --abbrev-ref HEAD", "printf INJECTED_OK"],
},
tool: { messageID: ASST_MSG_ID, callID: "call-skill-shell-001" },
}
@@ -45,6 +45,8 @@ export interface PermissionRequest {
description?: string
heredoc?: boolean
skillShell?: boolean
commands?: string[]
skill?: string
}
message?: string
tool?: { messageID: string; callID: string }