Merge pull request #12846 from Kilo-Org/rough-makemake

fix(cli): allow explicit external markdown sources
This commit is contained in:
Marius
2026-08-04 11:56:23 +02:00
committed by GitHub
11 changed files with 250 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Support project agent and command directory symlinks explicitly allowed by global Markdown source permissions.
@@ -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),
@@ -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
@@ -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
+1 -1
View File
@@ -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<string, ConfigAgentV1.Info> = {}
+1 -1
View File
@@ -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<string, ConfigCommandV1.Info> = {}
+16 -2
View File
@@ -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(
@@ -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<string, Record<string, "global" | "local">>
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
}
}
@@ -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,
@@ -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) => {
@@ -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([])
})
})