mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
feat(cli): resolve why a tool call was auto-approved
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import type { Permission } from "@/permission"
|
||||
|
||||
/**
|
||||
* Explains *why* a tool call was allowed so clients can surface auto-approval to users.
|
||||
*
|
||||
* A permission rule is a plain object that flows through `Permission.evaluate`'s `findLast`
|
||||
* unchanged, so we hang an optional, non-schema `source` marker on each rule when we assemble
|
||||
* the ruleset. `evaluate`/`resolve` return the matched rule object as-is, letting us read that
|
||||
* marker back out to report the winning source.
|
||||
*/
|
||||
export namespace PermissionProvenance {
|
||||
/** Where the deciding rule came from. */
|
||||
export type Source = "agent" | "global" | "project" | "yolo" | "manual" | "default"
|
||||
|
||||
/** A rule optionally carrying its origin. `source` is runtime-only, never persisted. */
|
||||
export type SourcedRule = Permission.Rule & { source?: Source }
|
||||
|
||||
/** The approval recorded onto a tool call's metadata. */
|
||||
export type Approval = {
|
||||
source: Source
|
||||
/** Agent name when `source` is "agent". */
|
||||
agent?: string
|
||||
/** The winning rule, omitted for manual replies and the ask fallback. */
|
||||
rule?: { permission: string; pattern: string; action: Permission.Action }
|
||||
}
|
||||
|
||||
/** Scope that last set each config permission key (global XDG vs local project). */
|
||||
export type Origins = Record<string, "global" | "local"> | undefined
|
||||
|
||||
/** Origin of a config-derived or agent-default rule, keyed by its permission. */
|
||||
export function configSource(permission: string, origins: Origins): Source {
|
||||
const scope = origins?.[permission]
|
||||
if (scope === "global") return "global"
|
||||
if (scope === "local") return "project"
|
||||
return "agent"
|
||||
}
|
||||
|
||||
/** Classify the winning rule of an auto-approval into an Approval payload. */
|
||||
export function classify(input: { rule?: Permission.Rule; agent: string; origins: Origins }): Approval {
|
||||
const rule = input.rule
|
||||
if (!rule) return { source: "default" }
|
||||
const tagged = (rule as SourcedRule).source
|
||||
const source =
|
||||
tagged ??
|
||||
// Untagged winning rules were contributed inside Permission.ask by saved global approvals.
|
||||
(rule.permission === "*" && rule.pattern === "*" ? "yolo" : configSource(rule.permission, input.origins))
|
||||
return {
|
||||
source,
|
||||
...(source === "agent" ? { agent: input.agent } : {}),
|
||||
rule: { permission: rule.permission, pattern: rule.pattern, action: rule.action },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { KiloSession } from "@/kilocode/session"
|
||||
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order"
|
||||
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionProvenance } from "@/kilocode/permission/provenance"
|
||||
import { Question } from "@/question"
|
||||
import { environmentDetails } from "@/kilocode/editor-context"
|
||||
import { Identifier } from "@/id/id"
|
||||
@@ -229,6 +230,7 @@ export namespace KiloSessionPrompt {
|
||||
permission: Pick<Permission.Interface, "ask">
|
||||
agents: Pick<Agent.Interface, "get">
|
||||
sessions: Pick<Session.Interface, "get">
|
||||
origins?: PermissionProvenance.Origins
|
||||
agent: Agent.Info
|
||||
session: Session.Info
|
||||
request: Omit<Permission.AskInput, "ruleset" | "hardRuleset">
|
||||
@@ -237,11 +239,20 @@ export namespace KiloSessionPrompt {
|
||||
const session = yield* input.sessions
|
||||
.get(input.session.id)
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(input.session)))
|
||||
yield* input.permission.ask({
|
||||
|
||||
// kilocode_change start - tag agent rules with provenance so the winning rule reports its source
|
||||
const tagged: PermissionProvenance.SourcedRule[] = agent.permission.map((rule) => ({
|
||||
...rule,
|
||||
source: PermissionProvenance.configSource(rule.permission, input.origins),
|
||||
}))
|
||||
const outcome = yield* input.permission.ask({
|
||||
...input.request,
|
||||
ruleset: Permission.merge(agent.permission, guardPermissions({ agent, session })),
|
||||
ruleset: Permission.merge(tagged, guardPermissions({ agent, session })),
|
||||
hardRuleset: hardPermissions({ agent }),
|
||||
})
|
||||
if (outcome.manual) return { source: "manual" } satisfies PermissionProvenance.Approval
|
||||
return PermissionProvenance.classify({ rule: outcome.rule, agent: agent.name, origins: input.origins })
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,8 +73,17 @@ export const AllowEverythingInput = z.object({
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - describe why a call was allowed so clients can explain auto-approval
|
||||
export interface AskOutcome {
|
||||
/** true when the user was prompted and replied; false when a rule auto-approved. */
|
||||
manual: boolean
|
||||
/** The winning rule (carries an optional `source` marker set at ruleset-build time). */
|
||||
rule?: Rule
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
|
||||
readonly ask: (input: AskInput) => Effect.Effect<AskOutcome, Error> // kilocode_change - was Effect<void>; returns the decision
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
// kilocode_change start
|
||||
@@ -191,6 +200,7 @@ export const layer = Layer.effect(
|
||||
const local = s.session[request.sessionID] ?? []
|
||||
// kilocode_change end
|
||||
let needsAsk = false
|
||||
let approvedRule: Rule | undefined // kilocode_change - remember the rule that auto-approved
|
||||
|
||||
// kilocode_change start - protect config access while honoring explicit global skill trust
|
||||
const isProtected = ConfigProtection.isRequest(request)
|
||||
@@ -225,12 +235,15 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
// kilocode_change start - override "allow" to "ask" for protected config paths
|
||||
if (rule.action === "allow" && (!isProtected || trusted)) continue
|
||||
if (rule.action === "allow" && (!isProtected || trusted)) {
|
||||
approvedRule = rule // remember the winning rule so callers can explain the auto-approval
|
||||
continue
|
||||
}
|
||||
// kilocode_change end
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!needsAsk) return
|
||||
if (!needsAsk) return { manual: false, rule: approvedRule } // kilocode_change - report auto-approval
|
||||
|
||||
// kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903)
|
||||
if (yield* KiloHeadless.denies(request.sessionID).pipe(Effect.provideService(Database.Service, database))) {
|
||||
@@ -261,12 +274,15 @@ export const layer = Layer.effect(
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, ruleset, hardRuleset, deferred }) // kilocode_change
|
||||
yield* events.publish(Event.Asked, info) // kilocode_change - was bus.publish
|
||||
return yield* Effect.ensuring(
|
||||
// kilocode_change start - was `return yield* Effect.ensuring(...)`; report the manual decision to callers
|
||||
yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
return { manual: true } // the user was prompted and replied
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) {
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -204,7 +204,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
|
||||
// curl was NOT in rules — still requires permission
|
||||
const curlFiber = yield* ask({
|
||||
@@ -255,7 +255,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -325,7 +325,7 @@ describe("saveAlwaysRules", () => {
|
||||
{ permission: "bash", pattern: "gh *", action: "ask" },
|
||||
],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -350,7 +350,7 @@ describe("saveAlwaysRules", () => {
|
||||
ruleset,
|
||||
hardRuleset: ruleset,
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -386,7 +386,7 @@ describe("saveAlwaysRules", () => {
|
||||
],
|
||||
hardRuleset: [{ permission: "*", pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -448,7 +448,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -486,7 +486,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -522,7 +522,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(allowed).toBeUndefined()
|
||||
expect(allowed.manual).toBe(false)
|
||||
|
||||
// "git status" should be denied (only matches broad deny)
|
||||
const exit = yield* ask({
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// kilocode_change - new file
|
||||
// Verifies that Config.permission_origins attributes each permission key to the scope
|
||||
// (global XDG vs local project) that last set it, which drives auto-approval provenance.
|
||||
|
||||
import { expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "../../../src/config/config"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Env } from "../../../src/env"
|
||||
import { Git } from "../../../src/git"
|
||||
import { Auth } from "../../../src/auth"
|
||||
import { Account } from "../../../src/account/account"
|
||||
import { provideTestInstance } from "../../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
const infra = CrossSpawnSpawner.defaultLayer.pipe(
|
||||
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
||||
)
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}) })
|
||||
const noopNpm = Layer.mock(Npm.Service)({
|
||||
install: () => Effect.void,
|
||||
add: () => Effect.die("not implemented"),
|
||||
which: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`))
|
||||
const testLayer = Config.layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)),
|
||||
)
|
||||
|
||||
test("project config permission keys are attributed to the local scope", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "a")
|
||||
const kilo = path.join(dir, ".kilo")
|
||||
await fs.mkdir(kilo, { recursive: true })
|
||||
await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: { "echo *": "allow" } } }))
|
||||
|
||||
await provideTestInstance({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const cfg = await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)),
|
||||
)
|
||||
expect(cfg.permission?.bash).toEqual({ "echo *": "allow" })
|
||||
expect(cfg.permission_origins?.bash).toBe("local")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("a scalar project bash permission is still attributed to the local scope", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "a")
|
||||
const kilo = path.join(dir, ".kilo")
|
||||
await fs.mkdir(kilo, { recursive: true })
|
||||
await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: "allow" } }))
|
||||
|
||||
await provideTestInstance({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const cfg = await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)),
|
||||
)
|
||||
expect(cfg.permission_origins?.bash).toBe("local")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "../../../src/agent/agent"
|
||||
import { Session } from "../../../src/session/session"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { PermissionProvenance } from "../../../src/kilocode/permission/provenance"
|
||||
import { KiloSessionPrompt } from "../../../src/kilocode/session/prompt"
|
||||
import { SessionID } from "../../../src/session/schema"
|
||||
|
||||
describe("PermissionProvenance", () => {
|
||||
test("configSource maps the scope of a permission key", () => {
|
||||
expect(PermissionProvenance.configSource("edit", { edit: "global" })).toBe("global")
|
||||
expect(PermissionProvenance.configSource("edit", { edit: "local" })).toBe("project")
|
||||
expect(PermissionProvenance.configSource("edit", undefined)).toBe("agent")
|
||||
})
|
||||
|
||||
test("evaluate returns the winning rule object, preserving its source tag", () => {
|
||||
// The last matching rule wins; the returned object still carries the source we attached.
|
||||
const ruleset: PermissionProvenance.SourcedRule[] = [
|
||||
{ permission: "edit", pattern: "*", action: "ask", source: "agent" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow", source: "global" },
|
||||
]
|
||||
const winner = Permission.evaluate("edit", "src/index.ts", ruleset)
|
||||
expect((winner as PermissionProvenance.SourcedRule).source).toBe("global")
|
||||
})
|
||||
|
||||
test("classify reads a tagged rule's source and carries the agent name", () => {
|
||||
const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const }
|
||||
expect(PermissionProvenance.classify({ rule, agent: "build", origins: undefined })).toEqual({
|
||||
source: "agent",
|
||||
agent: "build",
|
||||
rule: { permission: "edit", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
|
||||
test("classify treats an untagged broad allow as yolo", () => {
|
||||
const out = PermissionProvenance.classify({
|
||||
rule: { permission: "*", pattern: "*", action: "allow" },
|
||||
agent: "build",
|
||||
origins: undefined,
|
||||
})
|
||||
expect(out.source).toBe("yolo")
|
||||
})
|
||||
|
||||
test("classify falls back to config origins for an untagged rule", () => {
|
||||
const out = PermissionProvenance.classify({
|
||||
rule: { permission: "edit", pattern: "src/*", action: "allow" },
|
||||
agent: "build",
|
||||
origins: { edit: "local" },
|
||||
})
|
||||
expect(out.source).toBe("project")
|
||||
})
|
||||
|
||||
test("classify without a rule reports the ask fallback", () => {
|
||||
expect(PermissionProvenance.classify({ agent: "build", origins: undefined })).toEqual({ source: "default" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("askPermission returns provenance", () => {
|
||||
const sessionID = SessionID.make("ses_prov")
|
||||
const agent: Agent.Info = {
|
||||
name: "build",
|
||||
mode: "primary",
|
||||
permission: Permission.fromConfig({ edit: "allow" }),
|
||||
options: {},
|
||||
}
|
||||
const session = { id: sessionID, permission: [] } as unknown as Session.Info
|
||||
|
||||
const run = (outcome: Permission.AskOutcome, origins?: PermissionProvenance.Origins) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* KiloSessionPrompt.askPermission({
|
||||
permission: yield* Permission.Service,
|
||||
agents: yield* Agent.Service,
|
||||
sessions: yield* Session.Service,
|
||||
origins,
|
||||
agent,
|
||||
session,
|
||||
request: { sessionID, permission: "edit", patterns: ["src/index.ts"], always: [], metadata: {} },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Permission.Service)({ ask: () => Effect.succeed(outcome) }),
|
||||
Layer.mock(Agent.Service)({ get: () => Effect.succeed(agent) }),
|
||||
Layer.mock(Session.Service)({ get: () => Effect.succeed(session) }),
|
||||
),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
test("manual reply reports the manual source", async () => {
|
||||
expect(await run({ manual: true })).toEqual({ source: "manual" })
|
||||
})
|
||||
|
||||
test("agent-default rule classifies as agent with its name", async () => {
|
||||
const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const }
|
||||
expect(await run({ manual: false, rule })).toEqual({
|
||||
source: "agent",
|
||||
agent: "build",
|
||||
rule: { permission: "edit", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
|
||||
test("untagged rule falls back to config origins", async () => {
|
||||
const out = await run(
|
||||
{ manual: false, rule: { permission: "edit", pattern: "src/*", action: "allow" } },
|
||||
{ edit: "local" },
|
||||
)
|
||||
expect(out.source).toBe("project")
|
||||
})
|
||||
})
|
||||
@@ -571,7 +571,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "*", action: "allow" } }) // kilocode_change - ask returns the auto-approval decision instead of void
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -803,7 +803,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "ls", action: "allow" } }) // kilocode_change - the persisted "always" rule auto-approves; ask reports that decision
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -1118,7 +1118,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "*", action: "allow" } }) // kilocode_change - ask returns the auto-approval decision instead of void
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user