From 67f6ae59fc598acd89532e021d7372fee0a657c5 Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Tue, 27 Jan 2026 13:54:16 +0100 Subject: [PATCH] feat(kilocode): add .kilocodeignore migration to permission rules --- packages/opencode/src/config/config.ts | 15 + .../opencode/src/kilocode/config-injector.ts | 40 +++ .../opencode/src/kilocode/ignore-migrator.ts | 224 +++++++++++++ packages/opencode/src/kilocode/index.ts | 1 + .../test/kilocode/config-injector.test.ts | 86 +++++ .../test/kilocode/ignore-migrator.test.ts | 308 ++++++++++++++++++ 6 files changed, 674 insertions(+) create mode 100644 packages/opencode/src/kilocode/ignore-migrator.ts create mode 100644 packages/opencode/test/kilocode/ignore-migrator.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 2a92657d79..3da54212c6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -32,6 +32,7 @@ import { ModesMigrator } from "../kilocode/modes-migrator" // kilocode_change import { RulesMigrator } from "../kilocode/rules-migrator" // kilocode_change import { WorkflowsMigrator } from "../kilocode/workflows-migrator" // kilocode_change import { McpMigrator } from "../kilocode/mcp-migrator" // kilocode_change +import { IgnoreMigrator } from "../kilocode/ignore-migrator" // kilocode_change export namespace Config { const log = Log.create({ service: "config" }) @@ -114,6 +115,20 @@ export namespace Config { if (Object.keys(kilocodeMcp).length > 0) { result = mergeConfigConcatArrays(result, { mcp: kilocodeMcp }) } + + // Load Kilocode .kilocodeignore patterns (legacy fallback) + try { + const ignorePermission = await IgnoreMigrator.loadIgnoreConfig(Instance.directory) + if (Object.keys(ignorePermission).length > 0) { + result = mergeConfigConcatArrays(result, { permission: ignorePermission }) + log.debug("loaded kilocode ignore patterns", { + hasRead: !!ignorePermission.read, + hasEdit: !!ignorePermission.edit, + }) + } + } catch (err) { + log.warn("failed to load kilocode ignore patterns", { error: err }) + } // kilocode_change end // Load remote/well-known config (overrides Kilocode legacy configs) diff --git a/packages/opencode/src/kilocode/config-injector.ts b/packages/opencode/src/kilocode/config-injector.ts index c41ba4656d..b8b577f2d6 100644 --- a/packages/opencode/src/kilocode/config-injector.ts +++ b/packages/opencode/src/kilocode/config-injector.ts @@ -2,6 +2,7 @@ import { Config } from "../config/config" import { ModesMigrator } from "./modes-migrator" import { RulesMigrator } from "./rules-migrator" // kilocode_change import { WorkflowsMigrator } from "./workflows-migrator" +import { IgnoreMigrator } from "./ignore-migrator" // kilocode_change export namespace KilocodeConfigInjector { export interface InjectionResult { @@ -16,6 +17,8 @@ export namespace KilocodeConfigInjector { skipGlobalPaths?: boolean /** Include rules migration. Defaults to true. */ includeRules?: boolean + /** Include ignore migration. Defaults to true. */ + includeIgnore?: boolean }): Promise { const warnings: string[] = [] @@ -59,12 +62,49 @@ export namespace KilocodeConfigInjector { } // kilocode_change end + // kilocode_change start - Ignore migration + if (options.includeIgnore !== false) { + const ignoreMigration = await IgnoreMigrator.migrate({ + projectDir: options.projectDir, + skipGlobalPaths: options.skipGlobalPaths, + }) + + warnings.push(...ignoreMigration.warnings) + + if (Object.keys(ignoreMigration.permission).length > 0) { + config.permission = mergePermissions(config.permission, ignoreMigration.permission) + } + } + // kilocode_change end + return { configJson: JSON.stringify(config), warnings, } } + /** + * Merge permission configs, preserving order and handling duplicates. + * Incoming rules take precedence (kilocode patterns override). + */ + function mergePermissions(existing: Config.Permission | undefined, incoming: Config.Permission): Config.Permission { + if (!existing) return incoming + + const result: Config.Permission = { ...existing } + + for (const [key, value] of Object.entries(incoming)) { + if (key === "read" || key === "edit") { + const existingRules = (result[key] as Record) ?? {} + const incomingRules = value as Record + result[key] = { ...existingRules, ...incomingRules } + } else { + result[key] = value + } + } + + return result + } + export function getEnvVars(configJson: string): Record { if (!configJson || configJson === "{}") { return {} diff --git a/packages/opencode/src/kilocode/ignore-migrator.ts b/packages/opencode/src/kilocode/ignore-migrator.ts new file mode 100644 index 0000000000..2898a94ac9 --- /dev/null +++ b/packages/opencode/src/kilocode/ignore-migrator.ts @@ -0,0 +1,224 @@ +// kilocode_change - new file + +import * as path from "path" +import os from "os" +import { Log } from "../util/log" +import type { Config } from "../config/config" + +export namespace IgnoreMigrator { + const log = Log.create({ service: "kilocode.ignore-migrator" }) + + const KILOCODEIGNORE_FILE = ".kilocodeignore" + const GLOBAL_KILOCODEIGNORE = path.join(os.homedir(), ".kilocode", KILOCODEIGNORE_FILE) + + export interface IgnorePattern { + pattern: string + negated: boolean + source: "global" | "project" + } + + export interface MigrationResult { + permission: Config.Permission + warnings: string[] + patternCount: number + } + + async function fileExists(filepath: string): Promise { + return Bun.file(filepath).exists() + } + + /** + * Parse .kilocodeignore content into patterns. + * Follows gitignore syntax: + * - Lines starting with # are comments + * - Empty lines are ignored + * - Lines starting with ! are negation patterns + */ + export function parseIgnoreContent(content: string): Array<{ pattern: string; negated: boolean }> { + const patterns: Array<{ pattern: string; negated: boolean }> = [] + + for (const line of content.split("\n")) { + const trimmed = line.trim() + + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith("#")) continue + + // Handle negation patterns + if (trimmed.startsWith("!")) { + patterns.push({ pattern: trimmed.slice(1), negated: true }) + } else { + patterns.push({ pattern: trimmed, negated: false }) + } + } + + return patterns + } + + /** + * Convert gitignore pattern to Opencode wildcard pattern. + * + * Opencode's Wildcard module uses simple patterns: + * - `*` matches any characters (converted to `.*` regex) + * - `?` matches single character + * + * Gitignore semantics we need to handle: + * - `foo/` matches directory and all contents -> `foo/*` + * - `*.env` matches anywhere in tree -> `*.env` (already works) + * - `/foo` matches only at root -> `foo` (rooted) + * - `foo` matches anywhere -> `*foo*` or just `foo` depending on context + * + * Note: Opencode's wildcard `*` already matches any path depth because + * it's converted to `.*` regex which matches `/` characters. + */ + export function convertToGlob(pattern: string): string { + let glob = pattern + + // Directory patterns (ending with /) should match all contents + if (glob.endsWith("/")) { + glob = glob.slice(0, -1) + "/*" + } + + // Patterns starting with / are rooted - remove the leading / + if (glob.startsWith("/")) { + glob = glob.slice(1) + } + + // Remove **/ prefix if present - Opencode's * already matches paths + if (glob.startsWith("**/")) { + glob = "*" + glob.slice(3) + } + + // Replace **/ in the middle with just * + glob = glob.replace(/\*\*\//g, "*") + + // Replace trailing /** with /* + if (glob.endsWith("/**")) { + glob = glob.slice(0, -3) + "/*" + } + + return glob + } + + /** + * Load patterns from a .kilocodeignore file + */ + async function loadIgnoreFile(filepath: string, source: "global" | "project"): Promise { + if (!(await fileExists(filepath))) return [] + + const content = await Bun.file(filepath).text() + const parsed = parseIgnoreContent(content) + + return parsed.map((p) => ({ + pattern: p.pattern, + negated: p.negated, + source, + })) + } + + /** + * Build permission rules from ignore patterns. + * + * Order matters! Patterns are evaluated in order: + * 1. Start with "*": "allow" (default allow) + * 2. Add deny patterns + * 3. Add negated patterns (allow) last to override denies + */ + export function buildPermissionRules(patterns: IgnorePattern[]): Record { + const rules: Record = { + "*": "allow", // Default: allow all + } + + // First pass: add deny rules + for (const p of patterns) { + if (!p.negated) { + const glob = convertToGlob(p.pattern) + rules[glob] = "deny" + } + } + + // Second pass: add negated (allow) rules - these override denies + for (const p of patterns) { + if (p.negated) { + const glob = convertToGlob(p.pattern) + rules[glob] = "allow" + } + } + + return rules + } + + /** + * Migrate .kilocodeignore to Opencode permission config + */ + export async function migrate(options: { projectDir: string; skipGlobalPaths?: boolean }): Promise { + const warnings: string[] = [] + const allPatterns: IgnorePattern[] = [] + + // 1. Load global .kilocodeignore (lower priority) + if (!options.skipGlobalPaths) { + const globalPatterns = await loadIgnoreFile(GLOBAL_KILOCODEIGNORE, "global") + allPatterns.push(...globalPatterns) + + if (globalPatterns.length > 0) { + log.debug("loaded global .kilocodeignore", { count: globalPatterns.length }) + } + } + + // 2. Load project .kilocodeignore (higher priority - added last) + const projectIgnorePath = path.join(options.projectDir, KILOCODEIGNORE_FILE) + const projectPatterns = await loadIgnoreFile(projectIgnorePath, "project") + allPatterns.push(...projectPatterns) + + if (projectPatterns.length > 0) { + log.debug("loaded project .kilocodeignore", { count: projectPatterns.length }) + } + + // 3. Build permission rules + if (allPatterns.length === 0) { + return { + permission: {}, + warnings, + patternCount: 0, + } + } + + const rules = buildPermissionRules(allPatterns) + + // 4. Create permission config for both read and edit + const permission: Config.Permission = { + read: rules, + edit: rules, + } + + return { + permission, + warnings, + patternCount: allPatterns.length, + } + } + + /** + * Load .kilocodeignore and return permission config. + * Handles all logging internally. + */ + export async function loadIgnoreConfig(projectDir: string, skipGlobalPaths?: boolean): Promise { + try { + const result = await migrate({ projectDir, skipGlobalPaths }) + + if (result.patternCount > 0) { + log.info("loaded .kilocodeignore patterns", { + count: result.patternCount, + }) + } + + for (const warning of result.warnings) { + log.warn("ignore migration warning", { warning }) + } + + return result.permission + } catch (err) { + log.warn("failed to load .kilocodeignore", { error: err }) + return {} + } + } +} diff --git a/packages/opencode/src/kilocode/index.ts b/packages/opencode/src/kilocode/index.ts index 1863a5876d..261ff142c7 100644 --- a/packages/opencode/src/kilocode/index.ts +++ b/packages/opencode/src/kilocode/index.ts @@ -2,5 +2,6 @@ export { ModesMigrator } from "./modes-migrator" export { RulesMigrator } from "./rules-migrator" export { WorkflowsMigrator } from "./workflows-migrator" export { McpMigrator } from "./mcp-migrator" +export { IgnoreMigrator } from "./ignore-migrator" // kilocode_change export { KilocodeConfigInjector } from "./config-injector" export { KilocodePaths } from "./paths" diff --git a/packages/opencode/test/kilocode/config-injector.test.ts b/packages/opencode/test/kilocode/config-injector.test.ts index e845b96e58..aa433b43ff 100644 --- a/packages/opencode/test/kilocode/config-injector.test.ts +++ b/packages/opencode/test/kilocode/config-injector.test.ts @@ -198,6 +198,92 @@ describe("KilocodeConfigInjector", () => { expect(result.warnings.some((w) => w.includes("Legacy"))).toBe(true) }) // kilocode_change end + + // kilocode_change start - Ignore migration tests + test("includes ignore patterns in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/\n*.env") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + const config = JSON.parse(result.configJson) + + expect(config.permission).toBeDefined() + expect(config.permission.read).toBeDefined() + expect(config.permission.edit).toBeDefined() + expect(config.permission.read["secrets/*"]).toBe("deny") + expect(config.permission.read["*.env"]).toBe("deny") + }) + + test("skips ignore when includeIgnore is false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + includeIgnore: false, + }) + const config = JSON.parse(result.configJson) + + expect(config.permission).toBeUndefined() + }) + + test("combines ignore with other migrations", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Add custom mode + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: translate + name: Translate + roleDefinition: You are a translator + groups: + - read`, + ) + // Add ignore patterns + await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + const config = JSON.parse(result.configJson) + + expect(config.agent).toBeDefined() + expect(config.agent.translate).toBeDefined() + expect(config.permission).toBeDefined() + expect(config.permission.read["secrets/*"]).toBe("deny") + }) + + test("handles negation patterns in ignore file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocodeignore"), "*.env\n!.env.example") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + const config = JSON.parse(result.configJson) + + expect(config.permission.read["*.env"]).toBe("deny") + expect(config.permission.read[".env.example"]).toBe("allow") + }) + // kilocode_change end }) describe("getEnvVars", () => { diff --git a/packages/opencode/test/kilocode/ignore-migrator.test.ts b/packages/opencode/test/kilocode/ignore-migrator.test.ts new file mode 100644 index 0000000000..b95557b6cb --- /dev/null +++ b/packages/opencode/test/kilocode/ignore-migrator.test.ts @@ -0,0 +1,308 @@ +import { test, expect, describe } from "bun:test" +import { IgnoreMigrator } from "../../src/kilocode/ignore-migrator" +import { tmpdir } from "../fixture/fixture" +import path from "path" + +describe("IgnoreMigrator", () => { + describe("parseIgnoreContent", () => { + test("parses basic patterns", () => { + const content = ` +# Comment +secrets/ +*.env +!.env.example +` + const patterns = IgnoreMigrator.parseIgnoreContent(content) + + expect(patterns).toEqual([ + { pattern: "secrets/", negated: false }, + { pattern: "*.env", negated: false }, + { pattern: ".env.example", negated: true }, + ]) + }) + + test("ignores empty lines and comments", () => { + const content = ` +# This is a comment +pattern1 + +# Another comment +pattern2 +` + const patterns = IgnoreMigrator.parseIgnoreContent(content) + + expect(patterns).toHaveLength(2) + expect(patterns[0].pattern).toBe("pattern1") + expect(patterns[1].pattern).toBe("pattern2") + }) + + test("handles negation patterns", () => { + const content = `*.log +!important.log` + const patterns = IgnoreMigrator.parseIgnoreContent(content) + + expect(patterns[0]).toEqual({ pattern: "*.log", negated: false }) + expect(patterns[1]).toEqual({ pattern: "important.log", negated: true }) + }) + + test("handles empty content", () => { + const patterns = IgnoreMigrator.parseIgnoreContent("") + expect(patterns).toHaveLength(0) + }) + + test("handles content with only comments", () => { + const content = `# Comment 1 +# Comment 2` + const patterns = IgnoreMigrator.parseIgnoreContent(content) + expect(patterns).toHaveLength(0) + }) + + test("trims whitespace from patterns", () => { + const content = ` secrets/ + *.env ` + const patterns = IgnoreMigrator.parseIgnoreContent(content) + + expect(patterns[0].pattern).toBe("secrets/") + expect(patterns[1].pattern).toBe("*.env") + }) + }) + + describe("convertToGlob", () => { + test("converts directory pattern", () => { + expect(IgnoreMigrator.convertToGlob("secrets/")).toBe("secrets/*") + }) + + test("preserves simple filename pattern", () => { + // Opencode's * already matches any path depth + expect(IgnoreMigrator.convertToGlob("*.env")).toBe("*.env") + }) + + test("converts rooted patterns", () => { + expect(IgnoreMigrator.convertToGlob("/root-only")).toBe("root-only") + }) + + test("preserves patterns with path separators", () => { + expect(IgnoreMigrator.convertToGlob("src/secrets/")).toBe("src/secrets/*") + }) + + test("handles double-star patterns", () => { + expect(IgnoreMigrator.convertToGlob("**/deep/")).toBe("*deep/*") + }) + + test("preserves simple filename without extension", () => { + // Opencode's * already matches any path depth + expect(IgnoreMigrator.convertToGlob("Dockerfile")).toBe("Dockerfile") + }) + + test("preserves extension-only pattern", () => { + expect(IgnoreMigrator.convertToGlob("*.log")).toBe("*.log") + }) + + test("preserves nested path pattern", () => { + expect(IgnoreMigrator.convertToGlob("config/secrets.json")).toBe("config/secrets.json") + }) + }) + + describe("buildPermissionRules", () => { + test("creates default allow rule", () => { + const rules = IgnoreMigrator.buildPermissionRules([]) + expect(rules["*"]).toBe("allow") + }) + + test("creates deny rules for patterns", () => { + const patterns = [{ pattern: "secrets/", negated: false, source: "project" as const }] + const rules = IgnoreMigrator.buildPermissionRules(patterns) + + expect(rules["*"]).toBe("allow") + expect(rules["secrets/*"]).toBe("deny") + }) + + test("creates allow rules for negated patterns", () => { + const patterns = [ + { pattern: "*.env", negated: false, source: "project" as const }, + { pattern: ".env.example", negated: true, source: "project" as const }, + ] + const rules = IgnoreMigrator.buildPermissionRules(patterns) + + expect(rules["*.env"]).toBe("deny") + expect(rules[".env.example"]).toBe("allow") + }) + + test("handles multiple deny patterns", () => { + const patterns = [ + { pattern: "secrets/", negated: false, source: "project" as const }, + { pattern: "*.env", negated: false, source: "project" as const }, + { pattern: "*.key", negated: false, source: "project" as const }, + ] + const rules = IgnoreMigrator.buildPermissionRules(patterns) + + expect(rules["secrets/*"]).toBe("deny") + expect(rules["*.env"]).toBe("deny") + expect(rules["*.key"]).toBe("deny") + }) + + test("negated patterns override deny patterns", () => { + const patterns = [ + { pattern: "config/", negated: false, source: "project" as const }, + { pattern: "config/public.json", negated: true, source: "project" as const }, + ] + const rules = IgnoreMigrator.buildPermissionRules(patterns) + + expect(rules["config/*"]).toBe("deny") + expect(rules["config/public.json"]).toBe("allow") + }) + }) + + describe("migrate", () => { + test("returns empty permission for project without .kilocodeignore", async () => { + await using tmp = await tmpdir() + + const result = await IgnoreMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.patternCount).toBe(0) + expect(Object.keys(result.permission)).toHaveLength(0) + }) + + test("loads project .kilocodeignore", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/\n*.env") + }, + }) + + const result = await IgnoreMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.patternCount).toBe(2) + expect(result.permission.read).toBeDefined() + expect(result.permission.edit).toBeDefined() + }) + + test("applies patterns to both read and edit", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/") + }, + }) + + const result = await IgnoreMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + const readRules = result.permission.read as Record + const editRules = result.permission.edit as Record + + expect(readRules["secrets/*"]).toBe("deny") + expect(editRules["secrets/*"]).toBe("deny") + }) + + test("handles negation patterns correctly", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocodeignore"), "*.env\n!.env.example") + }, + }) + + const result = await IgnoreMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + const readRules = result.permission.read as Record + + expect(readRules["*.env"]).toBe("deny") + expect(readRules[".env.example"]).toBe("allow") + }) + + test("handles complex .kilocodeignore file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, ".kilocodeignore"), + `# Secrets +secrets/ +*.env +!.env.example + +# Keys +*.key +*.pem + +# Config +/config/private/ +!config/private/public.json +`, + ) + }, + }) + + const result = await IgnoreMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.patternCount).toBe(7) + + const readRules = result.permission.read as Record + expect(readRules["*"]).toBe("allow") + expect(readRules["secrets/*"]).toBe("deny") + expect(readRules["*.env"]).toBe("deny") + expect(readRules[".env.example"]).toBe("allow") + expect(readRules["*.key"]).toBe("deny") + expect(readRules["*.pem"]).toBe("deny") + expect(readRules["config/private/*"]).toBe("deny") + expect(readRules["config/private/public.json"]).toBe("allow") + }) + + test("returns empty warnings array", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/") + }, + }) + + const result = await IgnoreMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.warnings).toHaveLength(0) + }) + }) + + describe("loadIgnoreConfig", () => { + test("returns empty object for project without .kilocodeignore", async () => { + await using tmp = await tmpdir() + + const permission = await IgnoreMigrator.loadIgnoreConfig(tmp.path, true) + + expect(Object.keys(permission)).toHaveLength(0) + }) + + test("returns permission config for project with .kilocodeignore", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocodeignore"), "secrets/") + }, + }) + + const permission = await IgnoreMigrator.loadIgnoreConfig(tmp.path, true) + + expect(permission.read).toBeDefined() + expect(permission.edit).toBeDefined() + }) + + test("handles errors gracefully", async () => { + // Pass a non-existent directory + const permission = await IgnoreMigrator.loadIgnoreConfig("/non/existent/path", true) + + expect(Object.keys(permission)).toHaveLength(0) + }) + }) +})