mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:32:08 +08:00
Merge branch 'main' into abalone-bactrosaurus
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Apply the Agent Manager base branch picker selection to the active diff immediately. Changing the base branch now refreshes the diff against the new base instead of keeping the previous comparison until the scope or session changed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open an embedded terminal automatically when switching to a worktree without one.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Include the underlying reason in search execution failures instead of showing a bare "ripgrep execution failed" message.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running.
|
||||
@@ -165,11 +165,13 @@ export const layer = Layer.effect(
|
||||
)
|
||||
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
|
||||
return abortable.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof Error || cause instanceof InvalidPatternError
|
||||
? cause
|
||||
: failure("ripgrep execution failed", cause),
|
||||
),
|
||||
// kilocode_change start - surface the underlying reason instead of a bare wrapper message
|
||||
Effect.mapError((cause) => {
|
||||
if (cause instanceof Error || cause instanceof InvalidPatternError) return cause
|
||||
const detail = cause instanceof globalThis.Error && cause.message.trim() ? `: ${cause.message.trim()}` : ""
|
||||
return failure(`ripgrep execution failed${detail}`, cause)
|
||||
}),
|
||||
// kilocode_change end
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ export type Reply = typeof Reply.Type
|
||||
export const ReplyBody = Schema.Struct({
|
||||
reply: Reply,
|
||||
message: Schema.String.pipe(Schema.optional),
|
||||
// kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches
|
||||
interactive: Schema.Boolean.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "PermissionReplyBody" })
|
||||
export type ReplyBody = typeof ReplyBody.Type
|
||||
|
||||
|
||||
@@ -61,4 +61,18 @@ describe("Ripgrep", () => {
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
// kilocode_change start - surfaced error keeps the underlying reason
|
||||
it.live("includes the underlying reason in execution failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const error = yield* ripgrep
|
||||
.find({ cwd: process.cwd(), pattern: "*", limit: 1, signal: controller.signal })
|
||||
.pipe(Effect.flip)
|
||||
expect(error.message).toMatch(/^ripgrep execution failed: .+/)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
+1
@@ -1579,6 +1579,7 @@ object KiloCliDataParser {
|
||||
sb.append("""{"reply":${escape(reply.reply)}""")
|
||||
val msg = reply.message
|
||||
if (msg != null) sb.append(""","message":${escape(msg)}""")
|
||||
if (reply.interactive) sb.append(""","interactive":true""")
|
||||
sb.append("}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
+32
-7
@@ -722,7 +722,10 @@ class SessionController(
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" }
|
||||
cs.launch {
|
||||
try {
|
||||
if (!autoApprove) {
|
||||
// Skill-shell batches must be answered by a human: the server refuses
|
||||
// non-interactive approvals, so auto-approve must show the card (whose
|
||||
// manual reply sets interactive=true) rather than send a machine reply.
|
||||
if (!autoApprove || restore().meta.raw["skillShell"] == "true") {
|
||||
edt {
|
||||
if (disposed) return@edt
|
||||
model.setState(SessionState.AwaitingPermission(restore()))
|
||||
@@ -759,9 +762,16 @@ class SessionController(
|
||||
try {
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip }
|
||||
val count = replyAll(permissions)
|
||||
if (count == 0) return@launch
|
||||
// Skill-shell requests are skipped by replyAll; surface one as a card so it
|
||||
// isn't stranded (never machine-approved, never shown).
|
||||
val card = skillShellCard(permissions)?.let { toPermission(it) }
|
||||
if (count == 0 && card == null) return@launch
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (card != null) {
|
||||
updateModel { model.setState(SessionState.AwaitingPermission(card)) }
|
||||
return@runEdt
|
||||
}
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) {
|
||||
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
|
||||
@@ -777,6 +787,8 @@ class SessionController(
|
||||
var count = 0
|
||||
for (request in permissions) {
|
||||
if (!autoApprove) return count
|
||||
// Skill-shell batches need a human; skip them here (callers surface the card).
|
||||
if (request.metadata["skillShell"] == "true") continue
|
||||
sessions.replyPermission(request.id, directory, PermissionReplyDto("once"))
|
||||
capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain"))
|
||||
count++
|
||||
@@ -784,6 +796,11 @@ class SessionController(
|
||||
return count
|
||||
}
|
||||
|
||||
// A skill-shell request is never machine-approved (the server refuses non-interactive
|
||||
// approvals); after draining, callers must surface one as a card so a human can answer.
|
||||
private fun skillShellCard(permissions: List<PermissionRequestDto>): PermissionRequestDto? =
|
||||
permissions.lastOrNull { it.metadata["skillShell"] == "true" }
|
||||
|
||||
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
|
||||
assertEdt()
|
||||
val current = model.state
|
||||
@@ -1156,11 +1173,15 @@ class SessionController(
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
|
||||
if (permissions.isEmpty()) return
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
|
||||
if (autoApprove) {
|
||||
// A skill-shell request must surface as a card even under auto-approve (replyAll
|
||||
// skips it); prefer it over the last pending so a human can answer.
|
||||
val show = if (autoApprove) {
|
||||
replyAll(permissions)
|
||||
return
|
||||
skillShellCard(permissions) ?: return
|
||||
} else {
|
||||
skillShellCard(permissions) ?: permissions.last()
|
||||
}
|
||||
val last = toPermission(permissions.last())
|
||||
val last = toPermission(show)
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (child !in childIds) return@runEdt
|
||||
@@ -1201,9 +1222,12 @@ class SessionController(
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id }
|
||||
val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id }
|
||||
val status = sessions.statuses.value[id]
|
||||
// replyAll auto-approves the ordinary permissions and skips skill-shell ones. A
|
||||
// skill-shell request must then fall through to a human card rather than go Busy.
|
||||
val skillCard = skillShellCard(permissions)
|
||||
if (permissions.isNotEmpty() && autoApprove) {
|
||||
val count = replyAll(permissions)
|
||||
if (count > 0) {
|
||||
if (count > 0 && skillCard == null) {
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (sid != id) return@runEdt
|
||||
@@ -1226,7 +1250,8 @@ class SessionController(
|
||||
if (sid != id) return@runEdt
|
||||
updateModel {
|
||||
if (permissions.isNotEmpty()) {
|
||||
model.setState(SessionState.AwaitingPermission(toPermission(permissions.last())))
|
||||
// Prefer a skill-shell request (needs a human) over the last pending.
|
||||
model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last())))
|
||||
} else if (questions.isNotEmpty()) {
|
||||
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
|
||||
} else if (status != null) {
|
||||
|
||||
+1
-1
@@ -356,7 +356,7 @@ class PermissionView(
|
||||
card.setActionEnabled(ID_RUN, false)
|
||||
card.setActionEnabled(ID_DENY, false)
|
||||
rules.setControlsEnabled(false)
|
||||
reply(id, PermissionReplyDto(reply = "once"), rulePayload())
|
||||
reply(id, PermissionReplyDto(reply = "once", interactive = true), rulePayload())
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
|
||||
+42
@@ -254,6 +254,23 @@ class PromptLifecycleTest : SessionControllerTestBase() {
|
||||
)
|
||||
}
|
||||
|
||||
fun `test auto approve does not machine-reply a skill shell batch`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
edt { m.setAutoApprove(true) }
|
||||
// skill-shell batches must be answered by a human; auto-approve must show the card
|
||||
// instead of sending a non-interactive reply the server would refuse.
|
||||
emit(
|
||||
ChatEventDto.PermissionAsked(
|
||||
"ses_test",
|
||||
permission("perm1").copy(metadata = mapOf("skillShell" to "true")),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(rpc.permissionReplies.isEmpty())
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
}
|
||||
|
||||
fun `test disabling auto approve before reply restores awaiting permission`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
@@ -310,6 +327,31 @@ class PromptLifecycleTest : SessionControllerTestBase() {
|
||||
assertEquals("once", rpc.permissionReplies[0].third.reply)
|
||||
}
|
||||
|
||||
fun `test enabling auto approve surfaces a pending skill shell as a card`() {
|
||||
val (m, _, _) = prompted()
|
||||
rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true")))
|
||||
|
||||
edt { m.setAutoApprove(true) }
|
||||
flush()
|
||||
|
||||
// skill-shell must not be machine-approved; it surfaces as a human card instead
|
||||
assertTrue(rpc.permissionReplies.isEmpty())
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
}
|
||||
|
||||
fun `test recovery surfaces a pending skill shell as a card under auto approve`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true")))
|
||||
edt { KiloPluginSettings.setAutoApprove(true) }
|
||||
|
||||
val m = controller("ses_test")
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.permissionReplies.isEmpty())
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
}
|
||||
|
||||
fun `test auto approve drains pending permissions during recovery`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
|
||||
projectRpc.state.value = workspaceReady()
|
||||
|
||||
@@ -328,6 +328,8 @@ data class ToolRefDto(
|
||||
data class PermissionReplyDto(
|
||||
val reply: String,
|
||||
val message: String? = null,
|
||||
// Set when a human answered the prompt; the CLI ignores machine approvals of skill-shell batches.
|
||||
val interactive: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -805,9 +805,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.setDiffBaseBranch") {
|
||||
void this.diffs.setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch).then(() => {
|
||||
void this.sendDiffBranches(m.sessionId, m.scope)
|
||||
})
|
||||
void this.diffs
|
||||
.setBase(composeDiffId(m.sessionId, normalizeScope(m.scope)), m.branch)
|
||||
.catch((err) => this.log("Failed to set diff base:", err instanceof Error ? err.message : String(err)))
|
||||
.then(() => void this.sendDiffBranches(m.sessionId, m.scope))
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.openFile") {
|
||||
|
||||
@@ -34,6 +34,8 @@ export class WorktreeDiffController {
|
||||
private readonly controller: SourceController
|
||||
private target: Target | undefined
|
||||
private applying: string | undefined
|
||||
/** Intended watch mode for the active context; isPolling lags the initial fetch. */
|
||||
private poll = false
|
||||
/** Ephemeral per-context base override, keyed by context id. */
|
||||
private baseOverrides = new Map<string, string>()
|
||||
|
||||
@@ -184,6 +186,7 @@ export class WorktreeDiffController {
|
||||
public stop(): void {
|
||||
this.controller.stop()
|
||||
this.target = undefined
|
||||
this.poll = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,8 +198,15 @@ export class WorktreeDiffController {
|
||||
const { ctx } = parseDiffId(id)
|
||||
if (branch) this.baseOverrides.set(ctx, branch)
|
||||
else this.baseOverrides.delete(ctx)
|
||||
this.target = undefined
|
||||
await this.controller.reactivate()
|
||||
// Nothing to rebuild when the context isn't active; the override is
|
||||
// picked up the next time start()/request() resolves it.
|
||||
if (this.controller.currentId !== id) return
|
||||
// Route through activate() so the base is re-resolved and pushed via
|
||||
// setContext() — SourceController.reactivate() alone would rebuild the
|
||||
// source against the stale context captured by the last activate(). The
|
||||
// recorded poll intent preserves watch mode even when the initial fetch
|
||||
// is still in flight (isPolling only turns true once it resolves).
|
||||
await this.activate(id, this.poll, true)
|
||||
}
|
||||
|
||||
/** Branch picker data for a context's directory, using any active override. */
|
||||
@@ -210,6 +220,7 @@ export class WorktreeDiffController {
|
||||
|
||||
private async activate(id: string, poll: boolean, fetch: boolean): Promise<void> {
|
||||
this.target = undefined
|
||||
this.poll = poll
|
||||
await this.ready("stateReady rejected, continuing diff activate:")
|
||||
const { ctx } = parseDiffId(id)
|
||||
const resolved = await this.resolve(ctx)
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function handlePermissionResponse(
|
||||
}
|
||||
|
||||
const replyResult = await ctx.client.permission
|
||||
.reply({ requestID: permissionId, reply: response, directory: dir }, { throwOnError: true })
|
||||
.reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { throwOnError: true })
|
||||
.then(() => "ok" as const)
|
||||
.catch((error: unknown) => {
|
||||
if (isNotFoundError(error)) return "stale" as const
|
||||
|
||||
@@ -12,10 +12,12 @@ function scene(
|
||||
saved?: "vscode" | "agentManager"
|
||||
visible?: boolean
|
||||
focusedId?: string
|
||||
mac?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const calls = {
|
||||
requestSide: 0,
|
||||
ensureSide: 0,
|
||||
closed: [] as string[],
|
||||
hide: 0,
|
||||
refocus: 0,
|
||||
@@ -32,6 +34,7 @@ function scene(
|
||||
calls.requestSide++
|
||||
visible = true
|
||||
},
|
||||
ensureSide: () => calls.ensureSide++,
|
||||
closeSide: (terminalId) => {
|
||||
calls.closed.push(terminalId)
|
||||
focusedId = undefined
|
||||
@@ -50,6 +53,7 @@ function scene(
|
||||
openVscode: () => calls.openVscode++,
|
||||
saved: opts.saved,
|
||||
save: (destination) => calls.persisted.push(destination),
|
||||
mac: opts.mac,
|
||||
})
|
||||
if (opts.destination) ctl.syncDefault(opts.destination)
|
||||
return { ctl, calls }
|
||||
@@ -73,6 +77,29 @@ describe("Agent Manager side terminal controller", () => {
|
||||
expect(hidden.calls.hide).toBe(0)
|
||||
})
|
||||
|
||||
it("ensures an open terminal panel has a terminal after switching contexts", async () => {
|
||||
const visible = scene({ visible: true })
|
||||
visible.ctl.syncContext("wt-2", "wt-1")
|
||||
await Promise.resolve()
|
||||
expect(visible.calls.ensureSide).toBe(1)
|
||||
|
||||
visible.ctl.syncContext("wt-2", "wt-2")
|
||||
visible.ctl.syncContext("wt-2", undefined)
|
||||
await Promise.resolve()
|
||||
expect(visible.calls.ensureSide).toBe(2)
|
||||
expect(visible.calls.requestSide).toBe(0)
|
||||
|
||||
const hidden = scene()
|
||||
hidden.ctl.syncContext("wt-2", "wt-1")
|
||||
expect(hidden.calls.ensureSide).toBe(0)
|
||||
|
||||
const closed = scene({ visible: true })
|
||||
closed.ctl.syncContext("wt-2", "wt-1")
|
||||
closed.ctl.toggle()
|
||||
await Promise.resolve()
|
||||
expect(closed.calls.ensureSide).toBe(0)
|
||||
})
|
||||
|
||||
it("kills the focused terminal and refocuses the chat", () => {
|
||||
const focused = scene({ focusedId: "terminal:two" })
|
||||
expect(focused.ctl.close()).toBe(true)
|
||||
@@ -99,32 +126,67 @@ describe("Agent Manager side terminal controller", () => {
|
||||
expect(panelFirst.calls.openVscode).toBe(0)
|
||||
})
|
||||
|
||||
it("handles Cmd/Ctrl+/ presses locally and dedupes the extension echo", () => {
|
||||
const press = (key: string, opts: Partial<KeyboardEvent> = {}) =>
|
||||
({ key, metaKey: true, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent
|
||||
it("handles the platform terminal shortcut locally and dedupes the extension echo", () => {
|
||||
const press = (opts: Partial<KeyboardEvent> = {}) =>
|
||||
({ key: "/", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent
|
||||
|
||||
const item = scene({ destination: "agentManager" })
|
||||
expect(item.ctl.press(press("/"))).toBe(true)
|
||||
expect(item.calls.requestSide).toBe(1)
|
||||
// The extension echoes the same keypress back as an action message;
|
||||
// it must be ignored so the panel does not toggle twice.
|
||||
expect(item.ctl.echo()).toBe(true)
|
||||
// macOS: the workbench binding is Cmd+/, so only Cmd is accepted.
|
||||
const mac = scene({ destination: "agentManager", mac: true })
|
||||
expect(mac.ctl.press(press({ metaKey: true }))).toBe(true)
|
||||
expect(mac.calls.requestSide).toBe(1)
|
||||
expect(mac.ctl.press(press({ ctrlKey: true }))).toBe(false)
|
||||
expect(mac.ctl.press(press({ metaKey: true, ctrlKey: true }))).toBe(false)
|
||||
expect(mac.calls.requestSide).toBe(1)
|
||||
|
||||
// Windows/Linux: the workbench binding is Ctrl+/, so only Ctrl is accepted.
|
||||
const win = scene({ destination: "agentManager", mac: false })
|
||||
expect(win.ctl.press(press({ ctrlKey: true }))).toBe(true)
|
||||
expect(win.calls.requestSide).toBe(1)
|
||||
expect(win.ctl.press(press({ metaKey: true }))).toBe(false)
|
||||
expect(win.calls.requestSide).toBe(1)
|
||||
|
||||
// Unrelated keys and modifier combinations are not the shortcut.
|
||||
const other = scene({ destination: "agentManager" })
|
||||
expect(other.ctl.press(press("?"))).toBe(false)
|
||||
expect(other.ctl.press(press("/", { shiftKey: true }))).toBe(false)
|
||||
expect(other.ctl.press(press("/", { altKey: true }))).toBe(false)
|
||||
expect(other.ctl.press(press("/", { metaKey: false }))).toBe(false)
|
||||
expect(other.ctl.press(press("/", { metaKey: false, ctrlKey: true }))).toBe(true)
|
||||
expect(other.calls.requestSide).toBe(1)
|
||||
expect(win.ctl.press(press({ key: "?" }))).toBe(false)
|
||||
expect(win.ctl.press(press({ ctrlKey: true, shiftKey: true }))).toBe(false)
|
||||
expect(win.ctl.press(press({ ctrlKey: true, altKey: true }))).toBe(false)
|
||||
expect(win.calls.requestSide).toBe(1)
|
||||
|
||||
// The extension echoes each locally handled keypress back as an action
|
||||
// message; one echo is consumed per press, then invocations run again.
|
||||
expect(mac.ctl.echo()).toBe(true)
|
||||
expect(mac.ctl.echo()).toBe(false)
|
||||
})
|
||||
|
||||
it("stops deduping after the echo window passes", async () => {
|
||||
const item = scene({ destination: "agentManager" })
|
||||
it("consumes one echo per press, even for rapid repeated presses", () => {
|
||||
const item = scene({ destination: "agentManager", mac: true })
|
||||
const press = () => item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
|
||||
press()
|
||||
press()
|
||||
// Two presses toggled the panel open and closed again; both echoes
|
||||
// must still be consumed so neither press toggles a third time.
|
||||
expect(item.calls.requestSide).toBe(1)
|
||||
expect(item.calls.hide).toBe(1)
|
||||
expect(item.ctl.echo()).toBe(true)
|
||||
expect(item.ctl.echo()).toBe(true)
|
||||
expect(item.ctl.echo()).toBe(false)
|
||||
})
|
||||
|
||||
it("drops a never-arriving echo after the timeout safety valve", async () => {
|
||||
const item = scene({ destination: "agentManager", mac: true })
|
||||
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
|
||||
await new Promise((resolve) => setTimeout(resolve, 550))
|
||||
expect(item.ctl.echo()).toBe(false)
|
||||
expect(item.ctl.echo()).toBe(false)
|
||||
})
|
||||
|
||||
it("expires a dropped echo's backlog at the next spaced press", async () => {
|
||||
const item = scene({ destination: "agentManager", mac: true })
|
||||
// First press's echo never arrives (dropped forwarding); its backlog
|
||||
// must not outlive the echo window into the next press.
|
||||
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
|
||||
await new Promise((resolve) => setTimeout(resolve, 550))
|
||||
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
|
||||
expect(item.ctl.echo()).toBe(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 550))
|
||||
expect(item.ctl.echo()).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -186,6 +186,23 @@ describe("Agent Manager terminal state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("ensures a side terminal without revealing the panel", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene("wt-1")
|
||||
item.handlers.ensureSide()
|
||||
item.handlers.ensureSide()
|
||||
|
||||
expect(item.events.shown).toEqual([])
|
||||
expect(item.posted).toHaveLength(1)
|
||||
expect(item.posted[0]).toMatchObject({
|
||||
type: "agentManager.terminal.create",
|
||||
placement: "side",
|
||||
worktreeId: "wt-1",
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("supports several side terminals per context with newest active", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
@@ -242,6 +259,30 @@ describe("Agent Manager terminal state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps only the target side terminal on close others", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
|
||||
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" })
|
||||
item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" })
|
||||
// Another context must survive untouched: "others" is per context.
|
||||
item.state.add("wt-1", { id: "terminal:other", title: "Other", wsUrl: "ws://other", font, placement: "side" })
|
||||
item.state.setSideActive(LOCAL, "terminal:one")
|
||||
|
||||
item.handlers.closeSideOthers("terminal:two")
|
||||
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:two"])
|
||||
expect(item.state.sidesForContext("wt-1").map((term) => term.id)).toEqual(["terminal:other"])
|
||||
// The survivor becomes visible and focused, like selecting its tab.
|
||||
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
|
||||
expect(item.state.focusRequest()?.id).toBe("terminal:two")
|
||||
expect(item.posted).toEqual([
|
||||
{ type: "agentManager.terminal.close", terminalId: "terminal:one" },
|
||||
{ type: "agentManager.terminal.close", terminalId: "terminal:three" },
|
||||
])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("waits for Run closure confirmation while user terminal closes stay optimistic", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
|
||||
@@ -141,7 +141,9 @@ describe("handlePermissionResponse", () => {
|
||||
|
||||
await handlePermissionResponse(fake, "p1", "s1", "once", [], [])
|
||||
|
||||
expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }])
|
||||
expect(replies).toEqual([
|
||||
{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it("saves selected rules and replies in the recorded SSE directory", async () => {
|
||||
@@ -158,7 +160,9 @@ describe("handlePermissionResponse", () => {
|
||||
deniedAlways: ["rm *"],
|
||||
},
|
||||
])
|
||||
expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }])
|
||||
expect(replies).toEqual([
|
||||
{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it("treats an SDK-wrapped 404 while saving rules as stale", async () => {
|
||||
@@ -192,7 +196,9 @@ describe("handlePermissionResponse", () => {
|
||||
|
||||
await handlePermissionResponse(fake, "p1", "s1", "once", [], [])
|
||||
|
||||
expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }])
|
||||
expect(replies).toEqual([
|
||||
{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
|
||||
])
|
||||
expect(permDirs.has("p1")).toBe(false)
|
||||
expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { WorktreeDiffController } from "../../src/agent-manager/worktree-diff-controller"
|
||||
import type { DiffSourceCatalog } from "../../src/diff/sources/catalog"
|
||||
import type { DiffSource } from "../../src/diff/sources/types"
|
||||
import type { PanelContext } from "../../src/diff/types"
|
||||
import type { GitOps } from "../../src/agent-manager/GitOps"
|
||||
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
// Records every PanelContext handed to catalog.build so tests can assert which
|
||||
// base branch the active source was (re)built with. The controller, scope
|
||||
// resolution, and SourceController lifecycle under test are all real.
|
||||
function make(onFetch?: (n: number) => Promise<void>) {
|
||||
const builds: { id: string; ctx: PanelContext }[] = []
|
||||
let fetches = 0
|
||||
const catalog = {
|
||||
build: (id: string, ctx: PanelContext): DiffSource => {
|
||||
builds.push({ id, ctx })
|
||||
return {
|
||||
descriptor: { id, type: "workspace", group: "Git", capabilities: { revert: true, comments: true } },
|
||||
async fetch() {
|
||||
await onFetch?.(++fetches)
|
||||
return { diffs: [] }
|
||||
},
|
||||
}
|
||||
},
|
||||
} as unknown as DiffSourceCatalog
|
||||
|
||||
const state = {
|
||||
getSession: (id: string) => (id === "s1" ? { id: "s1", worktreeId: "w1", createdAt: "" } : undefined),
|
||||
getWorktree: (id: string) =>
|
||||
id === "w1" ? { id: "w1", path: "/wt", parentBranch: "main", remote: "origin" } : undefined,
|
||||
} as unknown as WorktreeStateManager
|
||||
|
||||
const controller = new WorktreeDiffController({
|
||||
getState: () => state,
|
||||
getRoot: () => "/repo",
|
||||
getStateReady: () => undefined,
|
||||
catalog,
|
||||
git: {} as GitOps,
|
||||
localDiffFile: async () => null,
|
||||
post: () => {},
|
||||
log: () => {},
|
||||
})
|
||||
return { controller, builds }
|
||||
}
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
async function waitFor(cond: () => boolean): Promise<void> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (cond()) return
|
||||
await tick()
|
||||
}
|
||||
throw new Error("waitFor timed out")
|
||||
}
|
||||
|
||||
describe("WorktreeDiffController.setBase", () => {
|
||||
it("rebuilds the active source against the overridden base branch", async () => {
|
||||
const { controller, builds } = make()
|
||||
controller.start("s1#branch")
|
||||
await waitFor(() => builds.length === 1)
|
||||
expect(builds[0]!.ctx.dir).toBe("/wt")
|
||||
expect(builds[0]!.ctx.baseBranch).toBe("origin/main")
|
||||
|
||||
await controller.setBase("s1#branch", "feature-x")
|
||||
expect(builds.length).toBe(2)
|
||||
expect(builds[1]!.ctx.dir).toBe("/wt")
|
||||
expect(builds[1]!.ctx.baseBranch).toBe("feature-x")
|
||||
|
||||
// Clearing the override falls back to the recorded parent ref.
|
||||
await controller.setBase("s1#branch", undefined)
|
||||
expect(builds.length).toBe(3)
|
||||
expect(builds[2]!.ctx.baseBranch).toBe("origin/main")
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
it("stores the override without rebuilding when the context isn't active", async () => {
|
||||
const { controller, builds } = make()
|
||||
|
||||
await controller.setBase("s1#branch", "feature-x")
|
||||
expect(builds.length).toBe(0)
|
||||
|
||||
// The next activation of that context resolves the stored override.
|
||||
controller.start("s1#branch")
|
||||
await waitFor(() => builds.length === 1)
|
||||
expect(builds[0]!.ctx.baseBranch).toBe("feature-x")
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
|
||||
it("keeps watching when the base changes during the initial fetch", async () => {
|
||||
// Hold the first activation's fetch in flight, simulating a slow worktree
|
||||
// diff. isPolling is still false in this window, but the watch intent must
|
||||
// survive the base change rather than downgrading the panel to one-shot.
|
||||
let release: () => void = () => {}
|
||||
const gate = new Promise<void>((resolve) => (release = resolve))
|
||||
const { controller, builds } = make(async (n) => {
|
||||
if (n === 1) await gate
|
||||
})
|
||||
|
||||
controller.start("s1#branch")
|
||||
await waitFor(() => builds.length === 1)
|
||||
|
||||
const change = controller.setBase("s1#branch", "feature-x")
|
||||
release()
|
||||
await change
|
||||
expect(builds.length).toBe(2)
|
||||
expect(builds[1]!.ctx.baseBranch).toBe("feature-x")
|
||||
|
||||
// Polling survives: start() early-returns for an id that is already
|
||||
// watched. A downgraded one-shot panel would re-activate and rebuild here.
|
||||
controller.start("s1#branch")
|
||||
await tick()
|
||||
expect(builds.length).toBe(2)
|
||||
|
||||
controller.stop()
|
||||
})
|
||||
})
|
||||
@@ -1233,7 +1233,9 @@ const AgentManagerContent: Component = () => {
|
||||
onSideCreated: (contextKey, terminalId) => {
|
||||
// Focus only when the user is still looking at this panel —
|
||||
// a slow create landing after a mode switch must not steal it.
|
||||
if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId)
|
||||
if (sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) {
|
||||
terms.requestFocus(terminalId)
|
||||
}
|
||||
},
|
||||
onScriptRunning: (contextKey, terminalId) => {
|
||||
if (terms.sideKey() !== contextKey) return
|
||||
@@ -1942,7 +1944,7 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const sideCtl = createSideTerminal({
|
||||
handlers: termHandlers,
|
||||
visible: () => sidePanel() === "terminal",
|
||||
visible: () => sidePanel() === "terminal" && !history() && !reviewActive(),
|
||||
focusedId: () => terms.sideFocusedId(),
|
||||
hide: () => setSidePanel(null),
|
||||
refocus: () => window.dispatchEvent(new Event("focusPrompt")),
|
||||
@@ -1960,6 +1962,7 @@ const AgentManagerContent: Component = () => {
|
||||
) as never,
|
||||
),
|
||||
})
|
||||
createEffect(on(terms.sideKey, (key, previous) => sideCtl.syncContext(key, previous), { defer: true }))
|
||||
|
||||
const handleReviewTabMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 1) return
|
||||
@@ -2510,6 +2513,7 @@ const AgentManagerContent: Component = () => {
|
||||
visible={() => sidePanel() === "terminal"}
|
||||
onSelect={(id) => termHandlers.selectSide(id)}
|
||||
onClose={(id) => termHandlers.closeSide(id)}
|
||||
onCloseOthers={(id) => termHandlers.closeSideOthers(id)}
|
||||
onStart={() => termHandlers.addSide()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4805,15 +4805,17 @@ body.vscode-high-contrast-light {
|
||||
|
||||
/* Side terminal tab strip — one row of tabs reusing the top bar's
|
||||
.am-tab chrome, plus the "+" action. Height matches .am-diff-header
|
||||
(32px: 4px padding + 24px content) so switching inspector modes does
|
||||
not shift the panel chrome. The strip itself never scrolls; the tab
|
||||
list does, so a narrow panel never pushes the "+" action out of view
|
||||
(same split as .am-tab-list-wrap / .am-tab-add-wrap). */
|
||||
(32px) so switching inspector modes does not shift the panel chrome.
|
||||
No vertical padding: tabs fill the strip like they fill .am-tab-bar,
|
||||
which also keeps the "+" optically centered. The strip itself never
|
||||
scrolls; the tab list does, so a narrow panel never pushes the "+"
|
||||
action out of view (same split as .am-tab-list-wrap /
|
||||
.am-tab-add-wrap). */
|
||||
.am-side-terminal-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 32px;
|
||||
padding: 4px 4px 0;
|
||||
padding: 0 4px;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
@@ -4822,11 +4824,20 @@ body.vscode-high-contrast-light {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* Same width model as .am-tab-list: tabs claim an equal share of the
|
||||
strip up to a maximum, and the list itself only grows as wide as its
|
||||
tabs, so the "+" action stays glued to the last tab instead of
|
||||
drifting to the far edge of a wide panel. The cap is smaller than the
|
||||
top bar's 240px because the panel is narrow. */
|
||||
.am-side-terminal-tablist {
|
||||
--am-tab-max-width: 180px;
|
||||
--am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width));
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
/* No gap, like .am-tab-list: equal-share tab widths already consume
|
||||
the full width, so any gap would leave the list a few pixels
|
||||
scrollable and keep the overflow fade lit for nothing. */
|
||||
flex: 0 1 calc(var(--tab-count, 1) * var(--am-tab-max-width));
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
@@ -4838,20 +4849,20 @@ body.vscode-high-contrast-light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Each tab shares the available width and shrinks with ellipsis.
|
||||
touch-action unlocks pointer-based drag reordering (same as
|
||||
.am-tab-sortable). */
|
||||
.am-side-terminal-tab {
|
||||
display: flex;
|
||||
flex: 0 1 140px;
|
||||
min-width: 64px;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
.am-side-terminal-tablist[data-tab-widths-frozen] .am-tab-sortable {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* The left divider marks a terminal among session tabs in the top bar.
|
||||
Every tab here is a terminal, so it would just be noise. */
|
||||
.am-side-terminal-tablist .am-tab-terminal {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.am-side-terminal-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
@@ -6,19 +6,24 @@
|
||||
* and one width.
|
||||
*
|
||||
* A context can own several side terminals. The header is a tab strip
|
||||
* that reuses the top tab bar's `TerminalTabChrome` (same `am-tab*`
|
||||
* structure, same X close button) plus a `+` action to add terminals.
|
||||
* Tabs are drag-sortable via the same `@thisbeyond/solid-dnd` stack as
|
||||
* the top tab bar; the order lives in the terminal state, so it is
|
||||
* that reuses the top tab bar's whole chrome: `SortableTerminalTab`
|
||||
* (icon, title, X close, right-click Close / Close Others), the same
|
||||
* `@thisbeyond/solid-dnd` reorder stack, the same overflow scrolling
|
||||
* with edge fades, the same width freeze while tabs close, and the same
|
||||
* arrow-key tab navigation, so a terminal behaves identically in
|
||||
* either surface. Reorder state lives in the terminal state, so it is
|
||||
* preserved across sidebar context switches for the webview's lifetime.
|
||||
* The strip stays visible even when empty so the `+` action is always
|
||||
* reachable.
|
||||
*
|
||||
* The `+` action sits directly after the last tab (outside the
|
||||
* scrolling region, like the tab bar's `am-tab-add-wrap`), so it never
|
||||
* scrolls away and never drifts to the far edge of a wide panel. The
|
||||
* strip stays visible even when empty so `+` is always reachable.
|
||||
*
|
||||
* Visibility is opacity-based, never unmount: the xterm render loop
|
||||
* dies when its subtree leaves the paint tree (see `render.tsx`).
|
||||
*/
|
||||
|
||||
import type { Accessor, Component } from "solid-js"
|
||||
import type { Accessor, Component, JSX } from "solid-js"
|
||||
import { For, Show, createEffect, createSignal } from "solid-js"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
@@ -27,11 +32,17 @@ import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { useLanguage } from "../../src/context/language"
|
||||
import { ConstrainDragYAxis, SortableTabContainer } from "../../src/components/chat/TabDnd"
|
||||
import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd"
|
||||
import { useTabScroll } from "../../src/utils/tab-scroll"
|
||||
import { setTabWidths } from "../../src/utils/tab-widths"
|
||||
import { createTabFocus } from "../../src/utils/tab-navigation"
|
||||
import { renderSideTerminalLayer } from "./render"
|
||||
import { TerminalTabChrome } from "./SortableTerminalTab"
|
||||
import { SortableTerminalTab } from "./SortableTerminalTab"
|
||||
import type { TerminalStateControls } from "./state"
|
||||
|
||||
/** Only this strip's tabs freeze; the top tab bar keeps its own widths. */
|
||||
const TABLIST = ".am-side-terminal-tablist"
|
||||
|
||||
interface Props {
|
||||
state: TerminalStateControls
|
||||
/** Context the panel currently shows (`state.sideKey`). */
|
||||
@@ -42,6 +53,8 @@ interface Props {
|
||||
onSelect: (terminalId: string) => void
|
||||
/** Kill one terminal. */
|
||||
onClose: (terminalId: string) => void
|
||||
/** Kill every terminal of this context except the given one. */
|
||||
onCloseOthers: (terminalId: string) => void
|
||||
/** Create a new side terminal for this context. */
|
||||
onStart: () => void
|
||||
}
|
||||
@@ -49,13 +62,45 @@ interface Props {
|
||||
export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
let panel!: HTMLElement
|
||||
let strip!: HTMLDivElement
|
||||
createEffect(() => {
|
||||
panel.inert = !props.visible()
|
||||
})
|
||||
const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>()
|
||||
const sides = () => props.state.sidesForContext(props.contextKey())
|
||||
const ids = () => sides().map((term) => term.id)
|
||||
const active = () => props.state.sideActiveFor(props.contextKey())
|
||||
const pending = () => props.state.pendingSide(props.contextKey())
|
||||
const scroll = useTabScroll(ids, active)
|
||||
// Scoped to `strip` so arrow keys and focus restore never jump to a
|
||||
// tab in the top bar, which uses the same role="tab" markup.
|
||||
const focus = createTabFocus({ ids, select: props.onSelect, root: () => strip })
|
||||
// Only freeze while the pointer is over the strip: the widths must
|
||||
// survive until the pointer leaves, so the remaining X buttons stay
|
||||
// put across repeated closes. Releasing on the next frame would undo
|
||||
// the freeze before it is ever painted (rAF runs before paint).
|
||||
// "Close others" needs none of this: its context menu is portaled, so
|
||||
// the pointer is off the strip, and the survivor spans the strip anyway.
|
||||
const freeze = () => {
|
||||
if (strip.closest(".am-side-terminal-tabs")?.matches(":hover")) setTabWidths(true, document, TABLIST)
|
||||
}
|
||||
const release = () => setTabWidths(false, document, TABLIST)
|
||||
const close = (id: string) => {
|
||||
freeze()
|
||||
props.onClose(id)
|
||||
// Restore focus inside the strip only while it still owns a tab.
|
||||
// Falling through to `focusPrompt` would pull focus into the chat
|
||||
// composer while the panel is still open on its empty state.
|
||||
if (ids().length > 0) focus.restore()
|
||||
}
|
||||
// Adding a tab shrinks every tab's equal share, so any freeze left
|
||||
// over from a close in the same hover has to go first. `+` lives
|
||||
// inside the strip, so no pointerleave happens between the two
|
||||
// clicks and the surviving tabs would keep their wider pixel widths.
|
||||
const start = () => {
|
||||
release()
|
||||
props.onStart()
|
||||
}
|
||||
const onDragStart = (event: DragEvent) => {
|
||||
const id = event.draggable?.id
|
||||
if (typeof id !== "string") return
|
||||
@@ -63,9 +108,13 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
// min-width, so a long OSC title would otherwise overflow it and
|
||||
// shift the visual center off the cursor (the "drag offset" bug).
|
||||
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
|
||||
setTabWidths(true, document, TABLIST)
|
||||
setDragging({ id, width })
|
||||
}
|
||||
const onDragEnd = () => setDragging(undefined)
|
||||
const onDragEnd = () => {
|
||||
setDragging(undefined)
|
||||
release()
|
||||
}
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
const from = event.draggable?.id
|
||||
const to = event.droppable?.id
|
||||
@@ -79,7 +128,12 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
aria-label={t("agentManager.tab.terminal")}
|
||||
aria-hidden={!props.visible()}
|
||||
>
|
||||
<div class="am-side-terminal-tabs">
|
||||
<div
|
||||
class="am-side-terminal-tabs"
|
||||
onPointerLeave={() => {
|
||||
if (!dragging()) release()
|
||||
}}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
@@ -88,42 +142,56 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
{/* Scrollable tab list — mirrors the top bar's .am-tab-list split
|
||||
so the "+" action never scrolls away. role="tablist" only
|
||||
when tabs exist: axe aria-required-children rejects an empty
|
||||
tablist (and non-tab children like the add button). */}
|
||||
<div
|
||||
class="am-side-terminal-tablist"
|
||||
role={sides().length > 0 ? "tablist" : undefined}
|
||||
aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined}
|
||||
>
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={sides()}>
|
||||
{(term) => (
|
||||
<SortableTabContainer id={term.id} class="am-side-terminal-tab">
|
||||
<TerminalTabChrome
|
||||
label={props.state.title(term.id) ?? term.title}
|
||||
tooltip={props.state.title(term.id) ?? term.title}
|
||||
status={props.state.scriptStatus(term.id)}
|
||||
active={props.state.sideActiveFor(props.contextKey()) === term.id}
|
||||
role="tab"
|
||||
selected={props.state.sideActiveFor(props.contextKey()) === term.id}
|
||||
onSelect={() => props.onSelect(term.id)}
|
||||
onMiddleClick={(e: MouseEvent) => {
|
||||
if (e.button !== 1) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
props.onClose(term.id)
|
||||
}}
|
||||
onClose={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onClose(term.id)
|
||||
}}
|
||||
/>
|
||||
</SortableTabContainer>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
{/* Overflow chrome copied from the top tab bar: the list is the
|
||||
only scrolling element, wrapped by a fade host, so the "+"
|
||||
action stays pinned next to the last tab. */}
|
||||
<div class="am-tab-scroll-area">
|
||||
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
|
||||
<div class="am-tab-list-wrap">
|
||||
{/* role="tablist" only when tabs exist: axe
|
||||
aria-required-children rejects an empty tablist. */}
|
||||
<div
|
||||
class="am-side-terminal-tablist"
|
||||
ref={(el) => {
|
||||
strip = el
|
||||
scroll.setRef(el)
|
||||
}}
|
||||
role={sides().length > 0 ? "tablist" : undefined}
|
||||
aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined}
|
||||
style={{ "--tab-count": `${sides().length}` } as JSX.CSSProperties}
|
||||
>
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={sides()}>
|
||||
{(term) => (
|
||||
<SortableTerminalTab
|
||||
id={term.id}
|
||||
label={props.state.title(term.id) ?? term.title}
|
||||
tooltip={props.state.title(term.id) ?? term.title}
|
||||
status={props.state.scriptStatus(term.id)}
|
||||
active={active() === term.id}
|
||||
role="tab"
|
||||
selected={active() === term.id}
|
||||
tabIndex={active() === term.id ? 0 : -1}
|
||||
onKeyDown={(event) => focus.key(term.id, event)}
|
||||
onSelect={() => props.onSelect(term.id)}
|
||||
onMiddleClick={(e: MouseEvent) => {
|
||||
if (e.button !== 1) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
close(term.id)
|
||||
}}
|
||||
onClose={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
close(term.id)
|
||||
}}
|
||||
onCloseOthers={() => props.onCloseOthers(term.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
|
||||
</div>
|
||||
{/* Cursor-following clone of the dragged tab (same pattern as
|
||||
the top tab bar). The overlay is what makes the in-list
|
||||
@@ -147,7 +215,7 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.terminal.add")}
|
||||
onClick={props.onStart}
|
||||
onClick={start}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* `TerminalTabChrome` is the shared visual tab: console icon, title,
|
||||
* tooltip/keybinding hints, and the X close button — the same
|
||||
* `am-tab*` structure the session tabs use. `SortableTerminalTab`
|
||||
* wraps it with drag-and-drop and a right-click context menu for the
|
||||
* top tab bar; the side terminal panel uses the chrome directly so
|
||||
* both surfaces render identical terminal tabs.
|
||||
* wraps it with drag-and-drop and a right-click context menu; both the
|
||||
* top tab bar and the side terminal panel render that wrapper, so a
|
||||
* terminal tab behaves identically in either surface.
|
||||
*/
|
||||
|
||||
import { Component, Show, type JSX } from "solid-js"
|
||||
|
||||
@@ -61,6 +61,7 @@ export function resolveVscodeTerminalRequest(
|
||||
|
||||
interface Handlers {
|
||||
requestSide(): void
|
||||
ensureSide(): void
|
||||
closeSide(terminalId: string): boolean
|
||||
}
|
||||
|
||||
@@ -83,6 +84,9 @@ export interface SideTerminalDeps {
|
||||
saved: TerminalDestination | undefined
|
||||
/** Persist the panel-local choice so it survives webview reloads. */
|
||||
save: (destination: TerminalDestination) => void
|
||||
/** Platform override for tests; the workbench keybinding is Cmd on
|
||||
* macOS and Ctrl elsewhere, and the local fallback must match it. */
|
||||
mac?: boolean
|
||||
}
|
||||
|
||||
export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
@@ -109,6 +113,14 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
deps.handlers.requestSide()
|
||||
}
|
||||
|
||||
/** Keep an open terminal panel useful when its worktree context changes. */
|
||||
const syncContext = (key: string, previous: string | undefined) => {
|
||||
if (key === previous || !deps.visible()) return
|
||||
queueMicrotask(() => {
|
||||
if (deps.visible()) deps.handlers.ensureSide()
|
||||
})
|
||||
}
|
||||
|
||||
/** Kill the focused side terminal (Cmd/Ctrl+W). The panel stays open
|
||||
* on the remaining terminals, or on the empty state when this was
|
||||
* the last one. */
|
||||
@@ -162,23 +174,44 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
/**
|
||||
* Cmd/Ctrl+/ pressed while the webview holds DOM focus. VS Code normally
|
||||
* forwards the keybinding to the workbench too, and the extension echoes
|
||||
* it back as a showTerminal action message; `echo()` lets the action
|
||||
* handler skip that duplicate so one keypress never toggles twice.
|
||||
* it back as a showTerminal action message; `echo()` consumes one pending
|
||||
* echo per press so one keypress never toggles twice while an unrelated
|
||||
* invocation (command palette, terminal-focused press) still runs.
|
||||
* Handling the key locally keeps the shortcut working when the
|
||||
* forwarding path drops it (e.g. the chat prompt input is focused).
|
||||
* The modifier matches the declared keybinding: Cmd on macOS, Ctrl
|
||||
* elsewhere — accepting both would hijack the other platform's combo
|
||||
* (and any user keybinding on it) without a matching workbench binding.
|
||||
*/
|
||||
const mac = deps.mac ?? (typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent))
|
||||
let pending = 0
|
||||
let lastPress = 0
|
||||
const ECHO_MS = 500
|
||||
|
||||
const press = (e: KeyboardEvent): boolean => {
|
||||
if (e.key !== "/" || !(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return false
|
||||
const mod = mac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
|
||||
if (e.key !== "/" || e.shiftKey || e.altKey || !mod) return false
|
||||
// An echo either arrives promptly or never; drop the backlog of a
|
||||
// dropped echo so it cannot swallow a later unrelated invocation.
|
||||
if (Date.now() - lastPress > ECHO_MS) pending = 0
|
||||
pending++
|
||||
lastPress = Date.now()
|
||||
openPreferred("keyboard_shortcut")
|
||||
return true
|
||||
}
|
||||
|
||||
/** True while an incoming showTerminal action is the echo of `press`. */
|
||||
const echo = () => Date.now() - lastPress < ECHO_MS
|
||||
/** Consumes one pending extension echo of a local `press`. The timeout is
|
||||
* only a safety valve for an echo that never arrives; an echo that
|
||||
* outlasts it is indistinguishable from a real invocation and must run. */
|
||||
const echo = () => {
|
||||
if (pending === 0) return false
|
||||
if (Date.now() - lastPress > ECHO_MS) {
|
||||
pending = 0
|
||||
return false
|
||||
}
|
||||
pending--
|
||||
return true
|
||||
}
|
||||
|
||||
return { destination, syncDefault, toggle, close, openPreferred, choose, press, echo }
|
||||
return { destination, syncDefault, syncContext, toggle, close, openPreferred, choose, press, echo }
|
||||
}
|
||||
|
||||
@@ -618,14 +618,8 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Always create a fresh side terminal for the current context (the
|
||||
* panel's `+` action and empty state). Multiple creates may be in
|
||||
* flight at once; each lands as its own tab in the panel strip.
|
||||
*/
|
||||
const addSide = () => {
|
||||
const createSide = () => {
|
||||
const key = deps.state.sideKey()
|
||||
deps.onShowSide(key)
|
||||
const id = newId()
|
||||
deps.state.beginSide(key, id)
|
||||
deps.postMessage({
|
||||
@@ -636,6 +630,23 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Always create a fresh side terminal for the current context (the
|
||||
* panel's `+` action and empty state). Multiple creates may be in
|
||||
* flight at once; each lands as its own tab in the panel strip.
|
||||
*/
|
||||
const addSide = () => {
|
||||
deps.onShowSide(deps.state.sideKey())
|
||||
createSide()
|
||||
}
|
||||
|
||||
/** Ensure the current context has a terminal without changing panel mode. */
|
||||
const ensureSide = () => {
|
||||
const key = deps.state.sideKey()
|
||||
if (deps.state.sidesForContext(key).length > 0 || deps.state.pendingSide(key)) return
|
||||
createSide()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the side panel and focus the context's active side terminal,
|
||||
* creating one when the context has none. Never touches the tab strip
|
||||
@@ -651,8 +662,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
deps.state.requestFocus(active)
|
||||
return
|
||||
}
|
||||
if (deps.state.pendingSide(key)) return
|
||||
addSide()
|
||||
ensureSide()
|
||||
}
|
||||
|
||||
const closeTerminal = (terminalId: string) => {
|
||||
@@ -715,6 +725,22 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill every side terminal of a context except one, and make the
|
||||
* survivor the visible tab: the side-strip counterpart of the tab
|
||||
* bar's "Close Others".
|
||||
*/
|
||||
const closeSideOthers = (terminalId: string) => {
|
||||
const key = deps.state.contextFor(terminalId)
|
||||
if (!key) return
|
||||
for (const term of deps.state.sidesForContext(key)) {
|
||||
if (term.id === terminalId) continue
|
||||
closeSide(term.id)
|
||||
}
|
||||
deps.state.setSideActive(key, terminalId)
|
||||
deps.state.requestFocus(terminalId)
|
||||
}
|
||||
|
||||
/** Make a side terminal the visible one in its panel and focus it. */
|
||||
const selectSide = (terminalId: string) => {
|
||||
const key = deps.state.contextFor(terminalId)
|
||||
@@ -740,12 +766,14 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
return {
|
||||
closeTerminal,
|
||||
closeSide,
|
||||
closeSideOthers,
|
||||
selectSide,
|
||||
middleClick,
|
||||
activate,
|
||||
deactivate,
|
||||
requestNew,
|
||||
requestSide,
|
||||
ensureSide,
|
||||
addSide,
|
||||
closeActive,
|
||||
}
|
||||
|
||||
@@ -955,6 +955,7 @@ export const SideTerminalPanelEmpty: Story = {
|
||||
visible={() => true}
|
||||
onSelect={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onCloseOthers={() => undefined}
|
||||
onStart={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -995,6 +996,7 @@ export const SideTerminalPanelTabs: Story = {
|
||||
visible={() => true}
|
||||
onSelect={(id) => state.setSideActive(LOCAL, id)}
|
||||
onClose={() => undefined}
|
||||
onCloseOthers={() => undefined}
|
||||
onStart={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export function setTabWidths(frozen: boolean, root: ParentNode = document) {
|
||||
const list = root.querySelector(".am-tab-list")
|
||||
/**
|
||||
* Pin tab widths while a strip mutates, so closing a tab does not reflow
|
||||
* the remaining ones out from under the cursor. `selector` picks the
|
||||
* strip: the agent-manager tab bar by default, or the side terminal
|
||||
* strip, which mirrors the same chrome.
|
||||
*/
|
||||
export function setTabWidths(frozen: boolean, root: ParentNode = document, selector = ".am-tab-list") {
|
||||
const list = root.querySelector(selector)
|
||||
if (!(list instanceof HTMLElement)) return
|
||||
list.toggleAttribute("data-tab-widths-frozen", frozen)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { exists, readText } from "@/util/filesystem"
|
||||
import type { ACPSession } from "./session"
|
||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
import { Effect } from "effect"
|
||||
import { SkillShellPrompt } from "@/kilocode/acp/permission" // kilocode_change
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
@@ -51,18 +52,19 @@ export class Handler {
|
||||
return
|
||||
}
|
||||
|
||||
const skillShell = SkillShellPrompt.is(permission.metadata) // kilocode_change - skill batches list commands and never persist
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
title: skillShell ? SkillShellPrompt.title : permission.permission, // kilocode_change
|
||||
rawInput: permission.metadata, // kilocode_change - metadata.commands carries the verbatim command list
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: permissionOptions,
|
||||
options: skillShell ? SkillShellPrompt.options : permissionOptions, // kilocode_change
|
||||
})
|
||||
.catch(async () => {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
@@ -81,14 +83,15 @@ export class Handler {
|
||||
await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
|
||||
}
|
||||
|
||||
await this.reply(permission.id, reply, session.cwd)
|
||||
await this.reply(permission.id, reply, session.cwd, true) // kilocode_change - human selected via requestPermission
|
||||
}
|
||||
|
||||
private async reply(requestID: string, reply: Reply, directory: string) {
|
||||
private async reply(requestID: string, reply: Reply, directory: string, interactive = false) { // kilocode_change - interactive param
|
||||
await this.input.sdk.permission.reply({
|
||||
requestID,
|
||||
reply,
|
||||
directory,
|
||||
interactive, // kilocode_change
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -848,6 +848,13 @@ export const RunCommand = effectCmd({
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
// kilocode_change start - skill shell batches need an interactive human decision. The server ignores
|
||||
// non-interactive approvals, so headless runs must reject explicitly rather than leave them pending.
|
||||
if (permission.metadata?.["skillShell"] === true) {
|
||||
await client.permission.reply({ requestID: permission.id, reply: "reject" })
|
||||
continue
|
||||
}
|
||||
// kilocode_change end
|
||||
// kilocode_change start - approve root and tracked Task child permissions in auto mode
|
||||
if (args.auto) {
|
||||
if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue
|
||||
|
||||
@@ -141,7 +141,8 @@ export function RunPermissionBody(props: {
|
||||
const info = createMemo(() => permissionInfo(props.request))
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
const opts = createMemo(() => permissionOptions(state().stage))
|
||||
const skillShell = createMemo(() => props.request.metadata?.["skillShell"] === true) // kilocode_change
|
||||
const opts = createMemo(() => permissionOptions(state().stage, skillShell())) // kilocode_change - skillShell-aware options
|
||||
const busy = createMemo(() => state().submitting)
|
||||
const title = createMemo(() => {
|
||||
if (state().stage === "always") {
|
||||
@@ -165,7 +166,7 @@ export function RunPermissionBody(props: {
|
||||
})
|
||||
|
||||
const shift = (dir: -1 | 1) => {
|
||||
setState((prev) => permissionShift(prev, dir))
|
||||
setState((prev) => permissionShift(prev, dir, skillShell())) // kilocode_change - skillShell-aware options
|
||||
}
|
||||
|
||||
const submit = async (next: PermissionReply) => {
|
||||
|
||||
@@ -77,9 +77,11 @@ export function createPermissionBodyState(requestID: string): PermissionBodyStat
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
||||
export function permissionOptions(stage: PermissionStage, skillShell?: boolean): PermissionOption[] { // kilocode_change - skillShell param
|
||||
if (stage === "permission") {
|
||||
return ["once", "always", "reject"]
|
||||
// kilocode_change start - skill-shell batches are never persisted, so no "Allow always"
|
||||
return skillShell ? ["once", "reject"] : ["once", "always", "reject"]
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
if (stage === "always") {
|
||||
@@ -146,12 +148,13 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply
|
||||
return {
|
||||
requestID,
|
||||
reply,
|
||||
interactive: true, // kilocode_change - footer replies are human-driven; the server refuses non-interactive skill-shell approvals
|
||||
...(message && message.trim() ? { message: message.trim() } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
|
||||
const list = permissionOptions(state.stage)
|
||||
export function permissionShift(state: PermissionBodyState, dir: -1 | 1, skillShell?: boolean): PermissionBodyState { // kilocode_change - skillShell param
|
||||
const list = permissionOptions(state.stage, skillShell) // kilocode_change - skillShell-aware options
|
||||
if (list.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const Info = Schema.Struct({
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Schema.String),
|
||||
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
|
||||
trusted: Schema.optional(Schema.Boolean), // kilocode_change - skill-sourced templates only run `!`cmd`` shell when trusted
|
||||
// Some command templates are lazy promises from MCP prompt resolution.
|
||||
template: Schema.Unknown,
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
@@ -67,6 +68,7 @@ function fromSkill(item: Skill.Info): Info {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
source: "skill",
|
||||
trusted: item.trusted === true,
|
||||
get template() {
|
||||
return item.content
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
disableChannelDb: bool("KILO_DISABLE_CHANNEL_DB"), // kilocode_change
|
||||
disableEmbeddedWebUi: bool("KILO_DISABLE_EMBEDDED_WEB_UI"),
|
||||
disableExternalSkills: bool("KILO_DISABLE_EXTERNAL_SKILLS"),
|
||||
disableSkillShell: bool("KILO_DISABLE_SKILL_SHELL"), // kilocode_change - disable shell injection in skill bodies
|
||||
disableLspDownload: bool("KILO_DISABLE_LSP_DOWNLOAD"),
|
||||
skipMigrations: bool("KILO_SKIP_MIGRATIONS"), // kilocode_change
|
||||
disableClaudeCodePrompt: Config.all({
|
||||
|
||||
@@ -38,6 +38,9 @@ const PermissionData = z.object({
|
||||
requestID: z.string(),
|
||||
reply: z.enum(["once", "always", "reject"]),
|
||||
message: z.string().optional(),
|
||||
// Set by a remote human client; threads through to permission.reply so the server
|
||||
// accepts a human approval of a skill-shell batch (non-interactive ones are refused).
|
||||
interactive: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const SuggestionData = z.object({
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk"
|
||||
|
||||
// Skill-shell batches list their commands in the prompt and are never persisted, so the ACP
|
||||
// prompt offers only Allow / Reject (no "Always allow") and a fixed title.
|
||||
const options: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
export const SkillShellPrompt = {
|
||||
is(metadata: unknown) {
|
||||
return (metadata as { skillShell?: unknown })?.skillShell === true
|
||||
},
|
||||
options,
|
||||
title: "Run skill shell commands",
|
||||
}
|
||||
@@ -33,6 +33,8 @@ export function drainCovered(
|
||||
// Never auto-resolve config file edit permissions
|
||||
const skill = ConfigProtection.globalSkillPattern(entry.info)
|
||||
if (ConfigProtection.isRequest(entry.info) && !skill) continue
|
||||
// Never auto-resolve a skill shell batch; it must get an explicit reply.
|
||||
if (entry.info.metadata?.["skillShell"] === true) continue
|
||||
const actions = entry.info.patterns.map((pattern: string) => {
|
||||
const rule = skill
|
||||
? Permission.evaluate(entry.info.permission, skill, approved)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { posix, win32 } from "node:path"
|
||||
|
||||
// Segment/relative-path validation for remotely-discovered skills. The remote index controls
|
||||
// skill.name and each file path, so validate them before they become cache write targets: reject
|
||||
// traversal, absolute paths, URLs, and null bytes. Mirrors core v2 SkillDiscovery.
|
||||
|
||||
export function isSafeSegment(value: string) {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
value !== "." &&
|
||||
value !== ".." &&
|
||||
!value.includes("/") &&
|
||||
!value.includes("\\") &&
|
||||
!value.includes("\0")
|
||||
)
|
||||
}
|
||||
|
||||
export function isSafeRelativePath(value: string) {
|
||||
const segments = value.split("/")
|
||||
return (
|
||||
value.length > 0 &&
|
||||
!value.includes("\\") &&
|
||||
!value.includes("\0") &&
|
||||
!value.includes("?") &&
|
||||
!value.includes("#") &&
|
||||
!URL.canParse(value) &&
|
||||
!posix.isAbsolute(value) &&
|
||||
!win32.isAbsolute(value) &&
|
||||
segments.every((segment) => {
|
||||
try {
|
||||
const decoded = decodeURIComponent(segment)
|
||||
return (
|
||||
decoded.length > 0 &&
|
||||
decoded !== "." &&
|
||||
decoded !== ".." &&
|
||||
!decoded.includes("/") &&
|
||||
!decoded.includes("\\") &&
|
||||
!decoded.includes("\0")
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { realpathSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
// A skill discovered under a trusted directory (~/.agents, ~/.claude, config dirs,
|
||||
// KILO_CONFIG_DIR) mints trust: shell execution after one approval, and unconfined
|
||||
// {env:}/{file:} substitution. Symlinks are followed during the scan, so a link from a
|
||||
// trusted dir into the current project (a commonly suggested convenience) would otherwise
|
||||
// grant project-controlled markdown that trust. Resolve the real path and drop trust when
|
||||
// it lands inside the project, so project content is never trusted regardless of symlinks.
|
||||
export function trustedInProject(match: string, projectRoot: string | undefined): boolean {
|
||||
if (!projectRoot) return false
|
||||
const real = (() => {
|
||||
try {
|
||||
return realpathSync.native(match)
|
||||
} catch {
|
||||
return match
|
||||
}
|
||||
})()
|
||||
const root = (() => {
|
||||
try {
|
||||
return realpathSync.native(projectRoot)
|
||||
} catch {
|
||||
return projectRoot
|
||||
}
|
||||
})()
|
||||
const rel = path.relative(root, real)
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Markers inlined in place of a `!`cmd`` placeholder when it is not executed. Shared by the
|
||||
// skill tool (inject.ts) and the slash-command path (session/prompt.ts) so both render identically.
|
||||
export const SKILL_SHELL_DISABLED = "[skill shell execution disabled by policy]"
|
||||
export const SKILL_SHELL_UNTRUSTED = "[skill shell execution disabled for untrusted skill]"
|
||||
|
||||
// Render a skill command for a permission prompt as a single, tamper-evident line.
|
||||
// Escape control chars (CR/LF/ESC/etc.) so a command can't repaint the terminal, and
|
||||
// bidi/format controls (U+202A-202E, U+2066-2069, U+200E/F, U+2028/9) so a Trojan-Source
|
||||
// style reorder can't make the visible text differ from what will execute.
|
||||
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g
|
||||
|
||||
export function displayCommand(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")
|
||||
})
|
||||
}
|
||||
|
||||
// Presentation for a skill-shell permission prompt: the title (naming the skill when known)
|
||||
// and the verbatim, escaped commands to show. Reads metadata.commands (never the decomposed
|
||||
// patterns, which drop `cd` segments and split pipelines) so the display matches what executes.
|
||||
// Returns undefined when the request is not a skill-shell batch.
|
||||
export function skillShellPrompt(metadata: Record<string, unknown> | undefined) {
|
||||
if (metadata?.["skillShell"] !== true) return undefined
|
||||
const raw = metadata["commands"]
|
||||
const commands = (Array.isArray(raw) ? raw : []).filter((c): c is string => typeof c === "string").map(displayCommand)
|
||||
const skill = typeof metadata["skill"] === "string" ? metadata["skill"] : undefined
|
||||
return {
|
||||
title: skill ? `Run shell commands from skill "${skill}"?` : "Run these skill commands?",
|
||||
commands,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Effect } from "effect"
|
||||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { Process } from "@/util/process"
|
||||
import { SKILL_SHELL_DISABLED, SKILL_SHELL_UNTRUSTED } from "@/kilocode/skills/display"
|
||||
import type * as Tool from "@/tool/tool"
|
||||
|
||||
// Shell injection for skill bodies mirrors Claude's "dynamic context injection":
|
||||
// a `!`cmd`` placeholder in SKILL.md is replaced by the command's stdout before
|
||||
// the content reaches the model. Unlike the slash-command path, this runs for
|
||||
// model-initiated skill loads, so it is gated on three independent controls:
|
||||
//
|
||||
// 1. Trust: only skills from trusted sources (global ~/.claude, ~/.agents,
|
||||
// KILO_CONFIG_DIR, and builtins) may execute. Untrusted project/downloaded
|
||||
// skills never spawn a process.
|
||||
// 2. Kill-switch: `disabled` (KILO_DISABLE_SKILL_SHELL) turns injection off
|
||||
// entirely, matching Claude's disableSkillShellExecution.
|
||||
// 3. Batch approval: every command in the file is decomposed with the same
|
||||
// tree-sitter scan the bash tool uses (per sub-command patterns plus any
|
||||
// out-of-project directories), then presented once, up front, in a single
|
||||
// permission prompt. The `skillShell` marker forces this prompt regardless
|
||||
// of any allow/auto-approve rule; a deny rule or plan-mode veto on any
|
||||
// sub-command still blocks. Approve runs the batch; reject aborts the load.
|
||||
//
|
||||
// Trust and the kill-switch also gate the slash-command path (`/skill`, session/prompt.ts),
|
||||
// which is user-initiated. Batch approval (control 3) is specific to this model-initiated
|
||||
// tool path — the slash-command path is not prompted because the user invoked it directly.
|
||||
//
|
||||
// Substitution runs exactly once. Command output is inlined as plain text and is
|
||||
// never re-scanned, so a command cannot emit a `!`cmd`` placeholder that a later
|
||||
// pass would execute (second-order injection).
|
||||
|
||||
// Execution bounds: model-initiated commands must not hang the load, blow up
|
||||
// context, or overrun the batch.
|
||||
const TIMEOUT_MS = 2 * 60 * 1000 // per-command
|
||||
const BUDGET_MS = 5 * 60 * 1000 // aggregate across the batch
|
||||
const MAX_OUTPUT_BYTES = 32 * 1024
|
||||
const MAX_COMMANDS = 32
|
||||
const LIMIT_NOTE = "[skill shell command limit reached]"
|
||||
|
||||
export namespace SkillInject {
|
||||
export type Decompose = (input: {
|
||||
command: string
|
||||
cwd: string
|
||||
shell: string
|
||||
}) => Effect.Effect<{ patterns: string[]; dirs: string[] }>
|
||||
|
||||
export type Options = {
|
||||
content: string
|
||||
trusted: boolean
|
||||
disabled: boolean
|
||||
cwd: string
|
||||
skill: string
|
||||
shell: string
|
||||
ctx: Tool.Context
|
||||
decompose: Decompose
|
||||
}
|
||||
|
||||
export const render = Effect.fn("SkillInject.render")(function* (opts: Options) {
|
||||
// Placeholders inside fenced code blocks are documentation examples, not live commands.
|
||||
const fenced = fences(opts.content)
|
||||
const live = ConfigMarkdown.shell(opts.content).filter((m) => !fenced(m.index))
|
||||
if (live.length === 0) return opts.content
|
||||
|
||||
// Defense-in-depth ordering: policy checks first, approval gate last. `replace` only
|
||||
// rewrites live (unfenced) placeholders; fenced ones stay as literal text.
|
||||
const replace = (value: (command: string) => string) => rewrite(opts.content, fenced, value)
|
||||
if (opts.disabled) return replace(() => SKILL_SHELL_DISABLED)
|
||||
if (!opts.trusted) return replace(() => SKILL_SHELL_UNTRUSTED)
|
||||
|
||||
// `shell` is resolved by the caller via Shell.acceptable(cfg.shell), which
|
||||
// rejects shells the tree-sitter bash scanner can't parse (fish/nu), keeping
|
||||
// the parse used for the permission decision aligned with execution.
|
||||
const shell = opts.shell
|
||||
// Deduplicate identical commands, then cap the batch so a skill can't queue
|
||||
// an unbounded number of processes.
|
||||
const commands = Array.from(new Set(live.map(([, cmd]) => cmd))).slice(0, MAX_COMMANDS)
|
||||
|
||||
// Decompose each command into sub-command patterns + out-of-project dir globs
|
||||
// via the shared bash scan, so plan-mode denies and external_directory checks
|
||||
// apply per sub-command instead of matching the raw string as one glob. Also
|
||||
// authorize the verbatim command: decomposition drops cd/set-location segments
|
||||
// and strips chaining metacharacters, so a payload like `cd $HOME; cat secret`
|
||||
// would otherwise slip past the metachar deny rules (`*;*`, `*|*`, `*\n*`) and
|
||||
// hide the escape. Keeping the raw string as a pattern makes those rules fire.
|
||||
const patterns = new Set<string>()
|
||||
const dirs = new Set<string>()
|
||||
for (const command of commands) {
|
||||
patterns.add(command)
|
||||
const scan = yield* opts.decompose({ command, cwd: opts.cwd, shell })
|
||||
for (const pattern of scan.patterns) patterns.add(pattern)
|
||||
for (const dir of scan.dirs) dirs.add(dir)
|
||||
}
|
||||
|
||||
// Fail closed: an empty pattern set would make the bash ask below auto-approve
|
||||
// (Permission.ask iterates patterns, so forceAsk/veto never run for an empty
|
||||
// list). Each command contributes its verbatim string above, so this is
|
||||
// unreachable — but abort rather than risk a silent, unprompted execution.
|
||||
if (patterns.size === 0) return yield* Effect.die(new Error("skill shell produced no authorizable commands"))
|
||||
|
||||
// Single up-front approval. `patterns` are the decomposed sub-commands used for
|
||||
// rule matching; `metadata.commands` is the verbatim per-placeholder list the
|
||||
// prompt displays, so what is shown is exactly what runs (decomposition drops
|
||||
// cd/set-location segments and splits pipelines, which must not hide from the
|
||||
// user). `skillShell` forces the prompt over allow/YOLO rules; a deny/veto on
|
||||
// any sub-command propagates as a defect and aborts.
|
||||
const metadata = { skillShell: true, skill: opts.skill, commands }
|
||||
if (dirs.size > 0) {
|
||||
yield* opts.ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: Array.from(dirs),
|
||||
always: [],
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
yield* opts.ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(patterns),
|
||||
always: [],
|
||||
metadata,
|
||||
})
|
||||
|
||||
// Run each command in the instance directory, bounded per-command by ctx.abort (ESC)
|
||||
// and a timeout, and across the batch by an aggregate wall-clock budget, with output
|
||||
// truncated so it can't blow up or poison the prompt.
|
||||
const outputs = new Map<string, string>()
|
||||
const deadline = Date.now() + BUDGET_MS
|
||||
for (const command of commands) {
|
||||
if (Date.now() >= deadline) {
|
||||
outputs.set(command, "[skill shell batch time budget exceeded]")
|
||||
continue
|
||||
}
|
||||
outputs.set(command, yield* run(command, shell, opts.cwd, opts.ctx.abort))
|
||||
}
|
||||
|
||||
// A placeholder that was capped out of `commands` isn't in `outputs`; mark it rather
|
||||
// than silently inlining an empty string.
|
||||
return replace((command) => outputs.get(command) ?? LIMIT_NOTE)
|
||||
})
|
||||
|
||||
const run = Effect.fn("SkillInject.run")(function* (command: string, shell: string, cwd: string, abort: AbortSignal) {
|
||||
const timeout = new AbortController()
|
||||
// A cleared timer bounds the run without leaking a pending 2-minute timeout per command;
|
||||
// ESC (ctx.abort) still kills the child via the same combined signal.
|
||||
const signal = AbortSignal.any([abort, timeout.signal])
|
||||
const timer = setTimeout(() => timeout.abort(), TIMEOUT_MS)
|
||||
const result = yield* Effect.promise(() =>
|
||||
Process.text([command], { shell, cwd, abort: signal, nothrow: true }).catch(() => undefined),
|
||||
).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timer))))
|
||||
|
||||
// With nothrow the promise resolves even when the child was killed, inlining partial
|
||||
// stdout; detect the kill via the signals so an aborted/timed-out command is marked.
|
||||
if (abort.aborted) return "[skill shell command aborted]"
|
||||
if (timeout.signal.aborted) return "[skill shell command timed out]"
|
||||
if (!result) return "[skill shell command failed]"
|
||||
// A failing command with empty stdout would inline ""; surface a marker with any stderr.
|
||||
if (result.code !== 0 && result.text.length === 0) {
|
||||
const err = result.stderr.toString().trim()
|
||||
return err ? "[skill shell command failed]\n" + truncate(err) : "[skill shell command failed]"
|
||||
}
|
||||
return truncate(result.text)
|
||||
})
|
||||
|
||||
// Byte-accurate truncation: slice on a Buffer so a multibyte tail can't exceed the cap.
|
||||
function truncate(text: string) {
|
||||
const buf = Buffer.from(text)
|
||||
if (buf.byteLength <= MAX_OUTPUT_BYTES) return text
|
||||
return buf.toString("utf8", 0, MAX_OUTPUT_BYTES) + "\n[skill shell output truncated]"
|
||||
}
|
||||
|
||||
// Rewrite only live (unfenced) placeholders in the ORIGINAL content, substituting once and
|
||||
// never re-scanning the result, so inlined output containing `!`cmd`` stays inert and a
|
||||
// fenced documentation example is left as literal text.
|
||||
function rewrite(content: string, fenced: (index: number) => boolean, value: (command: string) => string) {
|
||||
return content.replace(ConfigMarkdown.SHELL_REGEX, (match, command: string, index: number) =>
|
||||
fenced(index) ? match : value(command),
|
||||
)
|
||||
}
|
||||
|
||||
// Return a predicate that reports whether a character offset falls inside a fenced code
|
||||
// block (``` or ~~~), so placeholders in documentation examples are treated as inert.
|
||||
function fences(content: string): (index: number) => boolean {
|
||||
const ranges: Array<[number, number]> = []
|
||||
const fence = /^[ \t]*(`{3,}|~{3,})[^\n]*$/gm
|
||||
let open: { start: number; marker: string } | undefined
|
||||
for (const m of content.matchAll(fence)) {
|
||||
const marker = m[1]
|
||||
// CommonMark: a closing fence uses the same char and is at least as long as the opener,
|
||||
// so an inner shorter/different fence stays content. Keep the real opener length.
|
||||
if (!open) open = { start: m.index, marker }
|
||||
else if (marker[0] === open.marker[0] && marker.length >= open.marker.length) {
|
||||
ranges.push([open.start, m.index + m[0].length])
|
||||
open = undefined
|
||||
}
|
||||
}
|
||||
if (open) ranges.push([open.start, content.length]) // unterminated fence runs to EOF
|
||||
return (index: number) => ranges.some(([s, e]) => index >= s && index < e)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ function subset(permission: string, ruleset: Ruleset) {
|
||||
|
||||
function covered(entry: PendingEntry, approved: Ruleset, local: Ruleset) {
|
||||
if (ConfigProtection.isRequest(entry.info)) return false
|
||||
if (entry.info.metadata?.["skillShell"] === true) return false // kilocode_change - skill batch needs an explicit reply
|
||||
return entry.info.patterns.every((pattern) => {
|
||||
if (veto(entry.info.permission, pattern, entry.hardRuleset)) return false
|
||||
return resolve(entry.info.permission, pattern, entry.ruleset, approved, local).action === "allow"
|
||||
@@ -221,6 +222,7 @@ export const layer = Layer.effect(
|
||||
: false
|
||||
// kilocode_change end
|
||||
|
||||
const forceAsk = request.metadata?.["skillShell"] === true // kilocode_change
|
||||
for (const pattern of request.patterns) {
|
||||
const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules
|
||||
yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
@@ -234,6 +236,12 @@ export const layer = Layer.effect(
|
||||
ruleset: subset(request.permission, ruleset), // kilocode_change
|
||||
})
|
||||
}
|
||||
// kilocode_change start - skill shell forces a prompt instead of honoring an allow/auto-approve rule
|
||||
if (forceAsk) {
|
||||
needsAsk = true
|
||||
continue
|
||||
}
|
||||
// kilocode_change end
|
||||
// kilocode_change start - override "allow" to "ask" for protected config paths
|
||||
if (rule.action === "allow" && (!isProtected || trusted)) {
|
||||
approvedRule = rule // remember the winning rule so callers can explain the auto-approval
|
||||
@@ -290,6 +298,18 @@ export const layer = Layer.effect(
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID })
|
||||
|
||||
// kilocode_change start - skill-shell batches must be answered by a human; ignore machine approvals
|
||||
// (auto-approve/YOLO clients omit `interactive`) so the prompt stays pending for a real decision.
|
||||
// Log rather than fail silently: a genuine human client sets `interactive`, so a refused reply here
|
||||
// means an auto-approver tried to answer — the request intentionally stays pending for a human.
|
||||
if (existing.info.metadata?.["skillShell"] === true && input.reply !== "reject" && input.interactive !== true) {
|
||||
yield* Effect.logWarning("skill shell approval refused: requires an interactive human reply", {
|
||||
id: input.requestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
pending.delete(input.requestID)
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
|
||||
@@ -12,6 +12,7 @@ const root = "/permission"
|
||||
const ReplyPayload = Schema.Struct({
|
||||
reply: PermissionV1.Reply,
|
||||
message: Schema.optional(Schema.String),
|
||||
interactive: Schema.optional(Schema.Boolean), // kilocode_change - human-answered flag; gates skill-shell approvals
|
||||
})
|
||||
|
||||
// kilocode_change start
|
||||
|
||||
@@ -30,6 +30,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss
|
||||
requestID: ctx.params.requestID,
|
||||
reply: ctx.payload.reply,
|
||||
message: ctx.payload.message,
|
||||
interactive: ctx.payload.interactive, // kilocode_change
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Permission.NotFoundError", (error) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import os from "os"
|
||||
import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change
|
||||
import { SKILL_SHELL_DISABLED, SKILL_SHELL_UNTRUSTED } from "@/kilocode/skills/display" // kilocode_change
|
||||
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change
|
||||
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change
|
||||
import { KiloSession } from "@/kilocode/session" // kilocode_change
|
||||
@@ -2035,7 +2036,15 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const shellMatches = ConfigMarkdown.shell(template)
|
||||
if (shellMatches.length > 0) {
|
||||
// kilocode_change start - skill templates run !`cmd`` only when trusted and the kill-switch is off,
|
||||
// mirroring the skill tool's gate (the slash-command path is user-initiated, so it is not prompted).
|
||||
const skillTemplate = cmd.source === "skill"
|
||||
const skillShellBlocked = skillTemplate && (cmd.trusted !== true || flags.disableSkillShell)
|
||||
if (shellMatches.length > 0 && skillShellBlocked) {
|
||||
const note = cmd.trusted !== true ? SKILL_SHELL_UNTRUSTED : SKILL_SHELL_DISABLED
|
||||
template = template.replace(bashRegex, () => note)
|
||||
} else if (shellMatches.length > 0) {
|
||||
// kilocode_change end
|
||||
const cfg = yield* config.get()
|
||||
const sh = Shell.preferred(cfg.shell)
|
||||
// kilocode_change start
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { isSafeSegment, isSafeRelativePath } from "@/kilocode/skill/discovery-validate" // kilocode_change
|
||||
|
||||
const skillConcurrency = 4
|
||||
const fileConcurrency = 8
|
||||
@@ -47,8 +48,10 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
|
||||
|
||||
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
||||
const base = url.endsWith("/") ? url : `${url}/`
|
||||
const index = new URL("index.json", base).href
|
||||
const host = base.slice(0, -1)
|
||||
// kilocode_change start - resolve the index origin so file downloads can be pinned to it
|
||||
const source = new URL(base)
|
||||
const index = new URL("index.json", source).href
|
||||
// kilocode_change end
|
||||
|
||||
yield* Effect.logInfo("fetching index", { url: index })
|
||||
|
||||
@@ -63,33 +66,52 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
|
||||
|
||||
if (!data) return []
|
||||
|
||||
const missing = data.skills.filter((skill) => !skill.files.includes("SKILL.md"))
|
||||
yield* Effect.forEach(
|
||||
missing,
|
||||
(skill) => Effect.logWarning("skill entry missing SKILL.md", { url: index, skill: skill.name }),
|
||||
{ discard: true },
|
||||
)
|
||||
const list = data.skills.filter((skill) => skill.files.includes("SKILL.md"))
|
||||
// kilocode_change start - the remote index controls skill.name and file, so validate every segment,
|
||||
// pin file downloads to the index origin, and confine writes to the cache (mirrors core v2 SkillDiscovery)
|
||||
const contained = (parent: string, child: string) => {
|
||||
const rel = path.relative(parent, child)
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
}
|
||||
const plan = (skill: IndexSkill) => {
|
||||
if (!skill.files.includes("SKILL.md")) return "skill entry missing SKILL.md"
|
||||
if (!isSafeSegment(skill.name)) return "skipping skill with unsafe name"
|
||||
const root = path.join(cache, skill.name)
|
||||
if (!contained(cache, root)) return "skipping skill with unsafe name"
|
||||
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
|
||||
const files: { url: string; dest: string }[] = []
|
||||
for (const file of skill.files) {
|
||||
if (!isSafeRelativePath(file)) return "skipping skill with unsafe file path"
|
||||
const resource = URL.parse(file, skillUrl) ?? undefined
|
||||
if (!resource || resource.origin !== source.origin) return "skipping skill with cross-origin file"
|
||||
const dest = path.join(root, file)
|
||||
if (!contained(root, dest)) return "skipping skill with unsafe file path"
|
||||
files.push({ url: resource.href, dest })
|
||||
}
|
||||
return { root, files }
|
||||
}
|
||||
|
||||
const planned: { root: string; files: { url: string; dest: string }[] }[] = []
|
||||
for (const skill of data.skills) {
|
||||
const result = plan(skill)
|
||||
if (typeof result === "string") yield* Effect.logWarning(result, { url: index, skill: skill.name })
|
||||
else planned.push(result)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - download each validated, origin-pinned, cache-confined plan
|
||||
const dirs = yield* Effect.forEach(
|
||||
list,
|
||||
planned,
|
||||
(skill) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(cache, skill.name)
|
||||
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{
|
||||
concurrency: fileConcurrency,
|
||||
},
|
||||
)
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||
yield* Effect.forEach(skill.files, (file) => download(file.url, file.dest), {
|
||||
concurrency: fileConcurrency,
|
||||
})
|
||||
const md = path.join(skill.root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? skill.root : null
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
return dirs.filter((dir): dir is string => dir !== null)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import { primaryPaths } from "../kilocode/primary-worktree" // kilocode_change
|
||||
import { Git } from "@/git" // kilocode_change
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change
|
||||
import { trustedInProject } from "../kilocode/skill/trust" // kilocode_change
|
||||
|
||||
const CLAUDE_EXTERNAL_DIR = ".claude"
|
||||
const AGENTS_EXTERNAL_DIR = ".agents"
|
||||
@@ -35,6 +36,7 @@ export const Info = Schema.Struct({
|
||||
description: Schema.optional(Schema.String),
|
||||
location: Schema.String,
|
||||
content: Schema.String,
|
||||
trusted: Schema.optional(Schema.Boolean), // kilocode_change - gate skill shell injection to trusted sources
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
@@ -151,6 +153,7 @@ const add = Effect.fnUntraced(function* (state: State, match: Match, events: Eve
|
||||
description: md.data.description,
|
||||
location: match.path, // kilocode_change
|
||||
content: md.content,
|
||||
trusted: match.trusted, // kilocode_change
|
||||
}
|
||||
})
|
||||
|
||||
@@ -158,7 +161,7 @@ const scan = Effect.fnUntraced(function* (
|
||||
state: ScanState,
|
||||
root: string,
|
||||
pattern: string,
|
||||
opts?: { dot?: boolean; scope?: string; trusted?: boolean; root?: string; sourceRoot?: string }, // kilocode_change
|
||||
opts?: { dot?: boolean; scope?: string; trusted?: boolean; root?: string; sourceRoot?: string; projectRoot?: string }, // kilocode_change
|
||||
) {
|
||||
const matches = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
@@ -180,12 +183,14 @@ const scan = Effect.fnUntraced(function* (
|
||||
)
|
||||
|
||||
for (const match of matches) {
|
||||
// kilocode_change start
|
||||
// kilocode_change start - a trusted match whose realpath resolves inside the project (e.g. a
|
||||
// symlink from ~/.agents/skills into the repo) must not mint trust for project-controlled content
|
||||
const trusted = (opts?.trusted ?? false) && !trustedInProject(match, opts?.projectRoot)
|
||||
state.matches.set(match, {
|
||||
path: match,
|
||||
trusted: opts?.trusted ?? false,
|
||||
root: opts?.root,
|
||||
sourceRoot: opts?.sourceRoot,
|
||||
trusted,
|
||||
root: trusted ? opts?.root : (opts?.root ?? opts?.projectRoot),
|
||||
sourceRoot: trusted ? opts?.sourceRoot : (opts?.sourceRoot ?? opts?.projectRoot),
|
||||
})
|
||||
// kilocode_change end
|
||||
state.dirs.add(path.dirname(match))
|
||||
@@ -213,7 +218,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
for (const dir of externalDirs) {
|
||||
const root = path.join(global.home, dir)
|
||||
if (!(yield* fsys.isDir(root))) continue
|
||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global", trusted: true }) // kilocode_change
|
||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global", trusted: true, projectRoot }) // kilocode_change
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
@@ -250,6 +255,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
trusted,
|
||||
root: trusted ? undefined : projectRoot,
|
||||
sourceRoot: trusted ? undefined : sourceRoot,
|
||||
projectRoot,
|
||||
})
|
||||
// kilocode_change end
|
||||
}
|
||||
@@ -266,7 +272,11 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
// kilocode_change start - trust follows the config source that declared the path, never the selected path.
|
||||
const origin = cfg.skill_path_origins?.[item]
|
||||
const trusted = origin?.trusted === true && path.isAbsolute(expanded)
|
||||
yield* scan(state, dir, SKILL_PATTERN, { trusted, root: trusted ? undefined : (origin?.root ?? projectRoot) })
|
||||
yield* scan(state, dir, SKILL_PATTERN, {
|
||||
trusted,
|
||||
root: trusted ? undefined : (origin?.root ?? projectRoot),
|
||||
projectRoot,
|
||||
})
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
@@ -295,6 +305,7 @@ const loadSkills = Effect.fnUntraced(function* (
|
||||
description: skill.description,
|
||||
location: BUILTIN_LOCATION,
|
||||
content: skill.content,
|
||||
trusted: true, // kilocode_change - builtin skills ship in the binary
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
@@ -433,7 +433,24 @@ export const ShellPermission = Effect.gen(function* () {
|
||||
)
|
||||
})
|
||||
|
||||
return { ask: check, resolve }
|
||||
// kilocode_change start - expose the tree-sitter scan (sub-command patterns + external-dir globs) for skill-shell batching
|
||||
const dirGlob = (dir: string) =>
|
||||
process.platform === "win32" ? FSUtil.normalizePathPattern(path.join(dir, "*")) : path.join(dir, "*")
|
||||
const decompose = Effect.fn("ShellTool.decompose")(function* (input: { command: string; cwd: string; shell: string }) {
|
||||
const instance = yield* InstanceState.context
|
||||
const ps = Shell.ps(input.shell)
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const tree = yield* Effect.acquireRelease(parse(input.command, ps), (tree) => Effect.sync(() => tree.delete()))
|
||||
const scan = yield* collect(tree.rootNode, input.cwd, ps, input.shell, instance)
|
||||
if (!containsPath(input.cwd, instance)) scan.dirs.add(input.cwd)
|
||||
return { patterns: Array.from(scan.patterns), dirs: Array.from(scan.dirs, dirGlob) }
|
||||
}),
|
||||
)
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
return { ask: check, resolve, decompose } // kilocode_change - decompose for skill-shell
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { Skill } from "../skill"
|
||||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./skill.txt"
|
||||
// kilocode_change start - gate + run shell injection in skill bodies
|
||||
import { Config } from "@/config/config"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ShellPermission } from "./shell"
|
||||
import { SkillInject } from "@/kilocode/skills/inject"
|
||||
// kilocode_change end
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
name: Schema.String.annotate({ description: "The name of the skill from available_skills" }),
|
||||
@@ -15,6 +23,9 @@ export const SkillTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const flags = yield* RuntimeFlags.Service // kilocode_change
|
||||
const permission = yield* ShellPermission // kilocode_change - decompose skill commands like the bash tool
|
||||
const config = yield* Config.Service // kilocode_change - resolve a parseable shell for injection
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -32,6 +43,20 @@ export const SkillTool = Tool.define(
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
// kilocode_change start - render `!`cmd`` shell injection, gated by trust + kill-switch + batch approval
|
||||
const cfg = yield* config.get()
|
||||
const content = yield* SkillInject.render({
|
||||
content: info.content,
|
||||
trusted: info.trusted === true,
|
||||
disabled: flags.disableSkillShell,
|
||||
cwd: yield* InstanceState.directory,
|
||||
skill: info.name,
|
||||
shell: Shell.acceptable(cfg.shell),
|
||||
ctx,
|
||||
decompose: permission.decompose,
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - built-in skills have no filesystem directory
|
||||
if (info.location === Skill.BUILTIN_LOCATION) {
|
||||
return {
|
||||
@@ -40,7 +65,7 @@ export const SkillTool = Tool.define(
|
||||
`<skill_content name="${info.name}">`,
|
||||
`# Skill: ${info.name}`,
|
||||
"",
|
||||
info.content.trim(),
|
||||
content.trim(), // kilocode_change
|
||||
"</skill_content>",
|
||||
].join("\n"),
|
||||
metadata: {
|
||||
@@ -68,7 +93,7 @@ export const SkillTool = Tool.define(
|
||||
`<skill_content name="${info.name}">`,
|
||||
`# Skill: ${info.name}`,
|
||||
"",
|
||||
info.content.trim(),
|
||||
content.trim(), // kilocode_change
|
||||
"",
|
||||
`Base directory for this skill: ${base}`,
|
||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||
|
||||
@@ -162,7 +162,11 @@ describe("acp permissions", () => {
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
|
||||
// kilocode_change start - human selections are marked interactive
|
||||
expect(harness.replies).toEqual([
|
||||
{ requestID: "perm_1", reply: "once", directory: "/workspace", interactive: true },
|
||||
])
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
it("forwards external_directory metadata and locations to requestPermission", async () => {
|
||||
@@ -201,6 +205,39 @@ describe("acp permissions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change start - skill shell batches surface their command list and cannot be persisted
|
||||
it("forwards skill shell commands and omits the persist option", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_a", "perm_skill", {
|
||||
permission: "bash",
|
||||
// metadata.commands carries the verbatim command list the injector sends
|
||||
metadata: { skillShell: true, commands: ["git status", "printf hi"] },
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "skill shell permission was never replied")
|
||||
|
||||
expect(harness.requests[0]).toMatchObject({
|
||||
toolCall: {
|
||||
title: "Run skill shell commands",
|
||||
rawInput: { skillShell: true, commands: ["git status", "printf hi"] },
|
||||
},
|
||||
// no allow_always: skill shell is never persisted
|
||||
options: [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(harness.requests[0].options.some((o) => o.kind === "allow_always")).toBe(false)
|
||||
// the human selection is marked interactive so the server accepts the approval
|
||||
expect(harness.replies[0]).toMatchObject({ requestID: "perm_skill", reply: "once", interactive: true })
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
it("rejects non-selected outcomes", async () => {
|
||||
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
permissionCancel,
|
||||
permissionEscape,
|
||||
permissionInfo,
|
||||
permissionOptions, // kilocode_change
|
||||
permissionReject,
|
||||
permissionRun,
|
||||
} from "@/cli/cmd/run/permission.shared"
|
||||
@@ -29,6 +30,7 @@ describe("run permission shared", () => {
|
||||
expect(out.reply).toEqual({
|
||||
requestID: "perm-1",
|
||||
reply: "once",
|
||||
interactive: true, // kilocode_change
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,6 +43,7 @@ describe("run permission shared", () => {
|
||||
expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
|
||||
requestID: "perm-1",
|
||||
reply: "always",
|
||||
interactive: true, // kilocode_change
|
||||
})
|
||||
|
||||
expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({
|
||||
@@ -57,6 +60,7 @@ describe("run permission shared", () => {
|
||||
expect(out).toEqual({
|
||||
requestID: "perm-1",
|
||||
reply: "reject",
|
||||
interactive: true, // kilocode_change
|
||||
message: "use rg",
|
||||
})
|
||||
|
||||
@@ -130,6 +134,13 @@ describe("run permission shared", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change start - skill-shell options
|
||||
test("skill shell offers only Allow / Reject (never Allow always)", () => {
|
||||
expect(permissionOptions("permission", true)).toEqual(["once", "reject"])
|
||||
expect(permissionOptions("permission")).toEqual(["once", "always", "reject"])
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("formats always-allow copy for wildcard and explicit patterns", () => {
|
||||
expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([
|
||||
"This will allow bash until Kilo is restarted.",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { SkillShellPrompt } from "@/kilocode/acp/permission"
|
||||
|
||||
describe("SkillShellPrompt", () => {
|
||||
test("detects the skillShell metadata flag", () => {
|
||||
expect(SkillShellPrompt.is({ skillShell: true })).toBe(true)
|
||||
expect(SkillShellPrompt.is({ skillShell: false })).toBe(false)
|
||||
expect(SkillShellPrompt.is({})).toBe(false)
|
||||
expect(SkillShellPrompt.is(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("offers only allow-once and reject, never allow-always", () => {
|
||||
expect(SkillShellPrompt.options.map((o) => o.optionId)).toEqual(["once", "reject"])
|
||||
expect(SkillShellPrompt.options.some((o) => o.kind === "allow_always")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Permission } from "@/permission"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap-service"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
// skillShell forces a single up-front prompt over soft allow/deny/auto-approve
|
||||
// rules, but must never override a hard (plan-mode) veto, and must never be
|
||||
// auto-resolved while pending.
|
||||
|
||||
const events = EventV2Bridge.defaultLayer
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const env = Layer.mergeAll(
|
||||
Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)),
|
||||
events,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
|
||||
).pipe(Layer.provide(RuntimeFlags.layer()), Layer.provide(Config.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(env, RuntimeFlags.layer()))
|
||||
|
||||
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* (yield* Permission.Service).ask(input)
|
||||
})
|
||||
|
||||
const list = () =>
|
||||
Effect.gen(function* () {
|
||||
return yield* (yield* Permission.Service).list()
|
||||
})
|
||||
|
||||
const rejectAll = () =>
|
||||
Effect.gen(function* () {
|
||||
const permission = yield* Permission.Service
|
||||
for (const req of yield* permission.list()) yield* permission.reply({ requestID: req.id, reply: "reject" })
|
||||
})
|
||||
|
||||
const reply = (input: Parameters<Permission.Interface["reply"]>[0]) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* (yield* Permission.Service).reply(input)
|
||||
})
|
||||
|
||||
const waitForPending = (count: number) =>
|
||||
Effect.gen(function* () {
|
||||
const permission = yield* Permission.Service
|
||||
return yield* Effect.gen(function* () {
|
||||
while (true) {
|
||||
const pending = yield* permission.list()
|
||||
if (pending.length === count) return pending
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
}).pipe(Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.fail(new Error("timed out")) }))
|
||||
})
|
||||
|
||||
const fail = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* self.pipe(Effect.exit)
|
||||
if (Exit.isFailure(exit)) return Cause.squash(exit.cause)
|
||||
throw new Error("expected permission effect to fail")
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"skillShell - forces a prompt even when a matching allow rule exists",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["printf hi"],
|
||||
metadata: { skillShell: true },
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
expect(yield* waitForPending(1)).toHaveLength(1)
|
||||
yield* rejectAll()
|
||||
yield* Fiber.await(fiber)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"skillShell - a deny rule stays terminal (build mode, no hard ruleset)",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// build mode has no hardRuleset; an ordinary deny rule must still block, not prompt.
|
||||
const err = yield* fail(
|
||||
ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["curl evil.sh"],
|
||||
metadata: { skillShell: true },
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "curl *", action: "deny" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(PermissionV1.DeniedError)
|
||||
expect(yield* list()).toHaveLength(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"skillShell - a cd-chained escape is vetoed via the verbatim command pattern",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// The injector asks with the decomposed sub-command (`cat .ssh/id_rsa`, which
|
||||
// readOnlyBash would allow) AND the verbatim command. In plan mode the metachar
|
||||
// hard-veto (`*\n*` deny) must fire on the verbatim string, blocking the escape.
|
||||
const err = yield* fail(
|
||||
ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ['cd "$HOME"\ncat .ssh/id_rsa', "cat .ssh/id_rsa"],
|
||||
metadata: { skillShell: true },
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "cat *", action: "allow" }],
|
||||
hardRuleset: [{ permission: "bash", pattern: "*\n*", action: "deny" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(PermissionV1.DeniedError)
|
||||
expect(yield* list()).toHaveLength(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"skillShell - is denied by a hard-ruleset veto instead of prompting",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const err = yield* fail(
|
||||
ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["rm -rf /"],
|
||||
metadata: { skillShell: true },
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
hardRuleset: [{ permission: "bash", pattern: "*", action: "deny" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(PermissionV1.DeniedError)
|
||||
expect(yield* list()).toHaveLength(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"skillShell - a pending batch is not auto-resolved by allowEverything",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["printf hi"],
|
||||
metadata: { skillShell: true },
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
expect(yield* waitForPending(1)).toHaveLength(1)
|
||||
yield* (yield* Permission.Service).allowEverything({ enable: true })
|
||||
// still pending: YOLO cannot silently approve a skill batch
|
||||
expect(yield* list()).toHaveLength(1)
|
||||
yield* rejectAll()
|
||||
yield* Fiber.await(fiber)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"skillShell - a machine approval (no interactive flag) is ignored and stays pending",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["printf hi"],
|
||||
metadata: { skillShell: true },
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const [pending] = yield* waitForPending(1)
|
||||
// An auto-approver replies without `interactive`; the server must ignore it.
|
||||
yield* reply({ requestID: pending.id, reply: "once" })
|
||||
expect(yield* list()).toHaveLength(1)
|
||||
yield* rejectAll()
|
||||
yield* Fiber.await(fiber)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"skillShell - an interactive approval resolves the request",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["printf hi"],
|
||||
metadata: { skillShell: true },
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const [pending] = yield* waitForPending(1)
|
||||
yield* reply({ requestID: pending.id, reply: "once", interactive: true })
|
||||
// human approval clears the prompt and the ask succeeds
|
||||
expect(yield* list()).toHaveLength(0)
|
||||
yield* Fiber.await(fiber)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -54,4 +54,28 @@ Skill content.
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// The slash-command path runs a template's `!`cmd`` shell without a permission
|
||||
// prompt, so it must only do so for trusted skills. A project-local skill is
|
||||
// untrusted, and Command.Info carries the flag the prompt executor gates on.
|
||||
it.live("marks project skills untrusted so their slash-command shell is disabled", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".kilo", "skill", "proj", "SKILL.md"),
|
||||
`---\nname: proj\ndescription: proj.\n---\n\nRun: !\`printf hi\`\n`,
|
||||
),
|
||||
)
|
||||
|
||||
const command = yield* Command.Service
|
||||
const proj = yield* command.get("proj")
|
||||
|
||||
expect(proj?.source).toBe("skill")
|
||||
expect(proj?.trusted).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isSafeSegment, isSafeRelativePath } from "@/kilocode/skill/discovery-validate"
|
||||
|
||||
describe("isSafeSegment", () => {
|
||||
test("accepts a plain skill name", () => {
|
||||
expect(isSafeSegment("git-status")).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects traversal, separators, empties, and null bytes", () => {
|
||||
for (const value of ["", ".", "..", "a/b", "a\\b", "a\0b"]) {
|
||||
expect(isSafeSegment(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSafeRelativePath", () => {
|
||||
test("accepts nested relative paths", () => {
|
||||
expect(isSafeRelativePath("SKILL.md")).toBe(true)
|
||||
expect(isSafeRelativePath("scripts/setup.sh")).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects traversal segments", () => {
|
||||
expect(isSafeRelativePath("../evil")).toBe(false)
|
||||
expect(isSafeRelativePath("a/../../b")).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects absolute paths on either platform", () => {
|
||||
expect(isSafeRelativePath("/etc/passwd")).toBe(false)
|
||||
expect(isSafeRelativePath("C:\\Windows")).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects URLs, query/fragment, backslashes, and null bytes", () => {
|
||||
for (const value of ["http://evil.test/x", "a?b", "a#b", "a\\b", "a\0b"]) {
|
||||
expect(isSafeRelativePath(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects percent-encoded traversal", () => {
|
||||
expect(isSafeRelativePath("%2e%2e/evil")).toBe(false)
|
||||
expect(isSafeRelativePath("%2f")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { displayCommand, skillShellPrompt } from "@/kilocode/skills/display"
|
||||
|
||||
describe("displayCommand", () => {
|
||||
it("escapes control characters so a command cannot repaint the prompt", () => {
|
||||
// CR/ESC would otherwise let the visible text differ from what executes
|
||||
const out = displayCommand("echo ok\r\x1b[2Krm -rf /\nnext")
|
||||
expect(out).toBe("echo ok\\r\\x1b[2Krm -rf /\\nnext")
|
||||
expect(out).not.toMatch(/[\u0000-\u001f]/)
|
||||
})
|
||||
|
||||
it("escapes bidi/format controls so Trojan-Source reordering can't hide intent", () => {
|
||||
// RLO (U+202E) + PDI (U+2069) would visually reorder the command in the prompt
|
||||
const out = displayCommand("echo \u202esafe\u2069 rm -rf /")
|
||||
expect(out).toBe("echo \\u202esafe\\u2069 rm -rf /")
|
||||
expect(out).not.toMatch(/[\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/)
|
||||
})
|
||||
|
||||
it("leaves ordinary commands unchanged", () => {
|
||||
expect(displayCommand("git status --short")).toBe("git status --short")
|
||||
})
|
||||
})
|
||||
|
||||
describe("skillShellPrompt", () => {
|
||||
it("returns undefined when the request is not a skill-shell batch", () => {
|
||||
expect(skillShellPrompt(undefined)).toBeUndefined()
|
||||
expect(skillShellPrompt({ skillShell: false })).toBeUndefined()
|
||||
expect(skillShellPrompt({})).toBeUndefined()
|
||||
})
|
||||
|
||||
it("names the skill and returns verbatim, escaped commands", () => {
|
||||
const out = skillShellPrompt({ skillShell: true, skill: "git-status", commands: ["git status", "echo \u202ex"] })
|
||||
expect(out).toEqual({
|
||||
title: 'Run shell commands from skill "git-status"?',
|
||||
commands: ["git status", "echo \\u202ex"],
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to a generic title and drops non-string commands", () => {
|
||||
const out = skillShellPrompt({ skillShell: true, commands: ["ok", 42, null] })
|
||||
expect(out).toEqual({ title: "Run these skill commands?", commands: ["ok"] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
import { SkillTool } from "@/tool/skill"
|
||||
import { SkillInject } from "@/kilocode/skills/inject"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { disposeAllInstances, TestInstance } from "../../fixture/fixture"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
// Global (~/.claude) skills are trusted, but Global.Service snapshots the home
|
||||
// path when its layer is built, so KILO_TEST_HOME must be set before the runtime
|
||||
// layer below is constructed — not inside a test body.
|
||||
const HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "skill-inject-home-")))
|
||||
process.env.KILO_TEST_HOME = HOME
|
||||
|
||||
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("msg_test"),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(ToolRegistry.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)),
|
||||
)
|
||||
|
||||
// Shell injection spawns real processes; skip on windows CI like the sibling suite.
|
||||
const unix = process.platform !== "win32" ? it.instance : it.instance.skip
|
||||
|
||||
afterEach(async () => {
|
||||
// reset discovered skills between tests by clearing the global skill dir
|
||||
await fs.promises.rm(path.join(HOME, ".agents"), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Global ~/.agents skills are trusted (and, unlike ~/.claude, not gated by the
|
||||
// KILO_DISABLE_CLAUDE_CODE flag the test env sets); project .kilo skills are untrusted.
|
||||
function writeGlobalSkill(name: string, body: string) {
|
||||
return Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(HOME, ".agents", "skills", name, "SKILL.md"),
|
||||
`---\nname: ${name}\ndescription: ${name} test skill.\n---\n\n${body}\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function writeProjectSkill(dir: string, name: string, body: string) {
|
||||
return Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".kilo", "skill", name, "SKILL.md"),
|
||||
`---\nname: ${name}\ndescription: ${name} test skill.\n---\n\n${body}\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function loadSkill(name: string, ask: Tool.Context["ask"]) {
|
||||
return Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const tool = (yield* registry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent,
|
||||
})).find((t) => t.id === SkillTool.id)
|
||||
if (!tool) throw new Error("Skill tool not found")
|
||||
return yield* tool.execute({ name }, { ...baseCtx, ask })
|
||||
})
|
||||
}
|
||||
|
||||
describe("skill shell injection", () => {
|
||||
unix("runs the batch after a single forced approval listing every command", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* writeGlobalSkill("trusted-shell", "A: !`printf one` B: !`printf two`")
|
||||
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
const result = yield* loadSkill("trusted-shell", (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("A: one B: two")
|
||||
// one skill-load ask plus exactly one batch bash ask carrying all commands
|
||||
const bash = requests.filter((r) => r.permission === "bash")
|
||||
expect(bash.length).toBe(1)
|
||||
expect(bash[0].metadata?.["skillShell"]).toBe(true)
|
||||
// patterns drive rule matching; metadata.commands is the verbatim list the prompt renders
|
||||
expect(bash[0].patterns).toEqual(["printf one", "printf two"])
|
||||
expect(bash[0].metadata?.["commands"]).toEqual(["printf one", "printf two"])
|
||||
}),
|
||||
)
|
||||
|
||||
unix("runs commands in the instance directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
yield* writeGlobalSkill("cwd-shell", "Here: !`pwd`")
|
||||
|
||||
const result = yield* loadSkill("cwd-shell", () => Effect.void)
|
||||
|
||||
// pwd resolves to the instance dir (realpath), not the server process cwd
|
||||
expect(result.output).toContain(path.basename(dir))
|
||||
}),
|
||||
)
|
||||
|
||||
unix("authorizes both the decomposed sub-commands and the verbatim command", () =>
|
||||
Effect.gen(function* () {
|
||||
// A chained placeholder is asked with per-sub-command patterns (so deny/veto
|
||||
// rules apply to each) AND the verbatim string (so the metachar deny rules
|
||||
// `*;*`/`*|*`/`*\n*` fire and cd/set-location escapes can't hide). The prompt
|
||||
// still displays the verbatim placeholder.
|
||||
yield* writeGlobalSkill("compound-shell", "Out: !`cat README.md; printf hi`")
|
||||
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
yield* loadSkill("compound-shell", (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
)
|
||||
|
||||
const bash = requests.filter((r) => r.permission === "bash")
|
||||
expect(bash.length).toBe(1)
|
||||
expect(bash[0].patterns).toContain("cat README.md")
|
||||
expect(bash[0].patterns).toContain("printf hi")
|
||||
// the raw chained string is authorized too, so metachar deny rules can match it
|
||||
expect(bash[0].patterns).toContain("cat README.md; printf hi")
|
||||
expect(bash[0].metadata?.["commands"]).toEqual(["cat README.md; printf hi"])
|
||||
}),
|
||||
)
|
||||
|
||||
unix("still prompts for a cd-only command (no empty-pattern auto-approve)", () =>
|
||||
Effect.gen(function* () {
|
||||
// `cd` decomposes to no sub-command patterns; without the verbatim command the
|
||||
// bash ask would carry an empty pattern list and Permission.ask would silently
|
||||
// auto-approve (forceAsk never runs on an empty list). The raw command keeps
|
||||
// the prompt firing.
|
||||
yield* writeGlobalSkill("cd-only", "Out: !`cd sub`")
|
||||
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
yield* loadSkill("cd-only", (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
)
|
||||
|
||||
const bash = requests.filter((r) => r.permission === "bash")
|
||||
expect(bash.length).toBe(1)
|
||||
expect(bash[0].patterns).toContain("cd sub")
|
||||
}),
|
||||
)
|
||||
|
||||
unix("aborts the entire skill load when the batch is rejected", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* writeGlobalSkill("denied-shell", "Secret: !`printf leaked`")
|
||||
|
||||
const exit = yield* loadSkill("denied-shell", (req) =>
|
||||
// Reject the batch. Tools wrap ctx.ask with Effect.orDie, so this reaches
|
||||
// the injector as a defect and must abort the whole skill load.
|
||||
req.permission === "bash"
|
||||
? Effect.fail(new PermissionV1.RejectedError()).pipe(Effect.orDie)
|
||||
: Effect.void,
|
||||
).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
unix("does not run shell injection for untrusted project skills", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
yield* writeProjectSkill(dir, "untrusted-shell", "Value: !`printf shouldnotrun`")
|
||||
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
const result = yield* loadSkill("untrusted-shell", (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("[skill shell execution disabled for untrusted skill]")
|
||||
expect(result.output).not.toContain("shouldnotrun")
|
||||
// no bash permission ask because nothing was scanned or spawned
|
||||
expect(requests.some((r) => r.permission === "bash")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
unix("does not trust a global skill symlinked into the project", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
// A SKILL.md that lives in the project, symlinked into the trusted ~/.agents/skills dir
|
||||
// (a suggested convenience), must not mint trust for project-controlled markdown.
|
||||
const projectSkillDir = path.join(dir, "skills", "linked")
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(
|
||||
path.join(projectSkillDir, "SKILL.md"),
|
||||
"---\nname: linked\ndescription: linked test skill.\n---\n\nValue: !`printf shouldnotrun`\n",
|
||||
)
|
||||
const linkDir = path.join(HOME, ".agents", "skills", "linked")
|
||||
await fs.promises.mkdir(path.dirname(linkDir), { recursive: true })
|
||||
await fs.promises.symlink(projectSkillDir, linkDir, "dir")
|
||||
})
|
||||
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
const result = yield* loadSkill("linked", (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
)
|
||||
|
||||
// realpath is inside the project → treated as untrusted, no execution, no bash ask
|
||||
expect(result.output).toContain("[skill shell execution disabled for untrusted skill]")
|
||||
expect(result.output).not.toContain("shouldnotrun")
|
||||
expect(requests.some((r) => r.permission === "bash")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
unix("does not execute placeholders inside fenced code blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
// The fenced placeholder is a documentation example and must stay literal; only the
|
||||
// live one runs.
|
||||
yield* writeGlobalSkill("fenced-shell", "Live: !`printf LIVE`\n\n```\nExample: !`printf FENCED`\n```\n")
|
||||
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
const result = yield* loadSkill("fenced-shell", (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Live: LIVE")
|
||||
// the fenced example is left verbatim, not executed
|
||||
expect(result.output).toContain("Example: !`printf FENCED`")
|
||||
expect(result.output).not.toContain("Example: FENCED")
|
||||
// only the live command is authorized
|
||||
const bash = requests.filter((r) => r.permission === "bash")
|
||||
expect(bash[0]?.patterns).toEqual(["printf LIVE"])
|
||||
}),
|
||||
)
|
||||
|
||||
unix("treats a placeholder inside a nested (```` wrapping ```) fence as inert", () =>
|
||||
Effect.gen(function* () {
|
||||
// The common "wrap a ``` example in a ```` fence" pattern must not execute the inner
|
||||
// example; a shorter inner fence does not close the longer outer one.
|
||||
const body = "Live: !`printf LIVE`\n\n````md\n```bash\nExample: !`printf FENCED`\n```\n````\n"
|
||||
yield* writeGlobalSkill("nested-fence", body)
|
||||
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
const result = yield* loadSkill("nested-fence", (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Live: LIVE")
|
||||
expect(result.output).toContain("!`printf FENCED`")
|
||||
expect(result.output).not.toContain("Example: FENCED")
|
||||
const bash = requests.filter((r) => r.permission === "bash")
|
||||
expect(bash[0]?.patterns).toEqual(["printf LIVE"])
|
||||
}),
|
||||
)
|
||||
|
||||
unix("does not re-execute shell placeholders emitted by command output", () =>
|
||||
Effect.gen(function* () {
|
||||
// The command emits a literal placeholder `!<backtick>echo pwned<backtick>`
|
||||
// built from octal escapes so the SKILL.md itself contains no nested
|
||||
// backticks. If render re-scanned command output, `echo pwned` would run.
|
||||
yield* writeGlobalSkill("nested-shell", "Out: !`printf '!\\140echo pwned\\140'`")
|
||||
|
||||
const result = yield* loadSkill("nested-shell", () => Effect.void)
|
||||
|
||||
expect(result.output).toContain("!`echo pwned`")
|
||||
// "pwned" must appear only inside the inert placeholder, never executed alone.
|
||||
expect(result.output).not.toMatch(/Out:\s*pwned\s*$/m)
|
||||
}),
|
||||
)
|
||||
|
||||
unix("truncates oversized command output before inlining", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
// render directly (bypassing the tool's own output truncation) to assert the injector caps output
|
||||
const rendered = yield* SkillInject.render({
|
||||
content: "Out: !`yes x | head -c 65536`",
|
||||
trusted: true,
|
||||
disabled: false,
|
||||
cwd: dir,
|
||||
skill: "big-shell",
|
||||
shell: Shell.acceptable(),
|
||||
ctx: { ...baseCtx, ask: () => Effect.void } as Tool.Context,
|
||||
decompose: ({ command }) => Effect.succeed({ patterns: [command], dirs: [] }),
|
||||
})
|
||||
|
||||
expect(rendered).toContain("[skill shell output truncated]")
|
||||
expect(rendered.length).toBeLessThan(40000)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// The disabled (kill-switch) and untrusted branches must short-circuit before
|
||||
// asking permission or spawning. Passing an ask/spawner that throws proves
|
||||
// neither is reached.
|
||||
describe("SkillInject.render gating", () => {
|
||||
const boom = () => {
|
||||
throw new Error("must not be reached")
|
||||
}
|
||||
const ctx = { ...baseCtx, ask: () => Effect.sync(boom) } as Tool.Context
|
||||
const decompose = (() => Effect.sync(boom)) as unknown as SkillInject.Decompose
|
||||
|
||||
const run = (opts: { trusted: boolean; disabled: boolean; content?: string }) =>
|
||||
Effect.runPromise(
|
||||
SkillInject.render({
|
||||
content: opts.content ?? "Value: !`printf ran`",
|
||||
trusted: opts.trusted,
|
||||
disabled: opts.disabled,
|
||||
cwd: "/tmp",
|
||||
skill: "test",
|
||||
shell: Shell.acceptable(),
|
||||
ctx,
|
||||
decompose,
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("kill-switch replaces every command without asking or running it", () =>
|
||||
Effect.gen(function* () {
|
||||
const out = yield* Effect.promise(() => run({ trusted: true, disabled: true }))
|
||||
expect(out).toBe("Value: [skill shell execution disabled by policy]")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("untrusted skills never ask or run their commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const out = yield* Effect.promise(() => run({ trusted: false, disabled: false }))
|
||||
expect(out).toBe("Value: [skill shell execution disabled for untrusted skill]")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("content without placeholders is returned unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const out = yield* Effect.promise(() => run({ trusted: true, disabled: false, content: "no commands here" }))
|
||||
expect(out).toBe("no commands here")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -24,6 +24,19 @@ beforeAll(async () => {
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// kilocode_change start - serve a crafted index whose skill name escapes the cache via `../`
|
||||
if (url.pathname === "/evil/index.json") {
|
||||
return Response.json({ skills: [{ name: "../../../.agents/skills/evil", files: ["SKILL.md"] }] })
|
||||
}
|
||||
if (url.pathname.endsWith("/.agents/skills/evil/SKILL.md")) {
|
||||
return new Response("---\nname: evil\ndescription: evil.\n---\npwned")
|
||||
}
|
||||
// A file entry pointing at another origin (exfil/arbitrary-host download) must be rejected.
|
||||
if (url.pathname === "/cross-origin/index.json") {
|
||||
return Response.json({ skills: [{ name: "x", files: ["SKILL.md", "https://evil.example/payload"] }] })
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// route /.well-known/skills/* to the fixture directory
|
||||
if (url.pathname.startsWith("/.well-known/skills/")) {
|
||||
const filePath = url.pathname.replace("/.well-known/skills/", "")
|
||||
@@ -114,6 +127,29 @@ describe("Discovery.pull", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// kilocode_change start - path-traversal in the remote index must not plant a trusted skill
|
||||
it.live("rejects a skill name that escapes the cache directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* FSUtil.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(`http://localhost:${server.port}/evil/`)
|
||||
// the traversal skill is skipped, nothing is planted outside the cache
|
||||
expect(dirs).toEqual([])
|
||||
const escaped = path.join(cacheDir, "../../../.agents/skills/evil/SKILL.md")
|
||||
expect(yield* fsys.existsSafe(escaped)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects a skill file that points at another origin", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* Discovery.Service
|
||||
// a file entry resolving to a different host must be dropped (no download, skill skipped)
|
||||
const dirs = yield* discovery.pull(`http://localhost:${server.port}/cross-origin/`)
|
||||
expect(dirs).toEqual([])
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
it.live("caches downloaded files on second pull", () =>
|
||||
Effect.gen(function* () {
|
||||
// clear dir and downloadCount
|
||||
|
||||
@@ -3759,6 +3759,7 @@ export class Permission extends HeyApiClient {
|
||||
workspace?: string
|
||||
reply?: "once" | "always" | "reject"
|
||||
message?: string
|
||||
interactive?: boolean
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -3772,6 +3773,7 @@ export class Permission extends HeyApiClient {
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "reply" },
|
||||
{ in: "body", key: "message" },
|
||||
{ in: "body", key: "interactive" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2103,6 +2103,7 @@ export type Command = {
|
||||
agent?: string
|
||||
model?: string
|
||||
source?: "command" | "mcp" | "skill"
|
||||
trusted?: boolean
|
||||
template: string
|
||||
subtask?: boolean
|
||||
hints: Array<string>
|
||||
@@ -7760,6 +7761,7 @@ export type AppSkillsResponses = {
|
||||
description?: string
|
||||
location: string
|
||||
content: string
|
||||
trusted?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -8671,6 +8673,7 @@ export type PermissionReplyData = {
|
||||
body?: {
|
||||
reply: "once" | "always" | "reject"
|
||||
message?: string
|
||||
interactive?: boolean
|
||||
}
|
||||
path: {
|
||||
requestID: string
|
||||
|
||||
@@ -3137,6 +3137,9 @@
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"trusted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["name", "location", "content"],
|
||||
@@ -5312,6 +5315,9 @@
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"interactive": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["reply"],
|
||||
@@ -30778,6 +30784,9 @@
|
||||
"type": "string",
|
||||
"enum": ["command", "mcp", "skill"]
|
||||
},
|
||||
"trusted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"template": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ConfigProtection } from "@/kilocode/permission/config-paths"
|
||||
import { splitDiffHunks } from "@/kilocode/tui/diff"
|
||||
import { normalizeUrls } from "@/kilocode/util/url"
|
||||
import { MemoryPermissionRegistry } from "@/kilocode/cli/cmd/tui/routes/session/memory-permission"
|
||||
import { skillShellPrompt } from "@/kilocode/skills/display"
|
||||
// kilocode_change end
|
||||
import { KILO_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
@@ -192,6 +193,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
workspace: project.workspace.current(),
|
||||
interactive: true, // kilocode_change - human answered this prompt
|
||||
})
|
||||
}}
|
||||
/>
|
||||
@@ -291,6 +293,20 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
}
|
||||
|
||||
if (permission === "bash") {
|
||||
// kilocode_change start - skill shell batches show the verbatim, escaped commands + skill title
|
||||
const skillShell = skillShellPrompt(props.request.metadata)
|
||||
if (skillShell) {
|
||||
return {
|
||||
icon: "#",
|
||||
title: skillShell.title,
|
||||
body: (
|
||||
<box paddingLeft={1}>
|
||||
<For each={skillShell.commands}>{(cmd) => <text fg={theme.text}>{"$ " + cmd}</text>}</For>
|
||||
</box>
|
||||
),
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
// kilocode_change start
|
||||
const meta = props.request.metadata ?? {}
|
||||
const desc =
|
||||
@@ -446,10 +462,12 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
</box>
|
||||
)
|
||||
|
||||
// kilocode_change start - hide "Always allow" for protected Kilo configuration access
|
||||
const options: Record<string, string> = props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY]
|
||||
? { once: "Allow once", reject: "Reject" }
|
||||
: { once: "Allow once", always: "Allow always", reject: "Reject" }
|
||||
// kilocode_change start - skill shell batches are never persisted: only Allow / Reject
|
||||
const options: Record<string, string> = props.request.metadata?.["skillShell"]
|
||||
? { once: "Allow", reject: "Reject" }
|
||||
: props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY]
|
||||
? { once: "Allow once", reject: "Reject" }
|
||||
: { once: "Allow once", always: "Allow always", reject: "Reject" }
|
||||
// kilocode_change end
|
||||
|
||||
const body = (
|
||||
@@ -483,6 +501,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
requestID: props.request.id,
|
||||
directory: props.directory,
|
||||
workspace: project.workspace.current(),
|
||||
interactive: true, // kilocode_change - human answered this prompt
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user