refactor: wip

This commit is contained in:
Catriel Müller
2026-04-11 12:55:19 -03:00
parent d9b2fc7ffd
commit f23e2818b3
6 changed files with 558 additions and 1026 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
default =
let
kilo-dev = pkgs.writeShellScriptBin "kilo-dev" ''
cd "$KILO_ROOT"
cd "$KILO_ROOT"
exec ${pkgs.bun}/bin/bun dev "$@"
'';
+1
View File
@@ -124,6 +124,7 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
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")),
}
+28 -617
View File
@@ -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<string, "allow" | "ask" | "deny"> = {
"*": "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<string, "allow" | "ask" | "deny"> = {
"*": "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<string, "allow" | "ask" | "deny"> = {}
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<string, Info> = {
// 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<Interface, "generate">
export class Service extends ServiceMap.Service<Service, Interface>()("@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<string, Info> = {
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<typeof generateObject>[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
}
@@ -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<string, "allow" | "ask" | "deny"> = {
"*": "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<string, "allow" | "ask" | "deny"> = {
"*": "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<string, "allow" | "ask" | "deny"> {
const rules: Record<string, "allow" | "ask" | "deny"> = {}
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<string, "allow" | "ask" | "deny">
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<T>(agentConfig: Record<string, T>): Record<string, T> {
const result: Record<string, T> = {}
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<string, unknown>
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<string, unknown>
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()
}
+52 -376
View File
@@ -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
)
}
@@ -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<string, "allow" | "ask" | "deny"> = {
"*": "deny",
// read-only / informational
"cat *": "allow",
"head *": "allow",
"tail *": "allow",
@@ -24,6 +26,7 @@ const readOnlyBash: Record<string, "allow" | "ask" | "deny"> = {
"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<string, "allow" | "ask" | "deny"> = {
"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",
}