diff --git a/.changeset/silver-maps-read.md b/.changeset/silver-maps-read.md new file mode 100644 index 00000000000..d55439e9882 --- /dev/null +++ b/.changeset/silver-maps-read.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Honor approved external directory read access in Ask and Plan modes. diff --git a/packages/opencode/src/kilocode/permission/external-directory.ts b/packages/opencode/src/kilocode/permission/external-directory.ts new file mode 100644 index 00000000000..4fe5da865a0 --- /dev/null +++ b/packages/opencode/src/kilocode/permission/external-directory.ts @@ -0,0 +1,29 @@ +import { evaluate as evalRule } from "@/permission/evaluate" + +type Rule = { + permission: string + pattern: string + action: "allow" | "deny" | "ask" +} + +type Ruleset = Rule[] + +function mode(rule: Rule) { + return rule.permission === "*" && rule.pattern === "*" && rule.action === "deny" +} + +function rules(permission: string, ruleset?: Ruleset) { + if (!ruleset) return [] + if (permission !== "external_directory") return ruleset + return ruleset.filter((rule) => !mode(rule)) +} + +export namespace ExternalDirectoryPermission { + export function evaluate(permission: string, pattern: string, ...sets: Array) { + return evalRule( + permission, + pattern, + ...sets.map((set) => rules(permission, set)), + ) + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 991b0a52f4e..fcb140b5ca5 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -20,6 +20,7 @@ import { makeRuntime } from "@/effect/run-service" // kilocode_change import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change import { Identifier } from "@/id/id" // kilocode_change import { drainCovered } from "@/kilocode/permission/drain" // kilocode_change +import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory" // kilocode_change const log = Log.create({ service: "permission" }) @@ -177,7 +178,7 @@ export function evaluate(permission: string, pattern: string, ...rulesets: Rules // kilocode_change start function veto(permission: string, pattern: string, ruleset?: Ruleset) { if (!ruleset) return false - return evaluate(permission, pattern, ruleset).action === "deny" + return ExternalDirectoryPermission.evaluate(permission, pattern, ruleset).action === "deny" } function subset(permission: string, ruleset: Ruleset) { @@ -227,7 +228,11 @@ export const layer = Layer.effect( // kilocode_change end for (const pattern of request.patterns) { - const rule = evaluate(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules + // kilocode_change start - external_directory allows must survive Ask/Plan hard rules + const rule = hardRuleset + ? ExternalDirectoryPermission.evaluate(request.permission, pattern, ruleset, approved, local) + : evaluate(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules + // kilocode_change end log.info("evaluated", { permission: request.permission, pattern, action: rule }) // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials if (veto(request.permission, pattern, hardRuleset)) { diff --git a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts index 92386c76eff..065fd9ddde6 100644 --- a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts +++ b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts @@ -309,6 +309,93 @@ describe("saveAlwaysRules", () => { ), ) + it.live("explicit external directory allows are not shadowed by ask plan broad denies", () => + withDir({ git: true }, (dir) => + Effect.gen(function* () { + const root = path.resolve(path.dirname(dir), "legacy") + const glob = path.join(root, "*") + const ruleset: Permission.Ruleset = [ + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: glob, action: "allow" }, + { permission: "*", pattern: "*", action: "deny" }, + ] + + const result = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "external_directory", + patterns: [glob], + metadata: { filepath: path.join(root, "main.ts"), parentDir: root }, + always: [glob], + ruleset, + hardRuleset: ruleset, + }) + expect(result).toBeUndefined() + }), + ), + ) + + it.live("saved external directory approvals survive ask plan hard rules", () => + withDir({ git: true }, (dir) => + Effect.gen(function* () { + const root = path.resolve(path.dirname(dir), "legacy") + const glob = path.join(root, "*") + const asking = yield* ask({ + id: PermissionID.make("permission_external_seed"), + sessionID: SessionID.make("session_test"), + permission: "external_directory", + patterns: [glob], + metadata: { filepath: path.join(root, "main.ts"), parentDir: root }, + always: [glob], + ruleset: [{ permission: "external_directory", pattern: "*", action: "ask" }], + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("permission_external_seed"), reply: "always" }) + yield* Fiber.join(asking) + + const result = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "external_directory", + patterns: [glob], + metadata: { filepath: path.join(root, "main.ts"), parentDir: root }, + always: [glob], + ruleset: [ + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "*", pattern: "*", action: "deny" }, + ], + hardRuleset: [{ permission: "*", pattern: "*", action: "deny" }], + }) + expect(result).toBeUndefined() + }), + ), + ) + + it.live("explicit external directory denies still win over ask plan exceptions", () => + withDir({ git: true }, (dir) => + Effect.gen(function* () { + const root = path.resolve(path.dirname(dir), "legacy") + const glob = path.join(root, "*") + const exit = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "external_directory", + patterns: [glob], + metadata: { filepath: path.join(root, "main.ts"), parentDir: root }, + always: [glob], + ruleset: [ + { permission: "external_directory", pattern: glob, action: "allow" }, + { permission: "external_directory", pattern: glob, action: "deny" }, + { permission: "*", pattern: "*", action: "deny" }, + ], + hardRuleset: [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "external_directory", pattern: glob, action: "deny" }, + ], + }).pipe(Effect.exit) + expectFailure(exit, Permission.DeniedError) + }), + ), + ) + it.live("accepts hierarchy patterns from metadata.rules", () => withDir({ git: true }, () => Effect.gen(function* () {