fix(cli): tag permission rules by origin so auto-approval provenance is accurate

This commit is contained in:
Bruno Agatao
2026-07-24 12:12:58 +02:00
parent 207feccdbc
commit 7f37d015ed
24 changed files with 137 additions and 18 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
})