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:
bagatao@anaconda.com
2026-07-31 15:35:01 +02:00
committed by GitHub
26 changed files with 249 additions and 3 deletions
@@ -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()
@@ -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("""
@@ -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,
@@ -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(
@@ -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}:
@@ -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}:
@@ -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}:
@@ -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}:
@@ -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