Skip to content

Commit c5c7b3f

Browse files
committed
feat: Add glob pattern-based external_directory permission settings
1 parent 921fd99 commit c5c7b3f

8 files changed

Lines changed: 419 additions & 8 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -391,13 +391,24 @@ export namespace Config {
391391
export const Permission = z.enum(["ask", "allow", "deny"])
392392
export type Permission = z.infer<typeof Permission>
393393

394+
export const PermissionPatternMap = z.record(z.string(), Permission).describe(
395+
"Map of glob patterns to permissions. Patterns are evaluated in insertion order; first match wins. Use '*' as a catch-all default.",
396+
)
397+
export type PermissionPatternMap = z.infer<typeof PermissionPatternMap>
398+
394399
export const ExternalDirectoryPermission = z
395400
.union([
396401
Permission,
397402
z
398403
.object({
399-
read: Permission.optional().describe("Permission for reading files outside working directory"),
400-
write: Permission.optional().describe("Permission for writing files outside working directory"),
404+
read: z
405+
.union([Permission, PermissionPatternMap])
406+
.optional()
407+
.describe("Permission for reading files outside working directory"),
408+
write: z
409+
.union([Permission, PermissionPatternMap])
410+
.optional()
411+
.describe("Permission for writing files outside working directory"),
401412
})
402413
.strict(),
403414
])
@@ -411,6 +422,7 @@ export namespace Config {
411422
): Permission | undefined {
412423
if (permission === undefined) return undefined
413424
if (typeof permission === "string") return permission
425+
if (typeof permission.read === "object") return undefined // Pattern map requires filepath
414426
return permission.read
415427
}
416428

@@ -419,9 +431,73 @@ export namespace Config {
419431
): Permission | undefined {
420432
if (permission === undefined) return undefined
421433
if (typeof permission === "string") return permission
434+
if (typeof permission.write === "object") return undefined // Pattern map requires filepath
422435
return permission.write
423436
}
424437

438+
/**
439+
* Resolves the read permission for a specific file path.
440+
* When permission is a pattern map, patterns are evaluated in insertion order (first match wins).
441+
* The "*" pattern is treated as a catch-all default and only evaluated if no other pattern matches.
442+
*/
443+
export function getExternalDirectoryReadForPath(
444+
permission: ExternalDirectoryPermission | undefined,
445+
filepath: string,
446+
): Permission | undefined {
447+
if (permission === undefined) return undefined
448+
if (typeof permission === "string") return permission
449+
450+
const readPerm = permission.read
451+
if (readPerm === undefined) return undefined
452+
if (typeof readPerm === "string") return readPerm
453+
454+
return resolvePermissionFromPatternMap(readPerm, filepath)
455+
}
456+
457+
/**
458+
* Resolves the write permission for a specific file path.
459+
* When permission is a pattern map, patterns are evaluated in insertion order (first match wins).
460+
* The "*" pattern is treated as a catch-all default and only evaluated if no other pattern matches.
461+
*/
462+
export function getExternalDirectoryWriteForPath(
463+
permission: ExternalDirectoryPermission | undefined,
464+
filepath: string,
465+
): Permission | undefined {
466+
if (permission === undefined) return undefined
467+
if (typeof permission === "string") return permission
468+
469+
const writePerm = permission.write
470+
if (writePerm === undefined) return undefined
471+
if (typeof writePerm === "string") return writePerm
472+
473+
return resolvePermissionFromPatternMap(writePerm, filepath)
474+
}
475+
476+
/**
477+
* Resolves permission from a pattern map for a given filepath.
478+
* Patterns are evaluated in insertion order; "*" is skipped and used as fallback.
479+
*/
480+
function resolvePermissionFromPatternMap(
481+
patternMap: PermissionPatternMap,
482+
filepath: string,
483+
): Permission | undefined {
484+
for (const [pattern, perm] of Object.entries(patternMap)) {
485+
// Skip "*" (catch-all default) - evaluate it last
486+
if (pattern === "*") continue
487+
488+
// Expand ~ to home directory
489+
const normalizedPattern = pattern.startsWith("~/") ? pattern.replace("~", os.homedir()) : pattern
490+
491+
const glob = new Bun.Glob(normalizedPattern)
492+
if (glob.match(filepath)) {
493+
return perm
494+
}
495+
}
496+
497+
// Return catch-all default if present
498+
return patternMap["*"]
499+
}
500+
425501
export const Command = z.object({
426502
template: z.string(),
427503
description: z.string().optional(),

packages/opencode/src/tool/bash.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const BashTool = Tool.define("bash", async () => {
8787
const checkExternalDirectory = async (dir: string) => {
8888
if (Filesystem.contains(Instance.directory, dir)) return
8989
const title = `This command references paths outside of ${Instance.directory}`
90-
const writePermission = Config.getExternalDirectoryWrite(agent.permission.external_directory)
90+
const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, dir)
9191
if (writePermission === "ask") {
9292
await Permission.ask({
9393
type: "external_directory",

packages/opencode/src/tool/edit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const EditTool = Tool.define("edit", {
4747
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
4848
if (!Filesystem.contains(Instance.directory, filePath)) {
4949
const parentDir = path.dirname(filePath)
50-
const writePermission = Config.getExternalDirectoryWrite(agent.permission.external_directory)
50+
const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filePath)
5151
if (writePermission === "ask") {
5252
await Permission.ask({
5353
type: "external_directory",

packages/opencode/src/tool/patch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const PatchTool = Tool.define("patch", {
5656

5757
if (!Filesystem.contains(Instance.directory, filePath)) {
5858
const parentDir = path.dirname(filePath)
59-
const writePermission = Config.getExternalDirectoryWrite(agent.permission.external_directory)
59+
const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filePath)
6060
if (writePermission === "ask") {
6161
await Permission.ask({
6262
type: "external_directory",

packages/opencode/src/tool/read.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const ReadTool = Tool.define("read", {
3333

3434
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
3535
const parentDir = path.dirname(filepath)
36-
const readPermission = Config.getExternalDirectoryRead(agent.permission.external_directory)
36+
const readPermission = Config.getExternalDirectoryReadForPath(agent.permission.external_directory, filepath)
3737
if (readPermission === "ask") {
3838
await Permission.ask({
3939
type: "external_directory",

packages/opencode/src/tool/write.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const WriteTool = Tool.define("write", {
2727
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
2828
if (!Filesystem.contains(Instance.directory, filepath)) {
2929
const parentDir = path.dirname(filepath)
30-
const writePermission = Config.getExternalDirectoryWrite(agent.permission.external_directory)
30+
const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filepath)
3131
if (writePermission === "ask") {
3232
await Permission.ask({
3333
type: "external_directory",

packages/opencode/test/config/config.test.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { test, expect } from "bun:test"
1+
import { test, expect, describe } from "bun:test"
22
import { Config } from "../../src/config/config"
33
import { Instance } from "../../src/project/instance"
44
import { tmpdir } from "../fixture/fixture"
55
import path from "path"
66
import fs from "fs/promises"
77
import { pathToFileURL } from "url"
8+
import os from "os"
89

910
test("loads config with defaults when no files exist", async () => {
1011
await using tmp = await tmpdir()
@@ -501,3 +502,128 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
501502
},
502503
})
503504
})
505+
506+
// Unit tests for pattern-based external directory permission helpers
507+
describe("getExternalDirectoryReadForPath", () => {
508+
test("returns permission when string", () => {
509+
expect(Config.getExternalDirectoryReadForPath("allow", "/any/path")).toBe("allow")
510+
expect(Config.getExternalDirectoryReadForPath("deny", "/any/path")).toBe("deny")
511+
expect(Config.getExternalDirectoryReadForPath("ask", "/any/path")).toBe("ask")
512+
})
513+
514+
test("returns undefined when permission is undefined", () => {
515+
expect(Config.getExternalDirectoryReadForPath(undefined, "/any/path")).toBeUndefined()
516+
})
517+
518+
test("returns permission from pattern map when path matches", () => {
519+
const permission = {
520+
read: {
521+
"/etc/**": "allow" as const,
522+
"*": "deny" as const,
523+
},
524+
}
525+
expect(Config.getExternalDirectoryReadForPath(permission, "/etc/hosts")).toBe("allow")
526+
expect(Config.getExternalDirectoryReadForPath(permission, "/etc/subdir/file")).toBe("allow")
527+
expect(Config.getExternalDirectoryReadForPath(permission, "/other/path")).toBe("deny")
528+
})
529+
530+
test("returns catch-all (*) when no pattern matches", () => {
531+
const permission = {
532+
read: {
533+
"/nonexistent/**": "allow" as const,
534+
"*": "ask" as const,
535+
},
536+
}
537+
expect(Config.getExternalDirectoryReadForPath(permission, "/etc/hosts")).toBe("ask")
538+
})
539+
540+
test("returns undefined when no pattern matches and no catch-all", () => {
541+
const permission = {
542+
read: {
543+
"/nonexistent/**": "deny" as const,
544+
},
545+
}
546+
expect(Config.getExternalDirectoryReadForPath(permission, "/etc/hosts")).toBeUndefined()
547+
})
548+
549+
test("first matching pattern takes precedence", () => {
550+
const permission = {
551+
read: {
552+
"/etc/hosts": "allow" as const,
553+
"/etc/**": "deny" as const,
554+
"*": "ask" as const,
555+
},
556+
}
557+
expect(Config.getExternalDirectoryReadForPath(permission, "/etc/hosts")).toBe("allow")
558+
})
559+
560+
test("expands ~ to home directory in patterns", () => {
561+
const homeDir = os.homedir()
562+
const permission = {
563+
read: {
564+
"~/.ssh/**": "deny" as const,
565+
"~/reference/**": "allow" as const,
566+
"*": "ask" as const,
567+
},
568+
}
569+
expect(Config.getExternalDirectoryReadForPath(permission, `${homeDir}/.ssh/id_rsa`)).toBe("deny")
570+
expect(Config.getExternalDirectoryReadForPath(permission, `${homeDir}/reference/doc.txt`)).toBe("allow")
571+
expect(Config.getExternalDirectoryReadForPath(permission, `${homeDir}/other/file.txt`)).toBe("ask")
572+
})
573+
574+
test("returns simple read permission when read is string", () => {
575+
const permission = {
576+
read: "allow" as const,
577+
write: "deny" as const,
578+
}
579+
expect(Config.getExternalDirectoryReadForPath(permission, "/any/path")).toBe("allow")
580+
})
581+
})
582+
583+
describe("getExternalDirectoryWriteForPath", () => {
584+
test("returns permission when string", () => {
585+
expect(Config.getExternalDirectoryWriteForPath("allow", "/any/path")).toBe("allow")
586+
expect(Config.getExternalDirectoryWriteForPath("deny", "/any/path")).toBe("deny")
587+
expect(Config.getExternalDirectoryWriteForPath("ask", "/any/path")).toBe("ask")
588+
})
589+
590+
test("returns undefined when permission is undefined", () => {
591+
expect(Config.getExternalDirectoryWriteForPath(undefined, "/any/path")).toBeUndefined()
592+
})
593+
594+
test("returns permission from pattern map when path matches", () => {
595+
const permission = {
596+
write: {
597+
"/tmp/**": "allow" as const,
598+
"*": "deny" as const,
599+
},
600+
}
601+
expect(Config.getExternalDirectoryWriteForPath(permission, "/tmp/file.txt")).toBe("allow")
602+
expect(Config.getExternalDirectoryWriteForPath(permission, "/tmp/subdir/file")).toBe("allow")
603+
expect(Config.getExternalDirectoryWriteForPath(permission, "/other/path")).toBe("deny")
604+
})
605+
606+
test("expands ~ to home directory in patterns", () => {
607+
const homeDir = os.homedir()
608+
const permission = {
609+
write: {
610+
"~/temp/**": "allow" as const,
611+
"*": "deny" as const,
612+
},
613+
}
614+
expect(Config.getExternalDirectoryWriteForPath(permission, `${homeDir}/temp/file.txt`)).toBe("allow")
615+
expect(Config.getExternalDirectoryWriteForPath(permission, `${homeDir}/other/file.txt`)).toBe("deny")
616+
})
617+
618+
test("mixed config: pattern map for write, simple value for read", () => {
619+
const permission = {
620+
read: "allow" as const,
621+
write: {
622+
"/protected/**": "deny" as const,
623+
"*": "ask" as const,
624+
},
625+
}
626+
expect(Config.getExternalDirectoryWriteForPath(permission, "/protected/file.txt")).toBe("deny")
627+
expect(Config.getExternalDirectoryWriteForPath(permission, "/other/file.txt")).toBe("ask")
628+
})
629+
})

0 commit comments

Comments
 (0)