mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix(vscode): align skill-shell permission prompt with CLI backend
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user