diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index c2cd4d17d9c..667761cd890 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -16,6 +16,7 @@ import { Locale } from "@/util/locale" import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { useTuiConfig } from "../../context/tui-config" +import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change type PermissionStage = "permission" | "always" | "reject" @@ -428,12 +429,18 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { ) + // kilocode_change start — hide "Always allow" for config file edits + const options: Record = props.request.metadata?.[ConfigProtection.DISABLE_ALWAYS_KEY] + ? { once: "Allow once", reject: "Reject" } + : { once: "Allow once", always: "Allow always", reject: "Reject" } + // kilocode_change end + const body = ( { diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts new file mode 100644 index 00000000000..facd70987ca --- /dev/null +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -0,0 +1,129 @@ +import path from "path" +import { Global } from "@/global" +import { KilocodePaths } from "@/kilocode/paths" + +export namespace ConfigProtection { + /** + * Config directory prefixes (relative paths, forward-slash normalized). + * Matches .kilo/, .kilocode/, .opencode/ at any depth within the project. + */ + const CONFIG_DIRS = [".kilo/", ".kilocode/", ".opencode/"] + + /** + * Subdirectories under CONFIG_DIRS that are NOT config files (e.g. plan files). + * Paths under these subdirs are exempt from config protection. + */ + const EXCLUDED_SUBDIRS = ["plans/"] + + /** + * Root-level config files that must be protected. + * Matched only when the relative path has no directory component. + */ + const CONFIG_ROOT_FILES = new Set(["kilo.json", "kilo.jsonc", "opencode.json", "opencode.jsonc", "AGENTS.md"]) + + /** Metadata key used to signal the UI to hide the "Allow always" option. */ + export const DISABLE_ALWAYS_KEY = "disableAlways" as const + + function normalize(p: string): string { + return path.posix.normalize(p.replaceAll("\\", "/")) + } + + /** Return the remainder after the config dir prefix, or undefined if excluded. */ + function excluded(remainder: string): boolean { + return EXCLUDED_SUBDIRS.some((sub) => remainder.startsWith(sub)) + } + + /** Check if a project-relative path points to a config file or directory. */ + export function isRelative(pattern: string): boolean { + const normalized = normalize(pattern) + for (const dir of CONFIG_DIRS) { + const bare = dir.slice(0, -1) // e.g. ".kilo" + // Match at root (e.g. ".kilo/foo") or nested (e.g. "packages/sub/.kilo/foo") + if (normalized === bare || normalized.endsWith("/" + bare)) return true + if (normalized.startsWith(dir)) { + if (excluded(normalized.slice(dir.length))) continue + return true + } + const nested = normalized.indexOf("/" + dir) + if (nested !== -1) { + if (excluded(normalized.slice(nested + 1 + dir.length))) continue + return true + } + } + return CONFIG_ROOT_FILES.has(normalized) + } + + /** Check if `child` is equal to or nested inside `parent`. */ + function within(child: string, parent: string): boolean { + return child === parent || child.startsWith(parent + path.sep) + } + + /** Check if an absolute path is inside a known CLI config directory. */ + export function isAbsolute(filepath: string): boolean { + const resolved = path.resolve(filepath) + + // ~/.config/kilo/ (XDG config) + if (within(resolved, path.resolve(Global.Path.config))) return true + + // ~/.kilo/ and ~/.kilocode/ (legacy global dirs) + for (const dir of KilocodePaths.globalDirs()) { + if (within(resolved, path.resolve(dir))) return true + } + + return false + } + + /** Check a single path (absolute or relative) against config protection. */ + function protected_(p: string): boolean { + return path.isAbsolute(p) ? isAbsolute(p) : isRelative(p) + } + + /** + * Determine if a permission request targets config files. + * Checks `edit` and `external_directory` permissions — read access is not restricted. + */ + export function isRequest(request: { + permission: string + patterns: string[] + metadata?: Record + }): boolean { + // external_directory patterns are absolute globs like "/Users/alex/.config/kilo/*" + if (request.permission === "external_directory") { + for (const pattern of request.patterns) { + const dir = pattern.replace(/\/\*$/, "") + if (isAbsolute(dir)) return true + } + return false + } + + if (request.permission !== "edit") return false + + // Check patterns — handle both relative and absolute + for (const pattern of request.patterns) { + if (protected_(pattern)) return true + } + + // Check metadata.filepath (absolute for edit, comma-joined relative for apply_patch) + const fp = request.metadata?.filepath + if (typeof fp === "string") { + // apply_patch joins relative paths with ", " + const parts = fp.includes(", ") ? fp.split(", ") : [fp] + for (const part of parts) { + if (protected_(part)) return true + } + } + + // Check metadata.files[] (apply_patch file objects with absolute filePath/movePath) + const files = request.metadata?.files + if (Array.isArray(files)) { + for (const file of files) { + for (const key of ["filePath", "movePath"] as const) { + const val = file?.[key] + if (typeof val === "string" && protected_(val)) return true + } + } + } + + return false + } +} diff --git a/packages/opencode/src/kilocode/permission/drain.ts b/packages/opencode/src/kilocode/permission/drain.ts index 608932a4f0b..cfd6949c45d 100644 --- a/packages/opencode/src/kilocode/permission/drain.ts +++ b/packages/opencode/src/kilocode/permission/drain.ts @@ -1,6 +1,7 @@ import { Bus } from "@/bus" import { Wildcard } from "@/util/wildcard" import type { PermissionNext } from "@/permission/next" +import { ConfigProtection } from "@/kilocode/permission/config-paths" /** * Auto-resolve pending permissions now fully covered by approved or denied rules. @@ -25,6 +26,8 @@ export async function drainCovered( ) { for (const [id, entry] of Object.entries(pending)) { if (id === exclude) continue + // Never auto-resolve config file edit permissions + if (ConfigProtection.isRequest(entry.info)) continue const actions = entry.info.patterns.map((pattern) => evaluate(entry.info.permission, pattern, entry.ruleset, approved), ) diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 5534572da26..b68fafd1f8a 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -9,6 +9,7 @@ import { fn } from "@/util/fn" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import { drainCovered } from "@/kilocode/permission/drain" // kilocode_change +import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change import os from "os" import z from "zod" @@ -188,18 +189,27 @@ export namespace PermissionNext { async (input) => { const s = await state() const { ruleset, ...request } = input + // kilocode_change start — force "ask" for config file edits + const protected_ = ConfigProtection.isRequest(request) + // kilocode_change end for (const pattern of request.patterns ?? []) { const rule = evaluate(request.permission, pattern, ruleset, s.approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) - if (rule.action === "ask") { + // kilocode_change start — override "allow" to "ask" for config paths + if (rule.action === "ask" || (rule.action === "allow" && protected_)) { const id = input.id ?? Identifier.ascending("permission") return new Promise((resolve, reject) => { const info: Request = { id, ...request, + metadata: { + ...request.metadata, + ...(protected_ ? { [ConfigProtection.DISABLE_ALWAYS_KEY]: true } : {}), + }, } + // kilocode_change end s.pending[id] = { info, ruleset, // kilocode_change @@ -227,6 +237,10 @@ export namespace PermissionNext { const existing = s.pending[input.requestID] if (!existing) throw new NotFoundError({ message: `Permission request ${input.requestID} not found` }) + // kilocode_change start — skip rule persistence for config file edits + if (ConfigProtection.isRequest(existing.info)) return + // kilocode_change end + // Combine metadata.rules (bash hierarchy) and always (all tools). // Set preserves insertion order and deduplicates. const validRules = new Set([...(existing.info.metadata?.rules ?? []), ...existing.info.always]) @@ -289,6 +303,13 @@ export namespace PermissionNext { return } if (input.reply === "always") { + // kilocode_change start — downgrade "always" to "once" for config file edits + if (ConfigProtection.isRequest(existing.info)) { + existing.resolve() + return + } + // kilocode_change end + for (const pattern of existing.info.always) { s.approved.push({ permission: existing.info.permission,