From 3d4ccc25cf1caee91af93f50be127190bead2a23 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 19 Jun 2026 18:11:54 +0200 Subject: [PATCH] fix(cli): preserve custom subagent permissions --- .changeset/keep-subagent-permissions.md | 5 ++ packages/opencode/src/kilocode/tool/task.ts | 15 +++- .../test/kilocode/task-nesting.test.ts | 74 +++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 .changeset/keep-subagent-permissions.md diff --git a/.changeset/keep-subagent-permissions.md b/.changeset/keep-subagent-permissions.md new file mode 100644 index 0000000000..8df6c7136e --- /dev/null +++ b/.changeset/keep-subagent-permissions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Preserve custom subagent tool permissions when tasks inherit restrictions from their parent agent. diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index d70f6b8f61..17f06cb4f9 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -42,12 +42,18 @@ export namespace KiloTask { } /** - * Build inherited permission rules from the calling agent. + * Build inherited permission ceilings from the calling agent. * Merges the static agent definition with the session's accumulated permissions - * so restrictions survive multi-hop chains (plan → general → explore). + * so denials survive multi-hop chains (plan → general → explore) without + * overriding the selected subagent's own allowlist with parent ask/allow rules. + * + * OpenCode removed parent-agent inheritance entirely in anomalyco/opencode#31696. + * Kilo intentionally differs: parent denials remain hard ceilings for Plan Mode + * and MCP restrictions, while parent ask/allow rules must not replace the + * selected subagent's policy. Preserve this distinction during upstream merges. * * The caller must resolve `caller` (Agent.Info) and `session` (Session.Info) - * before calling — this function is pure/synchronous. + * before calling. This function is pure/synchronous. */ export function inherited(input: { caller: Agent.Info @@ -58,7 +64,8 @@ export namespace KiloTask { const prefixes = Object.keys(input.mcp ?? {}).map((k) => k.replace(/[^a-zA-Z0-9_-]/g, "_") + "_") const isMcp = (p: string) => prefixes.some((prefix) => p.startsWith(prefix)) return rules.filter( - (r: Permission.Rule) => r.permission === "edit" || r.permission === "bash" || isMcp(r.permission), + (r: Permission.Rule) => + r.action === "deny" && (r.permission === "edit" || r.permission === "bash" || isMcp(r.permission)), ) } diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index 2a8751e16f..5e56c90dd5 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -14,6 +14,7 @@ import type { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Provider } from "../../src/provider/provider" +import { Permission } from "../../src/permission" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" import { Truncate } from "../../src/tool/truncate" @@ -232,6 +233,79 @@ describe("Kilo task nesting", () => { ]) }) + it.live("preserves a custom subagent bash policy while inheriting parent denials", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const agents = yield* Agent.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const result = yield* def.execute( + { + description: "validate ansible", + prompt: "run ansible-lint --version", + subagent_type: "validator", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const child = yield* sessions.get(result.metadata.sessionId) + const validator = yield* agents.get("validator") + expect(validator).toBeDefined() + if (!validator) return + + expect(Permission.evaluate("bash", "ansible-lint --version", validator.permission).action).toBe("allow") + expect(Permission.evaluate("bash", "rm -rf build", validator.permission).action).toBe("deny") + + const effective = Permission.merge( + validator.permission, + KiloSessionPrompt.guardPermissions({ agent: validator, session: child }), + ) + expect(child.permission).not.toContainEqual({ permission: "bash", pattern: "*", action: "ask" }) + expect(child.permission).toContainEqual({ permission: "bash", pattern: "rm -rf *", action: "deny" }) + expect({ + allowed: Permission.evaluate("bash", "ansible-lint --version", effective).action, + denied: Permission.evaluate("bash", "rm -rf build", effective).action, + }).toEqual({ allowed: "allow", denied: "deny" }) + }), + { + config: { + permission: { + bash: { + "*": "ask", + "git -c *": "allow", + "echo *": "allow", + "rm -rf *": "deny", + }, + }, + agent: { + validator: { + mode: "subagent", + permission: { + bash: { + "*": "deny", + "*ansible-lint*": "allow", + }, + }, + }, + }, + }, + }, + ), + ) + it.live("refreshes inherited restrictions when resuming a task child", () => provideTmpdirInstance(() => Effect.gen(function* () {