mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix(cli): tag permission rules by origin so auto-approval provenance is accurate
This commit is contained in:
@@ -8,7 +8,7 @@ import { createContext, useContext, Show, type Accessor, type ParentProps } from
|
||||
* free of any i18n key coupling.
|
||||
*/
|
||||
export type ToolApproval = {
|
||||
source: "agent" | "global" | "project" | "yolo" | "manual" | "default"
|
||||
source: "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default"
|
||||
agent?: string
|
||||
rule?: { permission: string; pattern: string; action: string }
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export type ToolApprovalDisplay = {
|
||||
rule?: string
|
||||
}
|
||||
|
||||
const SOURCE_KEYS = ["agent", "global", "project", "yolo", "manual", "default"] as const
|
||||
const SOURCE_KEYS = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const
|
||||
|
||||
const Context = createContext<Accessor<ToolApprovalDisplay | undefined>>(() => undefined)
|
||||
|
||||
|
||||
+1
@@ -649,6 +649,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "بواسطة الإعداد العام",
|
||||
"ui.approval.source.project": "بواسطة إعداد المشروع",
|
||||
"ui.approval.source.yolo": "بواسطة وضع الموافقة التلقائية (YOLO)",
|
||||
"ui.approval.source.session": "بواسطة قاعدة موافقة تلقائية للجلسة",
|
||||
"ui.approval.source.default": "افتراضيًا",
|
||||
"notification.question.title": "سؤال",
|
||||
"notification.question.description": "{{sessionTitle}} في {{projectName}} لديه سؤال",
|
||||
|
||||
+1
@@ -660,6 +660,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "pela configuração global",
|
||||
"ui.approval.source.project": "pela configuração do projeto",
|
||||
"ui.approval.source.yolo": "pelo modo de aprovação automática (YOLO)",
|
||||
"ui.approval.source.session": "por uma regra de aprovação automática da sessão",
|
||||
"ui.approval.source.default": "por padrão",
|
||||
"notification.question.title": "Pergunta",
|
||||
"notification.question.description": "{{sessionTitle}} em {{projectName}} tem uma pergunta",
|
||||
|
||||
+1
@@ -661,6 +661,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "globalnom konfiguracijom",
|
||||
"ui.approval.source.project": "konfiguracijom projekta",
|
||||
"ui.approval.source.yolo": "režimom automatskog odobravanja (YOLO)",
|
||||
"ui.approval.source.session": "pravilom automatskog odobravanja sesije",
|
||||
"ui.approval.source.default": "podrazumevano",
|
||||
"notification.question.title": "Pitanje",
|
||||
"notification.question.description": "{{sessionTitle}} u {{projectName}} ima pitanje",
|
||||
|
||||
+1
@@ -658,6 +658,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "af din globale konfiguration",
|
||||
"ui.approval.source.project": "af projektkonfigurationen",
|
||||
"ui.approval.source.yolo": "af automatisk godkendelse (YOLO)",
|
||||
"ui.approval.source.session": "af en session-autogodkendelsesregel",
|
||||
"ui.approval.source.default": "som standard",
|
||||
"notification.question.title": "Spørgsmål",
|
||||
"notification.question.description": "{{sessionTitle}} i {{projectName}} har et spørgsmål",
|
||||
|
||||
@@ -670,6 +670,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "durch deine globale Konfiguration",
|
||||
"ui.approval.source.project": "durch die Projektkonfiguration",
|
||||
"ui.approval.source.yolo": "durch den Auto-Genehmigungsmodus (YOLO)",
|
||||
"ui.approval.source.session": "durch eine Sitzungs-Auto-Genehmigungsregel",
|
||||
"ui.approval.source.default": "standardmäßig",
|
||||
"notification.question.title": "Frage",
|
||||
"notification.question.description": "{{sessionTitle}} in {{projectName}} hat eine Frage",
|
||||
|
||||
@@ -657,6 +657,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "by your global config",
|
||||
"ui.approval.source.project": "by the project config",
|
||||
"ui.approval.source.yolo": "by auto-approve (YOLO) mode",
|
||||
"ui.approval.source.session": "by a session auto-approve rule",
|
||||
"ui.approval.source.default": "by default",
|
||||
"notification.question.title": "Question",
|
||||
"notification.question.description": "{{sessionTitle}} in {{projectName}} has a question",
|
||||
|
||||
+1
@@ -664,6 +664,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "por tu configuración global",
|
||||
"ui.approval.source.project": "por la configuración del proyecto",
|
||||
"ui.approval.source.yolo": "por el modo de aprobación automática (YOLO)",
|
||||
"ui.approval.source.session": "por una regla de aprobación automática de sesión",
|
||||
"ui.approval.source.default": "de forma predeterminada",
|
||||
"notification.question.title": "Pregunta",
|
||||
"notification.question.description": "{{sessionTitle}} en {{projectName}} tiene una pregunta",
|
||||
|
||||
+1
@@ -668,6 +668,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "par votre configuration globale",
|
||||
"ui.approval.source.project": "par la configuration du projet",
|
||||
"ui.approval.source.yolo": "par le mode d'approbation automatique (YOLO)",
|
||||
"ui.approval.source.session": "par une règle d'approbation automatique de session",
|
||||
"ui.approval.source.default": "par défaut",
|
||||
"notification.question.title": "Question",
|
||||
"notification.question.description": "{{sessionTitle}} dans {{projectName}} a une question",
|
||||
|
||||
+1
@@ -536,6 +536,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "dalla configurazione globale",
|
||||
"ui.approval.source.project": "dalla configurazione del progetto",
|
||||
"ui.approval.source.yolo": "dalla modalità di approvazione automatica (YOLO)",
|
||||
"ui.approval.source.session": "da una regola di approvazione automatica della sessione",
|
||||
"ui.approval.source.default": "per impostazione predefinita",
|
||||
"notification.question.title": "Domanda",
|
||||
"notification.question.description": "{{sessionTitle}} in {{projectName}} ha una domanda",
|
||||
|
||||
+1
@@ -656,6 +656,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "グローバル設定によって",
|
||||
"ui.approval.source.project": "プロジェクト設定によって",
|
||||
"ui.approval.source.yolo": "自動承認(YOLO)モードによって",
|
||||
"ui.approval.source.session": "セッションの自動承認ルールによって",
|
||||
"ui.approval.source.default": "デフォルトで",
|
||||
"notification.question.title": "質問",
|
||||
"notification.question.description": "{{projectName}} の {{sessionTitle}} から質問があります",
|
||||
|
||||
+1
@@ -656,6 +656,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "전역 설정에 의해",
|
||||
"ui.approval.source.project": "프로젝트 설정에 의해",
|
||||
"ui.approval.source.yolo": "자동 승인(YOLO) 모드에 의해",
|
||||
"ui.approval.source.session": "세션 자동 승인 규칙에 의해",
|
||||
"ui.approval.source.default": "기본값으로",
|
||||
"notification.question.title": "질문",
|
||||
"notification.question.description": "{{projectName}}의 {{sessionTitle}}에서 질문이 있습니다",
|
||||
|
||||
+1
@@ -663,6 +663,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "door je globale configuratie",
|
||||
"ui.approval.source.project": "door de projectconfiguratie",
|
||||
"ui.approval.source.yolo": "door de automatische goedkeuringsmodus (YOLO)",
|
||||
"ui.approval.source.session": "door een sessie-automatische-goedkeuringsregel",
|
||||
"ui.approval.source.default": "standaard",
|
||||
"notification.question.title": "Vraag",
|
||||
"notification.question.description": "{{sessionTitle}} in {{projectName}} heeft een vraag",
|
||||
|
||||
+1
@@ -663,6 +663,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "av den globale konfigurasjonen",
|
||||
"ui.approval.source.project": "av prosjektkonfigurasjonen",
|
||||
"ui.approval.source.yolo": "av automatisk godkjenning (YOLO)",
|
||||
"ui.approval.source.session": "av en økt-autogodkjenningsregel",
|
||||
"ui.approval.source.default": "som standard",
|
||||
"notification.question.title": "Spørsmål",
|
||||
"notification.question.description": "{{sessionTitle}} i {{projectName}} har et spørsmål",
|
||||
|
||||
+1
@@ -660,6 +660,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "przez konfigurację globalną",
|
||||
"ui.approval.source.project": "przez konfigurację projektu",
|
||||
"ui.approval.source.yolo": "przez tryb automatycznego zatwierdzania (YOLO)",
|
||||
"ui.approval.source.session": "przez regułę automatycznego zatwierdzania sesji",
|
||||
"ui.approval.source.default": "domyślnie",
|
||||
"notification.question.title": "Pytanie",
|
||||
"notification.question.description": "{{sessionTitle}} w {{projectName}} ma pytanie",
|
||||
|
||||
+1
@@ -660,6 +660,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "вашей глобальной конфигурацией",
|
||||
"ui.approval.source.project": "конфигурацией проекта",
|
||||
"ui.approval.source.yolo": "режимом автоодобрения (YOLO)",
|
||||
"ui.approval.source.session": "правилом автоодобрения сессии",
|
||||
"ui.approval.source.default": "по умолчанию",
|
||||
"notification.question.title": "Вопрос",
|
||||
"notification.question.description": "У {{sessionTitle}} в {{projectName}} есть вопрос",
|
||||
|
||||
+1
@@ -654,6 +654,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "โดยการตั้งค่าส่วนกลาง",
|
||||
"ui.approval.source.project": "โดยการตั้งค่าโปรเจกต์",
|
||||
"ui.approval.source.yolo": "โดยโหมดอนุมัติอัตโนมัติ (YOLO)",
|
||||
"ui.approval.source.session": "โดยกฎอนุมัติอัตโนมัติของเซสชัน",
|
||||
"ui.approval.source.default": "ตามค่าเริ่มต้น",
|
||||
"notification.question.title": "คำถาม",
|
||||
"notification.question.description": "{{sessionTitle}} ใน {{projectName}} มีคำถาม",
|
||||
|
||||
+1
@@ -660,6 +660,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "genel yapılandırmanız tarafından",
|
||||
"ui.approval.source.project": "proje yapılandırması tarafından",
|
||||
"ui.approval.source.yolo": "otomatik onay (YOLO) modu tarafından",
|
||||
"ui.approval.source.session": "bir oturum otomatik onay kuralı tarafından",
|
||||
"ui.approval.source.default": "varsayılan olarak",
|
||||
"notification.question.title": "Soru",
|
||||
"notification.question.description": "{{projectName}} içindeki {{sessionTitle}} bir soru soruyor",
|
||||
|
||||
+1
@@ -662,6 +662,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "вашою глобальною конфігурацією",
|
||||
"ui.approval.source.project": "конфігурацією проєкту",
|
||||
"ui.approval.source.yolo": "режимом автосхвалення (YOLO)",
|
||||
"ui.approval.source.session": "правилом автосхвалення сесії",
|
||||
"ui.approval.source.default": "за замовчуванням",
|
||||
"notification.question.title": "Питання",
|
||||
"notification.question.description": "{{sessionTitle}} у {{projectName}} задає питання",
|
||||
|
||||
+1
@@ -641,6 +641,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "由你的全局配置",
|
||||
"ui.approval.source.project": "由项目配置",
|
||||
"ui.approval.source.yolo": "由自动批准(YOLO)模式",
|
||||
"ui.approval.source.session": "由会话自动批准规则",
|
||||
"ui.approval.source.default": "默认",
|
||||
"notification.question.title": "问题",
|
||||
"notification.question.description": "{{sessionTitle}}({{projectName}})有一个问题",
|
||||
|
||||
+1
@@ -640,6 +640,7 @@ export const dict = {
|
||||
"ui.approval.source.global": "由你的全域設定",
|
||||
"ui.approval.source.project": "由專案設定",
|
||||
"ui.approval.source.yolo": "由自動核准(YOLO)模式",
|
||||
"ui.approval.source.session": "由工作階段自動核准規則",
|
||||
"ui.approval.source.default": "預設",
|
||||
"notification.question.title": "問題",
|
||||
"notification.question.description": "{{sessionTitle}}({{projectName}})有一個問題",
|
||||
|
||||
@@ -10,11 +10,16 @@ import type { Permission } from "@/permission"
|
||||
*/
|
||||
export namespace PermissionProvenance {
|
||||
/** Where the deciding rule came from. */
|
||||
export type Source = "agent" | "global" | "project" | "yolo" | "manual" | "default"
|
||||
export type Source = "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default"
|
||||
|
||||
/** A rule optionally carrying its origin. `source` is runtime-only, never persisted. */
|
||||
export type SourcedRule = Permission.Rule & { source?: Source }
|
||||
|
||||
/** True for the broad allow rule that auto-approve (YOLO) mode installs. */
|
||||
function isYolo(rule: Permission.Rule) {
|
||||
return rule.permission === "*" && rule.pattern === "*" && rule.action === "allow"
|
||||
}
|
||||
|
||||
/** The approval recorded onto a tool call's metadata. */
|
||||
export type Approval = {
|
||||
source: Source
|
||||
@@ -35,6 +40,22 @@ export namespace PermissionProvenance {
|
||||
return "agent"
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag agent-owned rules with their config origin (global/project) or the agent default.
|
||||
* These come from the agent's merged permission set.
|
||||
*/
|
||||
export function tagAgent(ruleset: Permission.Ruleset, origins: Origins): SourcedRule[] {
|
||||
return ruleset.map((rule) => ({ ...rule, source: configSource(rule.permission, origins) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag session-scoped rules. The broad allow rule is auto-approve (YOLO) mode, which is stored
|
||||
* on the session; any other session rule is an explicit per-session runtime toggle.
|
||||
*/
|
||||
export function tagSession(ruleset: Permission.Ruleset): SourcedRule[] {
|
||||
return ruleset.map((rule) => ({ ...rule, source: isYolo(rule) ? "yolo" : "session" }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve an existing `approval` marker when a tool part's metadata is replaced.
|
||||
*
|
||||
@@ -50,15 +71,18 @@ export namespace PermissionProvenance {
|
||||
return { ...next, approval: prev.approval }
|
||||
}
|
||||
|
||||
/** Classify the winning rule of an auto-approval into an Approval payload. */
|
||||
/**
|
||||
* Classify the winning rule of an auto-approval into an Approval payload.
|
||||
*
|
||||
* Rules assembled by `askPermission` are tagged with their true origin, so we read the tag
|
||||
* directly. An untagged winner can only come from the saved global approvals that `Permission.ask`
|
||||
* merges internally: the broad allow rule there is YOLO mode, otherwise fall back to config origin.
|
||||
*/
|
||||
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))
|
||||
(rule as SourcedRule).source ?? (isYolo(rule) ? "yolo" : configSource(rule.permission, input.origins))
|
||||
return {
|
||||
source,
|
||||
...(source === "agent" ? { agent: input.agent } : {}),
|
||||
|
||||
@@ -240,16 +240,17 @@ export namespace KiloSessionPrompt {
|
||||
.get(input.session.id)
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(input.session)))
|
||||
|
||||
// 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(tagged, guardPermissions({ agent, session })),
|
||||
hardRuleset: hardPermissions({ agent }),
|
||||
})
|
||||
// kilocode_change start - tag every rule with its true origin before merging, so the winning
|
||||
// rule (chosen by findLast) reports the correct source instead of classify() having to guess.
|
||||
// guardPermissions re-appends agent.permission for ask/plan/architect modes and prepends
|
||||
// session.permission, so tag those inputs up front rather than the outer copy alone.
|
||||
const taggedAgent = PermissionProvenance.tagAgent(agent.permission, input.origins)
|
||||
const taggedSession = PermissionProvenance.tagSession(session.permission ?? [])
|
||||
const ruleset = Permission.merge(
|
||||
taggedAgent,
|
||||
guardPermissions({ agent: { name: agent.name, permission: taggedAgent }, session: { permission: taggedSession } }),
|
||||
)
|
||||
const outcome = yield* input.permission.ask({ ...input.request, ruleset, 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
|
||||
|
||||
@@ -54,6 +54,39 @@ describe("PermissionProvenance", () => {
|
||||
test("classify without a rule reports the ask fallback", () => {
|
||||
expect(PermissionProvenance.classify({ agent: "build", origins: undefined })).toEqual({ source: "default" })
|
||||
})
|
||||
|
||||
test("tagAgent stamps each rule with its config origin, defaulting to agent", () => {
|
||||
const tagged = PermissionProvenance.tagAgent(
|
||||
[
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "edit", pattern: "*", action: "allow" },
|
||||
],
|
||||
{ bash: "local" },
|
||||
)
|
||||
expect(tagged.map((r) => r.source)).toEqual(["project", "agent"])
|
||||
})
|
||||
|
||||
test("tagSession marks the broad allow as yolo and other rules as session", () => {
|
||||
const tagged = PermissionProvenance.tagSession([
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "git *", action: "allow" },
|
||||
])
|
||||
expect(tagged.map((r) => r.source)).toEqual(["yolo", "session"])
|
||||
})
|
||||
|
||||
test("a tagged agent rule wins over an untagged duplicate and is not misread as yolo", () => {
|
||||
// Regression: guardPermissions re-appends agent rules for ask/plan/architect; every rule that
|
||||
// reaches evaluate must be tagged so the broad agent allow is not mistaken for YOLO mode.
|
||||
const agent = PermissionProvenance.tagAgent([{ permission: "*", pattern: "*", action: "allow" }], undefined)
|
||||
const session = PermissionProvenance.tagSession([])
|
||||
const ruleset = [...agent, ...session, ...agent] // mirrors merge(tagged, guardPermissions(...)) for a mode
|
||||
const winner = Permission.evaluate("bash", "echo hi", ruleset)
|
||||
expect(PermissionProvenance.classify({ rule: winner, agent: "plan", origins: undefined })).toEqual({
|
||||
source: "agent",
|
||||
agent: "plan",
|
||||
rule: { permission: "*", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionProvenance.carryApproval", () => {
|
||||
@@ -134,4 +167,45 @@ describe("askPermission returns provenance", () => {
|
||||
)
|
||||
expect(out.source).toBe("project")
|
||||
})
|
||||
|
||||
test("every rule passed to ask is tagged, even the guardPermissions re-append for modes", async () => {
|
||||
// Regression guard: a plan/ask/architect agent's rules are duplicated by guardPermissions.
|
||||
// Capture the ruleset askPermission builds and confirm no rule reaches evaluate untagged.
|
||||
const captured: Permission.Ruleset[] = []
|
||||
const planAgent: Agent.Info = {
|
||||
name: "plan",
|
||||
mode: "primary",
|
||||
permission: Permission.fromConfig({ bash: "allow" }),
|
||||
options: {},
|
||||
}
|
||||
const planSession = { id: sessionID, permission: [{ permission: "edit", pattern: "*", action: "deny" }] } as unknown as Session.Info
|
||||
await Effect.gen(function* () {
|
||||
yield* KiloSessionPrompt.askPermission({
|
||||
permission: yield* Permission.Service,
|
||||
agents: yield* Agent.Service,
|
||||
sessions: yield* Session.Service,
|
||||
agent: planAgent,
|
||||
session: planSession,
|
||||
request: { sessionID, permission: "bash", patterns: ["echo hi"], always: [], metadata: {} },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Permission.Service)({
|
||||
ask: (req) =>
|
||||
Effect.sync(() => {
|
||||
captured.push(req.ruleset)
|
||||
return { manual: false } as const
|
||||
}),
|
||||
}),
|
||||
Layer.mock(Agent.Service)({ get: () => Effect.succeed(planAgent) }),
|
||||
Layer.mock(Session.Service)({ get: () => Effect.succeed(planSession) }),
|
||||
),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
const ruleset = captured[0]
|
||||
expect(ruleset.length).toBeGreaterThan(0)
|
||||
expect(ruleset.every((rule) => (rule as PermissionProvenance.SourcedRule).source !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user