diff --git a/.changeset/show-outside-workspace-approval-reason.md b/.changeset/show-outside-workspace-approval-reason.md
new file mode 100644
index 0000000000..1475ae34cd
--- /dev/null
+++ b/.changeset/show-outside-workspace-approval-reason.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Show the permission approval reason for reads and writes outside the workspace, matching other tools
diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css
index 9d7ff5acc7..7f54737247 100644
--- a/packages/kilo-ui/src/components/basic-tool.css
+++ b/packages/kilo-ui/src/components/basic-tool.css
@@ -508,8 +508,8 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
}
/* "why was this allowed" line inside a tool's expanded body. Styled like
- [data-component="tool-hint"] (muted, italic) so it reads as ambient
- context rather than a call to action, and recedes the way reasoning text does. */
+ [data-component="tool-hint"] (muted) so it reads as ambient context rather
+ than a call to action, and recedes the way reasoning text does. */
[data-slot="tool-approval-line"] {
display: flex;
flex-wrap: wrap;
@@ -518,7 +518,6 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
padding: 4px 0 6px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
- font-style: italic;
line-height: var(--line-height-normal);
color: var(--text-weak);
opacity: 0.9;
@@ -528,6 +527,11 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
color: var(--text-weak);
}
+ [data-slot="tool-approval-decision"] {
+ font-weight: var(--font-weight-medium);
+ color: var(--text-strong);
+ }
+
[data-slot="tool-approval-rule"] {
font-family: var(--font-family-mono);
}
diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx
index bce8f1c891..1ddb5a04b6 100644
--- a/packages/kilo-ui/src/components/message-part.tsx
+++ b/packages/kilo-ui/src/components/message-part.tsx
@@ -48,7 +48,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
-import { ToolApprovalProvider, resolveToolApproval } from "./tool-approval"
+import { ToolApprovalProvider, resolveToolApproval, useToolApproval } from "./tool-approval"
export { ToolApprovalProvider, resolveToolApproval, ToolApprovalVisibilityProvider } from "./tool-approval"
import { GrowBox } from "./grow-box"
import { COLLAPSIBLE_SPRING } from "./motion"
@@ -1888,10 +1888,13 @@ ToolRegistry.register({
const pending = createMemo(() => busy(props.status))
const images = createMemo(() => (props.attachments ?? []).filter((f) => f.mime.startsWith("image/") && f.url))
const preview = (url: string, alt?: string) => dialog.show(() => )
+ // Read is high-frequency and low-risk, so details stay hidden unless the target was outside
+ // the workspace, in which case the approval reason explains what looks like an "agent escape".
+ const approval = useToolApproval()
return (
<>
{
expect(out?.source).toBe("ui.approval.source.agent(agent=code)")
expect(out?.rule).toBeUndefined()
})
+
+ test("adds the outsideWorkspace text with just the filename when a path is known", () => {
+ const approval = {
+ source: "agent" as const,
+ agent: "code",
+ outsideWorkspace: true,
+ outsideWorkspacePath: "/etc/secrets/hello.txt",
+ }
+ const out = resolveToolApproval({ approval }, t)
+ expect(out?.outsideWorkspace).toBe("ui.approval.outsideWorkspace(file=hello.txt)")
+ })
+
+ test("omits the outsideWorkspace text for an ordinary in-workspace approval", () => {
+ const approval = { source: "agent" as const, agent: "code" }
+ const out = resolveToolApproval({ approval }, t)
+ expect(out?.outsideWorkspace).toBeUndefined()
+ })
+
+ test("omits the outsideWorkspace text when outsideWorkspace is set but no path is known", () => {
+ // e.g. a bash command scanning multiple external directories has no single filepath to show.
+ const approval = { source: "agent" as const, agent: "code", outsideWorkspace: true }
+ const out = resolveToolApproval({ approval }, t)
+ expect(out?.outsideWorkspace).toBeUndefined()
+ })
})
diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx
index 9fc6e10269..aa9d6c322a 100644
--- a/packages/kilo-ui/src/components/tool-approval.tsx
+++ b/packages/kilo-ui/src/components/tool-approval.tsx
@@ -1,4 +1,5 @@
import { createContext, useContext, Show, type Accessor, type ParentProps } from "solid-js"
+import { getFilename } from "@opencode-ai/core/util/path"
import { Icon } from "./icon"
/**
@@ -12,6 +13,10 @@ export type ToolApproval = {
source: "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default"
agent?: string
rule?: { permission: string; pattern: string; action: string }
+ /** True when the tool call's target path was outside the workspace/worktree. */
+ outsideWorkspace?: boolean
+ /** The target file path, when known, for display as a filename next to the note above. */
+ outsideWorkspacePath?: string
}
/** Pre-resolved, localized text plus the raw approval, supplied by the caller. */
@@ -20,6 +25,7 @@ export type ToolApprovalDisplay = {
decision: string
source?: string
rule?: string
+ outsideWorkspace?: string
}
const SOURCE_KEYS = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const
@@ -82,11 +88,16 @@ export function resolveToolApproval(
rule && !(rule.permission === "*" && rule.pattern === "*")
? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern })
: undefined
+ // Only worth calling out when we know which file it was; a bare "outside your workspace" note
+ // without a filename (e.g. a bash command touching several directories) isn't actionable.
+ const filename = approval.outsideWorkspacePath ? getFilename(approval.outsideWorkspacePath) : undefined
return {
approval,
decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"),
source: sourceText(),
rule: ruleText,
+ outsideWorkspace:
+ approval.outsideWorkspace && filename ? t("ui.approval.outsideWorkspace", { file: filename }) : undefined,
}
}
@@ -101,6 +112,9 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
{(text) => {text()}}
{(text) => {text()}}
+
+ {(text) => {text()}}
+
)
}
diff --git a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts
index df8d576e03..9175ea673d 100644
--- a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts
+++ b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts
@@ -73,6 +73,7 @@ function slimEdit(state: Record): Record {
}
}
if (meta.diagnostics) result.diagnostics = meta.diagnostics
+ if (meta.approval) result.approval = meta.approval
next.metadata = result
return next
}
@@ -84,6 +85,7 @@ function slimPatch(state: Record): Record {
if (isObj(meta)) {
const slim: Record = {}
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
+ if (meta.approval) slim.approval = meta.approval
if (Array.isArray(meta.files)) {
slim.files = (meta.files as Record[]).map((f) => {
const diff = patch(f.patch) ?? patch(f.diff)
@@ -115,6 +117,7 @@ function slimMultiedit(state: Record): Record
if (isObj(meta)) {
const slim: Record = {}
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
+ if (meta.approval) slim.approval = meta.approval
if (Array.isArray(meta.results)) {
slim.results = (meta.results as Record[]).map((r) => {
const rs: Record = {}
@@ -149,6 +152,7 @@ function slimWrite(state: Record): Record {
if (meta.filepath) slim.filepath = meta.filepath
if (meta.exists !== undefined) slim.exists = meta.exists
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
+ if (meta.approval) slim.approval = meta.approval
const fd = meta.filediff
if (isObj(fd)) {
slim.filediff = {
diff --git a/packages/kilo-vscode/tests/unit/slim-metadata.test.ts b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts
index 21c62f4722..96ee681858 100644
--- a/packages/kilo-vscode/tests/unit/slim-metadata.test.ts
+++ b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts
@@ -30,6 +30,10 @@ const BIG = "x".repeat(200_000) // 200 KB — typical file content size
const DIAG = [
{ range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } }, message: "err", severity: 1 },
]
+// Regression for #13001: slimmers used to rebuild `metadata` from an explicit allowlist that
+// didn't include `approval`, silently dropping the auto-approval reason (and the
+// outside-workspace note) before it ever reached the webview.
+const APPROVAL = { source: "agent", agent: "code", outsideWorkspace: true, outsideWorkspacePath: "/tmp/a.ts" }
// ---------------------------------------------------------------------------
// Tests
@@ -96,6 +100,7 @@ describe("slimPart", () => {
diff: BIG,
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 3, deletions: 1 },
diagnostics: { "/a.ts": DIAG },
+ approval: APPROVAL,
},
})
@@ -103,7 +108,7 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
- it("keeps filediff counts and diagnostics", () => {
+ it("keeps filediff counts, diagnostics, and approval", () => {
const slim = slimPart(heavy) as Record
const meta = slim.state.metadata
expect(meta.filediff.file).toBe("/a.ts")
@@ -111,6 +116,7 @@ describe("slimPart", () => {
expect(meta.filediff.additions).toBe(3)
expect(meta.filediff.deletions).toBe(1)
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
+ expect(meta.approval).toEqual(APPROVAL)
})
it("keeps output and input intact", () => {
@@ -176,6 +182,7 @@ describe("slimPart", () => {
},
],
diagnostics: { "/a.ts": DIAG },
+ approval: APPROVAL,
},
})
@@ -183,7 +190,7 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
- it("keeps file summary fields and diagnostics", () => {
+ it("keeps file summary fields, diagnostics, and approval", () => {
const slim = slimPart(heavy) as Record
const meta = slim.state.metadata
expect(meta.files[0].filePath).toBe("/a.ts")
@@ -193,6 +200,7 @@ describe("slimPart", () => {
expect(meta.files[0].additions).toBe(5)
expect(meta.files[1].type).toBe("add")
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
+ expect(meta.approval).toEqual(APPROVAL)
})
it("drops unknown heavy metadata fields", () => {
@@ -266,6 +274,7 @@ describe("slimPart", () => {
},
{ filediff: { file: "/b.ts", before: BIG, after: BIG, additions: 2, deletions: 0 }, diagnostics: {} },
],
+ approval: APPROVAL,
},
})
@@ -273,7 +282,7 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
- it("keeps filediff counts and per-result diagnostics", () => {
+ it("keeps filediff counts, per-result diagnostics, and approval", () => {
const slim = slimPart(heavy) as Record
const meta = slim.state.metadata
expect(meta.results[0].filediff.file).toBe("/a.ts")
@@ -282,6 +291,7 @@ describe("slimPart", () => {
expect(meta.results[0].diagnostics).toEqual({ "/a.ts": DIAG })
expect(meta.results[1].filediff.file).toBe("/b.ts")
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
+ expect(meta.approval).toEqual(APPROVAL)
})
it("drops unknown heavy metadata fields", () => {
@@ -318,6 +328,7 @@ describe("slimPart", () => {
diff: BIG,
filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 100, deletions: 0 },
diagnostics: { "/a.ts": DIAG },
+ approval: APPROVAL,
},
})
@@ -325,7 +336,7 @@ describe("slimPart", () => {
expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES)
})
- it("keeps filepath, exists, filediff counts, diagnostics", () => {
+ it("keeps filepath, exists, filediff counts, diagnostics, and approval", () => {
const slim = slimPart(heavy) as Record
const meta = slim.state.metadata
expect(meta.filepath).toBe("/a.ts")
@@ -335,6 +346,7 @@ describe("slimPart", () => {
expect(meta.filediff.additions).toBe(100)
expect(meta.filediff.deletions).toBe(0)
expect(meta.diagnostics).toEqual({ "/a.ts": DIAG })
+ expect(meta.approval).toEqual(APPROVAL)
})
it("drops unknown heavy metadata fields", () => {
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
index 86f2eef279..9a3310b655 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
@@ -297,6 +297,7 @@ export const dict = {
"ui.approval.source.yolo": "بواسطة وضع الموافقة التلقائية (YOLO)",
"ui.approval.source.session": "بواسطة قاعدة موافقة تلقائية للجلسة",
"ui.approval.source.default": "افتراضيًا",
+ "ui.approval.outsideWorkspace": "(خارج مساحة العمل: {{file}})",
"session.tab.review": "مراجعة",
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts
index d29e57f2ce..0df91b651c 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts
@@ -307,6 +307,7 @@ export const dict = {
"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",
+ "ui.approval.outsideWorkspace": "(fora do seu espaço de trabalho: {{file}})",
"session.tab.review": "Revisão",
"session.review.filesChanged": "{{count}} Arquivos Alterados",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
index cf67d75e52..db0f459014 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
@@ -305,6 +305,7 @@ export const dict = {
"ui.approval.source.yolo": "režimom automatskog odobravanja (YOLO)",
"ui.approval.source.session": "pravilom automatskog odobravanja sesije",
"ui.approval.source.default": "podrazumevano",
+ "ui.approval.outsideWorkspace": "(izvan vašeg radnog prostora: {{file}})",
"session.tab.review": "Pregled",
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts
index e432d83977..6f36adc3d3 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts
@@ -304,6 +304,7 @@ export const dict = {
"ui.approval.source.yolo": "af automatisk godkendelse (YOLO)",
"ui.approval.source.session": "af en session-autogodkendelsesregel",
"ui.approval.source.default": "som standard",
+ "ui.approval.outsideWorkspace": "(uden for dit arbejdsområde: {{file}})",
"session.tab.review": "Gennemgang",
"session.review.filesChanged": "{{count}} Filer ændret",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts
index 3d7058a7ad..4066d9396d 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts
@@ -313,6 +313,7 @@ export const dict = {
"ui.approval.source.yolo": "durch den Auto-Genehmigungsmodus (YOLO)",
"ui.approval.source.session": "durch eine Sitzungs-Auto-Genehmigungsregel",
"ui.approval.source.default": "standardmäßig",
+ "ui.approval.outsideWorkspace": "(außerhalb deines Arbeitsbereichs: {{file}})",
"session.tab.review": "Überprüfung",
"session.review.filesChanged": "{{count}} Dateien geändert",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts
index c0704c00d6..80f804d969 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts
@@ -302,6 +302,7 @@ export const dict = {
"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",
+ "ui.approval.outsideWorkspace": "(outside your workspace: {{file}})",
"session.tab.review": "Review",
"session.review.filesChanged": "{{count}} Files Changed",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts
index 3af575f0c5..558eb4a26a 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts
@@ -308,6 +308,7 @@ export const dict = {
"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",
+ "ui.approval.outsideWorkspace": "(fuera de tu espacio de trabajo: {{file}})",
"session.tab.review": "Revisión",
"session.review.filesChanged": "{{count}} Archivos Cambiados",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts
index 97f0b6b457..cda714862b 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts
@@ -302,6 +302,7 @@ export const dict = {
"ui.approval.source.yolo": "توسط حالت تأیید خودکار (YOLO)",
"ui.approval.source.session": "توسط قانون تأیید خودکار جلسه",
"ui.approval.source.default": "بهطور پیشفرض",
+ "ui.approval.outsideWorkspace": "(خارج از فضای کاری شما: {{file}})",
"session.tab.review": "بررسی",
"session.review.filesChanged": "{{count}} فایل تغییر یافته",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
index be9791bc66..0292d8767a 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
@@ -307,6 +307,7 @@ export const dict = {
"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",
+ "ui.approval.outsideWorkspace": "(hors de votre espace de travail : {{file}})",
"session.tab.review": "Revue",
"session.review.filesChanged": "{{count}} fichiers modifiés",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts
index f1d977e188..10ef2fd2dc 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts
@@ -219,6 +219,7 @@ export const dict = {
"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",
+ "ui.approval.outsideWorkspace": "(fuori dall'area di lavoro: {{file}})",
"session.tab.review": "Revisione",
"session.review.filesChanged": "{{count}} file modificati",
"session.review.loadingChanges": "Caricamento modifiche...",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
index 279600efc2..67c60731ef 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
@@ -304,6 +304,7 @@ export const dict = {
"ui.approval.source.yolo": "自動承認(YOLO)モードによって",
"ui.approval.source.session": "セッションの自動承認ルールによって",
"ui.approval.source.default": "デフォルトで",
+ "ui.approval.outsideWorkspace": "(ワークスペース外:{{file}})",
"session.tab.review": "レビュー",
"session.review.filesChanged": "{{count}} ファイル変更",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
index bfc1ae50ae..e7d0664e7b 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
@@ -305,6 +305,7 @@ export const dict = {
"ui.approval.source.yolo": "자동 승인(YOLO) 모드에 의해",
"ui.approval.source.session": "세션 자동 승인 규칙에 의해",
"ui.approval.source.default": "기본값으로",
+ "ui.approval.outsideWorkspace": "(작업 영역 외부: {{file}})",
"session.tab.review": "검토",
"session.review.filesChanged": "{{count}}개 파일 변경됨",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
index c3ad428f56..40169d8672 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
@@ -308,6 +308,7 @@ export const dict = {
"ui.approval.source.yolo": "door de automatische goedkeuringsmodus (YOLO)",
"ui.approval.source.session": "door een sessie-automatische-goedkeuringsregel",
"ui.approval.source.default": "standaard",
+ "ui.approval.outsideWorkspace": "(buiten je werkruimte: {{file}})",
"session.tab.review": "Beoordelen",
"session.review.filesChanged": "{{count}} bestanden gewijzigd",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts
index 519b8ebc2c..bea79dee72 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts
@@ -311,6 +311,7 @@ export const dict = {
"ui.approval.source.yolo": "av automatisk godkjenning (YOLO)",
"ui.approval.source.session": "av en økt-autogodkjenningsregel",
"ui.approval.source.default": "som standard",
+ "ui.approval.outsideWorkspace": "(utenfor arbeidsområdet ditt: {{file}})",
"session.tab.review": "Gjennomgang",
"session.review.filesChanged": "{{count}} filer endret",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
index dc7f924e6a..701c94122f 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
@@ -305,6 +305,7 @@ export const dict = {
"ui.approval.source.yolo": "przez tryb automatycznego zatwierdzania (YOLO)",
"ui.approval.source.session": "przez regułę automatycznego zatwierdzania sesji",
"ui.approval.source.default": "domyślnie",
+ "ui.approval.outsideWorkspace": "(poza obszarem roboczym: {{file}})",
"session.tab.review": "Przegląd",
"session.review.filesChanged": "Zmieniono {{count}} plików",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
index 0ecea51604..9f64e84a17 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
@@ -303,6 +303,7 @@ export const dict = {
"ui.approval.source.yolo": "режимом автоодобрения (YOLO)",
"ui.approval.source.session": "правилом автоодобрения сессии",
"ui.approval.source.default": "по умолчанию",
+ "ui.approval.outsideWorkspace": "(за пределами вашей рабочей области: {{file}})",
"session.tab.review": "Обзор",
"session.review.filesChanged": "{{count}} файлов изменено",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts
index 1f2cc4ac42..5b0e38ddf1 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts
@@ -302,6 +302,7 @@ export const dict = {
"ui.approval.source.yolo": "โดยโหมดอนุมัติอัตโนมัติ (YOLO)",
"ui.approval.source.session": "โดยกฎอนุมัติอัตโนมัติของเซสชัน",
"ui.approval.source.default": "ตามค่าเริ่มต้น",
+ "ui.approval.outsideWorkspace": "(นอกพื้นที่ทำงานของคุณ: {{file}})",
"session.tab.review": "ตรวจสอบ",
"session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
index e5748071b2..83a9d8f16c 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
@@ -303,6 +303,7 @@ export const dict = {
"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",
+ "ui.approval.outsideWorkspace": "(çalışma alanınızın dışında: {{file}})",
"session.tab.review": "İnceleme",
"session.review.filesChanged": "{{count}} Dosya Değişti",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
index 183912721f..e773ee2d54 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
@@ -307,6 +307,7 @@ export const dict = {
"ui.approval.source.yolo": "режимом автосхвалення (YOLO)",
"ui.approval.source.session": "правилом автосхвалення сесії",
"ui.approval.source.default": "за замовчуванням",
+ "ui.approval.outsideWorkspace": "(за межами вашого робочого простору: {{file}})",
"session.tab.review": "Огляд",
"session.review.filesChanged": "{{count}} файлів змінено",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
index dd35db0e2d..249a332bd1 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
@@ -292,6 +292,7 @@ export const dict = {
"ui.approval.source.yolo": "由自动批准(YOLO)模式",
"ui.approval.source.session": "由会话自动批准规则",
"ui.approval.source.default": "默认",
+ "ui.approval.outsideWorkspace": "(工作区之外:{{file}})",
"session.tab.review": "审查",
"session.review.filesChanged": "{{count}} 个文件变更",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
index aabed9a3e8..93746129e7 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
@@ -290,6 +290,7 @@ export const dict = {
"ui.approval.source.yolo": "由自動核准(YOLO)模式",
"ui.approval.source.session": "由工作階段自動核准規則",
"ui.approval.source.default": "預設",
+ "ui.approval.outsideWorkspace": "(工作區之外:{{file}})",
"session.tab.review": "審查",
"session.review.filesChanged": "{{count}} 個檔案變更",
diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts
index 73f7fec4e0..38b244448e 100644
--- a/packages/opencode/src/kilocode/permission/provenance.ts
+++ b/packages/opencode/src/kilocode/permission/provenance.ts
@@ -27,6 +27,21 @@ export namespace PermissionProvenance {
agent?: string
/** The winning rule, omitted for manual replies and the ask fallback. */
rule?: { permission: string; pattern: string; action: Permission.Action }
+ /** True when the ask's target path was outside the workspace/worktree (an `external_directory` ask). */
+ outsideWorkspace?: boolean
+ /** The target file path, when the `external_directory` ask carried one, for display as a filename. */
+ outsideWorkspacePath?: string
+ }
+
+ /** The `filepath` an `external_directory` ask's metadata carries, if any (see `Tool.assertExternalDirectory`). */
+ export function filepathOf(metadata: Record | undefined): string | undefined {
+ return typeof metadata?.filepath === "string" ? metadata.filepath : undefined
+ }
+
+ /** Tag an approval as outside-workspace when it answers an `external_directory` ask. */
+ export function tagOutsideWorkspace(approval: Approval, permission: string, path?: string): Approval {
+ if (permission !== "external_directory") return approval
+ return { ...approval, outsideWorkspace: true, ...(path ? { outsideWorkspacePath: path } : {}) }
}
export type Scope = "global" | "local"
@@ -71,13 +86,29 @@ export namespace PermissionProvenance {
* The approval is written once during `ask()`, but tools freely overwrite `state.metadata`
* during execution and on completion. Carry the prior `approval` onto the replacement unless
* the replacement sets its own.
+ *
+ * A file tool that crosses the workspace boundary issues *two* asks for one call: the generic
+ * `external_directory` ask first, then its own `read`/`write`/`edit` ask. Both write `approval`
+ * metadata, so the second ask's `outsideWorkspace` marker would otherwise clobber the first's
+ * even though `"approval" in next` is true. Merge that marker forward so it survives.
*/
export function carryApproval(
prev: Record | undefined,
next: Record | undefined,
) {
- if (!next || !prev?.approval || "approval" in next) return next
- return { ...next, approval: prev.approval }
+ if (!next) return next
+ const prior = prev?.approval as Approval | undefined
+ if (!("approval" in next)) return prior ? { ...next, approval: prior } : next
+ const current = next.approval as Approval | undefined
+ if (!prior?.outsideWorkspace || !current || current.outsideWorkspace) return next
+ return {
+ ...next,
+ approval: {
+ ...current,
+ outsideWorkspace: true,
+ ...(prior.outsideWorkspacePath ? { outsideWorkspacePath: prior.outsideWorkspacePath } : {}),
+ },
+ }
}
/**
diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts
index 2d7f45fbf2..9b4907512a 100644
--- a/packages/opencode/src/session/tools.ts
+++ b/packages/opencode/src/session/tools.ts
@@ -97,18 +97,32 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
},
}).pipe(
// record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask
- Effect.tap((approval) => input.processor.metadata(options.toolCallId, { metadata: { approval } })),
+ Effect.tap((approval) =>
+ input.processor.metadata(options.toolCallId, {
+ metadata: {
+ approval: PermissionProvenance.tagOutsideWorkspace(
+ approval,
+ req.permission,
+ PermissionProvenance.filepathOf(req.metadata),
+ ),
+ },
+ }),
+ ),
// record why the call was denied too, so JSON exports and clients can explain the denial
Effect.tapErrorTag("PermissionDeniedError", (err) =>
input.processor.metadata(options.toolCallId, {
metadata: {
- approval: PermissionProvenance.classifyDenial({
- ruleset: err.ruleset,
- permission: req.permission,
- patterns: req.patterns,
- agent: input.agent.name,
- origins: permissionOrigins,
- }),
+ approval: PermissionProvenance.tagOutsideWorkspace(
+ PermissionProvenance.classifyDenial({
+ ruleset: err.ruleset,
+ permission: req.permission,
+ patterns: req.patterns,
+ agent: input.agent.name,
+ origins: permissionOrigins,
+ }),
+ req.permission,
+ PermissionProvenance.filepathOf(req.metadata),
+ ),
},
}),
),
diff --git a/packages/opencode/test/kilocode/permission/provenance.test.ts b/packages/opencode/test/kilocode/permission/provenance.test.ts
index ad179e7e71..b2ffae3ecd 100644
--- a/packages/opencode/test/kilocode/permission/provenance.test.ts
+++ b/packages/opencode/test/kilocode/permission/provenance.test.ts
@@ -121,6 +121,80 @@ describe("PermissionProvenance.carryApproval", () => {
test("returns the replacement as-is when it is undefined", () => {
expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined()
})
+
+ test("merges outsideWorkspace onto a replacement's own approval instead of clobbering it", () => {
+ // A file tool crossing the workspace boundary asks twice: external_directory first, then its
+ // own read/write/edit ask. The second ask's approval must not lose the outsideWorkspace marker.
+ const outside = { source: "manual" as const, outsideWorkspace: true }
+ const next = { approval: { source: "agent" as const, agent: "build" } }
+ expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toEqual({
+ approval: { source: "agent", agent: "build", outsideWorkspace: true },
+ })
+ })
+
+ test("also carries the outsideWorkspacePath forward alongside the marker", () => {
+ const outside = { source: "manual" as const, outsideWorkspace: true, outsideWorkspacePath: "/tmp/secret.txt" }
+ const next = { approval: { source: "agent" as const, agent: "build" } }
+ expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toEqual({
+ approval: { source: "agent", agent: "build", outsideWorkspace: true, outsideWorkspacePath: "/tmp/secret.txt" },
+ })
+ })
+
+ test("does not add outsideWorkspace when the prior approval was not outside the workspace", () => {
+ const next = { approval: { source: "agent" as const, agent: "build" } }
+ expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next)
+ })
+
+ test("leaves a replacement's own outsideWorkspace marker untouched", () => {
+ const outside = { source: "manual" as const, outsideWorkspace: true }
+ const next = { approval: { source: "agent" as const, agent: "build", outsideWorkspace: true } }
+ expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toBe(next)
+ })
+})
+
+describe("PermissionProvenance.tagOutsideWorkspace", () => {
+ test("marks an external_directory approval as outsideWorkspace", () => {
+ const approval = { source: "manual" as const }
+ expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory")).toEqual({
+ source: "manual",
+ outsideWorkspace: true,
+ })
+ })
+
+ test("leaves other permissions' approvals untouched", () => {
+ const approval = { source: "manual" as const }
+ expect(PermissionProvenance.tagOutsideWorkspace(approval, "read")).toBe(approval)
+ })
+
+ test("carries the target path when one is given", () => {
+ const approval = { source: "manual" as const }
+ expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory", "/tmp/secret.txt")).toEqual({
+ source: "manual",
+ outsideWorkspace: true,
+ outsideWorkspacePath: "/tmp/secret.txt",
+ })
+ })
+
+ test("omits outsideWorkspacePath when no path is given", () => {
+ const approval = { source: "manual" as const }
+ expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory")).toEqual({
+ source: "manual",
+ outsideWorkspace: true,
+ })
+ })
+})
+
+describe("PermissionProvenance.filepathOf", () => {
+ test("reads the filepath an external_directory ask's metadata carries", () => {
+ expect(PermissionProvenance.filepathOf({ filepath: "/tmp/secret.txt", parentDir: "/tmp" })).toBe(
+ "/tmp/secret.txt",
+ )
+ })
+
+ test("returns undefined when there is no filepath, e.g. a bash directory scan", () => {
+ expect(PermissionProvenance.filepathOf({ command: "cat /tmp/secret.txt", access: "read" })).toBeUndefined()
+ expect(PermissionProvenance.filepathOf(undefined)).toBeUndefined()
+ })
})
describe("askPermission returns provenance", () => {