mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #12724 from Kilo-Org/feat/execute-cmds-in-skill-context-jetbrains
feat(jetbrains): show verbatim skill commands and skill name in permission prompt
This commit is contained in:
+8
@@ -1237,6 +1237,7 @@ object KiloCliDataParser {
|
||||
ruleDecisions = rawRules.ifEmpty { always.map { PermissionRuleDecisionDto(it) } },
|
||||
filePath = path,
|
||||
fileDiffs = diffs,
|
||||
skillCommands = metaObj.skillCommands(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1653,6 +1654,13 @@ private fun JsonObject?.path(): String? {
|
||||
return str("filepath") ?: str("filePath") ?: str("file") ?: str("path")
|
||||
}
|
||||
|
||||
// metadata.commands is the verbatim skill-shell command list; the flat meta map loses the
|
||||
// array, so read it as a list for the prompt to display.
|
||||
private fun JsonObject?.skillCommands(): List<String> {
|
||||
if (this == null) return emptyList()
|
||||
return this["commands"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
||||
}
|
||||
|
||||
private fun JsonObject?.ruleDecisions(): List<PermissionRuleDecisionDto> {
|
||||
if (this == null) return emptyList()
|
||||
val raw = this["rules"] ?: return emptyList()
|
||||
|
||||
+39
@@ -2299,6 +2299,21 @@ class KiloCliDataParserTest {
|
||||
assertTrue(result.contains(""""message":"approved""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPermissionReplyJson - interactive reply serializes interactive true`() {
|
||||
// Wire contract the CLI server checks (permission/index.ts requires interactive
|
||||
// === true to accept a non-reject reply to a skill-shell batch); a serialization
|
||||
// regression here would silently break the entire approval flow.
|
||||
val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once", interactive = true))
|
||||
assertEquals("""{"reply":"once","interactive":true}""", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPermissionReplyJson - non-interactive reply omits the interactive field`() {
|
||||
val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once", interactive = false))
|
||||
assertFalse(result.contains("interactive"), "interactive must be omitted for a machine (non-interactive) reply, got: $result")
|
||||
}
|
||||
|
||||
// ---- buildPermissionAlwaysRulesJson ----
|
||||
|
||||
@Test
|
||||
@@ -2435,6 +2450,30 @@ class KiloCliDataParserTest {
|
||||
assertEquals("git status --short", asked.request.metadata["command"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parsePermissionRequest - skill shell commands and skill name extracted`() {
|
||||
val data = globalEvent("""
|
||||
"type": "permission.asked",
|
||||
"properties": {
|
||||
"id": "perm_skill",
|
||||
"sessionID": "ses_1",
|
||||
"permission": "bash",
|
||||
"patterns": ["git status"],
|
||||
"always": [],
|
||||
"metadata": {"skillShell": true, "skill": "git-status", "commands": ["git status", "printf hi"]}
|
||||
}
|
||||
""")
|
||||
|
||||
val result = KiloCliDataParser.parseChatEvent("permission.asked", data)
|
||||
assertNotNull(result)
|
||||
val asked = result as? ChatEventDto.PermissionAsked ?: error("Expected PermissionAsked")
|
||||
// verbatim commands are parsed as a list for the prompt to display
|
||||
assertEquals(listOf("git status", "printf hi"), asked.request.skillCommands)
|
||||
// skillShell + skill name survive the flat metadata map for card attribution
|
||||
assertEquals("true", asked.request.metadata["skillShell"])
|
||||
assertEquals("git-status", asked.request.metadata["skill"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parsePermissionRequest - parses rule decisions`() {
|
||||
val data = globalEvent("""
|
||||
|
||||
+1
@@ -2511,6 +2511,7 @@ private fun toPermission(dto: PermissionRequestDto): Permission {
|
||||
fileDiff = diffs.firstOrNull(),
|
||||
fileDiffs = diffs,
|
||||
raw = dto.metadata,
|
||||
skillCommands = dto.skillCommands,
|
||||
),
|
||||
message = dto.message ?: dto.metadata["message"],
|
||||
tool = ref,
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ data class PermissionMeta(
|
||||
val fileDiff: PermissionFileDiff? = null,
|
||||
val fileDiffs: List<PermissionFileDiff> = emptyList(),
|
||||
val raw: Map<String, String> = emptyMap(),
|
||||
// Verbatim skill-shell commands to display when raw["skillShell"] == "true".
|
||||
val skillCommands: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class PermissionRuleCandidate(
|
||||
|
||||
+45
-3
@@ -116,15 +116,32 @@ class PermissionView(
|
||||
val prev = requestId
|
||||
requestId = permission.id
|
||||
|
||||
card.setHeader(KiloBundle.message("session.permission.title"))
|
||||
val skillShell = permission.meta.raw["skillShell"] == "true"
|
||||
val skill = permission.meta.raw["skill"]
|
||||
card.setHeader(
|
||||
if (skillShell && !skill.isNullOrBlank())
|
||||
// skill is the untrusted SKILL.md frontmatter name; escape it the same way as
|
||||
// the command list so it can't reorder/repaint the header.
|
||||
KiloBundle.message("session.permission.skillShell.title", escapeControl(skill))
|
||||
else KiloBundle.message("session.permission.title"),
|
||||
)
|
||||
syncDescription(description(permission))
|
||||
|
||||
val tool = permission.name
|
||||
val target = if (tool == "bash") permission.meta.command else resolveTarget(permission)
|
||||
// A skill-shell bash batch shows the verbatim command list (control-char-escaped so the
|
||||
// displayed command can't repaint the line). Its external_directory sibling still shows
|
||||
// directories via resolveTarget; only the header carries the skill attribution.
|
||||
val target = when {
|
||||
skillShell && tool == "bash" -> permission.meta.skillCommands.joinToString("\n") { escapeControl(it) }
|
||||
tool == "bash" -> permission.meta.command
|
||||
else -> resolveTarget(permission)
|
||||
}
|
||||
syncCode(tool, target)
|
||||
syncDiffs(permission.meta.fileDiffs)
|
||||
responding = permission.state == PermissionRequestState.RESPONDING || permission.state == PermissionRequestState.RESOLVED
|
||||
rules.update(permission.meta.ruleDecisions, reset = prev != permission.id)
|
||||
// Skill-shell approvals are never persisted, so no auto-approve rule toggles even if a
|
||||
// future backend change starts sending candidates for this batch.
|
||||
rules.update(if (skillShell) emptyList() else permission.meta.ruleDecisions, reset = prev != permission.id)
|
||||
syncState(permission)
|
||||
syncPrimaryText()
|
||||
|
||||
@@ -265,6 +282,31 @@ class PermissionView(
|
||||
view.component.border = JBUI.Borders.empty()
|
||||
}
|
||||
|
||||
// Escape control chars (CR/LF/ESC/etc.) and bidi/format characters so a skill command can't
|
||||
// repaint the prompt or use Trojan-Source reordering to make the displayed text differ from
|
||||
// what executes; newlines become a visible marker. Mirrors displayCommand in the CLI
|
||||
// (packages/opencode/src/kilocode/skills/display.ts); keep the ranges in sync.
|
||||
private fun escapeControl(command: String): String = buildString {
|
||||
for (ch in command) {
|
||||
val code = ch.code
|
||||
when {
|
||||
ch == '\n' -> append("\\n")
|
||||
ch == '\r' -> append("\\r")
|
||||
ch == '\t' -> append("\\t")
|
||||
isEscapedControlOrFormat(code) -> append(if (code <= 0xff) "\\x%02x".format(code) else "\\u%04x".format(code))
|
||||
else -> append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isEscapedControlOrFormat(code: Int): Boolean =
|
||||
code < 0x20 ||
|
||||
code in 0x7f..0x9f ||
|
||||
code in 0x200e..0x200f ||
|
||||
code in 0x2028..0x2029 ||
|
||||
code in 0x202a..0x202e ||
|
||||
code in 0x2066..0x2069
|
||||
|
||||
private fun description(permission: Permission): String = if (permission.name == "bash") {
|
||||
permission.meta.raw["description"] ?: toolLabel(permission.name)
|
||||
} else {
|
||||
|
||||
@@ -52,6 +52,7 @@ session.error.revert.timeout=Operation timed out. Waiting for it to finish befor
|
||||
|
||||
session.permission.title=Permission required
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Run shell commands from skill "{0}"?
|
||||
session.permission.meta=Tool: {0} • Patterns: {1}
|
||||
session.permission.run=Run
|
||||
session.permission.ask=Ask
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=هل تريد تشغيل أوامر الصدفة من المهارة «{0}»؟
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Pokrenuti shell komande iz vještine „{0}“?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Kør shell-kommandoer fra færdigheden „{0}“?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Shell-Befehle aus dem Skill „{0}“ ausführen?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=¿Ejecutar comandos de shell de la habilidad «{0}»?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Exécuter les commandes shell de la compétence « {0} » ?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=スキル「{0}」のシェルコマンドを実行しますか?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=스킬 "{0}"의 셸 명령을 실행할까요?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Shell-opdrachten uit vaardigheid "{0}" uitvoeren?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -177,6 +177,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Kjøre skallkommandoer fra ferdigheten «{0}»?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -177,6 +177,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Uruchomić polecenia powłoki z umiejętności „{0}”?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
+1
@@ -177,6 +177,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Executar comandos de shell da skill "{0}"?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -177,6 +177,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Выполнить команды оболочки из навыка «{0}»?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -177,6 +177,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=เรียกใช้คำสั่งเชลล์จากสกิล "{0}" หรือไม่?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -177,6 +177,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title="{0}" becerisindeki kabuk komutları çalıştırılsın mı?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=Виконати команди оболонки з навички «{0}»?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
+1
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=要执行技能「{0}」的 shell 命令吗?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
+1
@@ -172,6 +172,7 @@ session.account.switcher=Switch account
|
||||
session.scroll.question=Scroll to question
|
||||
|
||||
session.permission.title.subagent=Permission required (subagent)
|
||||
session.permission.skillShell.title=要執行技能「{0}」的 shell 指令嗎?
|
||||
session.permission.run=Run
|
||||
session.permission.command=Command
|
||||
session.permission.patterns={0}:
|
||||
|
||||
+133
@@ -164,6 +164,139 @@ class PermissionViewTest : BasePlatformTestCase() {
|
||||
assertFalse("Should not show state message for PENDING, got: $text", text.contains("Run this command?"))
|
||||
}
|
||||
|
||||
fun `test skill-shell bash permission shows skill-named header and verbatim commands`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_skill",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = listOf("git status"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
raw = mapOf("skillShell" to "true", "skill" to "git-status"),
|
||||
skillCommands = listOf("git status", "printf hi"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected skill-named header, got: $text", text.contains("Run shell commands from skill \"git-status\"?"))
|
||||
assertFalse("Should not show generic header, got: $text", text.contains("Permission required"))
|
||||
val label = view.codeLabelsForTest().single()
|
||||
assertTrue("Expected first verbatim command, got: ${label.text}", label.text.contains("git status"))
|
||||
assertTrue("Expected second verbatim command, got: ${label.text}", label.text.contains("printf hi"))
|
||||
}
|
||||
|
||||
fun `test skill-shell permission without a skill name falls back to the generic header`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_skill_noname",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = listOf("printf hi"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
raw = mapOf("skillShell" to "true"),
|
||||
skillCommands = listOf("printf hi"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected fallback header, got: $text", text.contains("Permission required"))
|
||||
}
|
||||
|
||||
fun `test skill-shell external_directory sibling shows the directory target, not the command list`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_skill_dir",
|
||||
sessionId = "ses",
|
||||
name = "external_directory",
|
||||
patterns = listOf("/tmp/*"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
raw = mapOf("skillShell" to "true", "skill" to "git-status"),
|
||||
skillCommands = listOf("cd /tmp && pwd"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
// The header still names the skill — both asks in the batch carry the same metadata.
|
||||
assertTrue("Expected skill-named header, got: $text", text.contains("Run shell commands from skill \"git-status\"?"))
|
||||
val label = view.codeLabelsForTest().single()
|
||||
assertEquals("/tmp/*", label.text)
|
||||
assertFalse("Should not show the verbatim command list, got: ${label.text}", label.text.contains("cd /tmp"))
|
||||
}
|
||||
|
||||
fun `test skill-shell commands are escaped for control and bidi characters`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_skill_escape",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = listOf("printf hi"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
raw = mapOf("skillShell" to "true", "skill" to "git-status"),
|
||||
// \u202e (RLO) would otherwise reverse the trailing text in the prompt.
|
||||
skillCommands = listOf("rm \u202etxt.exe", "a\nb"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val label = view.codeLabelsForTest().single()
|
||||
assertTrue("Expected escaped RLO, got: ${label.text}", label.text.contains("rm \\u202etxt.exe"))
|
||||
assertTrue("Expected escaped newline, got: ${label.text}", label.text.contains("a\\nb"))
|
||||
assertFalse("Raw control char must not reach the label", label.text.contains("\u202e"))
|
||||
}
|
||||
|
||||
fun `test skill-shell header escapes control and bidi characters in the skill name`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_skill_name_escape",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = listOf("printf hi"),
|
||||
always = emptyList(),
|
||||
meta = PermissionMeta(
|
||||
// The skill name is untrusted SKILL.md frontmatter; a bidi override here
|
||||
// must not be able to reorder the header's attribution text.
|
||||
raw = mapOf("skillShell" to "true", "skill" to "git-status\u202e"),
|
||||
skillCommands = listOf("printf hi"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertTrue("Expected escaped RLO in header, got: $text", text.contains("git-status\\u202e"))
|
||||
assertFalse("Raw control char must not reach the header, got: $text", text.contains("git-status\u202e"))
|
||||
}
|
||||
|
||||
fun `test skill-shell permission never shows auto-approve rule toggles`() {
|
||||
view.show(
|
||||
Permission(
|
||||
id = "perm_skill_norules",
|
||||
sessionId = "ses",
|
||||
name = "bash",
|
||||
patterns = listOf("git status"),
|
||||
always = listOf("git status"),
|
||||
meta = PermissionMeta(
|
||||
raw = mapOf("skillShell" to "true", "skill" to "git-status"),
|
||||
skillCommands = listOf("git status"),
|
||||
// Simulates a future backend sending rule candidates alongside skillShell
|
||||
// metadata; approvals must still never be persisted for a skill batch.
|
||||
ruleDecisions = listOf(PermissionRuleCandidate("git status")),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val text = allText(view)
|
||||
assertFalse("Should not contain rules title, got: $text", text.contains("Auto-approve Rules"))
|
||||
assertFalse(view.rulesForTest().isVisible)
|
||||
assertEquals("Allow once", view.runButtonForTest().text)
|
||||
}
|
||||
|
||||
fun `test non-bash patterns show action and path in editor`() {
|
||||
view.show(
|
||||
Permission(
|
||||
|
||||
@@ -309,6 +309,8 @@ data class PermissionRequestDto(
|
||||
val ruleDecisions: List<PermissionRuleDecisionDto> = emptyList(),
|
||||
val filePath: String? = null,
|
||||
val fileDiffs: List<PermissionFileDiffDto> = emptyList(),
|
||||
// Verbatim skill-shell commands (metadata.commands) the prompt must display; empty for non-skill requests.
|
||||
val skillCommands: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
Reference in New Issue
Block a user