Merge pull request #13001 from Kilo-Org/feat/show-approval-reason-outside-workspace-reads-and-writes

feat(vscode): show approval reason outside workspace reads and writes
This commit is contained in:
bagatao@anaconda.com
2026-08-07 19:38:18 +02:00
committed by GitHub
31 changed files with 225 additions and 19 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show the permission approval reason for reads and writes outside the workspace, matching other tools
@@ -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);
}
@@ -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(() => <ImagePreview src={url} alt={alt} />)
// 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 (
<>
<BasicTool
hideDetails
hideDetails={!approval()?.approval.outsideWorkspace}
{...props}
icon="glasses"
onSubtitleClick={
@@ -54,4 +54,28 @@ describe("resolveToolApproval", () => {
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()
})
})
@@ -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 }) {
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
</Show>
<Show when={props.display.outsideWorkspace}>
{(text) => <span data-slot="tool-approval-outside-workspace">{text()}</span>}
</Show>
</div>
)
}
@@ -73,6 +73,7 @@ function slimEdit(state: Record<string, unknown>): Record<string, unknown> {
}
}
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<string, unknown>): Record<string, unknown> {
if (isObj(meta)) {
const slim: Record<string, unknown> = {}
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<string, unknown>[]).map((f) => {
const diff = patch(f.patch) ?? patch(f.diff)
@@ -115,6 +117,7 @@ function slimMultiedit(state: Record<string, unknown>): Record<string, unknown>
if (isObj(meta)) {
const slim: Record<string, unknown> = {}
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<string, unknown>[]).map((r) => {
const rs: Record<string, unknown> = {}
@@ -149,6 +152,7 @@ function slimWrite(state: Record<string, unknown>): Record<string, unknown> {
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 = {
@@ -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<string, any>
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<string, any>
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<string, any>
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<string, any>
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", () => {
+1
View File
@@ -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}} ملفات",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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",
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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}} فایل تغییر یافته",
+1
View File
@@ -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",
+1
View File
@@ -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...",
+1
View File
@@ -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}} ファイル変更",
+1
View File
@@ -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}}개 파일 변경됨",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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}} файлов изменено",
+1
View File
@@ -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}} ไฟล์ที่เปลี่ยนแปลง",
+1
View File
@@ -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",
+1
View File
@@ -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}} файлів змінено",
+1
View File
@@ -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}} 个文件变更",
+1
View File
@@ -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}} 個檔案變更",
@@ -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<string, unknown> | 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<string, unknown> | undefined,
next: Record<string, unknown> | 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 } : {}),
},
}
}
/**
+22 -8
View File
@@ -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),
),
},
}),
),
@@ -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", () => {