mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #12846 from Kilo-Org/rough-makemake
fix(cli): allow explicit external markdown sources
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
@@ -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> = {}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user