From 63d35d06cf03a27766b47f02a37008706ec98540 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Thu, 30 Jul 2026 18:04:39 +0200 Subject: [PATCH 1/7] feat(cli): record why a tool call was denied on its metadata Auto-approval provenance was only recorded on the metadata of allowed tool calls (state.metadata.approval), so denied calls had no structured explanation of which rule/config/agent denied them. Since 'kilo export' serializes state.metadata verbatim into the JSON session log, denials showed up with no provenance at all. Add PermissionProvenance.classifyDenial, which reads the deciding deny rule off a DeniedError's tagged ruleset and classifies it the same way approvals are classified. Wire it into SessionTools' ctx.ask via Effect.tapErrorTag so denials are recorded before the tool call fails, reusing the existing carryApproval/failToolCall preservation so the metadata survives onto the final error state. --- .../src/kilocode/permission/provenance.ts | 13 ++++++++ packages/opencode/src/session/tools.ts | 13 ++++++++ .../kilocode/sandbox/session-tools.test.ts | 31 +++++++++++++++++-- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index 50e4309bdb..abf9647d72 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -98,4 +98,17 @@ export namespace PermissionProvenance { rule: { permission: rule.permission, pattern: rule.pattern, action: rule.action }, } } + + /** + * Classify why a tool call was denied, from the `ruleset` a `DeniedError` carries. + * + * `DeniedError.ruleset` is untyped (`Schema.Any`) but is always the tagged ruleset `askPermission` + * built, filtered to the request's permission. The last `deny` rule in it is the one that decided. + */ + export function classifyDenial(input: { ruleset: unknown; agent: string; origins: Origins }): Approval { + const rule = Array.isArray(input.ruleset) + ? (input.ruleset as Permission.Rule[]).findLast((rule) => rule.action === "deny") + : undefined + return classify({ rule, agent: input.agent, origins: input.origins }) + } } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f6d78654cb..f33c8fc627 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -26,6 +26,7 @@ import { ModelV2 } from "@opencode-ai/core/model" // kilocode_change start import { SwePruner } from "@/kilocode/swe-pruner" import { Config } from "@/config/config" +import { PermissionProvenance } from "@/kilocode/permission/provenance" // kilocode_change end export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { @@ -82,6 +83,18 @@ 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 } })), + // 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, + agent: input.agent.name, + origins: permissionOrigins, + }), + }, + }), + ), Effect.asVoid, Effect.orDie, ), diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index 39331ff436..a41a244cec 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -157,14 +157,15 @@ const registry = Layer.effect( const it = testEffect(registry) const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec") ? it.live : it.live.skip -function resolve(ctx: InstanceContext) { +function resolve(ctx: InstanceContext, metadataCalls: { toolCallID: string; value: Record }[] = []) { return SessionTools.resolve({ agent, model, session: session(ctx.directory), processor: { message: message(ctx), - metadata: () => Effect.void, + // capture metadata writes so tests can assert on recorded approval provenance + metadata: (toolCallID, value) => Effect.sync(() => void metadataCalls.push({ toolCallID, value })), completeToolCall: () => Effect.void, }, bypassAgentCheck: false, @@ -355,3 +356,29 @@ mac("confines a model-originated sandboxed process to the active worktree", () = expect(yield* exists(primary)).toBe(false) }), ) + +it.live("records why a denied tool call was refused on the tool part's metadata", () => + Effect.gen(function* () { + const dirs = yield* fixture() + const metadataCalls: { toolCallID: string; value: Record }[] = [] + const deniedRuleset = [{ permission: "bash", pattern: "*", action: "deny" as const, source: "project" as const }] + const overrides = Layer.mergeAll( + TestConfig.layer({ get: () => Effect.succeed({ sandbox: { enabled: false } }) }), + Layer.mock(Permission.Service)({ + ask: () => Effect.fail(new Permission.DeniedError({ ruleset: deniedRuleset })), + }), + ) + const tools = yield* resolve(dirs.ctx, metadataCalls).pipe(Effect.provide(overrides)) + const shell = tools.bash + if (!shell) yield* Effect.die(new Error("bash tool is missing")) + + const result = yield* call(shell, { command: "echo hi", workdir: dirs.a }, "call-denied").pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + const approval = metadataCalls.find((c) => c.toolCallID === "call-denied")?.value?.metadata?.approval + expect(approval).toEqual({ + source: "project", + rule: { permission: "bash", pattern: "*", action: "deny" }, + }) + }), +) From 8e515dd6f12c112a1c61de3d46b80dbce14ad585 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Thu, 30 Jul 2026 18:04:55 +0200 Subject: [PATCH 2/7] feat(tui): show why a tool call was auto-approved or denied Ports the auto-approval provenance explanation already shown in kilo-ui/vscode to the TUI. Adds a Kilo-owned tool-approval.tsx with a plain-text description helper (describeApproval) and a shared ApprovalNote row component, then wires a single call into InlineTool/ InlineToolRow (Shell, Read, Grep, Glob, WebFetch, etc.) and BlockTool (Write, Edit, ApplyPatch, Task), showing a muted line under completed/ failed tool calls. Todo writes are excluded via a hideApproval prop, mirroring the kilo-ui behavior that treats them as orchestration rather than an auditable action. --- packages/tui/src/kilocode/tool-approval.tsx | 64 +++++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 21 ++++++- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/kilocode/tool-approval.tsx diff --git a/packages/tui/src/kilocode/tool-approval.tsx b/packages/tui/src/kilocode/tool-approval.tsx new file mode 100644 index 0000000000..fefc09419b --- /dev/null +++ b/packages/tui/src/kilocode/tool-approval.tsx @@ -0,0 +1,64 @@ +import type { RGBA } from "@opentui/core" +import { Show } from "solid-js" +import type { PermissionProvenance } from "@/kilocode/permission/provenance" +import type { ToolState } from "@kilocode/sdk/v2" + +/** `state.metadata` off any tool state, including the pending variant that lacks the field. */ +export function stateMetadata(state: ToolState | undefined) { + return state && "metadata" in state ? state.metadata : undefined +} + +const SOURCES = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const + +/** Read the approval/denial provenance off a tool part's metadata, if present. */ +export function toolApprovalFrom(metadata: Record | undefined) { + const value = metadata?.approval + if (!value || typeof value !== "object") return undefined + const approval = value as PermissionProvenance.Approval + return (SOURCES as readonly string[]).includes(approval.source) ? approval : undefined +} + +function sourceLabel(approval: PermissionProvenance.Approval): string | undefined { + switch (approval.source) { + case "agent": + return approval.agent ? `by the ${approval.agent} agent` : "by the agent" + case "global": + return "by your global config" + case "project": + return "by the project config" + case "yolo": + return "by auto-approve (YOLO) mode" + case "session": + return "by a session auto-approve rule" + case "default": + return "by default" + default: + return undefined + } +} + +/** A short "why" line describing an auto-approval or denial, for the TUI's plain-text rows. */ +export function describeApproval(metadata: Record | undefined): string | undefined { + const approval = toolApprovalFrom(metadata) + if (!approval) return undefined + const manual = approval.source === "manual" + const decision = manual ? "approved by you" : approval.rule?.action === "deny" ? "denied" : "auto-approved" + if (manual) return decision + const source = sourceLabel(approval) + const rule = approval.rule + // The catch-all "*"/"*" rule carries no useful detail; let the source alone explain it. + const ruleText = + rule && !(rule.permission === "*" && rule.pattern === "*") ? ` (matched ${rule.permission} \`${rule.pattern}\`)` : "" + return source ? `${decision} ${source}${ruleText}` : decision +} + +/** The muted "why" row rendered under a completed/failed inline or block tool. */ +export function ApprovalNote(props: { note: string | undefined; color?: RGBA; paddingLeft: number }) { + return ( + + + {props.note} + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index fc1d28b8d7..1b41ba61d3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -63,6 +63,7 @@ import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" +import { ApprovalNote, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" @@ -2204,6 +2205,8 @@ function InlineTool(props: { const failed = createMemo(() => Boolean(error() && !denied())) const clickable = createMemo(() => Boolean(props.onClick || failed())) + // kilocode_change - explain why the call was auto-approved or denied + const approvalNote = createMemo(() => describeApproval(stateMetadata(props.part.state))) const fg = createMemo(() => { if (props.color) return props.color if (permission()) return theme.warning @@ -2228,6 +2231,8 @@ function InlineTool(props: { failure={props.failure} spinner={props.spinner} separate={props.separate} + note={approvalNote()} // kilocode_change + noteColor={theme.textMuted} // kilocode_change onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -2258,6 +2263,8 @@ export function InlineToolRow(props: { failure?: string spinner?: boolean separate?: boolean + note?: string // kilocode_change - why the call was auto-approved or denied + noteColor?: RGBA // kilocode_change children: JSX.Element onMouseOver?: () => void onMouseOut?: () => void @@ -2320,6 +2327,12 @@ export function InlineToolRow(props: { {props.error} + {/* kilocode_change - explain why the call was auto-approved or denied */} + ) } @@ -2330,11 +2343,14 @@ function BlockTool(props: { onClick?: () => void part?: ToolPart spinner?: boolean + hideApproval?: boolean // kilocode_change - suppress the auto-approval note (e.g. todowrite) }) { const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) + // kilocode_change - explain why the call was auto-approved or denied + const approvalNote = createMemo(() => (props.hideApproval ? undefined : describeApproval(stateMetadata(props.part?.state)))) return ( alwaysSeparate.add(el)} @@ -2368,6 +2384,8 @@ function BlockTool(props: { {props.title.replace(/^# /, "")} {props.children} + {/* kilocode_change - explain why the call was auto-approved or denied */} + {error()} @@ -2825,7 +2843,8 @@ function TodoWrite(props: ToolProps) { return ( - + {/* kilocode_change - todo writes are orchestration, not a mutating action to explain */} + {(todo) => } From 6b27a26f929f570275e26529189b4d2fc3c392cf Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 31 Jul 2026 13:32:40 +0200 Subject: [PATCH 3/7] docs: add changeset for TUI auto-approval/denial explanation --- .changeset/explain-tool-auto-approval-tui.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/explain-tool-auto-approval-tui.md diff --git a/.changeset/explain-tool-auto-approval-tui.md b/.changeset/explain-tool-auto-approval-tui.md new file mode 100644 index 0000000000..df3e2f5cd0 --- /dev/null +++ b/.changeset/explain-tool-auto-approval-tui.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Show why a tool call was auto-approved or denied in the TUI, and record the denial reason on the tool call metadata (visible in `kilo export`) alongside the existing auto-approval reason. From abe17f1f9d0d20d25aede46b6c2299865d14a40b Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 31 Jul 2026 16:31:49 +0200 Subject: [PATCH 4/7] fix(tui): move the tool approval/denial note back onto the header line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note was appended after the tool's own output (or, in an interim revert, on its own line above it), which either looked like part of the output or was visually noisier than desired. Render it inline on the header/title line instead, matching the existing RoutedModelMeta badge convention (' · note'), so it reads unambiguously as metadata about the call rather than output. --- packages/tui/src/kilocode/tool-approval.tsx | 12 +++++++----- packages/tui/src/routes/session/index.tsx | 14 +++++--------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/kilocode/tool-approval.tsx b/packages/tui/src/kilocode/tool-approval.tsx index fefc09419b..36719d83dc 100644 --- a/packages/tui/src/kilocode/tool-approval.tsx +++ b/packages/tui/src/kilocode/tool-approval.tsx @@ -52,13 +52,15 @@ export function describeApproval(metadata: Record | undefined): return source ? `${decision} ${source}${ruleText}` : decision } -/** The muted "why" row rendered under a completed/failed inline or block tool. */ -export function ApprovalNote(props: { note: string | undefined; color?: RGBA; paddingLeft: number }) { +/** + * The muted "why" annotation appended inline after a tool's title/summary text, matching the + * `RoutedModelMeta.Badge` convention. Rendered on the header line (not after the tool's own + * output) so it reads as metadata about the call rather than part of the output itself. + */ +export function ApprovalBadge(props: { note: string | undefined; color?: RGBA }) { return ( - - {props.note} - + · {props.note} ) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1b41ba61d3..7cc3f9150f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -63,7 +63,7 @@ import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" -import { ApprovalNote, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change +import { ApprovalBadge, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" @@ -2317,6 +2317,8 @@ export function InlineToolRow(props: { attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined} > {props.failed && !props.complete ? (props.failure ?? props.children) : props.children} + {/* kilocode_change - explain why the call was auto-approved or denied, inline on the header */} + @@ -2327,12 +2329,6 @@ export function InlineToolRow(props: { {props.error} - {/* kilocode_change - explain why the call was auto-approved or denied */} - ) } @@ -2377,6 +2373,8 @@ function BlockTool(props: { {props.title} {/* kilocode_change start */} + {/* explain why the call was auto-approved or denied, inline on the title */} + {/* kilocode_change end */} } @@ -2384,8 +2382,6 @@ function BlockTool(props: { {props.title.replace(/^# /, "")} {props.children} - {/* kilocode_change - explain why the call was auto-approved or denied */} - {error()} From 5f3b57b97198fd84b35b54770831c06e4bb1f02c Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 31 Jul 2026 16:32:10 +0200 Subject: [PATCH 5/7] fix(cli): attribute tool call denials to the rule that actually decided them DeniedError.ruleset only carried the deny-permission subset, so PermissionProvenance.classifyDenial had to guess the deciding rule via findLast(action === "deny"). With two deny rules for different patterns under the same permission (e.g. bash: { "git push *": deny, "rm -rf *": deny }), this could attribute a denial to whichever rule sorted last instead of the one that actually matched the request. Permission.ask now embeds the exact rule resolve()/evaluate() matched against the request's pattern directly on the error (ruleset: { rule, matches }), so classifyDenial reads it instead of re-deriving it. Some denials carry no rule at all (e.g. the headless-subagent policy denial), where classify({ rule: undefined }) reports the same { source: "default" } shape as the *approval* fallback -- silently rendering a refusal as an auto-approval in the TUI and kilo export. classifyDenial now synthesizes an explicit deny rule for the request's permission/pattern in that case, so rule.action always reflects the real outcome. Adds test/kilocode/permission/deny-provenance.test.ts covering both regressions against the real Permission.Service, and updates the existing session-tools.test.ts denial fixture to the new ruleset shape. --- .../src/kilocode/permission/provenance.ts | 32 +++++-- packages/opencode/src/permission/index.ts | 18 +++- packages/opencode/src/session/tools.ts | 2 + .../permission/deny-provenance.test.ts | 91 +++++++++++++++++++ .../kilocode/sandbox/session-tools.test.ts | 4 +- 5 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/kilocode/permission/deny-provenance.test.ts diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index abf9647d72..499e07f866 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -102,13 +102,33 @@ export namespace PermissionProvenance { /** * Classify why a tool call was denied, from the `ruleset` a `DeniedError` carries. * - * `DeniedError.ruleset` is untyped (`Schema.Any`) but is always the tagged ruleset `askPermission` - * built, filtered to the request's permission. The last `deny` rule in it is the one that decided. + * `DeniedError.ruleset` is untyped (`Schema.Any`); `Permission.ask` shapes it as + * `{ rule, matches }`, where `rule` is the exact rule `resolve()` matched against the + * request's pattern (via `Wildcard.match`), not merely the last `deny` rule for the + * permission. Two deny rules for different patterns under the same permission (e.g. + * `bash: { "git push *": deny, "rm -rf *": deny }`) would otherwise be indistinguishable by + * permission alone, misattributing the denial to whichever rule happens to sort last. + * + * Some denials carry no `rule` at all — e.g. the headless-subagent policy denial in + * `Permission.ask`, which isn't decided by any rule. `classify({ rule: undefined })` reports + * `{ source: "default" }`, the exact same shape as the *approval* fallback for "no rule + * matched", so a denial with no rule would otherwise render (and export) as an auto-approval. + * Synthesize a `deny` rule for the request's permission/pattern in that case so `rule.action` + * always reflects the real outcome. */ - export function classifyDenial(input: { ruleset: unknown; agent: string; origins: Origins }): Approval { - const rule = Array.isArray(input.ruleset) - ? (input.ruleset as Permission.Rule[]).findLast((rule) => rule.action === "deny") - : undefined + export function classifyDenial(input: { + ruleset: unknown + permission: string + patterns: readonly string[] + agent: string + origins: Origins + }): Approval { + const denial = input.ruleset as { rule?: Permission.Rule } | undefined + const rule = denial?.rule ?? { + permission: input.permission, + pattern: input.patterns[0] ?? "*", + action: "deny" as const, + } return classify({ rule, agent: input.agent, origins: input.origins }) } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 9ce38c952f..efec951dfe 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -226,14 +226,21 @@ export const layer = Layer.effect( for (const pattern of request.patterns) { const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) - // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials - if (veto(request.permission, pattern, hardRuleset)) { - return yield* new DeniedError({ ruleset: subset(request.permission, hardRuleset ?? []) }) + // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials. + // Report the exact hard rule that matched this pattern (not just the deny-permission + // subset) so provenance attributes the denial to the right rule, not just any deny rule. + const hardRule = hardRuleset && ExternalDirectoryPermission.evaluate(request.permission, pattern, hardRuleset) + if (hardRule?.action === "deny") { + return yield* new DeniedError({ + ruleset: { rule: hardRule, matches: subset(request.permission, hardRuleset ?? []) }, + }) } // kilocode_change end if (rule.action === "deny") { + // kilocode_change - carry the exact matched `rule` (not just the deny-permission subset) + // so provenance can attribute the denial to the pattern that actually decided it. return yield* new DeniedError({ - ruleset: subset(request.permission, ruleset), // kilocode_change + ruleset: { rule, matches: subset(request.permission, ruleset) }, }) } // kilocode_change start - skill shell forces a prompt instead of honoring an allow/auto-approve rule @@ -255,7 +262,8 @@ export const layer = Layer.effect( // kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903) if (yield* KiloHeadless.denies(request.sessionID).pipe(Effect.provideService(Database.Service, database))) { - return yield* new DeniedError({ ruleset: subset(request.permission, ruleset) }) + // no single rule decided this — it's a headless policy denial, not a ruleset match + return yield* new DeniedError({ ruleset: { matches: subset(request.permission, ruleset) } }) } // kilocode_change end diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f33c8fc627..98bb1a11e4 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -89,6 +89,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { metadata: { approval: PermissionProvenance.classifyDenial({ ruleset: err.ruleset, + permission: req.permission, + patterns: req.patterns, agent: input.agent.name, origins: permissionOrigins, }), diff --git a/packages/opencode/test/kilocode/permission/deny-provenance.test.ts b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts new file mode 100644 index 0000000000..c04bce534c --- /dev/null +++ b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import { Bus } from "../../../src/bus" +import { Permission } from "../../../src/permission" +import { PermissionProvenance } from "../../../src/kilocode/permission/provenance" +import { EventV2Bridge } from "../../../src/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" +import { SessionID } from "../../../src/session/schema" +import * as Config from "../../../src/config/config" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { provideTmpdirInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const env = Layer.mergeAll( + Permission.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Database.defaultLayer), + ), + Config.defaultLayer, + Bus.layer, + CrossSpawnSpawner.defaultLayer, +) +const it = testEffect(env) + +const ask = (input: Parameters[0]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.ask(input) + }) + +function withDir(options: { git?: boolean } | undefined, self: (dir: string) => Effect.Effect) { + return provideTmpdirInstance(self, options) +} + +describe("Permission.ask denial provenance", () => { + it.live( + "attributes a denial to the rule that matched the request's pattern, not just the textually-last deny rule for the permission", + () => + withDir({ git: true }, () => + Effect.gen(function* () { + // Two deny rules under the same permission for different patterns. Matching by + // permission alone (e.g. findLast over rules with action "deny") would pick + // "rm -rf *" here since it sorts last, even though "git push *" is the one that + // actually matched the request. + const ruleset = [ + { permission: "bash", pattern: "git push *", action: "deny" as const }, + { permission: "bash", pattern: "rm -rf *", action: "deny" as const }, + ] + const exit = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["git push origin main"], + metadata: {}, + always: [], + ruleset, + }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + expect(err).toBeInstanceOf(Permission.DeniedError) + + const approval = PermissionProvenance.classifyDenial({ + ruleset: (err as Permission.DeniedError).ruleset, + permission: "bash", + patterns: ["git push origin main"], + agent: "build", + origins: undefined, + }) + expect(approval.rule).toEqual({ permission: "bash", pattern: "git push *", action: "deny" }) + }), + ), + ) + + test("a denial with no rule in the carried ruleset is still reported as denied, not as an ambiguous default approval", () => { + // Some denials carry no `rule` at all (e.g. the headless-subagent policy denial in + // Permission.ask, which isn't decided by any ruleset match). classify({ rule: undefined }) + // reports { source: "default" } — the same shape the *approval* fallback produces for "no + // rule matched" — so without a synthesized deny rule, a refusal would render (and export) + // as an auto-approval. + const approval = PermissionProvenance.classifyDenial({ + ruleset: { matches: [] }, + permission: "bash", + patterns: ["rm -rf /"], + agent: "build", + origins: undefined, + }) + expect(approval.rule?.action).toBe("deny") + expect(approval.rule).toEqual({ permission: "bash", pattern: "rm -rf /", action: "deny" }) + }) +}) diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index a41a244cec..d1eedce06e 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -361,11 +361,11 @@ it.live("records why a denied tool call was refused on the tool part's metadata" Effect.gen(function* () { const dirs = yield* fixture() const metadataCalls: { toolCallID: string; value: Record }[] = [] - const deniedRuleset = [{ permission: "bash", pattern: "*", action: "deny" as const, source: "project" as const }] + const deniedRule = { permission: "bash", pattern: "*", action: "deny" as const, source: "project" as const } const overrides = Layer.mergeAll( TestConfig.layer({ get: () => Effect.succeed({ sandbox: { enabled: false } }) }), Layer.mock(Permission.Service)({ - ask: () => Effect.fail(new Permission.DeniedError({ ruleset: deniedRuleset })), + ask: () => Effect.fail(new Permission.DeniedError({ ruleset: { rule: deniedRule, matches: [deniedRule] } })), }), ) const tools = yield* resolve(dirs.ctx, metadataCalls).pipe(Effect.provide(overrides)) From c56aad9d6c1861a843ab7502bd6b4e6aea04d4ec Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 31 Jul 2026 16:45:29 +0200 Subject: [PATCH 6/7] refactor(cli): shrink the denial-provenance shared-file diff in permission/index.ts Applies the kilocode-merge-minimizer skill to the prior fix. The hard-veto and headless-subagent DeniedError sites are reverted to their exact pre-fix shape -- neither carries a specific rule anyway, so wrapping their ruleset in a { rule, matches } object added shared upstream diff for no benefit. Only the main deny path (which already had the deciding rule in scope) still changes, and now passes the bare rule instead of a wrapper object, shrinking that hunk from a multi-line block to a single-line swap. PermissionProvenance.classifyDenial now duck-types ruleset as a possible bare Permission.Rule (checking action === "deny" and a string pattern) instead of expecting a { rule } wrapper, so it still reads the main deny path's rule directly while falling back to a synthesized deny rule for the other paths, exactly as before. Net shared-file diff across permission/index.ts, session/tools.ts, and the TUI's routes/session/index.tsx for this whole feature is now 9 insertions / 12 deletions, down from ~50+ lines. --- .../src/kilocode/permission/provenance.ts | 34 +++++++++---------- packages/opencode/src/permission/index.ts | 21 ++++-------- .../permission/deny-provenance.test.ts | 15 ++++---- .../kilocode/sandbox/session-tools.test.ts | 2 +- 4 files changed, 31 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index 499e07f866..73f7fec4e0 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -102,19 +102,18 @@ export namespace PermissionProvenance { /** * Classify why a tool call was denied, from the `ruleset` a `DeniedError` carries. * - * `DeniedError.ruleset` is untyped (`Schema.Any`); `Permission.ask` shapes it as - * `{ rule, matches }`, where `rule` is the exact rule `resolve()` matched against the - * request's pattern (via `Wildcard.match`), not merely the last `deny` rule for the - * permission. Two deny rules for different patterns under the same permission (e.g. - * `bash: { "git push *": deny, "rm -rf *": deny }`) would otherwise be indistinguishable by - * permission alone, misattributing the denial to whichever rule happens to sort last. + * `DeniedError.ruleset` is untyped (`Schema.Any`). `Permission.ask`'s main deny path sets it to + * the exact rule `resolve()` matched against the request's pattern (via `Wildcard.match`), not + * merely the deny-permission subset — two deny rules for different patterns under the same + * permission (e.g. `bash: { "git push *": deny, "rm -rf *": deny }`) would otherwise be + * indistinguishable by permission alone, misattributing the denial to whichever rule happens to + * sort last. * - * Some denials carry no `rule` at all — e.g. the headless-subagent policy denial in - * `Permission.ask`, which isn't decided by any rule. `classify({ rule: undefined })` reports - * `{ source: "default" }`, the exact same shape as the *approval* fallback for "no rule - * matched", so a denial with no rule would otherwise render (and export) as an auto-approval. - * Synthesize a `deny` rule for the request's permission/pattern in that case so `rule.action` - * always reflects the real outcome. + * Other denial paths (hard Ask/Plan/Architect vetoes, headless-subagent policy) don't carry a + * specific rule, so `ruleset` there is still just the permission subset (or absent). Synthesize + * an explicit `deny` rule for the request's permission/pattern in that case: falling through to + * `classify({ rule: undefined })` would report the exact same `{ source: "default" }` shape the + * *approval* fallback uses for "no rule matched," rendering a refusal as an auto-approval. */ export function classifyDenial(input: { ruleset: unknown @@ -123,12 +122,11 @@ export namespace PermissionProvenance { agent: string origins: Origins }): Approval { - const denial = input.ruleset as { rule?: Permission.Rule } | undefined - const rule = denial?.rule ?? { - permission: input.permission, - pattern: input.patterns[0] ?? "*", - action: "deny" as const, - } + const candidate = input.ruleset as Partial | undefined + const rule = + candidate?.action === "deny" && typeof candidate.pattern === "string" + ? (candidate as Permission.Rule) + : { permission: input.permission, pattern: input.patterns[0] ?? "*", action: "deny" as const } return classify({ rule, agent: input.agent, origins: input.origins }) } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index efec951dfe..8ffd4c53b3 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -226,22 +226,14 @@ export const layer = Layer.effect( for (const pattern of request.patterns) { const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) - // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials. - // Report the exact hard rule that matched this pattern (not just the deny-permission - // subset) so provenance attributes the denial to the right rule, not just any deny rule. - const hardRule = hardRuleset && ExternalDirectoryPermission.evaluate(request.permission, pattern, hardRuleset) - if (hardRule?.action === "deny") { - return yield* new DeniedError({ - ruleset: { rule: hardRule, matches: subset(request.permission, hardRuleset ?? []) }, - }) + // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials + if (veto(request.permission, pattern, hardRuleset)) { + return yield* new DeniedError({ ruleset: subset(request.permission, hardRuleset ?? []) }) } // kilocode_change end if (rule.action === "deny") { - // kilocode_change - carry the exact matched `rule` (not just the deny-permission subset) - // so provenance can attribute the denial to the pattern that actually decided it. - return yield* new DeniedError({ - ruleset: { rule, matches: subset(request.permission, ruleset) }, - }) + // kilocode_change - carry the deciding rule (not just the permission subset) for provenance + return yield* new DeniedError({ ruleset: rule }) } // kilocode_change start - skill shell forces a prompt instead of honoring an allow/auto-approve rule if (forceAsk) { @@ -262,8 +254,7 @@ export const layer = Layer.effect( // kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903) if (yield* KiloHeadless.denies(request.sessionID).pipe(Effect.provideService(Database.Service, database))) { - // no single rule decided this — it's a headless policy denial, not a ruleset match - return yield* new DeniedError({ ruleset: { matches: subset(request.permission, ruleset) } }) + return yield* new DeniedError({ ruleset: subset(request.permission, ruleset) }) } // kilocode_change end diff --git a/packages/opencode/test/kilocode/permission/deny-provenance.test.ts b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts index c04bce534c..67613e954b 100644 --- a/packages/opencode/test/kilocode/permission/deny-provenance.test.ts +++ b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts @@ -72,14 +72,15 @@ describe("Permission.ask denial provenance", () => { ), ) - test("a denial with no rule in the carried ruleset is still reported as denied, not as an ambiguous default approval", () => { - // Some denials carry no `rule` at all (e.g. the headless-subagent policy denial in - // Permission.ask, which isn't decided by any ruleset match). classify({ rule: undefined }) - // reports { source: "default" } — the same shape the *approval* fallback produces for "no - // rule matched" — so without a synthesized deny rule, a refusal would render (and export) - // as an auto-approval. + test("a denial with no specific rule (e.g. a headless-subagent policy denial) is still reported as denied, not as an ambiguous default approval", () => { + // Some denial paths don't carry a specific rule -- Permission.ask's headless-subagent policy + // denial, for instance, still sets `ruleset` to the plain deny-permission subset (an array, + // with no `.action`/`.pattern` of its own). classify({ rule: undefined }) reports + // { source: "default" } -- the same shape the *approval* fallback produces for "no rule + // matched" -- so without a synthesized deny rule, a refusal would render (and export) as an + // auto-approval. const approval = PermissionProvenance.classifyDenial({ - ruleset: { matches: [] }, + ruleset: [{ permission: "bash", pattern: "*", action: "ask" as const }], permission: "bash", patterns: ["rm -rf /"], agent: "build", diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index d1eedce06e..b2fa9a670e 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -365,7 +365,7 @@ it.live("records why a denied tool call was refused on the tool part's metadata" const overrides = Layer.mergeAll( TestConfig.layer({ get: () => Effect.succeed({ sandbox: { enabled: false } }) }), Layer.mock(Permission.Service)({ - ask: () => Effect.fail(new Permission.DeniedError({ ruleset: { rule: deniedRule, matches: [deniedRule] } })), + ask: () => Effect.fail(new Permission.DeniedError({ ruleset: deniedRule })), }), ) const tools = yield* resolve(dirs.ctx, metadataCalls).pipe(Effect.provide(overrides)) From 1f3a3b2d83cd8a639c7f96919a569d40685925cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 3 Aug 2026 13:12:11 +0200 Subject: [PATCH 7/7] feat(attachments): add remote CLI file delivery (#12747) * fix(cli): clarify remote binary attachment notice * feat(cli): add remote send_file tool * docs(mobile): explain remote session attachments * fix(cli): keep delivered files out of model context * fix(cli): mark delivery filter as kilocode change * fix(cli): preserve delivered image files * test(cli): narrow delivery attachment state * fix(send-file): preserve filesystem failures * test(send-file): assert propagated file read error --- .../pages/code-with-ai/platforms/mobile.md | 17 + .../src/kilocode/remote-attachments.ts | 14 +- .../opencode/src/kilocode/tool/registry.ts | 13 +- .../opencode/src/kilocode/tool/send-file.ts | 167 ++++++ .../opencode/src/kilocode/tool/send-file.txt | 14 + packages/opencode/src/session/message-v2.ts | 8 +- packages/opencode/src/session/processor.ts | 9 +- .../test/kilocode/remote-attachments.test.ts | 1 + ...l-registry-indexing-import-failure.test.ts | 1 + .../kilocode/tool-registry-indexing.test.ts | 10 + ...l-registry-semantic-import-failure.test.ts | 1 + .../test/kilocode/tool/send-file.test.ts | 541 ++++++++++++++++++ .../opencode/test/session/message-v2.test.ts | 60 ++ .../test/session/processor-effect.test.ts | 91 +++ 14 files changed, 935 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/kilocode/tool/send-file.ts create mode 100644 packages/opencode/src/kilocode/tool/send-file.txt create mode 100644 packages/opencode/test/kilocode/tool/send-file.test.ts diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md index b91950b5ec..6418c8fae7 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md @@ -58,6 +58,23 @@ The composer stays editable while the agent is working, so you don't have to wai A queued message shows a subtle **Queued** badge on its bubble. The badge clears when the message starts processing or when the queue drains or is cancelled. Queueing works for Cloud Agent sessions and for remote sessions on a connected `kilo remote` CLI instance. +## Attachments in remote sessions + +When you connect the mobile app to a `kilo remote` CLI session, you can share files in both directions. + +### Sending files from your phone to the CLI + +Attach up to **5 files** (each up to **20 MiB**) from your phone to the remote session. The CLI automatically processes them: + +- **Text, images, and PDFs** — the file content is converted to a `data:` URL and handed directly to the model as a file part. The model sees the content as if you had loaded it locally. +- **Other file types** (binaries, archives, etc.) — the file is saved to a per-session scratch directory on the CLI machine. The session transcript shows the saved path, filename, file size, and MIME type. The agent can inspect the file with the `read` tool for text content or shell utilities for binary content. + +Attaching files from the phone is the mobile flow — this is separate from `kilo run --file `, which attaches local files to a local prompt. + +### Receiving files from the CLI on your phone + +While the CLI is connected, the agent can deliver a file to your phone with the `send_file` tool (up to **4 MiB**, remote sessions only). The file appears as a chip on the tool card — tap the chip to open the share sheet and save or forward the file. This tool works only when `kilo remote` is actively connected; it is not available in Cloud Agent sessions. + ## Reviewing GitHub pull requests Open a pull request from a PR link to review it without leaving the app: diff --git a/packages/opencode/src/kilocode/remote-attachments.ts b/packages/opencode/src/kilocode/remote-attachments.ts index b329dbb47d..066b8aea36 100644 --- a/packages/opencode/src/kilocode/remote-attachments.ts +++ b/packages/opencode/src/kilocode/remote-attachments.ts @@ -66,13 +66,13 @@ export namespace RemoteAttachments { sql: "text/plain", } export const BINARY_MIME = "application/octet-stream" - // Hard cap on attachment bytes (5 MB + 1 byte so the helper aborts + // Hard cap on attachment bytes (20 MB + 1 byte so the helper aborts // strictly when the body exceeds the agreed ceiling). - export const MAX_BYTES = 5 * 1024 * 1024 + 1 + export const MAX_BYTES = 20 * 1024 * 1024 + 1 // Per-attachment fetch budget. R2 presigned GETs in the same region - // complete in tens of ms; 15s is generous but bounded so a stalled - // connection can never hold the prompt open indefinitely. - export const FETCH_TIMEOUT_MS = 15_000 + // complete quickly, but a 20 MB body may take a few seconds on slower + // mobile connections, so the budget is generous. + export const FETCH_TIMEOUT_MS = 60_000 export const SCRATCH_DIRNAME = "remote-attachments" export type Fetcher = (input: string, init?: RequestInit) => Promise @@ -194,7 +194,7 @@ export namespace RemoteAttachments { * - HTTPS only * - redirects rejected * - no credentials forwarded - * - body bounded to 5 MB + 1 byte + * - body bounded to 20 MB + 1 byte * - bounded timeout * - non-2xx rejected */ @@ -338,7 +338,7 @@ export namespace RemoteAttachments { type: "text" as const, text: `attachment saved to ${target} (filename: ${filename ?? basename}, mime: ${BINARY_MIME}, size: ${bytes.byteLength} bytes). ` + - `Use the read tool on that path to inspect it.`, + `Inspect it with the read tool (text content) or shell utilities (binary content).`, }) continue } diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index d0a480682a..3e1dda820b 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -9,6 +9,7 @@ import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./noteb import { MemoryRecallTool } from "./memory-recall" import { MemorySaveTool } from "./memory-save" import { NotifyUserTool } from "./notify-user" +import { SendFileTool } from "./send-file" import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect } from "effect" @@ -80,14 +81,15 @@ export namespace KiloToolRegistry { // context here and injects it into the tool's init Effect. const sessions = yield* KiloSessions.Service const notify = yield* NotifyUserTool.pipe(Effect.provideService(KiloSessions.Service, sessions)) + const send = yield* SendFileTool if (!notebook) - return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify } + return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, send } const tools = yield* Effect.all({ notebookRead: NotebookReadTool, notebookEdit: NotebookEditTool, notebookExecute: NotebookExecuteTool, }).pipe(Effect.provideService(Notebook.Service, notebook)) - return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, ...tools } + return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, send, ...tools } }) } @@ -105,6 +107,7 @@ export namespace KiloToolRegistry { image: Tool.Info terminal?: Tool.Info notify: Tool.Info + send: Tool.Info notebookRead?: Tool.Info notebookEdit?: Tool.Info notebookExecute?: Tool.Info @@ -123,6 +126,7 @@ export namespace KiloToolRegistry { process: Tool.init(tools.process), image: Tool.init(tools.image), notify: Tool.init(tools.notify), + send: Tool.init(tools.send), }) const terminal = tools.terminal ? yield* Tool.init(tools.terminal) : undefined const notebooks = @@ -134,7 +138,7 @@ export namespace KiloToolRegistry { }) : {} const semantic = yield* semanticTool(deps, loaders) - return { ...base, terminal, ...notebooks, semantic, notify: base.notify } + return { ...base, terminal, ...notebooks, semantic, notify: base.notify, send: base.send } }) } @@ -178,6 +182,7 @@ export namespace KiloToolRegistry { /** Hide human-driven tools from agents that cannot interact with the user directly. */ export function available(tool: Tool.Def, agent: Agent.Info) { if (tool.id === "notify_user") return KiloSessions.remoteStatus().enabled + if (tool.id === "send_file") return KiloSessions.remoteStatus().connected if (tool.id !== "interactive_terminal") return true return agent.mode === "primary" } @@ -196,6 +201,7 @@ export namespace KiloToolRegistry { image: Tool.Def terminal?: Tool.Def notify: Tool.Def + send: Tool.Def notebookRead?: Tool.Def notebookEdit?: Tool.Def notebookExecute?: Tool.Def @@ -221,6 +227,7 @@ export namespace KiloToolRegistry { ? [tools.notebookRead, tools.notebookEdit, tools.notebookExecute] : []), tools.notify, + tools.send, ] } diff --git a/packages/opencode/src/kilocode/tool/send-file.ts b/packages/opencode/src/kilocode/tool/send-file.ts new file mode 100644 index 0000000000..c2fac7a635 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/send-file.ts @@ -0,0 +1,167 @@ +import { Tool } from "@/tool/tool" +import { Effect, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "@/tool/external-directory" +import { KiloSessions } from "@/kilo-sessions/kilo-sessions" +import { KiloReadObject } from "@/kilocode/tool/read-object" +import { sniffAttachmentMime } from "@/util/media" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { KiloReference } from "@/kilocode/reference/contains" +import DESCRIPTION from "./send-file.txt" +import path from "node:path" + +/** + * Remote-CLI-only live-connection delivery cap. The send_file path rides the + * existing tool-attachment transport (remote-sender → UserConnectionDO → mobile + * SDK), which has no in-repo frame cap. Cloud-agent ingest trims tool-attachment + * URLs at 1 MiB (`MAX_INGEST_EVENT_BYTES`), so delivery through the cloud-agent + * path is impossible by design — the tool is gated on `KiloSessions.remoteStatus() + * .connected`, which is only true for the remote-CLI relay. History/cold-open + * re-hydration rides the existing R2 spill (>~1.94 MiB) and 8 MiB page budget; + * near the cap a cold-open "unavailable" is accepted page-pressure behavior. + */ +export const SEND_FILE_MAX_BYTES = 4 * 1024 * 1024 + +const SAMPLE_BYTES = 4096 + +const Params = Schema.Struct({ + path: Schema.String.annotate({ description: "Absolute or relative path to the file to send to the mobile app." }), +}) + +function fail(msg: string) { + return { title: "Send file failed", output: msg, metadata: {} } +} + +export const SendFileTool = Tool.define( + "send_file", + Effect.gen(function* () { + const fs = yield* FSUtil.Service + return { + description: DESCRIPTION, + parameters: Params, + execute: (params, ctx) => + Effect.gen(function* () { + if (!KiloSessions.remoteStatus().connected) { + return fail( + "Cannot send files: this session is not connected to Kilo cloud. Delivery needs an active link.", + ) + } + + const inst = yield* InstanceState.context + const requested = path.resolve(inst.directory, params.path) + const basename = path.basename(requested) + + // kilocode_change start — authorize missing and directory paths with the same + // security sequence as read.ts before any file inspection via KiloReadObject. + // Route absent targets through a read-style authorized failure, and produce a + // structured fail() for directories. This prevents access-pattern leakage where + // missing vs directory vs external-directory errors differ before permission + // checks. + const info = yield* fs.stat(requested).pipe( + Effect.catchIf( + (err) => "reason" in err && err.reason._tag === "NotFound", + () => Effect.succeed(undefined), + ), + ) + if (!info) { + const dir = path.dirname(requested) + const parent = yield* fs.realPath(dir).pipe(Effect.option) + if (parent._tag === "None") return fail(`File not found: ${basename}`) + yield* assertExternalDirectoryEffect(ctx, parent.value, { bypass: false, kind: "directory" }) + yield* ctx.ask({ + permission: "read", + patterns: [...new Set([requested, parent.value].map((item) => path.relative(inst.worktree, item)))], + always: ["*"], + metadata: {}, + }) + return fail(`File not found: ${basename}`) + } + if (info.type === "Directory") { + const resolved = yield* fs.realPath(requested) + const target = process.platform === "win32" ? FSUtil.normalizePath(resolved) : resolved + const explicit = + typeof ctx.extra?.["referenceRoot"] === "string" + ? yield* KiloReference.path(fs, ctx.extra["referenceRoot"], target).pipe( + Effect.option, + Effect.map((result) => result._tag === "Some" && result.value), + ) + : false + yield* assertExternalDirectoryEffect(ctx, target, { bypass: explicit, kind: "directory" }) + yield* ctx.ask({ + permission: "read", + patterns: [...new Set([requested, target].map((item) => path.relative(inst.worktree, item)))], + always: ["*"], + metadata: {}, + }) + return fail(`Cannot send: ${basename} is a directory.`) + } + // kilocode_change end + + // 1. Resolve via KiloReadObject.file (same authorization sequence as read.ts) + const file = yield* KiloReadObject.file(requested) + + // 2. Authorization — same pattern as read.ts + const explicit = + typeof ctx.extra?.["referenceRoot"] === "string" + ? yield* KiloReference.path(fs, ctx.extra["referenceRoot"], file.target).pipe( + Effect.option, + Effect.map((result) => result._tag === "Some" && result.value), + ) + : false + yield* assertExternalDirectoryEffect(ctx, file.target, { bypass: explicit, kind: "file" }) + yield* ctx.ask({ + permission: "read", + patterns: [...new Set([requested, file.target].map((item) => path.relative(inst.worktree, item)))], + always: ["*"], + metadata: {}, + }) + + // 3. Size check before reading content + if (Number(file.stat.size) > SEND_FILE_MAX_BYTES) { + return { + title: "Send file too large", + output: `Cannot send: ${basename} is ${file.stat.size} bytes, which exceeds the ${SEND_FILE_MAX_BYTES / (1024 * 1024)} MiB limit. For larger files, give the user the workspace path instead.`, + metadata: {}, + } + } + + // 4. Open and read with TOCTOU safety (same pattern as read.ts) + return yield* KiloReadObject.use(file, (bound) => + Effect.gen(function* () { + const sample = yield* Effect.tryPromise({ + try: (signal) => bound.sample(SAMPLE_BYTES, AbortSignal.any([ctx.abort, signal])), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + const mime = sniffAttachmentMime(sample, FSUtil.mimeType(requested)) + + const bytes = yield* Effect.tryPromise({ + try: (signal) => bound.read(SEND_FILE_MAX_BYTES + 1, AbortSignal.any([ctx.abort, signal])), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }) + if (bytes.byteLength > SEND_FILE_MAX_BYTES) { + return { + title: "Send file too large", + output: `Cannot send: ${basename} exceeds the ${SEND_FILE_MAX_BYTES / (1024 * 1024)} MiB limit. For larger files, give the user the workspace path instead.`, + metadata: {}, + } + } + + return { + title: `Sent file: ${basename}`, + output: `File ${basename} (${bytes.byteLength} bytes, ${mime}) delivered to the user's Kilo app. Older app builds ignore non-image file attachments — make sure the user has an up-to-date app to see the delivery.`, + metadata: {}, + attachments: [ + { + type: "file" as const, + mime, + filename: basename, + url: `data:${mime};base64,${bytes.toString("base64")}`, + }, + ], + } + }), + ) + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/kilocode/tool/send-file.txt b/packages/opencode/src/kilocode/tool/send-file.txt new file mode 100644 index 0000000000..a9572dcd26 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/send-file.txt @@ -0,0 +1,14 @@ +Send a file from the local machine to the user's Kilo app. This tool works only when this session is connected to Kilo cloud (remote CLI relay). It sends exactly one file per call, up to 4 MiB. + +Use this tool ONLY for: +- Files the user explicitly asked to see on mobile ("show me the log file on my phone") +- Sharing a generated file (a report, chart, or artifact) so the user can view it in the app +- Sending a screenshot or image the user requested + +Do NOT use this tool: +- For files the user did not ask to see — the user pulls files on demand +- For every file you generate or read — only when the user asks for it on mobile +- For files larger than 4 MiB — the tool rejects them; give the user the workspace path instead +- In a cloud-agent session — delivery is remote-CLI only + +The filename visible on mobile is always the basename, never a full path. Older Kilo app builds ignore non-image file deliveries. diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 686f21cdb6..845cecbda5 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -428,7 +428,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : truncateToolOutput(part.state.output, options?.toolOutputMaxChars) - const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) + // kilocode_change start — do not replay send_file delivery attachments to the model; + // they are mobile delivery artifacts (up to 4 MiB base64), not model context. + const attachments = + part.state.time.compacted || options?.stripMedia || part.tool === "send_file" + ? [] + : (part.state.attachments ?? []) + // kilocode_change end // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 3089bd26b7..d62e5d1acc 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -706,8 +706,14 @@ export const layer = Layer.effect( return } const rawOutput = toolResultOutput(value) + // kilocode_change start — send_file delivery attachments (up to 4 MiB raw) + // must reach mobile byte-for-byte. Base64-encoded images near the cap can + // exceed the generic 5 MiB normalization limit, causing rewrites or omission + // after the tool reports success. These attachments are delivery-only; the + // existing message-v2 filter already strips them from model context. + const skipNormalization = value.name === "send_file" const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) => - attachment.mime.startsWith("image/") + attachment.mime.startsWith("image/") && !skipNormalization ? image.normalize(attachment).pipe( Effect.catchIf( (error) => error instanceof Image.ResizerUnavailableError, @@ -717,6 +723,7 @@ export const layer = Layer.effect( ) : Effect.succeed(Exit.succeed(attachment)), ) + // kilocode_change end const omitted = normalized.filter(Exit.isFailure).length const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) const output = { diff --git a/packages/opencode/test/kilocode/remote-attachments.test.ts b/packages/opencode/test/kilocode/remote-attachments.test.ts index 4c1289f691..57877d9e8c 100644 --- a/packages/opencode/test/kilocode/remote-attachments.test.ts +++ b/packages/opencode/test/kilocode/remote-attachments.test.ts @@ -388,6 +388,7 @@ describe("RemoteAttachments.create().materialize", () => { expect(text.text).toContain("filename: blob.bin") expect(text.text).toContain("mime: application/octet-stream") expect(text.text).toContain(`size: ${bin.byteLength} bytes`) + expect(text.text).toContain("shell utilities") const entries = await fs.readdir(dir) expect(entries).toHaveLength(1) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 7699fc4f04..10739afdf7 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -44,6 +44,7 @@ function infos() { process: info("background_process"), image: info("generate_image"), notify: info("notify_user"), + send: info("send_file"), notebookRead: info("notebook_read"), notebookEdit: info("notebook_edit"), notebookExecute: info("notebook_execute"), diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index a05c11c909..772a8042e1 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -342,6 +342,7 @@ describe("kilocode tool registry indexing", () => { image: def("generate_image"), terminal: def("interactive_terminal"), notify: def("notify_user"), + send: def("send_file"), notebookRead: def("notebook_read"), notebookEdit: def("notebook_edit"), notebookExecute: def("notebook_execute"), @@ -357,6 +358,7 @@ describe("kilocode tool registry indexing", () => { "background_process", "interactive_terminal", "notify_user", + "send_file", ]) expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual( [ @@ -368,6 +370,7 @@ describe("kilocode tool registry indexing", () => { "background_process", "interactive_terminal", "notify_user", + "send_file", ], ) expect( @@ -384,6 +387,7 @@ describe("kilocode tool registry indexing", () => { "background_process", "interactive_terminal", "notify_user", + "send_file", ]) process.env["KILO_CLIENT"] = "vscode" @@ -398,6 +402,7 @@ describe("kilocode tool registry indexing", () => { "agent_manager_models", "agent_manager", "notify_user", + "send_file", ], ) expect( @@ -417,6 +422,7 @@ describe("kilocode tool registry indexing", () => { "notebook_edit", "notebook_execute", "notify_user", + "send_file", ]) expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([ "kilo_memory_recall", @@ -426,6 +432,7 @@ describe("kilocode tool registry indexing", () => { "agent_manager_models", "agent_manager", "notify_user", + "send_file", ]) process.env["KILO_CLIENT"] = "desktop" @@ -435,6 +442,7 @@ describe("kilocode tool registry indexing", () => { "kilo_memory_save", "recall", "notify_user", + "send_file", ]) process.env["KILO_CLIENT"] = "run" @@ -444,6 +452,7 @@ describe("kilocode tool registry indexing", () => { "kilo_memory_save", "recall", "notify_user", + "send_file", ]) process.env["KILO_CLIENT"] = "acp" @@ -453,6 +462,7 @@ describe("kilocode tool registry indexing", () => { "kilo_memory_save", "recall", "notify_user", + "send_file", ]) } finally { if (prev === undefined) delete process.env["KILO_CLIENT"] diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index ac74c2e714..e27689ec31 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -56,6 +56,7 @@ function infos() { process: info("background_process"), image: info("generate_image"), notify: info("notify_user"), + send: info("send_file"), notebookRead: info("notebook_read"), notebookEdit: info("notebook_edit"), notebookExecute: info("notebook_execute"), diff --git a/packages/opencode/test/kilocode/tool/send-file.test.ts b/packages/opencode/test/kilocode/tool/send-file.test.ts new file mode 100644 index 0000000000..69ae8ffd2a --- /dev/null +++ b/packages/opencode/test/kilocode/tool/send-file.test.ts @@ -0,0 +1,541 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "@/agent/agent" +import { KiloSessions } from "@/kilo-sessions/kilo-sessions" +import { KiloToolRegistry } from "@/kilocode/tool/registry" +import { SendFileTool } from "@/kilocode/tool/send-file" +import { MessageID, SessionID } from "@/session/schema" +import * as Truncate from "@/tool/truncate" +import type { Tool } from "@/tool/tool" +import { InstanceRef } from "@/effect/instance-ref" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { realpathSync } from "node:fs" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +const agentInfo = { + name: "code", + mode: "primary", + options: {}, + permission: {}, +} as Agent.Info + +const agents = Agent.Service.of({ + get: () => Effect.succeed(agentInfo), + list: () => Effect.succeed([agentInfo]), + defaultInfo: () => Effect.succeed(agentInfo), + defaultAgent: () => Effect.succeed("code"), + requirementStatus: () => + Effect.succeed({ + agent: "code", + directory: "", + enabled: false, + state: "ready", + skills: [], + mcps: [], + vscode_extensions: [], + }), + guardRequirements: () => Effect.void, + generate: () => Effect.succeed({ identifier: "code", whenToUse: "", systemPrompt: "" }), +}) + +const truncate = Truncate.Service.of({ + cleanup: () => Effect.void, + write: () => Effect.succeed(""), + output: (text) => Effect.succeed({ content: text as string, truncated: false }), + limits: () => Effect.succeed({ maxLines: Truncate.MAX_LINES, maxBytes: Truncate.MAX_BYTES }), +}) + +const ctx: Tool.Context = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "code", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const status = spyOn(KiloSessions, "remoteStatus") + +beforeEach(() => { + status.mockReturnValue({ enabled: true, connected: true }) +}) + +afterEach(() => { + status.mockReset() +}) + +function runSendTool(params: { readonly path: string }, dir: string) { + const layer = Layer.mergeAll( + Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }), + Layer.succeed(Agent.Service, agents), + Layer.succeed(Truncate.Service, truncate), + Layer.succeed(FSUtil.Service, fsService), + ) + return Effect.runPromise( + Effect.gen(function* () { + const result = yield* SendFileTool + const tool = yield* result.init() + return yield* tool.execute(params, ctx) + }).pipe(Effect.provide(layer)), + ) +} + +async function tmpdir() { + const d = await fs.mkdtemp(path.join(os.tmpdir(), "send-file-test-")) + return fs.realpath(d) +} + +describe("send_file tool", () => { + test("is only available while remote is connected", () => { + const tool = { id: "send_file" } as Tool.Def + + status.mockReturnValue({ enabled: false, connected: false }) + expect(KiloToolRegistry.available(tool, agentInfo)).toBe(false) + + status.mockReturnValue({ enabled: true, connected: false }) + expect(KiloToolRegistry.available(tool, agentInfo)).toBe(false) + + status.mockReturnValue({ enabled: true, connected: true }) + expect(KiloToolRegistry.available(tool, agentInfo)).toBe(true) + }) + + test("returns unavailable when not connected", async () => { + status.mockReturnValue({ enabled: true, connected: false }) + const dir = await tmpdir() + try { + await fs.writeFile(path.join(dir, "test.txt"), "hello") + const result = await runSendTool({ path: "test.txt" }, dir) + expect(result.title).toBe("Send file failed") + expect(result.output).toContain("not connected") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("sends a file with mime attachment field and base64 round-trip", async () => { + const dir = await tmpdir() + try { + const content = "hello world" + await fs.writeFile(path.join(dir, "hello.txt"), content) + const result = await runSendTool({ path: "hello.txt" }, dir) + + expect(result.title).toBe("Sent file: hello.txt") + expect(result.output).toContain("hello.txt") + expect(result.output).toContain("delivered to the user") + expect(result.attachments).toHaveLength(1) + const att = result.attachments![0] + expect(att.type).toBe("file") + expect(att.mime).toBe("text/plain") + expect(att.filename).toBe("hello.txt") + expect(att.url).toStartWith("data:text/plain;base64,") + + // Verify base64 round-trip + const prefix = "data:text/plain;base64," + const b64 = att.url!.slice(prefix.length) + const decoded = Buffer.from(b64, "base64").toString("utf-8") + expect(decoded).toBe(content) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("filename is always basename, never a full path", async () => { + const dir = await tmpdir() + try { + await fs.mkdir(path.join(dir, "sub"), { recursive: true }) + const content = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + await fs.writeFile(path.join(dir, "sub", "deep.png"), content) + + const result = await runSendTool({ path: "sub/deep.png" }, dir) + + expect(result.title).toBe("Sent file: deep.png") + expect(result.attachments).toHaveLength(1) + expect(result.attachments![0].filename).toBe("deep.png") + // The sniff should detect PNG from magic bytes + expect(result.attachments![0].mime).toBe("image/png") + expect(result.attachments![0].url).toStartWith("data:image/png;base64,") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("sniffs correct MIME for a file with wrong extension", async () => { + const dir = await tmpdir() + try { + // Actually just test a real PNG — KiloReadObject opens by path, + // the sniffAttachmentMime looks at magic bytes + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]) + await fs.writeFile(path.join(dir, "secret.dat"), png) + const result = await runSendTool({ path: "secret.dat" }, dir) + expect(result.attachments![0].mime).toBe("image/png") + expect(result.attachments![0].filename).toBe("secret.dat") + expect(result.attachments![0].url).toStartWith("data:image/png;base64,") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + // kilocode_change start — send_file now authorizes missing files with external_directory + read + // before returning a structured fail() result, matching the read.ts security sequence. + test("authorizes missing file before returning fail result", async () => { + const dir = await tmpdir() + try { + const asks: any[] = [] + const askCtx: Tool.Context = { + ...ctx, + ask: (req) => + Effect.sync(() => { + asks.push(req) + }), + } + const layer = Layer.mergeAll( + Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }), + Layer.succeed(Agent.Service, agents), + Layer.succeed(Truncate.Service, truncate), + Layer.succeed(FSUtil.Service, fsService), + ) + const result = await Effect.runPromise( + Effect.gen(function* () { + const info = yield* SendFileTool + const tool = yield* info.init() + return yield* tool.execute({ path: "nope.txt" }, askCtx) + }).pipe(Effect.provide(layer)), + ) + expect(result.title).toBe("Send file failed") + expect(result.output).toContain("File not found") + expect(result.output).toContain("nope.txt") + // Authorization must run before the failure: expect read permission. + // external_directory is only required for paths outside worktree — + // a missing file inside worktree only triggers read permission. + const read = asks.find((a: any) => a.permission === "read") + expect(read).toBeDefined() + expect(read?.always).toEqual(["*"]) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + // kilocode_change end + + // kilocode_change start — send_file now authorizes directories before returning a + // structured fail() result. + test("authorizes directory before returning fail result", async () => { + const dir = await tmpdir() + try { + await fs.mkdir(path.join(dir, "mydir")) + const asks: any[] = [] + const askCtx: Tool.Context = { + ...ctx, + ask: (req) => + Effect.sync(() => { + asks.push(req) + }), + } + const layer = Layer.mergeAll( + Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }), + Layer.succeed(Agent.Service, agents), + Layer.succeed(Truncate.Service, truncate), + Layer.succeed(FSUtil.Service, fsService), + ) + const result = await Effect.runPromise( + Effect.gen(function* () { + const info = yield* SendFileTool + const tool = yield* info.init() + return yield* tool.execute({ path: "mydir" }, askCtx) + }).pipe(Effect.provide(layer)), + ) + expect(result.title).toBe("Send file failed") + expect(result.output).toContain("is a directory") + // Authorization must run before the failure. + const ext = asks.find((a: any) => a.permission === "external_directory") + expect(ext).toBeUndefined() // inside worktree + const read = asks.find((a: any) => a.permission === "read") + expect(read).toBeDefined() + expect(read?.always).toEqual(["*"]) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + // kilocode_change end + + test("rejects file larger than 4 MiB before reading", async () => { + const dir = await tmpdir() + try { + const big = path.join(dir, "big.bin") + // Create a sparse file > 4 MiB without writing all bytes + const handle = await fs.open(big, "w") + await handle.truncate(5 * 1024 * 1024) + await handle.close() + + const result = await runSendTool({ path: "big.bin" }, dir) + expect(result.title).toBe("Send file too large") + expect(result.output).toContain("exceeds the 4 MiB limit") + expect(result.output).toContain("workspace path") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("asks for external_directory and read permissions when file is outside workspace", async () => { + const dir = await tmpdir() + const outside = await tmpdir() + try { + const outsideFile = path.join(outside, "outside.txt") + await fs.writeFile(outsideFile, "secret") + + const asks: any[] = [] + const askCtx: Tool.Context = { + ...ctx, + ask: (req) => + Effect.sync(() => { + asks.push(req) + }), + } + + const layer = Layer.mergeAll( + Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }), + Layer.succeed(Agent.Service, agents), + Layer.succeed(Truncate.Service, truncate), + Layer.succeed(FSUtil.Service, fsService), + ) + + await Effect.runPromise( + Effect.gen(function* () { + const info = yield* SendFileTool + const tool = yield* info.init() + return yield* tool.execute({ path: outsideFile }, askCtx) + }).pipe(Effect.provide(layer)), + ) + + const ext = asks.find((a: any) => a.permission === "external_directory") + expect(ext).toBeDefined() + expect(ext?.patterns).toBeDefined() + + const read = asks.find((a: any) => a.permission === "read") + expect(read).toBeDefined() + expect(read?.patterns).toBeDefined() + expect(read?.always).toEqual(["*"]) + // Patterns must be non-empty and relative to worktree. The exact content + // depends on filesystem layout (symlinks may produce ../ segments), but + // every pattern must be a valid relative path — never empty or ".". + for (const p of read.patterns) { + expect(p.length).toBeGreaterThan(0) + expect(p).not.toBe(".") + } + } finally { + await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(outside, { recursive: true, force: true }) + } + }) + + test("read permission patterns work with relative-path params inside worktree", async () => { + const dir = await tmpdir() + try { + await fs.mkdir(path.join(dir, "sub"), { recursive: true }) + await fs.writeFile(path.join(dir, "sub", "hello.txt"), "hello") + + const asks: any[] = [] + const askCtx: Tool.Context = { + ...ctx, + ask: (req) => + Effect.sync(() => { + asks.push(req) + }), + } + + const layer = Layer.mergeAll( + Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }), + Layer.succeed(Agent.Service, agents), + Layer.succeed(Truncate.Service, truncate), + Layer.succeed(FSUtil.Service, fsService), + ) + + const result = await Effect.runPromise( + Effect.gen(function* () { + const info = yield* SendFileTool + const tool = yield* info.init() + return yield* tool.execute({ path: "sub/hello.txt" }, askCtx) + }).pipe(Effect.provide(layer)), + ) + + // File inside worktree: no external_directory needed + const ext = asks.find((a: any) => a.permission === "external_directory") + expect(ext).toBeUndefined() + + // Read permission is still required + const read = asks.find((a: any) => a.permission === "read") + expect(read).toBeDefined() + expect(read.patterns.length).toBeGreaterThan(0) + expect(read.always).toEqual(["*"]) + + // Verify the tool actually sent the file + expect(result.title).toBe("Sent file: hello.txt") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("reference-root bypass skips external_directory ask", async () => { + const dir = await tmpdir() + const refDir = await tmpdir() + try { + const refFile = path.join(refDir, "inner.txt") + await fs.writeFile(refFile, "ref-content") + + const asks: any[] = [] + const askCtx: Tool.Context = { + ...ctx, + extra: { referenceRoot: refDir }, + ask: (req) => + Effect.sync(() => { + asks.push(req) + }), + } + + const layer = Layer.mergeAll( + Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }), + Layer.succeed(Agent.Service, agents), + Layer.succeed(Truncate.Service, truncate), + Layer.succeed(FSUtil.Service, fsService), + ) + + await Effect.runPromise( + Effect.gen(function* () { + const info = yield* SendFileTool + const tool = yield* info.init() + return yield* tool.execute({ path: refFile }, askCtx) + }).pipe(Effect.provide(layer)), + ) + + // A reference-root file outside the worktree bypasses external_directory. + const ext = asks.find((a: any) => a.permission === "external_directory") + expect(ext).toBeUndefined() + + // But read permission is still required + const read = asks.find((a: any) => a.permission === "read") + expect(read).toBeDefined() + } finally { + await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(refDir, { recursive: true, force: true }) + } + }) + + test("registers with id and has description in registry", async () => { + const dir = await tmpdir() + try { + const layer = Layer.mergeAll( + Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }), + Layer.succeed(Agent.Service, agents), + Layer.succeed(Truncate.Service, truncate), + Layer.succeed(FSUtil.Service, fsService), + ) + + const result = await Effect.runPromise( + Effect.gen(function* () { + const info = yield* SendFileTool + const tool = yield* info.init() + return { id: info.id, description: tool.description } + }).pipe(Effect.provide(layer)), + ) + + expect(result.id).toBe("send_file") + expect(result.description).toContain("Send a file from the local machine") + expect(result.description).toContain("Do NOT use this tool") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("included in extra() list", () => { + const tool = { id: "send_file" } as Tool.Def + const extra = KiloToolRegistry.extra( + { + codebase: tool, + recall: tool, + managerModels: tool, + memory: tool, + save: tool, + manager: tool, + process: tool, + image: tool, + notify: { id: "notify_user" } as Tool.Def, + send: tool, + }, + {}, + ) + + const ids = extra.map((t) => t.id) + expect(ids).toContain("send_file") + }) + + test("non-NotFound stat failure propagates as an error", async () => { + const dir = await tmpdir() + try { + const permError: any = new Error("Permission denied") + permError.code = "EPERM" + permError.reason = { _tag: "PermissionDenied" } + + const failingFs = FSUtil.Service.of({ + stat: () => Effect.fail(permError) as any, + realPath: (candidate: string) => + Effect.try({ + try: () => realpathSync(candidate), + catch: (cause) => cause, + }), + } as unknown as FSUtil.Interface) + + const layer = Layer.mergeAll( + Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }), + Layer.succeed(Agent.Service, agents), + Layer.succeed(Truncate.Service, truncate), + Layer.succeed(FSUtil.Service, failingFs), + ) + + const promise = Effect.runPromise( + Effect.gen(function* () { + const info = yield* SendFileTool + const tool = yield* info.init() + return yield* tool.execute({ path: "anything.txt" }, ctx) + }).pipe(Effect.provide(layer)), + ) + + await expect(promise).rejects.toMatchObject({ code: "EPERM" }) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) +}) +// kilocode_change start — fsService mock now includes stat + realPath for the +// missing/directory authorization sequence that runs before KiloReadObject.file(). +// stat only fabricates NotFound for ENOENT; other errors propagate. +const fsService = FSUtil.Service.of({ + stat: (candidate: string) => + Effect.tryPromise({ + try: async () => { + const info = await fs.stat(candidate) + return { type: info.isFile() ? "File" : info.isDirectory() ? "Directory" : "Other" } + }, + catch: (cause) => { + if ( + cause != null && + typeof cause === "object" && + "code" in cause && + (cause as NodeJS.ErrnoException).code === "ENOENT" + ) { + const err = new Error() as any + err.reason = { _tag: "NotFound" } + return err + } + return cause + }, + }) as any, + realPath: (candidate: string) => + Effect.try({ + try: () => realpathSync(candidate), + catch: (cause) => cause, + }), +} as FSUtil.Interface) +// kilocode_change end diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd9..b53e995161 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -494,6 +494,66 @@ describe("session.message-v2.toModelMessage", () => { }) }) + // kilocode_change start — send_file delivery attachments must not be replayed to the model + test("strips send_file delivery attachments from model context", async () => { + const userID = "m-user-sendfile" + const assistantID = "m-assistant-sendfile" + + const input: SessionV1.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1-sendfile"), + type: "text", + text: "send me the log", + }, + ] as SessionV1.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1-sendfile"), + type: "tool", + callID: "call-sendfile-1", + tool: "send_file", + state: { + status: "completed", + input: { path: "/tmp/example.log" }, + output: "File example.log (50 bytes, text/plain) delivered to the user's Kilo app.", + title: "Sent file: example.log", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-sendfile-1"), + type: "file", + mime: "text/plain", + filename: "example.log", + url: "data:text/plain;base64,aGVsbG8=", + }, + ], + }, + }, + ] as SessionV1.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + // There should be a tool-result but NO media attachment in the output + expect(result).toHaveLength(3) + expect(result[2].role).toBe("tool") + const toolContent = result[2].content[0] as any + expect(toolContent.toolName).toBe("send_file") + // Output should be plain text — no attachments replayed to the model + expect(toolContent.output).toStrictEqual({ + type: "text", + value: "File example.log (50 bytes, text/plain) delivered to the user's Kilo app.", + }) + }) + // kilocode_change end + test("moves bedrock pdf tool-result media into a separate user message", async () => { const bedrockModel: Provider.Model = { ...model, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index f0f2bd3692..71309ca5e4 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1209,3 +1209,94 @@ itFragmentFailure.live("session.processor effect tests flush partial v2 fragment { config: cfg }, ), ) +// kilocode_change start — send_file delivery attachments must skip image normalization. +// An image near the 4 MiB tool cap base64-encodes to ~5.5 MiB, exceeding the 5 MiB +// normalization limit. If normalized, the attachment would be omitted or rewritten +// after send_file reports success. The processor must preserve send_file attachments +// byte-for-byte. +const sendFileDeliveryLLM = Layer.succeed( + LLM.Service, + LLM.Service.of({ + stream: () => { + const largeBase64 = "x".repeat(6 * 1024 * 1024) // 6 MiB exceeds 5 MiB MAX_BASE64_BYTES + const attachment = { + type: "file" as const, + id: PartID.ascending(), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + mime: "image/png", + filename: "big.png", + url: `data:image/png;base64,${largeBase64}`, + } + return Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-1", name: "send_file" }), + LLMEvent.toolInputEnd({ id: "call-1", name: "send_file" }), + LLMEvent.toolCall({ id: "call-1", name: "send_file", input: { path: "big.png" }, providerExecuted: true }), + LLMEvent.toolResult({ + id: "call-1", + name: "send_file", + result: { type: "json", value: { output: "delivered", attachments: [attachment] } }, + output: { structured: { output: "delivered", attachments: [attachment] }, content: [] }, + providerExecuted: true, + }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ) + }, + }), +) +const sendFileDeliveryEnv = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { + replacements: [...replacements, LayerNode.replace(LLM.node, sendFileDeliveryLLM)], +}) +const itSendFileDelivery = testEffect(sendFileDeliveryEnv) + +itSendFileDelivery.live("session.processor preserves send_file delivery attachments without normalization", () => + provideTmpdirServer( + ({ dir }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "send file") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "send file" }], + tools: {}, + }) + const parts = yield* MessageV2.parts(msg.id) + const toolPart = parts.find( + (part): part is Extract => part.type === "tool" && part.tool === "send_file", + ) + if (!toolPart || toolPart.state.status !== "completed") { + return yield* Effect.fail(new Error("expected completed send_file tool part")) + } + expect(toolPart.state.output).not.toContain("omitted") + expect(toolPart.state.attachments).toHaveLength(1) + expect(toolPart.state.attachments?.[0]).toMatchObject({ + mime: "image/png", + filename: "big.png", + url: `data:image/png;base64,${"x".repeat(6 * 1024 * 1024)}`, + }) + }), + { config: (url: string) => providerCfg(url) }, + ), +) +// kilocode_change end