Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -428,12 +429,18 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
</box>
)

// kilocode_change start — hide "Always allow" for config file edits
const options: Record<string, string> = 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 = (
<Prompt
title="Permission required"
header={header()}
body={current.body}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
options={options}
escapeKey="reject"
fullscreen
onSelect={(option) => {
Expand Down
129 changes: 129 additions & 0 deletions packages/opencode/src/kilocode/permission/config-paths.ts
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
alex-alecu marked this conversation as resolved.
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
Comment thread
alex-alecu marked this conversation as resolved.
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<string, any>
}): 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
}
}
3 changes: 3 additions & 0 deletions packages/opencode/src/kilocode/permission/drain.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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),
)
Expand Down
23 changes: 22 additions & 1 deletion packages/opencode/src/permission/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<void>((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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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,
Expand Down
Loading