From 8c84f8ae5ef17a6e69a66cdd06680c6bf4e91db7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 4 Aug 2026 11:31:10 +0200 Subject: [PATCH] fix(cli): allow explicit external markdown sources --- .changeset/trust-shared-config.md | 5 ++ packages/core/src/v1/config/permission.ts | 1 + .../pages/customize/custom-subagents.md | 14 ++++ .../kilo-docs/pages/customize/workflows.md | 14 ++++ packages/opencode/src/config/agent.ts | 2 +- packages/opencode/src/config/command.ts | 2 +- packages/opencode/src/config/config.ts | 18 ++++- .../src/kilocode/config/external-markdown.ts | 66 +++++++++++++++++ .../opencode/src/kilocode/config/markdown.ts | 10 ++- .../test/kilocode/config-resilience.test.ts | 74 +++++++++++++++++++ .../kilocode/config/external-markdown.test.ts | 50 +++++++++++++ 11 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 .changeset/trust-shared-config.md create mode 100644 packages/opencode/src/kilocode/config/external-markdown.ts create mode 100644 packages/opencode/test/kilocode/config/external-markdown.test.ts diff --git a/.changeset/trust-shared-config.md b/.changeset/trust-shared-config.md new file mode 100644 index 0000000000..7a120f2c35 --- /dev/null +++ b/.changeset/trust-shared-config.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Support project agent and command directory symlinks explicitly allowed by global Markdown source permissions. diff --git a/packages/core/src/v1/config/permission.ts b/packages/core/src/v1/config/permission.ts index 36d2468bbe..6d6caa02ab 100644 --- a/packages/core/src/v1/config/permission.ts +++ b/packages/core/src/v1/config/permission.ts @@ -25,6 +25,7 @@ const InputObject = Schema.StructWithRest( bash: Schema.optional(Rule), task: Schema.optional(Rule), external_directory: Schema.optional(Rule), + markdown_source: Schema.optional(Rule), // kilocode_change - explicitly authorize external agent/command sources todowrite: Schema.optional(Action), question: Schema.optional(Action), webfetch: Schema.optional(Action), diff --git a/packages/kilo-docs/pages/customize/custom-subagents.md b/packages/kilo-docs/pages/customize/custom-subagents.md index 99c889db7f..5c00ea8d77 100644 --- a/packages/kilo-docs/pages/customize/custom-subagents.md +++ b/packages/kilo-docs/pages/customize/custom-subagents.md @@ -93,6 +93,20 @@ Define agents as markdown files with YAML frontmatter. Place them in: The **filename** (without `.md`) becomes the agent name. +If `.kilo/agents/` is a symlink to a directory outside the project, allow that exact source in your global `~/.config/kilo/kilo.jsonc`: + +```jsonc +{ + "permission": { + "markdown_source": { + "/path/to/shared/agents/*": "allow" + } + } +} +``` + +Project configuration cannot grant this permission. External agent files remain untrusted: `{env:...}` substitutions are blocked and `{file:...}` substitutions remain confined to the project. + ```markdown --- description: Reviews code for quality and best practices diff --git a/packages/kilo-docs/pages/customize/workflows.md b/packages/kilo-docs/pages/customize/workflows.md index cb55800c87..9d6da762ff 100644 --- a/packages/kilo-docs/pages/customize/workflows.md +++ b/packages/kilo-docs/pages/customize/workflows.md @@ -17,6 +17,20 @@ Workflows are Markdown files stored as **slash commands** in `.kilo/commands/`: - **Global commands**: `~/.config/kilo/commands/` (available in all projects) - **Project commands**: `[project]/.kilo/commands/` (project-specific) +If `.kilo/commands/` is a symlink to a directory outside the project, allow that exact source in your global `~/.config/kilo/kilo.jsonc`: + +```jsonc +{ + "permission": { + "markdown_source": { + "/path/to/shared/commands/*": "allow" + } + } +} +``` + +Project configuration cannot grant this permission. External command files remain untrusted: `{env:...}` substitutions are blocked and `{file:...}` substitutions remain confined to the project. + ### Basic Setup 1. Create a `.md` file with step-by-step instructions diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 7aa4430b72..31f898e82c 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -24,7 +24,7 @@ export async function load( warnings?: Warning[], trusted = false, fileScope?: ConfigVariable.FileScope, - sourceScope?: ConfigVariable.FileScope, + sourceScope?: ConfigVariable.FileScope | readonly ConfigVariable.FileScope[], ) { // kilocode_change end const result: Record = {} diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 36d40be13f..fe29288d2d 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -25,7 +25,7 @@ export async function load( warnings?: Warning[], trusted = false, fileScope?: ConfigVariable.FileScope, - sourceScope?: ConfigVariable.FileScope, + sourceScope?: ConfigVariable.FileScope | readonly ConfigVariable.FileScope[], ) { // kilocode_change end const result: Record = {} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index cdd433b92a..7ce0b002e4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -47,6 +47,7 @@ import { Git } from "@/git" import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" import { KilocodeGlobalConfigStamp } from "@/kilocode/config/global-stamp" import { SandboxConfig } from "@/kilocode/sandbox/config" +import { ExternalMarkdown } from "@/kilocode/config/external-markdown" import type { KilocodeMarkdown } from "@/kilocode/config/markdown" import { IndexingConfig as KiloIndexingConfig, @@ -763,13 +764,26 @@ export const layer = Layer.effect( deps.push(dep) // kilocode_change start - propagate parse errors to the Warning accumulator + const sourceScopes = (names: readonly string[]) => [ + ...(dirSourceScope ? [dirSourceScope] : []), + ...ExternalMarkdown.scopes({ + dir, + names, + permission: result.permission, + origins: result.permission_origins, + }), + ] result.command = mergeDeep( result.command ?? {}, - yield* Effect.promise(() => ConfigCommand.load(dir, warnings, dirTrusted, dirFileScope, dirSourceScope)), + yield* Effect.promise(() => + ConfigCommand.load(dir, warnings, dirTrusted, dirFileScope, sourceScopes(["command", "commands"])), + ), ) result.agent = KilocodeConfig.mergeAgentMarkdown( result.agent ?? {}, - yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted, dirFileScope, dirSourceScope)), + yield* Effect.promise(() => + ConfigAgent.load(dir, warnings, dirTrusted, dirFileScope, sourceScopes(["agent", "agents"])), + ), configuredAgents, ) result.agent = KilocodeConfig.mergeAgentMarkdown( diff --git a/packages/opencode/src/kilocode/config/external-markdown.ts b/packages/opencode/src/kilocode/config/external-markdown.ts new file mode 100644 index 0000000000..c8b75381c8 --- /dev/null +++ b/packages/opencode/src/kilocode/config/external-markdown.ts @@ -0,0 +1,66 @@ +import { realpathSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import type { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" +import type { ConfigVariableGuard } from "./variable" + +export namespace ExternalMarkdown { + type Origins = Record> + + function expand(pattern: string) { + if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) + if (pattern === "~") return os.homedir() + if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) + if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) + return pattern + } + + function normalize(value: string) { + const result = path.normalize(value) + return process.platform === "win32" ? result.toLowerCase() : result + } + + function bounded(pattern: string, root: string) { + const value = expand(pattern).replaceAll("\\", "/") + const index = value.search(/[?*]/) + if (index === -1) return false + const prefix = value.slice(0, index).replace(/\/+$/, "") + if (!path.isAbsolute(prefix) || value.slice(prefix.length) !== "/*") return false + try { + return normalize(realpathSync.native(prefix)) === normalize(root) + } catch { + return false + } + } + + function allowed(root: string, permission: ConfigPermissionV1.Info | undefined, origins: Origins | undefined) { + const rule = permission?.markdown_source + if (!rule || typeof rule === "string") return false + const winner = Object.entries(rule) + .filter(([, action]) => action !== null) + .findLast(([pattern]) => bounded(pattern, root)) + if (!winner || origins?.markdown_source?.[winner[0]] !== "global") return false + return winner[1] === "allow" + } + + export function scopes(input: { + dir: string + names: readonly string[] + permission: ConfigPermissionV1.Info | undefined + origins: Origins | undefined + }): ConfigVariableGuard.FileScope[] { + const result: ConfigVariableGuard.FileScope[] = [] + for (const name of input.names) { + const source = path.join(input.dir, name) + try { + const root = realpathSync.native(source) + if (normalize(root) === normalize(source)) continue + if (!allowed(root, input.permission, input.origins)) continue + result.push({ root, source }) + } catch { + continue + } + } + return result + } +} diff --git a/packages/opencode/src/kilocode/config/markdown.ts b/packages/opencode/src/kilocode/config/markdown.ts index 3b32c774db..ff7fbe3405 100644 --- a/packages/opencode/src/kilocode/config/markdown.ts +++ b/packages/opencode/src/kilocode/config/markdown.ts @@ -2,6 +2,7 @@ import { ConfigVariable } from "@/config/variable" import { InvalidError } from "@opencode-ai/core/v1/config/error" import { Filesystem } from "@/util/filesystem" import { ConfigVariableGuard } from "./variable" +import path from "node:path" export namespace KilocodeMarkdown { export type Source = { @@ -13,12 +14,17 @@ export namespace KilocodeMarkdown { export type Options = { trusted: boolean fileScope?: ConfigVariable.FileScope - sourceScope?: ConfigVariable.FileScope + sourceScope?: ConfigVariable.FileScope | readonly ConfigVariable.FileScope[] } export function read(item: string, options: Options) { if (options.trusted) return Filesystem.readText(item) - const scope = options.sourceScope ?? options.fileScope + const scope = Array.isArray(options.sourceScope) + ? options.sourceScope.findLast((scope) => { + const rel = path.relative(scope.source, item) + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) + }) + : (options.sourceScope ?? options.fileScope) if (!scope) { throw new InvalidError({ path: item, diff --git a/packages/opencode/test/kilocode/config-resilience.test.ts b/packages/opencode/test/kilocode/config-resilience.test.ts index 12949e44cf..7cfd6f3f8e 100644 --- a/packages/opencode/test/kilocode/config-resilience.test.ts +++ b/packages/opencode/test/kilocode/config-resilience.test.ts @@ -6,6 +6,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { provideTestInstance } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { Flag } from "@opencode-ai/core/flag/flag" const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) const warnings = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings())) @@ -106,6 +107,79 @@ describe("config resilience", () => { }) }) + test.serial( + "loads external directory symlinks explicitly allowed by global config without trusting tokens", + async () => { + const name = "KILO_EXTERNAL_MARKDOWN_SECRET" + const env = process.env[name] + const config = Flag.KILO_CONFIG + process.env[name] = "environment secret" + await using tmp = await tmpdir({ + init: async (dir) => { + const project = path.join(dir, "project") + const shared = path.join(dir, "shared") + const agents = path.join(shared, "agents") + const commands = path.join(shared, "commands") + const secret = path.join(dir, "secret.txt") + const escaped = path.join(dir, "escaped.md") + const global = path.join(dir, "global.json") + await Filesystem.write(path.join(agents, "shared.md"), "Shared agent prompt") + await Filesystem.write(path.join(commands, "shared.md"), "Shared command template") + await Filesystem.write(path.join(agents, "env.md"), `{env:${name}}`) + await Filesystem.write(path.join(commands, "file.md"), `{file:${secret}}`) + await Filesystem.write(secret, "file secret") + await Filesystem.write(escaped, "Escaped agent prompt") + await fs.symlink(escaped, path.join(agents, "escaped.md")) + await fs.mkdir(path.join(project, ".kilo"), { recursive: true }) + const type = process.platform === "win32" ? "junction" : "dir" + await fs.symlink(agents, path.join(project, ".kilo", "agents"), type) + await fs.symlink(commands, path.join(project, ".kilo", "commands"), type) + await Filesystem.write( + global, + JSON.stringify({ + permission: { + markdown_source: { + [path.join(agents, "*")]: "allow", + [path.join(commands, "*")]: "allow", + }, + }, + }), + ) + return { project, global } + }, + }) + Flag.KILO_CONFIG = tmp.extra.global + + try { + await provideTestInstance({ + directory: tmp.extra.project, + fn: async () => { + const cfg = await load() + const warns = await warnings() + + expect(cfg.agent?.shared).toMatchObject({ prompt: "Shared agent prompt" }) + expect(cfg.command?.shared).toMatchObject({ template: "Shared command template" }) + expect(cfg.agent?.env).toBeUndefined() + expect(cfg.agent?.escaped).toBeUndefined() + expect(cfg.command?.file).toBeUndefined() + expect( + warns.filter( + (warning) => + warning.path.endsWith("env.md") || + warning.path.endsWith("escaped.md") || + warning.path.endsWith("file.md"), + ), + ).toHaveLength(3) + }, + }) + } finally { + Flag.KILO_CONFIG = config + if (env === undefined) delete process.env[name] + else process.env[name] = env + } + }, + ) + test("skips invalid agent markdown configs", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/kilocode/config/external-markdown.test.ts b/packages/opencode/test/kilocode/config/external-markdown.test.ts new file mode 100644 index 0000000000..3263f5b319 --- /dev/null +++ b/packages/opencode/test/kilocode/config/external-markdown.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { ExternalMarkdown } from "../../../src/kilocode/config/external-markdown" +import { tmpdir } from "../../fixture/fixture" + +describe("external Markdown sources", () => { + test("requires a global allow for the exact canonical directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const source = path.join(dir, "project", ".kilo", "agents") + const root = path.join(dir, "shared", "agents") + await Bun.write(path.join(root, "shared.md"), "prompt") + await fs.mkdir(path.dirname(source), { recursive: true }) + await fs.symlink(root, source, process.platform === "win32" ? "junction" : "dir") + return { dir: path.dirname(source), root } + }, + }) + const exact = path.join(tmp.extra.root, "*") + const input = { + dir: tmp.extra.dir, + names: ["agents"], + permission: { markdown_source: { [exact]: "allow" as const } }, + origins: { markdown_source: { [exact]: "global" as const } }, + } + + expect(ExternalMarkdown.scopes(input)).toEqual([ + { root: tmp.extra.root, source: path.join(tmp.extra.dir, "agents") }, + ]) + expect(ExternalMarkdown.scopes({ ...input, origins: { markdown_source: { [exact]: "local" } } })).toEqual([]) + + const parent = path.join(path.dirname(tmp.extra.root), "*") + expect( + ExternalMarkdown.scopes({ + ...input, + permission: { markdown_source: { [parent]: "allow" } }, + origins: { markdown_source: { [parent]: "global" } }, + }), + ).toEqual([]) + + const prefix = `${tmp.extra.root}*` + expect( + ExternalMarkdown.scopes({ + ...input, + permission: { markdown_source: { [prefix]: "allow" } }, + origins: { markdown_source: { [prefix]: "global" } }, + }), + ).toEqual([]) + }) +})