mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge branch 'main' into fix/skill-shell-inline-code-spans
This commit is contained in:
@@ -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.
|
||||
@@ -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 <path>`, 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:
|
||||
|
||||
@@ -98,4 +98,35 @@ 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`). `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.
|
||||
*
|
||||
* 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
|
||||
permission: string
|
||||
patterns: readonly string[]
|
||||
agent: string
|
||||
origins: Origins
|
||||
}): Approval {
|
||||
const candidate = input.ruleset as Partial<Permission.Rule> | 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Response>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof Params, {}, FSUtil.Service, "send_file">(
|
||||
"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),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -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.
|
||||
@@ -232,9 +232,8 @@ export const layer = Layer.effect(
|
||||
}
|
||||
// kilocode_change end
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
ruleset: subset(request.permission, ruleset), // kilocode_change
|
||||
})
|
||||
// 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SessionV1.FilePart>(attachment)),
|
||||
)
|
||||
// kilocode_change end
|
||||
const omitted = normalized.filter(Exit.isFailure).length
|
||||
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
||||
const output = {
|
||||
|
||||
@@ -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,20 @@ 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,
|
||||
permission: req.permission,
|
||||
patterns: req.patterns,
|
||||
agent: input.agent.name,
|
||||
origins: permissionOrigins,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.orDie,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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<Permission.Interface["ask"]>[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<any, any, any>) {
|
||||
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 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: [{ permission: "bash", pattern: "*", action: "ask" as const }],
|
||||
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" })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, any> }[] = []) {
|
||||
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<string, any> }[] = []
|
||||
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: deniedRule })),
|
||||
}),
|
||||
)
|
||||
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" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SessionV1.Part, { type: "tool" }> => 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
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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<string, unknown> | 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<string, unknown> | 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" 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 (
|
||||
<Show when={props.note}>
|
||||
<span style={{ fg: props.color }}> · {props.note}</span>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -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 { ApprovalBadge, 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
|
||||
@@ -2310,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 */}
|
||||
<ApprovalBadge note={props.note} color={props.noteColor} />
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -2330,11 +2339,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 (
|
||||
<box
|
||||
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
||||
@@ -2361,6 +2373,8 @@ function BlockTool(props: {
|
||||
{props.title}
|
||||
{/* kilocode_change start */}
|
||||
<RoutedModelMeta.View id={props.part?.id} />
|
||||
{/* explain why the call was auto-approved or denied, inline on the title */}
|
||||
<ApprovalBadge note={approvalNote()} color={theme.textMuted} />
|
||||
{/* kilocode_change end */}
|
||||
</text>
|
||||
}
|
||||
@@ -2825,7 +2839,8 @@ function TodoWrite(props: ToolProps) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={parseTodos(props.metadata.todos).length}>
|
||||
<BlockTool title="# Todos" part={props.part}>
|
||||
{/* kilocode_change - todo writes are orchestration, not a mutating action to explain */}
|
||||
<BlockTool title="# Todos" part={props.part} hideApproval>
|
||||
<box>
|
||||
<For each={todos()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
|
||||
</box>
|
||||
|
||||
Reference in New Issue
Block a user