mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(cli): block project markdown secret exfiltration (#12168)
* fix(cli): guard markdown substitutions by config trust * chore(cli): annotate markdown trust test changes * fix(cli): preserve trusted global instruction patterns
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Block environment and out-of-project file substitutions in project markdown configuration.
|
||||
@@ -135,7 +135,13 @@ export const Info = AgentSchema.pipe(
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
// kilocode_change start - trusted gates {env:}; fileScope confines untrusted agent prompt {file:} reads
|
||||
export async function load(dir: string, warnings?: Warning[], trusted?: boolean, fileScope?: ConfigVariable.FileScope) {
|
||||
export async function load(
|
||||
dir: string,
|
||||
warnings?: Warning[],
|
||||
trusted = false,
|
||||
fileScope?: ConfigVariable.FileScope,
|
||||
sourceScope?: ConfigVariable.FileScope,
|
||||
) {
|
||||
// kilocode_change end
|
||||
const result: Record<string, Info> = {}
|
||||
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
|
||||
@@ -144,7 +150,9 @@ export async function load(dir: string, warnings?: Warning[], trusted?: boolean,
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
// kilocode_change start
|
||||
const md = await ConfigMarkdown.parse(item, { trusted, fileScope, sourceScope }).catch(async (err) => {
|
||||
// kilocode_change end
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse agent ${item}`
|
||||
@@ -211,7 +219,13 @@ export async function load(dir: string, warnings?: Warning[], trusted?: boolean,
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
export async function loadMode(dir: string, warnings?: Warning[]) {
|
||||
export async function loadMode(
|
||||
dir: string,
|
||||
warnings?: Warning[],
|
||||
trusted = false,
|
||||
fileScope?: ConfigVariable.FileScope,
|
||||
sourceScope?: ConfigVariable.FileScope,
|
||||
) {
|
||||
// kilocode_change end
|
||||
const result: Record<string, Info> = {}
|
||||
for (const item of await Glob.scan("{mode,modes}/*.md", {
|
||||
@@ -220,7 +234,9 @@ export async function loadMode(dir: string, warnings?: Warning[]) {
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
// kilocode_change start
|
||||
const md = await ConfigMarkdown.parse(item, { trusted, fileScope, sourceScope }).catch(async (err) => {
|
||||
// kilocode_change end
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse mode ${item}`
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Bus } from "@/bus"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { KilocodeConfig } from "@/kilocode/config/config"
|
||||
import type { Warning } from "./config"
|
||||
import type { ConfigVariable } from "./variable"
|
||||
// kilocode_change end
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -29,7 +30,13 @@ export type Info = Schema.Schema.Type<typeof Info>
|
||||
const decodeInfo = Schema.decodeUnknownExit(Info)
|
||||
|
||||
// kilocode_change start
|
||||
export async function load(dir: string, warnings?: Warning[]) {
|
||||
export async function load(
|
||||
dir: string,
|
||||
warnings?: Warning[],
|
||||
trusted = false,
|
||||
fileScope?: ConfigVariable.FileScope,
|
||||
sourceScope?: ConfigVariable.FileScope,
|
||||
) {
|
||||
// kilocode_change end
|
||||
const result: Record<string, Info> = {}
|
||||
for (const item of await Glob.scan("{command,commands}/**/*.md", {
|
||||
@@ -38,7 +45,9 @@ export async function load(dir: string, warnings?: Warning[]) {
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||
// kilocode_change start
|
||||
const md = await ConfigMarkdown.parse(item, { trusted, fileScope, sourceScope }).catch(async (err) => {
|
||||
// kilocode_change end
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse command ${item}`
|
||||
|
||||
@@ -55,6 +55,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 type { KilocodeMarkdown } from "@/kilocode/config/markdown"
|
||||
import {
|
||||
IndexingConfig as KiloIndexingConfig,
|
||||
IndexingSchema as KiloIndexingSchema,
|
||||
@@ -444,6 +445,10 @@ export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
|
||||
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
|
||||
plugin_origins?: ConfigPlugin.Origin[]
|
||||
// kilocode_change start - derived provenance for markdown paths selected by config
|
||||
instruction_origins?: Record<string, KilocodeMarkdown.Source>
|
||||
skill_path_origins?: Record<string, KilocodeMarkdown.Source>
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -523,7 +528,14 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string
|
||||
}
|
||||
|
||||
function writable(info: Info) {
|
||||
const { plugin_origins: _plugin_origins, ...next } = info
|
||||
// kilocode_change start - derived provenance is runtime-only and must never be persisted
|
||||
const {
|
||||
plugin_origins: _plugin_origins,
|
||||
instruction_origins: _instruction_origins,
|
||||
skill_path_origins: _skill_path_origins,
|
||||
...next
|
||||
} = info
|
||||
// kilocode_change end
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -731,6 +743,14 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
result = mergeConfigConcatArrays(result, legacy.config)
|
||||
// Legacy rules are discovered from fixed global/project directories, so their paths safely identify the
|
||||
// source boundary even though the migrator returns them as one merged instruction list.
|
||||
result.instruction_origins = Object.fromEntries(
|
||||
(legacy.config.instructions ?? []).map((item) => {
|
||||
const trusted = !containsPath(item, ctx)
|
||||
return [item, { trusted, source: item, root: trusted ? undefined : projectRoot }]
|
||||
}),
|
||||
)
|
||||
warnings.push(...legacy.warnings)
|
||||
|
||||
const orgModes = yield* Effect.promise(() => KilocodeConfig.loadOrganizationModes(auth))
|
||||
@@ -773,10 +793,36 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
// kilocode_change start
|
||||
const merge = Effect.fnUntraced(function* (source: string, next: Info, kind?: ConfigPlugin.Scope) {
|
||||
const origins = (
|
||||
prev: Record<string, KilocodeMarkdown.Source> | undefined,
|
||||
values: readonly string[],
|
||||
trusted: boolean,
|
||||
source: string,
|
||||
) => {
|
||||
const result = { ...prev }
|
||||
for (const value of values) {
|
||||
if (result[value]?.trusted) continue
|
||||
result[value] = { trusted, source, root: trusted ? undefined : projectRoot }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const merge = Effect.fnUntraced(function* (
|
||||
source: string,
|
||||
next: Info,
|
||||
kind?: ConfigPlugin.Scope,
|
||||
sourceTrusted?: boolean,
|
||||
) {
|
||||
const scope = kind ?? (yield* pluginScopeForSource(source))
|
||||
const trusted = sourceTrusted ?? scope === "global"
|
||||
const scoped = KilocodeConfig.scopeIndexing(SandboxConfig.scope(next, scope), scope)
|
||||
result = mergeConfigConcatArrays(result, scoped)
|
||||
if (next.instructions?.length) {
|
||||
result.instruction_origins = origins(result.instruction_origins, next.instructions, trusted, source)
|
||||
}
|
||||
if (next.skills?.paths?.length) {
|
||||
result.skill_path_origins = origins(result.skill_path_origins, next.skills.paths, trusted, source)
|
||||
}
|
||||
return yield* mergePluginOrigins(source, scoped.plugin, scope)
|
||||
})
|
||||
// kilocode_change end
|
||||
@@ -861,6 +907,8 @@ export const layer = Layer.effect(
|
||||
return Effect.succeed({} as Info)
|
||||
}),
|
||||
),
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
// kilocode_change end
|
||||
log.debug("loaded custom config", { path: Flag.KILO_CONFIG })
|
||||
@@ -911,9 +959,12 @@ export const layer = Layer.effect(
|
||||
const scope = primarySet.has(dir) ? "local" : undefined
|
||||
// kilocode_change - trust {file:}/{env:} only for global-scoped config dirs, never project ones
|
||||
const dirScope = scope ?? (yield* pluginScopeForSource(dir))
|
||||
const dirTrusted = dirScope === "global"
|
||||
const dirTrusted = dir === Flag.KILO_CONFIG_DIR || dirScope === "global"
|
||||
// kilocode_change - untrusted config dirs confine {file:} reads to projectRoot
|
||||
const dirFileScope = dirTrusted ? undefined : { root: projectRoot, source: dir }
|
||||
const dirSourceScope = dirTrusted
|
||||
? undefined
|
||||
: { root: primarySet.has(dir) ? path.dirname(dir) : projectRoot, source: dir }
|
||||
if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) {
|
||||
for (const file of KilocodeConfig.ALL_CONFIG_FILES) {
|
||||
const source = path.join(dir, file)
|
||||
@@ -929,7 +980,8 @@ export const layer = Layer.effect(
|
||||
return Effect.succeed({} as Info)
|
||||
}),
|
||||
),
|
||||
dirScope, // kilocode_change
|
||||
dirScope,
|
||||
dirTrusted,
|
||||
)
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
@@ -966,13 +1018,16 @@ export const layer = Layer.effect(
|
||||
// kilocode_change start - propagate parse errors to the Warning accumulator
|
||||
result.command = mergeDeep(
|
||||
result.command ?? {},
|
||||
yield* Effect.promise(() => ConfigCommand.load(dir, warnings)),
|
||||
yield* Effect.promise(() => ConfigCommand.load(dir, warnings, dirTrusted, dirFileScope, dirSourceScope)),
|
||||
)
|
||||
result.agent = mergeDeep(
|
||||
result.agent ?? {},
|
||||
yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted, dirFileScope)), // kilocode_change
|
||||
yield* Effect.promise(() => ConfigAgent.load(dir, warnings, dirTrusted, dirFileScope, dirSourceScope)),
|
||||
)
|
||||
result.agent = mergeDeep(
|
||||
result.agent ?? {},
|
||||
yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings, dirTrusted, dirFileScope, dirSourceScope)),
|
||||
)
|
||||
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings)))
|
||||
// kilocode_change end
|
||||
// kilocode_change - Auto-discovered plugins under config directories are already local files, so ConfigPlugin.load
|
||||
// returns normalized Specs and we only need to attach origin metadata here.
|
||||
@@ -1001,6 +1056,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
"local",
|
||||
true,
|
||||
)
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
@@ -68,18 +68,22 @@ export function fallbackSanitization(content: string): string {
|
||||
return content.replace(frontmatter, () => processed)
|
||||
}
|
||||
|
||||
export async function parse(filePath: string) {
|
||||
const template = await Filesystem.readText(filePath)
|
||||
// kilocode_change start - accept source trust and confine untrusted markdown source reads
|
||||
export async function parse(filePath: string, options: KilocodeMarkdown.Options) {
|
||||
const template = options.trusted
|
||||
? await Filesystem.readText(filePath)
|
||||
: await KilocodeMarkdown.read(filePath, options)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - substitute content and retry invalid frontmatter with permissive sanitization
|
||||
try {
|
||||
const md = matter(template)
|
||||
md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change
|
||||
md.content = await KilocodeMarkdown.substitute(md.content, filePath, options) // kilocode_change
|
||||
return md
|
||||
} catch {
|
||||
try {
|
||||
const md = matter(fallbackSanitization(template))
|
||||
md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change
|
||||
md.content = await KilocodeMarkdown.substitute(md.content, filePath, options) // kilocode_change
|
||||
return md
|
||||
} catch (err) {
|
||||
throw new FrontmatterError(
|
||||
|
||||
@@ -73,7 +73,13 @@ export namespace ConfigValidation {
|
||||
|
||||
let md: Awaited<ReturnType<typeof ConfigMarkdown.parse>>
|
||||
try {
|
||||
md = await ConfigMarkdown.parse(filepath)
|
||||
const trusted = path.isAbsolute(filepath) && ConfigProtection.isAbsolute(filepath)
|
||||
const ctx = Instance.current
|
||||
const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree
|
||||
md = await ConfigMarkdown.parse(filepath, {
|
||||
trusted,
|
||||
fileScope: trusted ? undefined : { root, source: filepath },
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
const msg = ConfigMarkdown.FrontmatterError.isInstance(e)
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { InvalidError } from "@/config/error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ConfigVariableGuard } from "./variable"
|
||||
|
||||
export namespace KilocodeMarkdown {
|
||||
function ref(token: string) {
|
||||
const file = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (file.startsWith("~/")) return path.join(os.homedir(), file.slice(2))
|
||||
return file
|
||||
export type Source = {
|
||||
trusted: boolean
|
||||
source: string
|
||||
root?: string
|
||||
}
|
||||
|
||||
export async function substitute(text: string, item: string) {
|
||||
const body = text.replace(/\{env:([^}]+)\}/g, (_, name) => process.env[name] || "")
|
||||
const matches = Array.from(body.matchAll(/\{file:[^}]+\}/g))
|
||||
if (!matches.length) return body
|
||||
export type Options = {
|
||||
trusted: boolean
|
||||
fileScope?: ConfigVariable.FileScope
|
||||
sourceScope?: ConfigVariable.FileScope
|
||||
}
|
||||
|
||||
const dir = path.dirname(item)
|
||||
const chunks = await Promise.all(
|
||||
matches.map(async (match, i) => {
|
||||
const token = match[0]
|
||||
const index = match.index ?? 0
|
||||
const prev = matches[i - 1]
|
||||
const cursor = prev ? (prev.index ?? 0) + prev[0].length : 0
|
||||
const head = body.slice(cursor, index)
|
||||
const start = body.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = body.slice(start, index).trimStart()
|
||||
if (prefix.startsWith("//")) return head + token
|
||||
export function read(item: string, options: Options) {
|
||||
if (options.trusted) return Filesystem.readText(item)
|
||||
const scope = options.sourceScope ?? options.fileScope
|
||||
if (!scope) {
|
||||
throw new InvalidError({
|
||||
path: item,
|
||||
message: "project markdown cannot be read without a project scope",
|
||||
})
|
||||
}
|
||||
return ConfigVariableGuard.read(item, { ...scope, token: `markdown source "${item}"` })
|
||||
}
|
||||
|
||||
const file = ref(token)
|
||||
const target = path.isAbsolute(file) ? file : path.resolve(dir, file)
|
||||
const content = await Filesystem.readText(target).catch(() => "")
|
||||
return head + content.trim()
|
||||
}),
|
||||
)
|
||||
const last = matches.at(-1)
|
||||
return chunks.join("") + (last ? body.slice((last.index ?? 0) + last[0].length) : "")
|
||||
export function substitute(text: string, item: string, options: Options) {
|
||||
return ConfigVariable.substitute({
|
||||
text,
|
||||
type: "path",
|
||||
path: item,
|
||||
missing: "empty",
|
||||
escapeJson: false,
|
||||
trusted: options.trusted,
|
||||
fileScope: options.fileScope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,8 +199,8 @@ export namespace KilocodeConfigOverlay {
|
||||
if (!dir) return input
|
||||
if (!existsSync(dir)) return withAgents(input, rest, trusted, root)
|
||||
const fileScope = trusted || !root ? undefined : { root, source: dir }
|
||||
const agent = await ConfigAgent.load(dir, undefined, trusted, fileScope)
|
||||
const mode = await ConfigAgent.loadMode(dir)
|
||||
const agent = await ConfigAgent.load(dir, undefined, trusted, fileScope, fileScope)
|
||||
const mode = await ConfigAgent.loadMode(dir, undefined, trusted, fileScope, fileScope)
|
||||
const next = KilocodeConfig.mergeConfig(KilocodeConfig.mergeConfig(input, { agent }), { agent: mode })
|
||||
return withAgents(next, rest, trusted, root)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { KilocodeMarkdown } from "../config/markdown"
|
||||
|
||||
export namespace KilocodeInstruction {
|
||||
export function content(text: string, item: string) {
|
||||
return KilocodeMarkdown.substitute(text, item)
|
||||
export function content(text: string, item: string, options: KilocodeMarkdown.Options) {
|
||||
return KilocodeMarkdown.substitute(text, item, options)
|
||||
}
|
||||
|
||||
export async function read(item: string, options: KilocodeMarkdown.Options) {
|
||||
return content(await KilocodeMarkdown.read(item, options), item, options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ export namespace KilocodeTuiConfig {
|
||||
function writable(config: Patch | TuiConfig.Info, defaults = true): Editable {
|
||||
const result = { ...config } as Record<string, unknown>
|
||||
delete result.plugin_origins
|
||||
delete result.instruction_origins
|
||||
delete result.skill_path_origins
|
||||
const keybinds: Record<string, string> = defaults
|
||||
? Object.fromEntries(KilocodeKeybinds.list().map((item) => [item.id, item.default]))
|
||||
: {}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import os from "os"
|
||||
import type { Config } from "../config/config"
|
||||
import type { ConfigCommand } from "../config/command"
|
||||
import { InvalidError } from "../config/error"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { KilocodeMarkdown } from "./config/markdown"
|
||||
import { KilocodePaths } from "./paths"
|
||||
@@ -55,12 +55,30 @@ export namespace WorkflowsMigrator {
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function loadWorkflowsFromDir(dir: string, source: "global" | "project"): Promise<KilocodeWorkflow[]> {
|
||||
async function loadWorkflowsFromDir(
|
||||
dir: string,
|
||||
source: "global" | "project",
|
||||
root?: string,
|
||||
warnings: string[] = [],
|
||||
): Promise<KilocodeWorkflow[]> {
|
||||
if (!(await Filesystem.isDir(dir))) return []
|
||||
const files = await findWorkflowFiles(dir)
|
||||
const workflows: KilocodeWorkflow[] = []
|
||||
for (const file of files) {
|
||||
const content = await KilocodeMarkdown.substitute(await fs.readFile(file, "utf-8"), file)
|
||||
const options = {
|
||||
trusted: source === "global",
|
||||
fileScope: source === "project" && root ? { root, source: file } : undefined,
|
||||
}
|
||||
const content = await KilocodeMarkdown.read(file, options)
|
||||
.then((text) => KilocodeMarkdown.substitute(text, file, options))
|
||||
.catch((err) => {
|
||||
const message = InvalidError.isInstance(err) ? err.data.message : undefined
|
||||
warnings.push(
|
||||
`Skipped workflow '${extractNameFromFilename(file)}': ${message ?? (err instanceof Error ? err.message : String(err))}`,
|
||||
)
|
||||
return undefined
|
||||
})
|
||||
if (content === undefined) continue
|
||||
workflows.push({
|
||||
name: extractNameFromFilename(file),
|
||||
path: file,
|
||||
@@ -71,23 +89,27 @@ export namespace WorkflowsMigrator {
|
||||
return workflows
|
||||
}
|
||||
|
||||
export async function discoverWorkflows(projectDir: string, skipGlobalPaths?: boolean): Promise<KilocodeWorkflow[]> {
|
||||
export async function discoverWorkflows(
|
||||
projectDir: string,
|
||||
skipGlobalPaths?: boolean,
|
||||
warnings: string[] = [],
|
||||
): Promise<KilocodeWorkflow[]> {
|
||||
const workflows: KilocodeWorkflow[] = []
|
||||
|
||||
if (!skipGlobalPaths) {
|
||||
// 1. VSCode extension global storage (primary location for global workflows)
|
||||
const vscodeWorkflowsDir = path.join(KilocodePaths.vscodeGlobalStorage(), "workflows")
|
||||
workflows.push(...(await loadWorkflowsFromDir(vscodeWorkflowsDir, "global")))
|
||||
workflows.push(...(await loadWorkflowsFromDir(vscodeWorkflowsDir, "global", undefined, warnings)))
|
||||
|
||||
// 2. Home directories ~/.kilocode/workflows and ~/.kilo/workflows
|
||||
for (const dir of globalWorkflowsDirs()) {
|
||||
workflows.push(...(await loadWorkflowsFromDir(dir, "global")))
|
||||
workflows.push(...(await loadWorkflowsFromDir(dir, "global", undefined, warnings)))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Project workflows (.kilo/workflows/ and .kilocode/workflows/)
|
||||
for (const dir of KILO_WORKFLOWS_DIRS) {
|
||||
workflows.push(...(await loadWorkflowsFromDir(path.join(projectDir, dir), "project")))
|
||||
workflows.push(...(await loadWorkflowsFromDir(path.join(projectDir, dir), "project", projectDir, warnings)))
|
||||
}
|
||||
|
||||
return workflows
|
||||
@@ -108,7 +130,7 @@ export namespace WorkflowsMigrator {
|
||||
const warnings: string[] = []
|
||||
const commands: Record<string, ConfigCommand.Info> = {}
|
||||
|
||||
const workflows = await discoverWorkflows(options.projectDir, options.skipGlobalPaths)
|
||||
const workflows = await discoverWorkflows(options.projectDir, options.skipGlobalPaths, warnings)
|
||||
|
||||
// Deduplicate by name (project takes precedence over global)
|
||||
const workflowsByName = new Map<string, KilocodeWorkflow>()
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { KilocodeInstruction } from "@/kilocode/session/instruction" // kilocode_change
|
||||
import type { KilocodeMarkdown } from "@/kilocode/config/markdown" // kilocode_change
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { MessageID } from "./schema"
|
||||
|
||||
@@ -91,11 +92,23 @@ export const layer: Layer.Layer<
|
||||
return yield* fs.globUp(instruction, root, root).pipe(Effect.catch(() => Effect.succeed([] as string[]))) // kilocode_change
|
||||
})
|
||||
|
||||
const read = Effect.fnUntraced(function* (filepath: string) {
|
||||
const content = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) // kilocode_change
|
||||
return yield* Effect.promise(() => KilocodeInstruction.content(content, filepath)) // kilocode_change
|
||||
// kilocode_change start - project instructions cannot read env or files outside the project root
|
||||
const options = Effect.fnUntraced(function* (filepath: string, origin?: KilocodeMarkdown.Source) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree
|
||||
const trusted = origin?.trusted ?? false
|
||||
return {
|
||||
trusted,
|
||||
fileScope: trusted ? undefined : { root: origin?.root ?? root, source: origin?.source ?? filepath },
|
||||
}
|
||||
})
|
||||
|
||||
const read = Effect.fnUntraced(function* (filepath: string, origin?: KilocodeMarkdown.Source) {
|
||||
const opts = yield* options(filepath, origin)
|
||||
return yield* Effect.promise(() => KilocodeInstruction.read(filepath, opts).catch(() => ""))
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
const fetch = Effect.fnUntraced(function* (url: string) {
|
||||
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
|
||||
Effect.timeout(5000),
|
||||
@@ -111,14 +124,21 @@ export const layer: Layer.Layer<
|
||||
s.claims.delete(messageID)
|
||||
})
|
||||
|
||||
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
|
||||
// kilocode_change start - retain declaration provenance through instruction path expansion
|
||||
const systemSources = Effect.fn("Instruction.systemSources")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const ctx = yield* InstanceState.context
|
||||
const paths = new Set<string>()
|
||||
const root = ctx.worktree === "/" ? ctx.directory : ctx.worktree
|
||||
const paths = new Map<string, KilocodeMarkdown.Source>()
|
||||
const add = (item: string, origin: KilocodeMarkdown.Source) => {
|
||||
const filepath = path.resolve(item)
|
||||
if (paths.get(filepath)?.trusted) return
|
||||
paths.set(filepath, origin)
|
||||
}
|
||||
|
||||
for (const file of globalFiles) {
|
||||
if (yield* fs.existsSafe(file)) {
|
||||
paths.add(path.resolve(file))
|
||||
add(file, { trusted: true, source: file })
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -130,7 +150,7 @@ export const layer: Layer.Layer<
|
||||
.findUp(file, ctx.directory, ctx.worktree)
|
||||
.pipe(Effect.catch(() => Effect.succeed([])))
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
matches.forEach((item) => add(item, { trusted: false, source: item, root }))
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -149,25 +169,38 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
: relative(instruction)
|
||||
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
const declared = config.instruction_origins?.[raw] ?? { trusted: false, source: raw, root }
|
||||
const trusted = declared.trusted && (path.isAbsolute(instruction) || Flag.KILO_DISABLE_PROJECT_CONFIG)
|
||||
const origin = { ...declared, trusted, root: trusted ? undefined : (declared.root ?? root) }
|
||||
matches.forEach((item) => add(item, origin))
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
})
|
||||
|
||||
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
|
||||
return new Set((yield* systemSources()).keys())
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
const system = Effect.fn("Instruction.system")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const paths = yield* systemPaths()
|
||||
const sources = yield* systemSources() // kilocode_change
|
||||
const paths = Array.from(sources.keys()) // kilocode_change
|
||||
const urls = (config.instructions ?? []).filter(
|
||||
(item) => item.startsWith("https://") || item.startsWith("http://"),
|
||||
)
|
||||
|
||||
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
|
||||
// kilocode_change start
|
||||
const files = yield* Effect.forEach(Array.from(sources.entries()), (item) => read(item[0], item[1]), {
|
||||
concurrency: 8,
|
||||
})
|
||||
// kilocode_change end
|
||||
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
|
||||
|
||||
return [
|
||||
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
|
||||
...paths.flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), // kilocode_change
|
||||
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import { BUILTIN_SKILLS } from "../kilocode/skills/builtin" // kilocode_change
|
||||
import { primaryPaths } from "../kilocode/primary-worktree" // kilocode_change
|
||||
import { Git } from "@/git" // kilocode_change
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change
|
||||
|
||||
const log = Log.create({ service: "skill" })
|
||||
const CLAUDE_EXTERNAL_DIR = ".claude"
|
||||
@@ -79,15 +80,24 @@ type State = {
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
||||
// kilocode_change start - retain markdown trust provenance through discovery
|
||||
type Match = {
|
||||
path: string
|
||||
trusted: boolean
|
||||
root?: string
|
||||
sourceRoot?: string
|
||||
}
|
||||
|
||||
type DiscoveryState = {
|
||||
matches: string[]
|
||||
matches: Match[]
|
||||
dirs: string[]
|
||||
}
|
||||
|
||||
type ScanState = {
|
||||
matches: Set<string>
|
||||
matches: Map<string, Match>
|
||||
dirs: Set<string>
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
@@ -97,19 +107,29 @@ export interface Interface {
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
|
||||
// kilocode_change start
|
||||
const add = Effect.fnUntraced(function* (state: State, match: Match, bus: Bus.Interface) {
|
||||
const source = match.sourceRoot ?? match.root
|
||||
// kilocode_change end
|
||||
const md = yield* Effect.tryPromise({
|
||||
try: () => ConfigMarkdown.parse(match),
|
||||
// kilocode_change start - project skills cannot read env or files outside the project root
|
||||
try: () =>
|
||||
ConfigMarkdown.parse(match.path, {
|
||||
trusted: match.trusted,
|
||||
fileScope: match.trusted || !match.root ? undefined : { root: match.root, source: match.path },
|
||||
sourceScope: match.trusted || !source ? undefined : { root: source, source: match.path },
|
||||
}),
|
||||
// kilocode_change end
|
||||
catch: (err) => err,
|
||||
}).pipe(
|
||||
Effect.catch(
|
||||
Effect.fnUntraced(function* (err) {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
: `Failed to parse skill ${match.path}` // kilocode_change
|
||||
const { Session } = yield* Effect.promise(() => import("@/session/session"))
|
||||
yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
log.error("failed to load skill", { skill: match.path, err }) // kilocode_change
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
@@ -123,15 +143,15 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
|
||||
log.warn("duplicate skill name", {
|
||||
name: md.data.name,
|
||||
existing: state.skills[md.data.name].location,
|
||||
duplicate: match,
|
||||
duplicate: match.path, // kilocode_change
|
||||
})
|
||||
}
|
||||
|
||||
state.dirs.add(path.dirname(match))
|
||||
state.dirs.add(path.dirname(match.path)) // kilocode_change
|
||||
state.skills[md.data.name] = {
|
||||
name: md.data.name,
|
||||
description: md.data.description,
|
||||
location: match,
|
||||
location: match.path, // kilocode_change
|
||||
content: md.content,
|
||||
}
|
||||
})
|
||||
@@ -140,7 +160,7 @@ const scan = Effect.fnUntraced(function* (
|
||||
state: ScanState,
|
||||
root: string,
|
||||
pattern: string,
|
||||
opts?: { dot?: boolean; scope?: string },
|
||||
opts?: { dot?: boolean; scope?: string; trusted?: boolean; root?: string; sourceRoot?: string }, // kilocode_change
|
||||
) {
|
||||
const matches = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
@@ -161,7 +181,14 @@ const scan = Effect.fnUntraced(function* (
|
||||
)
|
||||
|
||||
for (const match of matches) {
|
||||
state.matches.add(match)
|
||||
// kilocode_change start
|
||||
state.matches.set(match, {
|
||||
path: match,
|
||||
trusted: opts?.trusted ?? false,
|
||||
root: opts?.root,
|
||||
sourceRoot: opts?.sourceRoot,
|
||||
})
|
||||
// kilocode_change end
|
||||
state.dirs.add(path.dirname(match))
|
||||
}
|
||||
})
|
||||
@@ -176,7 +203,8 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
directory: string,
|
||||
worktree: string,
|
||||
) {
|
||||
const state: ScanState = { matches: new Set(), dirs: new Set() }
|
||||
const state: ScanState = { matches: new Map(), dirs: new Set() } // kilocode_change
|
||||
const projectRoot = worktree === "/" ? directory : worktree // kilocode_change - project substitution boundary
|
||||
|
||||
const externalDirs: string[] = []
|
||||
if (!disableExternalSkills) {
|
||||
@@ -186,24 +214,45 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
for (const dir of externalDirs) {
|
||||
const root = path.join(global.home, dir)
|
||||
if (!(yield* fsys.isDir(root))) continue
|
||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global", trusted: true }) // kilocode_change
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
const local = yield* fsys
|
||||
.up({ targets: externalDirs, start: directory, stop: worktree })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const upDirs = [...(yield* primaryPaths(directory, worktree, externalDirs)), ...local]
|
||||
const fallbacks = yield* primaryPaths(directory, worktree, externalDirs) // kilocode_change
|
||||
const upDirs = [...fallbacks, ...local]
|
||||
// kilocode_change end
|
||||
|
||||
for (const root of upDirs) {
|
||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
const scope = fallbacks.includes(root) ? path.dirname(root) : projectRoot // kilocode_change
|
||||
// kilocode_change start
|
||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, {
|
||||
dot: true,
|
||||
scope: "project",
|
||||
root: projectRoot,
|
||||
sourceRoot: scope,
|
||||
})
|
||||
// kilocode_change end
|
||||
}
|
||||
}
|
||||
|
||||
const configDirs = yield* config.directories()
|
||||
const primary = new Set(yield* primaryPaths(directory, worktree, [".kilocode", ".kilo"])) // kilocode_change
|
||||
for (const dir of configDirs) {
|
||||
yield* scan(state, dir, KILO_SKILL_PATTERN)
|
||||
// kilocode_change start - global and explicit KILO_CONFIG_DIR skills are trusted; project and primary-checkout
|
||||
// skills remain confined to the active project boundary.
|
||||
const rel = path.relative(projectRoot, dir)
|
||||
const local = primary.has(dir) || rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
|
||||
const trusted = dir === Flag.KILO_CONFIG_DIR || !local
|
||||
const sourceRoot = primary.has(dir) ? path.dirname(dir) : projectRoot
|
||||
yield* scan(state, dir, KILO_SKILL_PATTERN, {
|
||||
trusted,
|
||||
root: trusted ? undefined : projectRoot,
|
||||
sourceRoot: trusted ? undefined : sourceRoot,
|
||||
})
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
const cfg = yield* config.get()
|
||||
@@ -215,18 +264,22 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
yield* scan(state, dir, SKILL_PATTERN)
|
||||
// kilocode_change start - trust follows the config source that declared the path, never the selected path.
|
||||
const origin = cfg.skill_path_origins?.[item]
|
||||
const trusted = origin?.trusted === true && path.isAbsolute(expanded)
|
||||
yield* scan(state, dir, SKILL_PATTERN, { trusted, root: trusted ? undefined : (origin?.root ?? projectRoot) })
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
const pulledDirs = yield* discovery.pull(url)
|
||||
for (const dir of pulledDirs) {
|
||||
yield* scan(state, dir, SKILL_PATTERN)
|
||||
yield* scan(state, dir, SKILL_PATTERN, { root: dir }) // kilocode_change - downloaded markdown is untrusted
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches: Array.from(state.matches),
|
||||
matches: Array.from(state.matches.values()), // kilocode_change
|
||||
dirs: Array.from(state.dirs),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("ConfigMarkdown: normal template", () => {
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter parsing", async () => {
|
||||
const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md")
|
||||
const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md", { trusted: true }) // kilocode_change
|
||||
|
||||
test("should parse without throwing", () => {
|
||||
expect(parsed).toBeDefined()
|
||||
@@ -172,7 +172,7 @@ describe("ConfigMarkdown: frontmatter parsing", async () => {
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md")
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md", { trusted: true }) // kilocode_change
|
||||
|
||||
test("should parse without throwing", () => {
|
||||
expect(result).toBeDefined()
|
||||
@@ -182,7 +182,7 @@ describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () =>
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md")
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md", { trusted: true }) // kilocode_change
|
||||
|
||||
test("should parse without throwing", () => {
|
||||
expect(result).toBeDefined()
|
||||
@@ -192,7 +192,7 @@ describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter parsing w/ Markdown header", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/markdown-header.md")
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/markdown-header.md", { trusted: true }) // kilocode_change
|
||||
|
||||
test("should parse and match", () => {
|
||||
expect(result).toBeDefined()
|
||||
@@ -212,7 +212,7 @@ Always structure your responses using clear markdown formatting:
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter has weird model id", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/weird-model-id.md")
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/weird-model-id.md", { trusted: true }) // kilocode_change
|
||||
|
||||
test("should parse and match", () => {
|
||||
expect(result).toBeDefined()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "path"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
@@ -15,6 +16,96 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("config resilience", () => {
|
||||
test("retains untrusted provenance for external markdown paths selected by project config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const project = path.join(dir, "project")
|
||||
const instruction = path.join(dir, "external.md")
|
||||
await Filesystem.write(
|
||||
path.join(project, "kilo.json"),
|
||||
JSON.stringify({ instructions: [instruction], skills: { paths: ["../external-skills"] } }),
|
||||
)
|
||||
await Filesystem.write(instruction, "external")
|
||||
await Filesystem.write(path.join(dir, "external-skills", "SKILL.md"), "external")
|
||||
return { project, instruction }
|
||||
},
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory: tmp.extra.project,
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
expect(cfg.instruction_origins?.[tmp.extra.instruction]).toMatchObject({
|
||||
trusted: false,
|
||||
root: tmp.extra.project,
|
||||
})
|
||||
expect(cfg.skill_path_origins?.["../external-skills"]).toMatchObject({
|
||||
trusted: false,
|
||||
root: tmp.extra.project,
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("skips project markdown that references environment or out-of-project files", async () => {
|
||||
const name = "KILO_CONFIG_MARKDOWN_PROJECT_SECRET"
|
||||
const prior = process.env[name]
|
||||
process.env[name] = "environment secret"
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const project = path.join(dir, "project")
|
||||
const secret = path.join(dir, "secret.txt")
|
||||
const prompt = [`{file:${secret}}`, `{env:${name}}`].join("\n")
|
||||
await Filesystem.write(path.join(project, ".kilo", "agent", "unsafe.md"), prompt)
|
||||
await Filesystem.write(path.join(project, ".kilo", "command", "unsafe.md"), prompt)
|
||||
await Filesystem.write(secret, "file secret")
|
||||
return project
|
||||
},
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory: tmp.extra,
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
const warns = await warnings()
|
||||
|
||||
expect(cfg.agent?.unsafe).toBeUndefined()
|
||||
expect(cfg.command?.unsafe).toBeUndefined()
|
||||
expect(warns.filter((warning) => warning.path.endsWith("unsafe.md"))).toHaveLength(2)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env[name]
|
||||
else process.env[name] = prior
|
||||
}
|
||||
})
|
||||
|
||||
test("skips project markdown symlinks that escape the project root", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const project = path.join(dir, "project")
|
||||
const item = path.join(project, ".kilo", "agent", "unsafe.md")
|
||||
const secret = path.join(dir, "secret.md")
|
||||
await Filesystem.write(secret, "file secret")
|
||||
await fs.mkdir(path.dirname(item), { recursive: true })
|
||||
await fs.symlink(secret, item)
|
||||
return project
|
||||
},
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory: tmp.extra,
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
const warns = await warnings()
|
||||
|
||||
expect(cfg.agent?.unsafe).toBeUndefined()
|
||||
expect(warns.some((warning) => warning.path.endsWith("unsafe.md"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("skips invalid agent markdown configs", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("markdown substitutions", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const md = await ConfigMarkdown.parse(path.join(tmp.path, "SKILL.md"))
|
||||
const md = await ConfigMarkdown.parse(path.join(tmp.path, "SKILL.md"), { trusted: true })
|
||||
|
||||
expect(md.content).toContain("file content")
|
||||
expect(md.content).toContain("env content")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import path from "node:path"
|
||||
import { expect, test } from "bun:test"
|
||||
import { KilocodeMarkdown } from "@/kilocode/config/markdown"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
test("confines project markdown substitutions while preserving trusted substitutions", async () => {
|
||||
const name = "KILO_MARKDOWN_SUBSTITUTE_TEST_SECRET"
|
||||
const prior = process.env[name]
|
||||
process.env[name] = "environment secret"
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const project = path.join(dir, "project")
|
||||
const item = path.join(project, ".kilo", "agents", "unsafe.md")
|
||||
const global = path.join(dir, "global", "agents", "trusted.md")
|
||||
const secret = path.join(dir, "secret.txt")
|
||||
const file = `{file:${secret}}`
|
||||
const env = `{env:${name}}`
|
||||
const text = [file, env].join("\n")
|
||||
await Bun.write(item, text)
|
||||
await Bun.write(global, text)
|
||||
await Bun.write(secret, "file secret")
|
||||
await Bun.write(path.join(project, "allowed.txt"), "project content")
|
||||
return { project, item, global, file, env, text }
|
||||
},
|
||||
})
|
||||
|
||||
const file = await KilocodeMarkdown.substitute(tmp.extra.file, tmp.extra.item, {
|
||||
trusted: false,
|
||||
fileScope: { root: tmp.extra.project, source: tmp.extra.item },
|
||||
}).then(
|
||||
() => false,
|
||||
() => true,
|
||||
)
|
||||
expect(file).toBe(true)
|
||||
const env = await KilocodeMarkdown.substitute(tmp.extra.env, tmp.extra.item, {
|
||||
trusted: false,
|
||||
fileScope: { root: tmp.extra.project, source: tmp.extra.item },
|
||||
}).then(
|
||||
() => false,
|
||||
() => true,
|
||||
)
|
||||
expect(env).toBe(true)
|
||||
expect(
|
||||
await KilocodeMarkdown.substitute("{file:../../allowed.txt}", tmp.extra.item, {
|
||||
trusted: false,
|
||||
fileScope: { root: tmp.extra.project, source: tmp.extra.item },
|
||||
}),
|
||||
).toBe("project content")
|
||||
|
||||
const trusted = await KilocodeMarkdown.substitute(tmp.extra.text, tmp.extra.global, { trusted: true })
|
||||
expect(trusted).toContain("file secret")
|
||||
expect(trusted).toContain("environment secret")
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env[name]
|
||||
else process.env[name] = prior
|
||||
}
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { Reference } from "../../../src/reference/reference"
|
||||
import { Instruction } from "../../../src/session/instruction"
|
||||
import { MessageID } from "../../../src/session/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { provideTmpdirInstance } from "../../fixture/fixture"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
import { TestConfig } from "../../fixture/config"
|
||||
|
||||
@@ -27,9 +27,9 @@ const it = testEffect(
|
||||
|
||||
const configLayer = TestConfig.layer()
|
||||
|
||||
const layer = (dir: string) =>
|
||||
const layer = (dir: string, config = configLayer) =>
|
||||
Instruction.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(config),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Global.layerWith({ home: dir, config: dir })),
|
||||
@@ -43,15 +43,146 @@ const write = (filepath: string, content: string) =>
|
||||
})
|
||||
|
||||
describe("instruction markdown substitutions", () => {
|
||||
it.live("applies file and env substitutions to nearby AGENTS.md", () =>
|
||||
it.live("preserves trusted relative instructions when project config is disabled", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const prior = {
|
||||
flag: process.env.KILO_DISABLE_PROJECT_CONFIG,
|
||||
secret: process.env.KILO_INSTRUCTION_GLOBAL_PATTERN_SECRET,
|
||||
}
|
||||
process.env.KILO_DISABLE_PROJECT_CONFIG = "1"
|
||||
process.env.KILO_INSTRUCTION_GLOBAL_PATTERN_SECRET = "environment secret"
|
||||
return prior
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const project = path.join(dir, "project")
|
||||
const home = path.join(dir, "global")
|
||||
yield* write(path.join(project, "README.md"), "project")
|
||||
yield* write(
|
||||
path.join(home, "rules", "trusted.md"),
|
||||
"{env:KILO_INSTRUCTION_GLOBAL_PATTERN_SECRET}",
|
||||
)
|
||||
const config = TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
instructions: ["rules/*.md"],
|
||||
instruction_origins: { "rules/*.md": { trusted: true, source: "global config" } },
|
||||
}),
|
||||
})
|
||||
|
||||
yield* provideInstance(project)(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const results = yield* svc.system()
|
||||
expect(results.join("\n")).toContain("environment secret")
|
||||
}).pipe(Effect.provide(layer(home, config))),
|
||||
)
|
||||
}),
|
||||
(prior) =>
|
||||
Effect.sync(() => {
|
||||
if (prior.flag === undefined) delete process.env.KILO_DISABLE_PROJECT_CONFIG
|
||||
else process.env.KILO_DISABLE_PROJECT_CONFIG = prior.flag
|
||||
if (prior.secret === undefined) delete process.env.KILO_INSTRUCTION_GLOBAL_PATTERN_SECRET
|
||||
else process.env.KILO_INSTRUCTION_GLOBAL_PATTERN_SECRET = prior.secret
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not trust project markdown selected by a trusted relative instruction", () =>
|
||||
provideTmpdirInstance((dir) => {
|
||||
const config = TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
instructions: ["AGENTS.md"],
|
||||
instruction_origins: { "AGENTS.md": { trusted: true, source: "global config" } },
|
||||
}),
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const name = "KILO_INSTRUCTION_RELATIVE_SECRET"
|
||||
process.env[name] = "environment secret"
|
||||
yield* write(path.join(dir, "AGENTS.md"), `{env:${name}}`)
|
||||
|
||||
const svc = yield* Instruction.Service
|
||||
const results = yield* svc.system()
|
||||
expect(results.join("\n")).not.toContain("environment secret")
|
||||
delete process.env[name]
|
||||
}).pipe(Effect.provide(layer(path.join(dir, "global"), config)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not trust a global-path instruction selected by project config", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const project = path.join(dir, "project")
|
||||
const home = path.join(dir, "global")
|
||||
const item = path.join(home, "private.md")
|
||||
const secret = path.join(dir, "secret.txt")
|
||||
const name = "KILO_INSTRUCTION_SELECTED_SECRET"
|
||||
process.env[name] = "environment secret"
|
||||
yield* write(path.join(project, "README.md"), "project")
|
||||
yield* write(secret, "file secret")
|
||||
yield* write(item, [`{file:${secret}}`, `{env:${name}}`].join("\n"))
|
||||
const config = TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
instructions: [item],
|
||||
instruction_origins: {
|
||||
[item]: { trusted: false, source: path.join(project, "kilo.json"), root: project },
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
yield* provideInstance(project)(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const results = yield* svc.system()
|
||||
expect(results.join("\n")).not.toContain("file secret")
|
||||
expect(results.join("\n")).not.toContain("environment secret")
|
||||
}).pipe(Effect.provide(layer(home, config))),
|
||||
)
|
||||
delete process.env[name]
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("trusts a global-path instruction declared by trusted config", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const project = path.join(dir, "project")
|
||||
const home = path.join(dir, "global")
|
||||
const item = path.join(home, "private.md")
|
||||
const secret = path.join(dir, "secret.txt")
|
||||
const name = "KILO_INSTRUCTION_TRUSTED_SECRET"
|
||||
process.env[name] = "environment secret"
|
||||
yield* write(path.join(project, "README.md"), "project")
|
||||
yield* write(secret, "file secret")
|
||||
yield* write(item, [`{file:${secret}}`, `{env:${name}}`].join("\n"))
|
||||
const config = TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
instructions: [item],
|
||||
instruction_origins: { [item]: { trusted: true, source: "global config" } },
|
||||
}),
|
||||
})
|
||||
|
||||
yield* provideInstance(project)(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const results = yield* svc.system()
|
||||
expect(results.join("\n")).toContain("file secret")
|
||||
expect(results.join("\n")).toContain("environment secret")
|
||||
}).pipe(Effect.provide(layer(home, config))),
|
||||
)
|
||||
delete process.env[name]
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("applies in-project file substitutions to nearby AGENTS.md", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
process.env.KILO_INSTRUCTION_TEST = "env content"
|
||||
yield* write(path.join(dir, "subdir", "guide.md"), "file content")
|
||||
yield* write(
|
||||
path.join(dir, "subdir", "AGENTS.md"),
|
||||
["# Instructions", "", "{file:guide.md}", "{env:KILO_INSTRUCTION_TEST}"].join("\n"),
|
||||
)
|
||||
yield* write(path.join(dir, "subdir", "AGENTS.md"), ["# Instructions", "", "{file:guide.md}"].join("\n"))
|
||||
yield* write(path.join(dir, "subdir", "nested", "file.ts"), "const value = 1")
|
||||
|
||||
const svc = yield* Instruction.Service
|
||||
@@ -59,11 +190,44 @@ describe("instruction markdown substitutions", () => {
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].content).toContain("file content")
|
||||
expect(results[0].content).toContain("env content")
|
||||
expect(results[0].content).not.toContain("{file:")
|
||||
expect(results[0].content).not.toContain("{env:")
|
||||
delete process.env.KILO_INSTRUCTION_TEST
|
||||
}).pipe(Effect.provide(layer(dir))),
|
||||
}).pipe(Effect.provide(layer(path.join(dir, "global")))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("omits nearby project instructions with environment substitutions", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const name = "KILO_INSTRUCTION_PROJECT_SECRET"
|
||||
process.env[name] = "environment secret"
|
||||
yield* write(path.join(dir, "subdir", "AGENTS.md"), `{env:${name}}`)
|
||||
yield* write(path.join(dir, "subdir", "nested", "file.ts"), "const value = 1")
|
||||
|
||||
const svc = yield* Instruction.Service
|
||||
const results = yield* svc.resolve([], path.join(dir, "subdir", "nested", "file.ts"), MessageID.ascending())
|
||||
|
||||
expect(results).toEqual([])
|
||||
delete process.env[name]
|
||||
}).pipe(Effect.provide(layer(path.join(dir, "global")))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves substitutions in trusted global instructions", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const name = "KILO_INSTRUCTION_GLOBAL_SECRET"
|
||||
process.env[name] = "environment secret"
|
||||
const home = path.join(dir, "global")
|
||||
yield* write(path.join(home, "guide.md"), "file secret")
|
||||
yield* write(path.join(home, "AGENTS.md"), [`{file:guide.md}`, `{env:${name}}`].join("\n"))
|
||||
|
||||
const svc = yield* Instruction.Service
|
||||
const results = yield* svc.system()
|
||||
|
||||
expect(results.join("\n")).toContain("file secret")
|
||||
expect(results.join("\n")).toContain("environment secret")
|
||||
delete process.env[name]
|
||||
}).pipe(Effect.provide(layer(path.join(dir, "global")))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -137,26 +137,75 @@ Actual description here.`
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("applies markdown substitutions to workflow content", async () => {
|
||||
process.env.KILO_WORKFLOW_TEST = "env content"
|
||||
test("applies in-project file substitutions to project workflow content", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const workflowsDir = path.join(dir, ".kilo", "workflows")
|
||||
await Bun.write(path.join(dir, "guide.md"), "file content")
|
||||
await Bun.write(
|
||||
path.join(workflowsDir, "workflow.md"),
|
||||
["# Workflow", "", "{file:../../guide.md}", "{env:KILO_WORKFLOW_TEST}"].join("\n"),
|
||||
["# Workflow", "", "{file:../../guide.md}"].join("\n"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true)
|
||||
const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true)
|
||||
|
||||
expect(workflows[0].content).toContain("file content")
|
||||
expect(workflows[0].content).toContain("env content")
|
||||
expect(workflows[0].content).toContain("file content")
|
||||
})
|
||||
|
||||
test("skips environment substitutions in project workflows", async () => {
|
||||
const name = "KILO_WORKFLOW_PROJECT_SECRET"
|
||||
const prior = process.env[name]
|
||||
process.env[name] = "environment secret"
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, ".kilo", "workflows", "workflow.md"), `{env:${name}}`)
|
||||
await Bun.write(path.join(dir, ".kilo", "workflows", "safe.md"), "safe workflow")
|
||||
},
|
||||
})
|
||||
|
||||
const warnings: string[] = []
|
||||
const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true, warnings)
|
||||
expect(workflows.map((item) => item.name)).toEqual(["safe"])
|
||||
expect(
|
||||
warnings.some((warning) => warning.includes("workflow") && warning.includes("environment references")),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
delete process.env.KILO_WORKFLOW_TEST
|
||||
if (prior === undefined) delete process.env[name]
|
||||
else process.env[name] = prior
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves file and environment substitutions in trusted global workflows", async () => {
|
||||
const name = "KILO_WORKFLOW_GLOBAL_SECRET"
|
||||
const prior = process.env[name]
|
||||
process.env[name] = "environment secret"
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "secret.txt"), "file secret")
|
||||
await Bun.write(
|
||||
path.join(dir, ".kilo", "workflows", "trusted.md"),
|
||||
[`{file:../../secret.txt}`, `{env:${name}}`].join("\n"),
|
||||
)
|
||||
await Bun.write(path.join(dir, "project", "README.md"), "project")
|
||||
await Bun.write(path.join(dir, "project", ".kilo", "workflows", "trusted.md"), `{env:${name}}`)
|
||||
},
|
||||
})
|
||||
|
||||
const warnings: string[] = []
|
||||
const workflows = await withHome(tmp.path, () =>
|
||||
WorkflowsMigrator.discoverWorkflows(path.join(tmp.path, "project"), false, warnings),
|
||||
)
|
||||
const workflow = workflows.find((item) => item.source === "global" && item.name === "trusted")
|
||||
expect(workflow?.content).toContain("file secret")
|
||||
expect(workflow?.content).toContain("environment secret")
|
||||
expect(warnings.some((warning) => warning.includes("trusted"))).toBe(true)
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env[name]
|
||||
else process.env[name] = prior
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user