Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silver-maps-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Honor approved external directory read access in Ask and Plan modes.
29 changes: 29 additions & 0 deletions packages/opencode/src/kilocode/permission/external-directory.ts
Original file line number Diff line number Diff line change
@@ -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<Ruleset | undefined>) {
return evalRule(
permission,
pattern,
...sets.map((set) => rules(permission, set)),
)
}
}
9 changes: 7 additions & 2 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
Loading