diff --git a/flake.nix b/flake.nix index 1fa4fae94e..ce2fa1a5a0 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ default = let kilo-dev = pkgs.writeShellScriptBin "kilo-dev" '' - cd "$KILO_ROOT" + cd "$KILO_ROOT" exec ${pkgs.bun}/bin/bun dev "$@" ''; diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 37d4158bd6..f4cf0877d2 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -124,6 +124,7 @@ const loaders: Record, () => Promise> = { br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), + nl: () => merge(import("@/i18n/nl"), import("@opencode-ai/ui/i18n/nl")), tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5d20fc5169..7950f091cb 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -10,20 +10,10 @@ import { ProviderTransform } from "../provider/transform" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" -import PROMPT_DEBUG from "./prompt/debug.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" -import PROMPT_ASK from "./prompt/ask.txt" -import PROMPT_ORCHESTRATOR from "./prompt/orchestrator.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" -<<<<<<< HEAD - import { Permission } from "@/permission" -import { NamedError } from "@opencode-ai/util/error" // kilocode_change -import { Glob } from "../util/glob" // kilocode_change -======= -import { Permission } from "@/permission" ->>>>>>> catrielmuller/opencode-v1.3.1 import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" import path from "path" @@ -32,8 +22,7 @@ import { Skill } from "../skill" import { Effect, ServiceMap, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRunPromise } from "@/effect/run-service" - -import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change +import * as KiloAgent from "@/kilocode/agent" // kilocode_change export namespace Agent { export const Info = z @@ -41,10 +30,10 @@ export namespace Agent { name: z.string(), displayName: z.string().optional(), // kilocode_change - human-readable name for org modes description: z.string().optional(), + deprecated: z.boolean().optional(), // kilocode_change mode: z.enum(["subagent", "primary", "all"]), native: z.boolean().optional(), hidden: z.boolean().optional(), - deprecated: z.boolean().optional(), topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), @@ -79,316 +68,6 @@ export namespace Agent { }> } -<<<<<<< HEAD - const skillDirs = await Skill.dirs() - const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] - // kilocode_change start — safe bash commands that don't need user approval. - // only commands that cannot execute arbitrary code or subprocesses. - const bash: Record = { - "*": "ask", - // read-only / informational - "cat *": "allow", - "head *": "allow", - "tail *": "allow", - "less *": "allow", - "ls *": "allow", - "tree *": "allow", - "pwd *": "allow", - "echo *": "allow", - "wc *": "allow", - "which *": "allow", - "type *": "allow", - "file *": "allow", - "diff *": "allow", - "du *": "allow", - "df *": "allow", - "date *": "allow", - "uname *": "allow", - "whoami *": "allow", - "printenv *": "allow", - "man *": "allow", - // text processing - "grep *": "allow", - "rg *": "allow", - "ag *": "allow", - "sort *": "allow", - "uniq *": "allow", - "cut *": "allow", - "tr *": "allow", - "jq *": "allow", - // file operations - "touch *": "allow", - "mkdir *": "allow", - "cp *": "allow", - "mv *": "allow", - // compilers (no script execution) - "tsc *": "allow", - "tsgo *": "allow", - // archive - "tar *": "allow", - "unzip *": "allow", - "gzip *": "allow", - "gunzip *": "allow", - } - // kilocode_change end - - // kilocode_change start — read-only bash commands for the ask agent. - // Unlike the default bash allowlist, unknown commands are DENIED (not "ask") - // because the ask agent must never modify the filesystem. - const readOnlyBash: Record = { - "*": "deny", - // read-only / informational - "cat *": "allow", - "head *": "allow", - "tail *": "allow", - "less *": "allow", - "ls *": "allow", - "tree *": "allow", - "pwd *": "allow", - "echo *": "allow", - "wc *": "allow", - "which *": "allow", - "type *": "allow", - "file *": "allow", - "diff *": "allow", - "du *": "allow", - "df *": "allow", - "date *": "allow", - "uname *": "allow", - "whoami *": "allow", - "printenv *": "allow", - "man *": "allow", - // text processing (stdout only, no file modification) - "grep *": "allow", - "rg *": "allow", - "ag *": "allow", - "sort *": "allow", - "uniq *": "allow", - "cut *": "allow", - "tr *": "allow", - "jq *": "allow", - // git — allowlist of read-only subcommands, deny everything else - "git *": "deny", - "git log *": "allow", - "git show *": "allow", - "git diff *": "allow", - "git status *": "allow", - "git blame *": "allow", - "git rev-parse *": "allow", - "git rev-list *": "allow", - "git ls-files *": "allow", - "git ls-tree *": "allow", - "git ls-remote *": "allow", - "git shortlog *": "allow", - "git describe *": "allow", - "git cat-file *": "allow", - "git name-rev *": "allow", - "git stash list *": "allow", - "git tag -l *": "allow", - "git branch --list *": "allow", - "git branch -a *": "allow", - "git branch -r *": "allow", - "git remote -v *": "allow", - // gh — require user approval since commands vary widely - "gh *": "ask", - } - - // kilocode_change start — allow MCP tools in ask agent with user approval. - // Generates per-server wildcard rules that override "*": "deny". - const mcpRules: Record = {} - for (const key of Object.keys(cfg.mcp ?? {})) { - const sanitized = key.replace(/[^a-zA-Z0-9_-]/g, "_") - mcpRules[sanitized + "_*"] = "ask" - } - // kilocode_change end - - const defaults = Permission.fromConfig({ - "*": "allow", - bash, // kilocode_change - doom_loop: "ask", - recall: "ask", // kilocode_change - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, - question: "deny", - plan_enter: "deny", - plan_exit: "deny", - // mirrors github.com/github/gitignore Node.gitignore pattern for .env files - read: { - "*": "allow", - "*.env": "ask", - "*.env.*": "ask", - "*.env.example": "allow", - }, - }) - const user = Permission.fromConfig(cfg.permission ?? {}) - - const result: Record = { - // kilocode_change start - code: { - name: "code", - description: "The default agent. Executes tools based on configured permissions.", - // kilocode_change end - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_enter: "allow", - }), - user, - ), - mode: "primary", - native: true, - }, - plan: { - name: "plan", - description: "Plan mode. Only allows editing plan files; asks before editing anything else.", - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_exit: "allow", - bash: readOnlyBash, // kilocode_change: read-only bash for plan mode (mirrors ask agent) - ...mcpRules, // kilocode_change: MCP with user approval for plan mode - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, - edit: { - "*": "ask", // kilocode_change: ask (not deny) so user can approve edits outside plan files - [path.join(".kilo", "plans", "*.md")]: "allow", // kilocode_change - [path.join(".opencode", "plans", "*.md")]: "allow", // kilocode_change: .opencode fallback - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", - }, - }), - user, - ), - mode: "primary", - native: true, - }, - // kilocode_change start - add debug, orchestrator, and ask agents - debug: { - name: "debug", - description: "Diagnose and fix software issues with systematic debugging methodology.", - prompt: PROMPT_DEBUG, - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - question: "allow", - plan_enter: "allow", - }), - user, - ), - mode: "primary", - native: true, - }, - orchestrator: { - name: "orchestrator", - description: "Coordinate complex tasks by delegating to specialized agents in parallel.", - prompt: PROMPT_ORCHESTRATOR, - options: {}, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - read: "allow", - grep: "allow", - glob: "allow", - list: "allow", - // bash: "allow", // kilocode_change - disabled to prevent orchestrator from writing files via shell commands instead of delegating to sub-agents - question: "allow", - task: "allow", - todoread: "allow", - todowrite: "allow", - webfetch: "allow", - websearch: "allow", - codesearch: "allow", - codebase_search: "allow", // kilocode_change - external_directory: { - [Truncate.GLOB]: "allow", - }, - }), - user, - // kilocode_change start - enforce bash deny after user so user config cannot re-enable shell - Permission.fromConfig({ - bash: "deny", - }), - // kilocode_change end - ), - mode: "primary", - native: true, - deprecated: true, - }, - ask: { - name: "ask", - description: "Get answers and explanations without making changes to the codebase.", - prompt: PROMPT_ASK, - options: {}, - permission: Permission.merge( - defaults, - user, // kilocode_change: user before ask-specific so ask's deny+allowlist wins - Permission.fromConfig({ - "*": "deny", - bash: readOnlyBash, - read: { - "*": "allow", - "*.env": "ask", - "*.env.*": "ask", - "*.env.example": "allow", - }, - grep: "allow", - glob: "allow", - list: "allow", - question: "allow", - webfetch: "allow", - websearch: "allow", - codesearch: "allow", - codebase_search: "allow", // kilocode_change - external_directory: { - [Truncate.GLOB]: "allow", - }, - ...mcpRules, - }), - user.filter((r) => r.action === "deny"), // kilocode_change: re-apply user denies so explicit MCP blocks win over mcpRules - ), - mode: "primary", - native: true, - }, - // kilocode_change end - general: { - name: "general", - description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - todoread: "deny", - todowrite: "deny", - }), - user, - ), - options: {}, - mode: "subagent", - native: true, - }, - explore: { - name: "explore", - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - grep: "allow", - glob: "allow", - list: "allow", - bash: "allow", - webfetch: "allow", - websearch: "allow", - codesearch: "allow", - codebase_search: "allow", // kilocode_change - read: "allow", -======= type State = Omit export class Service extends ServiceMap.Service()("@opencode/Agent") {} @@ -405,114 +84,14 @@ export namespace Agent { const skillDirs = yield* Effect.promise(() => Skill.dirs()) const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] - const defaults = Permission.fromConfig({ + const baseDefaults = Permission.fromConfig({ + // kilocode_change: renamed from defaults "*": "allow", doom_loop: "ask", ->>>>>>> catrielmuller/opencode-v1.3.1 external_directory: { "*": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, -<<<<<<< HEAD - }), - user, - ), - description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - // kilocode_change - only advertise codebase_search when the experimental flag is on - prompt: cfg.experimental?.codebase_search - ? `Prefer using the codebase_search tool for codebase searches — it performs intelligent multi-step code search and returns the most relevant code spans.\n\n${PROMPT_EXPLORE}` - : PROMPT_EXPLORE, - options: {}, - mode: "subagent", - native: true, - }, - compaction: { - name: "compaction", - mode: "primary", - native: true, - hidden: true, - prompt: PROMPT_COMPACTION, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - options: {}, - }, - title: { - name: "title", - mode: "primary", - options: {}, - native: true, - hidden: true, - temperature: 0.5, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - prompt: PROMPT_TITLE, - }, - summary: { - name: "summary", - mode: "primary", - options: {}, - native: true, - hidden: true, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - "*": "deny", - }), - user, - ), - prompt: PROMPT_SUMMARY, - }, - } - - for (const [key, value] of Object.entries(cfg.agent ?? {})) { - // kilocode_change start - // Treat "build" config as "code" for backward compatibility - const effectiveKey = key === "build" ? "code" : key - if (value.disable) { - delete result[effectiveKey] - continue - } - let item = result[effectiveKey] - if (!item) - item = result[effectiveKey] = { - name: effectiveKey, - mode: "all", - permission: Permission.merge(defaults, user), - options: {}, - native: false, - } - // kilocode_change end - if (value.model) item.model = Provider.parseModel(value.model) - item.variant = value.variant ?? item.variant - item.prompt = value.prompt ?? item.prompt - item.description = value.description ?? item.description - item.temperature = value.temperature ?? item.temperature - item.topP = value.top_p ?? item.topP - item.mode = value.mode ?? item.mode - item.color = value.color ?? item.color - item.hidden = value.hidden ?? item.hidden - item.deprecated = value.deprecated ?? item.deprecated - item.name = value.name ?? item.name - item.steps = value.steps ?? item.steps - item.options = mergeDeep(item.options, value.options ?? {}) - // kilocode_change start - populate displayName from org mode options - if (item.options?.displayName && typeof item.options.displayName === "string") { - item.displayName = item.options.displayName - } - // kilocode_change end - item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) - } -======= question: "deny", plan_enter: "deny", plan_exit: "deny", @@ -525,8 +104,12 @@ export namespace Agent { }, }) + // kilocode_change start - patch defaults with bash allowlist and recall permission + const kilo = KiloAgent.prepare(cfg) + const defaults = Permission.merge(baseDefaults, kilo.defaultsPatch) + // kilocode_change end + const user = Permission.fromConfig(cfg.permission ?? {}) ->>>>>>> catrielmuller/opencode-v1.3.1 const agents: Record = { build: { @@ -658,12 +241,14 @@ export namespace Agent { }, } -<<<<<<< HEAD - result[name].permission = Permission.merge( - result[name].permission, - Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), -======= - for (const [key, value] of Object.entries(cfg.agent ?? {})) { + // kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore + KiloAgent.patchAgents(agents, defaults, user, cfg, kilo) + // kilocode_change end + + // kilocode_change start - preprocess config to remap "build" key → "code" + const agentConfigs = KiloAgent.preprocessConfig(cfg.agent ?? {}) + for (const [key, value] of Object.entries(agentConfigs)) { + // kilocode_change end if (value.disable) { delete agents[key] continue @@ -690,6 +275,7 @@ export namespace Agent { item.steps = value.steps ?? item.steps item.options = mergeDeep(item.options, value.options ?? {}) item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) + KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options } // Ensure Truncate.GLOB is allowed unless explicitly configured @@ -709,7 +295,7 @@ export namespace Agent { } const get = Effect.fnUntraced(function* (agent: string) { - return agents[agent] + return agents[KiloAgent.resolveKey(agent)] // kilocode_change - treat "build" as "code" }) const list = Effect.fnUntraced(function* () { @@ -718,7 +304,7 @@ export namespace Agent { agents, values(), sortBy( - [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], + [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "code"), "desc"], // kilocode_change - renamed from "build" to "code" [(x) => x.name, "asc"], ), ) @@ -727,7 +313,8 @@ export namespace Agent { const defaultAgent = Effect.fnUntraced(function* () { const c = yield* config() if (c.default_agent) { - const agent = agents[c.default_agent] + const effective = KiloAgent.resolveKey(c.default_agent) // kilocode_change - treat "build" as "code" + const agent = agents[effective] // kilocode_change if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) @@ -744,7 +331,6 @@ export namespace Agent { defaultAgent, } satisfies State }), ->>>>>>> catrielmuller/opencode-v1.3.1 ) return Service.of({ @@ -773,12 +359,9 @@ export namespace Agent { const existing = yield* InstanceState.useEffect(state, (s) => s.list()) const params = { - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - }, - }, + // kilocode_change start - enable telemetry with custom PostHog tracer + experimental_telemetry: KiloAgent.telemetryOptions(cfg), + // kilocode_change end temperature: 0.3, messages: [ ...system.map( @@ -829,106 +412,6 @@ export namespace Agent { const runPromise = makeRunPromise(Service, defaultLayer) export async function get(agent: string) { -<<<<<<< HEAD - // kilocode_change start - Treat "build" as "code" for backward compatibility - const effectiveAgent = agent === "build" ? "code" : agent - return state().then((x) => x[effectiveAgent]) - // kilocode_change end - } - - export async function list() { - const cfg = await Config.get() - return pipe( - await state(), - values(), - sortBy( - [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "code"), "desc"], // kilocode_change - renamed from "build" to "code" - [(x) => x.name, "asc"], - ), - ) - } - - export async function defaultAgent() { - const cfg = await Config.get() - const agents = await state() - - if (cfg.default_agent) { - // kilocode_change start - Treat "build" as "code" for backward compatibility - const effectiveDefault = cfg.default_agent === "build" ? "code" : cfg.default_agent - const agent = agents[effectiveDefault] - if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`) - // kilocode_change end - if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`) - if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`) - return agent.name - } - - const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) - if (!primaryVisible) throw new Error("no primary visible agent found") - return primaryVisible.name - } - - export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { - const cfg = await Config.get() - const defaultModel = input.model ?? (await Provider.defaultModel()) - const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) - const language = await Provider.getLanguage(model) - - const system = [PROMPT_GENERATE] - await Plugin.trigger("experimental.chat.system.transform", { model }, { system }) - const existing = await list() - - const params = { - // kilocode_change start - enable telemetry by default with custom PostHog tracer - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry !== false, - recordInputs: false, // Prevent recording prompts, messages, tool args - recordOutputs: false, // Prevent recording completions, tool results - tracer: Telemetry.getTracer() ?? undefined, - metadata: { - userId: cfg.username ?? "unknown", - }, - }, - // kilocode_change end - temperature: 0.3, - messages: [ - ...system.map( - (item): ModelMessage => ({ - role: "system", - content: item, - }), - ), - { - role: "user", - content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, - }, - ], - model: language, - schema: z.object({ - identifier: z.string(), - whenToUse: z.string(), - systemPrompt: z.string(), - }), - } satisfies Parameters[0] - - // TODO: clean this up so provider specific logic doesnt bleed over - if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { - const result = streamObject({ - ...params, - providerOptions: ProviderTransform.providerOptions(model, { - store: false, - }), - onError: () => {}, - }) - for await (const part of result.fullStream) { - if (part.type === "error") throw part.error - } - return result.object - } - - const result = await generateObject(params) - return result.object -======= return runPromise((svc) => svc.get(agent)) } @@ -942,84 +425,12 @@ export namespace Agent { export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { return runPromise((svc) => svc.generate(input)) ->>>>>>> catrielmuller/opencode-v1.3.1 } - // kilocode_change start - export const RemoveError = NamedError.create( - "AgentRemoveError", - z.object({ - name: z.string(), - message: z.string(), - }), - ) - - /** - * Remove a custom agent by deleting its markdown source file and/or - * removing it from legacy .kilocodemodes YAML files. - * Scans all config directories for agent/mode .md files matching the name, - * then also checks the .kilocodemodes files the ModesMigrator reads. - */ + // kilocode_change start - agent removal (delegated to kilocode module) + export const RemoveError = KiloAgent.RemoveError export async function remove(name: string) { - const agents = await state() - const agent = agents[name] - if (!agent) throw new RemoveError({ name, message: "agent not found" }) - if (agent.native) throw new RemoveError({ name, message: "cannot remove native agent" }) - // kilocode_change start - prevent removal of organization-managed agents - if (agent.options?.source === "organization") - throw new RemoveError({ name, message: "cannot remove organization agent — manage it from the cloud dashboard" }) - // kilocode_change end - - const { unlink, readFile, writeFile } = await import("fs/promises") - let found = false - - // 1. Delete .md files from config directories - const dirs = await Config.directories() - const patterns = ["{agent,agents}/**/" + name + ".md", "{mode,modes}/" + name + ".md"] - for (const dir of dirs) { - for (const pattern of patterns) { - const matches = await Glob.scan(pattern, { cwd: dir, absolute: true, dot: true }) - for (const file of matches) { - if (await Bun.file(file).exists()) { - await unlink(file) - found = true - } - } - } - } - - // 2. Remove from legacy .kilocodemodes YAML files (read by ModesMigrator) - const { ModesMigrator } = await import("@/kilocode/modes-migrator") - const { KilocodePaths } = await import("@/kilocode/paths") - const os = await import("os") - const matter = (await import("gray-matter")).default - const home = os.default.homedir() - const modesFiles = [ - path.join(KilocodePaths.vscodeGlobalStorage(), "settings", "custom_modes.yaml"), - path.join(home, ".kilocode", "cli", "global", "settings", "custom_modes.yaml"), - path.join(home, ".kilocodemodes"), - path.join(Instance.directory, ".kilocodemodes"), - ] - - for (const file of modesFiles) { - const modes = await ModesMigrator.readModesFile(file) - if (!modes.length) continue - - const filtered = modes.filter((m) => m.slug !== name) - if (filtered.length === modes.length) continue - - // Rewrite the file without the removed mode - const yaml = matter - .stringify("", { customModes: filtered }) - .replace(/^---\n/, "") - .replace(/\n---\n?$/, "") - await writeFile(file, yaml) - found = true - } - - if (!found) throw new RemoveError({ name, message: "no agent file found on disk" }) - - await Instance.dispose() + return KiloAgent.remove(name) } // kilocode_change end } diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts new file mode 100644 index 0000000000..700c6c2c41 --- /dev/null +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -0,0 +1,449 @@ +// kilocode_change - new file +import { Permission } from "@/permission" +import { NamedError } from "@opencode-ai/util/error" +import { Glob } from "../../util/glob" +import { Truncate } from "../../tool/truncate" +import { Config } from "../../config/config" +import { Instance } from "../../project/instance" +import { Global } from "@/global" +import { Telemetry } from "@kilocode/kilo-telemetry" +import z from "zod" +import path from "path" + +import PROMPT_DEBUG from "../../agent/prompt/debug.txt" +import PROMPT_ORCHESTRATOR from "../../agent/prompt/orchestrator.txt" +import PROMPT_ASK from "../../agent/prompt/ask.txt" +import PROMPT_EXPLORE from "../../agent/prompt/explore.txt" + +// Safe bash commands that don't need user approval. +// Only commands that cannot execute arbitrary code or subprocesses. +export const bash: Record = { + "*": "ask", + // read-only / informational + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "less *": "allow", + "ls *": "allow", + "tree *": "allow", + "pwd *": "allow", + "echo *": "allow", + "wc *": "allow", + "which *": "allow", + "type *": "allow", + "file *": "allow", + "diff *": "allow", + "du *": "allow", + "df *": "allow", + "date *": "allow", + "uname *": "allow", + "whoami *": "allow", + "printenv *": "allow", + "man *": "allow", + // text processing + "grep *": "allow", + "rg *": "allow", + "ag *": "allow", + "sort *": "allow", + "uniq *": "allow", + "cut *": "allow", + "tr *": "allow", + "jq *": "allow", + // file operations + "touch *": "allow", + "mkdir *": "allow", + "cp *": "allow", + "mv *": "allow", + // compilers (no script execution) + "tsc *": "allow", + "tsgo *": "allow", + // archive + "tar *": "allow", + "unzip *": "allow", + "gzip *": "allow", + "gunzip *": "allow", +} + +// Read-only bash commands for ask/plan agents. +// Unknown commands are DENIED (not "ask") because these agents must never modify the filesystem. +export const readOnlyBash: Record = { + "*": "deny", + // read-only / informational + "cat *": "allow", + "head *": "allow", + "tail *": "allow", + "less *": "allow", + "ls *": "allow", + "tree *": "allow", + "pwd *": "allow", + "echo *": "allow", + "wc *": "allow", + "which *": "allow", + "type *": "allow", + "file *": "allow", + "diff *": "allow", + "du *": "allow", + "df *": "allow", + "date *": "allow", + "uname *": "allow", + "whoami *": "allow", + "printenv *": "allow", + "man *": "allow", + // text processing (stdout only, no file modification) + "grep *": "allow", + "rg *": "allow", + "ag *": "allow", + "sort *": "allow", + "uniq *": "allow", + "cut *": "allow", + "tr *": "allow", + "jq *": "allow", + // git — allowlist of read-only subcommands, deny everything else + "git *": "deny", + "git log *": "allow", + "git show *": "allow", + "git diff *": "allow", + "git status *": "allow", + "git blame *": "allow", + "git rev-parse *": "allow", + "git rev-list *": "allow", + "git ls-files *": "allow", + "git ls-tree *": "allow", + "git ls-remote *": "allow", + "git shortlog *": "allow", + "git describe *": "allow", + "git cat-file *": "allow", + "git name-rev *": "allow", + "git stash list *": "allow", + "git tag -l *": "allow", + "git branch --list *": "allow", + "git branch -a *": "allow", + "git branch -r *": "allow", + "git remote -v *": "allow", + // gh — require user approval since commands vary widely + "gh *": "ask", +} + +// Generate per-server MCP wildcard rules that allow MCP tools with user approval. +export function getMcpRules(cfg: Config.Info): Record { + const rules: Record = {} + for (const key of Object.keys(cfg.mcp ?? {})) { + const sanitized = key.replace(/[^a-zA-Z0-9_-]/g, "_") + rules[sanitized + "_*"] = "ask" + } + return rules +} + +export interface KiloData { + mcpRules: Record + defaultsPatch: Permission.Ruleset +} + +// Prepare kilo-specific data derived from config. Call once per state initialization. +export function prepare(cfg: Config.Info): KiloData { + const mcpRules = getMcpRules(cfg) + const defaultsPatch = Permission.fromConfig({ bash, recall: "ask" }) + return { mcpRules, defaultsPatch } +} + +// Map "build" config key to "code" for backward compatibility. +export function resolveKey(name: string): string { + return name === "build" ? "code" : name +} + +// Remap "build" → "code" in agent config entries for backward compat in the config loop. +export function preprocessConfig(agentConfig: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(agentConfig)) { + result[key === "build" ? "code" : key] = value + } + return result +} + +// Set displayName and deprecated from options after config item is processed. +export function processConfigItem(item: { + options: Record + displayName?: string + deprecated?: boolean +}) { + if (item.options?.displayName && typeof item.options.displayName === "string") { + item.displayName = item.options.displayName + } +} + +// Returns experimental_telemetry config for generate calls. +export function telemetryOptions(cfg: Config.Info) { + return { + isEnabled: cfg.experimental?.openTelemetry !== false, + recordInputs: false, + recordOutputs: false, + tracer: Telemetry.getTracer() ?? undefined, + metadata: { + userId: cfg.username ?? "unknown", + }, + } +} + +// Patch the base agents map in-place with all kilo-specific changes: +// - Rename build → code +// - Patch plan with readOnlyBash, mcpRules, .kilo paths +// - Patch explore with codebase_search and conditional prompt +// - Add debug, orchestrator, ask agents +export function patchAgents( + agents: Record< + string, + { + name: string + displayName?: string + description?: string + deprecated?: boolean + mode: "subagent" | "primary" | "all" + native?: boolean + hidden?: boolean + topP?: number + temperature?: number + color?: string + permission: Permission.Ruleset + model?: { modelID: string; providerID: string } + variant?: string + prompt?: string + options: Record + steps?: number + } + >, + defaults: Permission.Ruleset, + user: Permission.Ruleset, + cfg: Config.Info, + kilo: KiloData, +) { + // Rename "build" → "code" for backward compatibility + if (agents.build) { + agents.code = { ...agents.build, name: "code" } + delete agents.build + } + + // Patch plan mode + if (agents.plan) { + agents.plan = { + ...agents.plan, + description: "Plan mode. Only allows editing plan files; asks before editing anything else.", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_exit: "allow", + bash: readOnlyBash, + ...kilo.mcpRules, + external_directory: { + [path.join(Global.Path.data, "plans", "*")]: "allow", + }, + edit: { + "*": "ask", + [path.join(".kilo", "plans", "*.md")]: "allow", + [path.join(".opencode", "plans", "*.md")]: "allow", + [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + }, + }), + user, + ), + } + } + + // Patch explore with codebase_search and conditional prompt + if (agents.explore) { + agents.explore = { + ...agents.explore, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + list: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + codebase_search: "allow", + read: "allow", + external_directory: { + "*": "ask", + [Truncate.GLOB]: "allow", + }, + }), + user, + ), + prompt: cfg.experimental?.codebase_search + ? `Prefer using the codebase_search tool for codebase searches — it performs intelligent multi-step code search and returns the most relevant code spans.\n\n${PROMPT_EXPLORE}` + : PROMPT_EXPLORE, + } + } + + // Add debug agent + agents.debug = { + name: "debug", + description: "Diagnose and fix software issues with systematic debugging methodology.", + prompt: PROMPT_DEBUG, + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_enter: "allow", + }), + user, + ), + mode: "primary", + native: true, + } + + // Add orchestrator agent + agents.orchestrator = { + name: "orchestrator", + description: "Coordinate complex tasks by delegating to specialized agents in parallel.", + prompt: PROMPT_ORCHESTRATOR, + options: {}, + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + read: "allow", + grep: "allow", + glob: "allow", + list: "allow", + question: "allow", + task: "allow", + todoread: "allow", + todowrite: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + codebase_search: "allow", + external_directory: { + [Truncate.GLOB]: "allow", + }, + }), + user, + // Enforce bash deny after user so user config cannot re-enable shell + Permission.fromConfig({ + bash: "deny", + }), + ), + mode: "primary", + native: true, + deprecated: true, + } + + // Add ask agent + agents.ask = { + name: "ask", + description: "Get answers and explanations without making changes to the codebase.", + prompt: PROMPT_ASK, + options: {}, + permission: Permission.merge( + defaults, + user, // user before ask-specific so ask's deny+allowlist wins + Permission.fromConfig({ + "*": "deny", + bash: readOnlyBash, + read: { + "*": "allow", + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow", + }, + grep: "allow", + glob: "allow", + list: "allow", + question: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + codebase_search: "allow", + external_directory: { + [Truncate.GLOB]: "allow", + }, + ...kilo.mcpRules, + }), + user.filter((r: Permission.Rule) => r.action === "deny"), // re-apply user denies so explicit MCP blocks win over mcpRules + ), + mode: "primary", + native: true, + } +} + +export const RemoveError = NamedError.create( + "AgentRemoveError", + z.object({ + name: z.string(), + message: z.string(), + }), +) + +/** + * Remove a custom agent by deleting its markdown source file and/or + * removing it from legacy .kilocodemodes YAML files. + * Scans all config directories for agent/mode .md files matching the name, + * then also checks the .kilocodemodes files the ModesMigrator reads. + */ +export async function remove(name: string) { + const { Agent } = await import("../../agent/agent") + const agent = await Agent.get(name) + if (!agent) throw new RemoveError({ name, message: "agent not found" }) + if (agent.native) throw new RemoveError({ name, message: "cannot remove native agent" }) + // Prevent removal of organization-managed agents + if (agent.options?.source === "organization") + throw new RemoveError({ name, message: "cannot remove organization agent — manage it from the cloud dashboard" }) + + const { unlink, writeFile } = await import("fs/promises") + let found = false + + // 1. Delete .md files from config directories + const { Config } = await import("../../config/config") + const dirs = await Config.directories() + const patterns = ["{agent,agents}/**/" + name + ".md", "{mode,modes}/" + name + ".md"] + for (const dir of dirs) { + for (const pattern of patterns) { + const matches = await Glob.scan(pattern, { cwd: dir, absolute: true, dot: true }) + for (const file of matches) { + if (await Bun.file(file).exists()) { + await unlink(file) + found = true + } + } + } + } + + // 2. Remove from legacy .kilocodemodes YAML files (read by ModesMigrator) + const { ModesMigrator } = await import("@/kilocode/modes-migrator") + const { KilocodePaths } = await import("@/kilocode/paths") + const os = await import("os") + const matter = (await import("gray-matter")).default + const home = os.default.homedir() + const modesFiles = [ + path.join(KilocodePaths.vscodeGlobalStorage(), "settings", "custom_modes.yaml"), + path.join(home, ".kilocode", "cli", "global", "settings", "custom_modes.yaml"), + path.join(home, ".kilocodemodes"), + path.join(Instance.directory, ".kilocodemodes"), + ] + + for (const file of modesFiles) { + const modes = await ModesMigrator.readModesFile(file) + if (!modes.length) continue + + const filtered = modes.filter((m: { slug: string }) => m.slug !== name) + if (filtered.length === modes.length) continue + + // Rewrite the file without the removed mode + const yaml = matter + .stringify("", { customModes: filtered }) + .replace(/^---\n/, "") + .replace(/\n---\n?$/, "") + await writeFile(file, yaml) + found = true + } + + if (!found) throw new RemoveError({ name, message: "no agent file found on disk" }) + + await Instance.dispose() +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index fc3059cb87..dbc0060df4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -32,6 +32,19 @@ import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" import { ProviderRoutes } from "./routes/provider" import { EventRoutes } from "./routes/event" +import { TelemetryRoutes } from "./routes/telemetry" // kilocode_change +import { CommitMessageRoutes } from "./routes/commit-message" // kilocode_change +import { EnhancePromptRoutes } from "./routes/enhance-prompt" // kilocode_change +import { KilocodeRoutes } from "./routes/kilocode" // kilocode_change +import { PermissionKilocodeRoutes } from "../kilocode/permission/routes" // kilocode_change +import { RemoteRoutes } from "./routes/remote" // kilocode_change +import { NetworkRoutes } from "./routes/network" // kilocode_change +import { createKiloRoutes } from "@kilocode/kilo-gateway" // kilocode_change +import { Database } from "../storage/db" // kilocode_change +import { Session } from "../session" // kilocode_change +import { Identifier } from "../id/id" // kilocode_change +import { SessionTable, MessageTable, PartTable } from "../session/session.sql" // kilocode_change +import { Bus } from "@/bus" // kilocode_change import { InstanceBootstrap } from "../project/bootstrap" import { NotFoundError } from "../storage/db" import type { ContentfulStatusCode } from "hono/utils/http-status" @@ -45,24 +58,6 @@ import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" - -// kilocode_change start -// KILO ROUTES -import { TelemetryRoutes } from "./routes/telemetry" -import { CommitMessageRoutes } from "./routes/commit-message" -import { EnhancePromptRoutes } from "./routes/enhance-prompt" -import { KilocodeRoutes } from "./routes/kilocode" -import { PermissionKilocodeRoutes } from "../kilocode/permission/routes" -import { RemoteRoutes } from "./routes/remote" -import { NetworkRoutes } from "./routes/network" -import { Database } from "../storage/db" -import { Session } from "../session" -import { Identifier } from "../id/id" -import { SessionTable, MessageTable, PartTable } from "../session/session.sql" -import { Bus } from "@/bus" -import { createKiloRoutes } from "@kilocode/kilo-gateway" -// kilocode_change end - // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -94,19 +89,14 @@ export namespace Server { status: 500, }) }) - if (err instanceof NamedError) { - let status: ContentfulStatusCode - if (err instanceof NotFoundError) status = 404 - else if (err instanceof Provider.ModelNotFoundError) status = 400 - else if (err.name === "ProviderAuthValidationFailed") status = 400 - else if (err.name.startsWith("Worktree")) status = 400 - else status = 500 - return c.json(err.toObject(), { status }) - } - if (err instanceof HTTPException) return err.getResponse() - const message = err instanceof Error && err.stack ? err.stack : err.toString() - return c.json(new NamedError.Unknown({ message }).toObject(), { - status: 500, + .use((c, next) => { + // Allow CORS preflight requests to succeed without auth. + // Browser clients sending Authorization headers will preflight with OPTIONS. + if (c.req.method === "OPTIONS") return next() + const password = Flag.KILO_SERVER_PASSWORD // kilocode_change + if (!password) return next() + const username = Flag.KILO_SERVER_USERNAME ?? "kilo" // kilocode_change + return basicAuth({ username, password })(c, next) }) .use(async (c, next) => { // kilocode_change start @@ -122,357 +112,43 @@ export namespace Server { method: c.req.method, path: c.req.path, }) - } - const timer = log.time("request", { - method: c.req.method, - path: c.req.path, - }) - await next() - if (!skipLogging) { - timer.stop() - } - }) - .use( - cors({ - origin(input) { - if (!input) return - - if (input.startsWith("http://localhost:")) return input - if (input.startsWith("http://127.0.0.1:")) return input - if ( - input === "tauri://localhost" || - input === "http://tauri.localhost" || - input === "https://tauri.localhost" - ) - return input - - // *.opencode.ai (https only, adjust if needed) - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { - return input - } - if (opts?.cors?.includes(input)) { - return input - } - - return - }, - }), - ) - .route("/global", GlobalRoutes()) - .put( - "/auth/:providerID", - describeRoute({ - summary: "Set auth credentials", - description: "Set authentication credentials", - operationId: "auth.set", - responses: { - 200: { - description: "Successfully set authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod, - }), - ), - validator("json", Auth.Info.zod), - async (c) => { - const providerID = c.req.valid("param").providerID - const info = c.req.valid("json") - await Auth.set(providerID, info) - return c.json(true) - }, - ) - .delete( - "/auth/:providerID", - describeRoute({ - summary: "Remove auth credentials", - description: "Remove authentication credentials", - operationId: "auth.remove", - responses: { - 200: { - description: "Successfully removed authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod, - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - await Auth.remove(providerID) - return c.json(true) - }, - ) - .use(async (c, next) => { - if (c.req.path === "/log") return next() - const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Filesystem.resolve( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) - - return WorkspaceContext.provide({ - workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined, - async fn() { - return Instance.provide({ - directory, - init: InstanceBootstrap, - async fn() { - return next() - }, - }) - }, - }) - }) - .use(WorkspaceRouterMiddleware) - .get( - "/doc", - openAPIRouteHandler(app, { - documentation: { - info: { - title: "opencode", - version: "0.0.3", - description: "opencode api", - }, - openapi: "3.1.1", - }, - }), - ) - .use( - validator( - "query", - z.object({ - directory: z.string().optional(), - workspace: z.string().optional(), - }), - ), - ) - .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes()) - .route("/config", ConfigRoutes()) - .route("/experimental", ExperimentalRoutes()) - .route("/session", SessionRoutes()) - .route("/permission", PermissionRoutes()) - .route("/question", QuestionRoutes()) - .route("/provider", ProviderRoutes()) - .route("/", FileRoutes()) - .route("/", EventRoutes()) - .route("/mcp", McpRoutes()) - .route("/tui", TuiRoutes()) - .post( - "/instance/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose the current OpenCode instance, releasing all resources.", - operationId: "instance.dispose", - responses: { - 200: { - description: "Instance disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Instance.dispose() - return c.json(true) - }, - ) - .get( - "/path", - describeRoute({ - summary: "Get paths", - description: "Retrieve the current working directory and related path information for the OpenCode instance.", - operationId: "path.get", - responses: { - 200: { - description: "Path", - content: { - "application/json": { - schema: resolver( - z - .object({ - home: z.string(), - state: z.string(), - config: z.string(), - worktree: z.string(), - directory: z.string(), - }) - .meta({ - ref: "Path", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, + const timer = log.time("request", { + method: c.req.method, + path: c.req.path, }) await next() if (!skipLogging) { timer.stop() } + }) + .use( + cors({ + origin(input) { + if (!input) return - return c.json(true) - }, - ) - .get( - "/agent", - describeRoute({ - summary: "List agents", - description: "Get a list of all available AI agents in the OpenCode system.", - operationId: "app.agents", - responses: { - 200: { - description: "List of agents", - content: { - "application/json": { - schema: resolver(Agent.Info.array()), - }, - }, + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if ( + input === "tauri://localhost" || + input === "http://tauri.localhost" || + input === "https://tauri.localhost" + ) + return input + + // kilocode_change start + // *.opencode.ai (https only, adjust if needed) + if (/^https:\/\/([a-z0-9-]+\.)*kilo\.ai$/.test(input)) { + return input + } + // kilocode_change end + if (opts?.cors?.includes(input)) { + return input + } + + return }, - }, - }), - async (c) => { - const modes = await Agent.list() - return c.json(modes) - }, - ) - .get( - "/skill", - describeRoute({ - summary: "List skills", - description: "Get a list of all available skills in the OpenCode system.", - operationId: "app.skills", - responses: { - 200: { - description: "List of skills", - content: { - "application/json": { - schema: resolver(Skill.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const skills = await Skill.all() - return c.json(skills) - }, - ) - .get( - "/lsp", - describeRoute({ - summary: "Get LSP status", - description: "Get LSP server status", - operationId: "lsp.status", - responses: { - 200: { - description: "LSP server status", - content: { - "application/json": { - schema: resolver(LSP.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await LSP.status()) - }, - ) - .get( - "/formatter", - describeRoute({ - summary: "Get formatter status", - description: "Get formatter status", - operationId: "formatter.status", - responses: { - 200: { - description: "Formatter status", - content: { - "application/json": { - schema: resolver(Format.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Format.status()) - }, - ) - // kilocode_change start - disable proxy - // .all("/*", async (c) => { - // const path = c.req.path - - // const response = await proxy(`https://app.opencode.ai${path}`, { - // ...c.req, - // headers: { - // ...c.req.raw.headers, - // host: "app.opencode.ai", - // }, - // }) - // .use( - // cors({ - // origin(input) { - // if (!input) return - - // if (input.startsWith("http://localhost:")) return input - // if (input.startsWith("http://127.0.0.1:")) return input - // if ( - // input === "tauri://localhost" || - // input === "http://tauri.localhost" || - // input === "https://tauri.localhost" - // ) - // return input - - // // *.opencode.ai (https only, adjust if needed) - // if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { - // return input - // } - // if (opts?.cors?.includes(input)) { - // return input - // } - - // return - // }, - // }), - // ) + }), + ) .route("/global", GlobalRoutes()) .put( "/auth/:providerID", @@ -886,7 +562,7 @@ export namespace Server { .all("/*", async (c) => { return c.notFound() }) - // kilocode_change end + // kilocode_change end ) } diff --git a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts index 2c9ddd2f0a..482235cec5 100644 --- a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts +++ b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts @@ -1,9 +1,11 @@ import { test, expect, describe } from "bun:test" import { Permission } from "../../src/permission" -// Reconstruct the Ask agent's readOnlyBash allowlist (mirrors agent.ts) +// Reconstruct the Ask agent's readOnlyBash allowlist (mirrors kilocode/agent/index.ts) +// Uses an allow-list approach for git: deny by default, allow specific read-only subcommands. const readOnlyBash: Record = { "*": "deny", + // read-only / informational "cat *": "allow", "head *": "allow", "tail *": "allow", @@ -24,6 +26,7 @@ const readOnlyBash: Record = { "whoami *": "allow", "printenv *": "allow", "man *": "allow", + // text processing (stdout only, no file modification) "grep *": "allow", "rg *": "allow", "ag *": "allow", @@ -32,37 +35,29 @@ const readOnlyBash: Record = { "cut *": "allow", "tr *": "allow", "jq *": "allow", - "git *": "allow", - "git add *": "deny", - "git commit *": "deny", - "git push *": "deny", - "git merge *": "deny", - "git rebase *": "deny", - "git cherry-pick *": "deny", - "git reset *": "deny", - "git checkout *": "deny", - "git switch *": "deny", - "git stash *": "deny", - "git tag *": "deny", - "git am *": "deny", - "git apply *": "deny", - "git remote set-url *": "deny", - "git remote add *": "deny", - "git remote remove *": "deny", - "git clean *": "deny", - "git mv *": "deny", - "git rm *": "deny", - "git config *": "deny", - "git clone *": "deny", - "git pull *": "deny", - "git init *": "deny", - "git worktree *": "deny", - "git submodule *": "deny", - "git revert *": "deny", - "git bisect *": "deny", - "git filter-branch *": "deny", - "git fetch *": "deny", - "git restore *": "deny", + // git — allowlist of read-only subcommands, deny everything else + "git *": "deny", + "git log *": "allow", + "git show *": "allow", + "git diff *": "allow", + "git status *": "allow", + "git blame *": "allow", + "git rev-parse *": "allow", + "git rev-list *": "allow", + "git ls-files *": "allow", + "git ls-tree *": "allow", + "git ls-remote *": "allow", + "git shortlog *": "allow", + "git describe *": "allow", + "git cat-file *": "allow", + "git name-rev *": "allow", + "git stash list *": "allow", + "git tag -l *": "allow", + "git branch --list *": "allow", + "git branch -a *": "allow", + "git branch -r *": "allow", + "git remote -v *": "allow", + // gh — require user approval since commands vary widely "gh *": "ask", }