diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 26f241fabbf..cb4d5ebaff7 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -32,7 +32,7 @@ export namespace Agent { bash: z.record(z.string(), Config.Permission), webfetch: Config.Permission.optional(), doom_loop: Config.Permission.optional(), - external_directory: Config.Permission.optional(), + external_directory: Config.ExternalDirectoryPermission.optional(), }), model: z .object({ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1158d67f4cc..90b518c37e9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -391,6 +391,113 @@ export namespace Config { export const Permission = z.enum(["ask", "allow", "deny"]) export type Permission = z.infer + export const PermissionPatternMap = z.record(z.string(), Permission).describe( + "Map of glob patterns to permissions. Patterns are evaluated in insertion order; first match wins. Use '*' as a catch-all default.", + ) + export type PermissionPatternMap = z.infer + + export const ExternalDirectoryPermission = z + .union([ + Permission, + z + .object({ + read: z + .union([Permission, PermissionPatternMap]) + .optional() + .describe("Permission for reading files outside working directory"), + write: z + .union([Permission, PermissionPatternMap]) + .optional() + .describe("Permission for writing files outside working directory"), + }) + .strict(), + ]) + .meta({ + ref: "ExternalDirectoryPermission", + }) + export type ExternalDirectoryPermission = z.infer + + export function getExternalDirectoryRead( + permission: ExternalDirectoryPermission | undefined, + ): Permission | undefined { + if (permission === undefined) return undefined + if (typeof permission === "string") return permission + if (typeof permission.read === "object") return undefined // Pattern map requires filepath + return permission.read + } + + export function getExternalDirectoryWrite( + permission: ExternalDirectoryPermission | undefined, + ): Permission | undefined { + if (permission === undefined) return undefined + if (typeof permission === "string") return permission + if (typeof permission.write === "object") return undefined // Pattern map requires filepath + return permission.write + } + + /** + * Resolves the read permission for a specific file path. + * When permission is a pattern map, patterns are evaluated in insertion order (first match wins). + * The "*" pattern is treated as a catch-all default and only evaluated if no other pattern matches. + */ + export function getExternalDirectoryReadForPath( + permission: ExternalDirectoryPermission | undefined, + filepath: string, + ): Permission | undefined { + if (permission === undefined) return undefined + if (typeof permission === "string") return permission + + const readPerm = permission.read + if (readPerm === undefined) return undefined + if (typeof readPerm === "string") return readPerm + + return resolvePermissionFromPatternMap(readPerm, filepath) + } + + /** + * Resolves the write permission for a specific file path. + * When permission is a pattern map, patterns are evaluated in insertion order (first match wins). + * The "*" pattern is treated as a catch-all default and only evaluated if no other pattern matches. + */ + export function getExternalDirectoryWriteForPath( + permission: ExternalDirectoryPermission | undefined, + filepath: string, + ): Permission | undefined { + if (permission === undefined) return undefined + if (typeof permission === "string") return permission + + const writePerm = permission.write + if (writePerm === undefined) return undefined + if (typeof writePerm === "string") return writePerm + + return resolvePermissionFromPatternMap(writePerm, filepath) + } + + /** + * Resolves permission from a pattern map for a given filepath. + * Patterns are evaluated in insertion order; "*" is skipped and used as fallback. + */ + function resolvePermissionFromPatternMap( + patternMap: PermissionPatternMap, + filepath: string, + ): Permission | undefined { + for (const [pattern, perm] of Object.entries(patternMap)) { + // Skip "*" (catch-all default) - evaluate it last + if (pattern === "*") continue + + // Expand ~ to home directory + const normalizedPattern = pattern.startsWith("~/") ? pattern.replace("~", os.homedir()) : pattern + + const glob = new Bun.Glob(normalizedPattern) + if (glob.match(filepath)) { + return perm + } + } + + // Return catch-all default if present + return patternMap["*"] + } + export const Command = z.object({ template: z.string(), description: z.string().optional(), @@ -427,7 +534,7 @@ export namespace Config { bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), doom_loop: Permission.optional(), - external_directory: Permission.optional(), + external_directory: ExternalDirectoryPermission.optional(), }) .optional(), }) @@ -774,7 +881,7 @@ export namespace Config { bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), doom_loop: Permission.optional(), - external_directory: Permission.optional(), + external_directory: ExternalDirectoryPermission.optional(), }) .optional(), tools: z.record(z.string(), z.boolean()).optional(), diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b492c7179e6..8197c1d1b1d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -77,7 +77,9 @@ export namespace Plugin { const hooks = await state().then((x) => x.hooks) const config = await Config.get() for (const hook of hooks) { - await hook.config?.(config) + // Since `@hey-api/openapi-ts` doesn't generate the union type for ExternalDirectoryPermission correctly, + // a type-assertion workaround is used here (cf. Config.ExternalDirectoryPermission) + await hook.config?.(config as Parameters>[0]) } Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 115d8f8b29d..d6fcc9bc110 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -15,6 +15,7 @@ import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import path from "path" import { Shell } from "@/shell/shell" +import { Config } from "@/config/config" const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -86,7 +87,8 @@ export const BashTool = Tool.define("bash", async () => { const checkExternalDirectory = async (dir: string) => { if (Filesystem.contains(Instance.directory, dir)) return const title = `This command references paths outside of ${Instance.directory}` - if (agent.permission.external_directory === "ask") { + const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, dir) + if (writePermission === "ask") { await Permission.ask({ type: "external_directory", pattern: [dir, path.join(dir, "*")], @@ -98,7 +100,7 @@ export const BashTool = Tool.define("bash", async () => { command: params.command, }, }) - } else if (agent.permission.external_directory === "deny") { + } else if (writePermission === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index b49bd7abe00..bbdd1d203ba 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -17,6 +17,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Snapshot } from "@/snapshot" +import { Config } from "@/config/config" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -46,7 +47,8 @@ export const EditTool = Tool.define("edit", { const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) if (!Filesystem.contains(Instance.directory, filePath)) { const parentDir = path.dirname(filePath) - if (agent.permission.external_directory === "ask") { + const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filePath) + if (writePermission === "ask") { await Permission.ask({ type: "external_directory", pattern: [parentDir, path.join(parentDir, "*")], @@ -59,7 +61,7 @@ export const EditTool = Tool.define("edit", { parentDir, }, }) - } else if (agent.permission.external_directory === "deny") { + } else if (writePermission === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 93888f60bd2..addb3ef5665 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -11,6 +11,7 @@ import { Agent } from "../agent/agent" import { Patch } from "../patch" import { Filesystem } from "../util/filesystem" import { createTwoFilesPatch } from "diff" +import { Config } from "../config/config" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -55,7 +56,8 @@ export const PatchTool = Tool.define("patch", { if (!Filesystem.contains(Instance.directory, filePath)) { const parentDir = path.dirname(filePath) - if (agent.permission.external_directory === "ask") { + const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filePath) + if (writePermission === "ask") { await Permission.ask({ type: "external_directory", pattern: [parentDir, path.join(parentDir, "*")], @@ -68,7 +70,7 @@ export const PatchTool = Tool.define("patch", { parentDir, }, }) - } else if (agent.permission.external_directory === "deny") { + } else if (writePermission === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 27426ad2412..b180625f24e 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { Identifier } from "../id/id" import { Permission } from "../permission" import { Agent } from "@/agent/agent" import { iife } from "@/util/iife" +import { Config } from "@/config/config" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -32,7 +33,8 @@ export const ReadTool = Tool.define("read", { if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) - if (agent.permission.external_directory === "ask") { + const readPermission = Config.getExternalDirectoryReadForPath(agent.permission.external_directory, filepath) + if (readPermission === "ask") { await Permission.ask({ type: "external_directory", pattern: [parentDir, path.join(parentDir, "*")], @@ -45,7 +47,7 @@ export const ReadTool = Tool.define("read", { parentDir, }, }) - } else if (agent.permission.external_directory === "deny") { + } else if (readPermission === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 6b8fd3dd111..e7edab36c0f 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,6 +10,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" +import { Config } from "../config/config" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -26,7 +27,8 @@ export const WriteTool = Tool.define("write", { const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) if (!Filesystem.contains(Instance.directory, filepath)) { const parentDir = path.dirname(filepath) - if (agent.permission.external_directory === "ask") { + const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filepath) + if (writePermission === "ask") { await Permission.ask({ type: "external_directory", pattern: [parentDir, path.join(parentDir, "*")], @@ -39,7 +41,7 @@ export const WriteTool = Tool.define("write", { parentDir, }, }) - } else if (agent.permission.external_directory === "deny") { + } else if (writePermission === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 2ff8c01cdb0..c4efb781dbe 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,10 +1,11 @@ -import { test, expect } from "bun:test" +import { test, expect, describe } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" +import os from "os" test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() @@ -501,3 +502,128 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) }) + +// Unit tests for pattern-based external directory permission helpers +describe("getExternalDirectoryReadForPath", () => { + test("returns permission when string", () => { + expect(Config.getExternalDirectoryReadForPath("allow", "/any/path")).toBe("allow") + expect(Config.getExternalDirectoryReadForPath("deny", "/any/path")).toBe("deny") + expect(Config.getExternalDirectoryReadForPath("ask", "/any/path")).toBe("ask") + }) + + test("returns undefined when permission is undefined", () => { + expect(Config.getExternalDirectoryReadForPath(undefined, "/any/path")).toBeUndefined() + }) + + test("returns permission from pattern map when path matches", () => { + const permission = { + read: { + "/etc/**": "allow" as const, + "*": "deny" as const, + }, + } + expect(Config.getExternalDirectoryReadForPath(permission, "/etc/hosts")).toBe("allow") + expect(Config.getExternalDirectoryReadForPath(permission, "/etc/subdir/file")).toBe("allow") + expect(Config.getExternalDirectoryReadForPath(permission, "/other/path")).toBe("deny") + }) + + test("returns catch-all (*) when no pattern matches", () => { + const permission = { + read: { + "/nonexistent/**": "allow" as const, + "*": "ask" as const, + }, + } + expect(Config.getExternalDirectoryReadForPath(permission, "/etc/hosts")).toBe("ask") + }) + + test("returns undefined when no pattern matches and no catch-all", () => { + const permission = { + read: { + "/nonexistent/**": "deny" as const, + }, + } + expect(Config.getExternalDirectoryReadForPath(permission, "/etc/hosts")).toBeUndefined() + }) + + test("first matching pattern takes precedence", () => { + const permission = { + read: { + "/etc/hosts": "allow" as const, + "/etc/**": "deny" as const, + "*": "ask" as const, + }, + } + expect(Config.getExternalDirectoryReadForPath(permission, "/etc/hosts")).toBe("allow") + }) + + test("expands ~ to home directory in patterns", () => { + const homeDir = os.homedir() + const permission = { + read: { + "~/.ssh/**": "deny" as const, + "~/reference/**": "allow" as const, + "*": "ask" as const, + }, + } + expect(Config.getExternalDirectoryReadForPath(permission, `${homeDir}/.ssh/id_rsa`)).toBe("deny") + expect(Config.getExternalDirectoryReadForPath(permission, `${homeDir}/reference/doc.txt`)).toBe("allow") + expect(Config.getExternalDirectoryReadForPath(permission, `${homeDir}/other/file.txt`)).toBe("ask") + }) + + test("returns simple read permission when read is string", () => { + const permission = { + read: "allow" as const, + write: "deny" as const, + } + expect(Config.getExternalDirectoryReadForPath(permission, "/any/path")).toBe("allow") + }) +}) + +describe("getExternalDirectoryWriteForPath", () => { + test("returns permission when string", () => { + expect(Config.getExternalDirectoryWriteForPath("allow", "/any/path")).toBe("allow") + expect(Config.getExternalDirectoryWriteForPath("deny", "/any/path")).toBe("deny") + expect(Config.getExternalDirectoryWriteForPath("ask", "/any/path")).toBe("ask") + }) + + test("returns undefined when permission is undefined", () => { + expect(Config.getExternalDirectoryWriteForPath(undefined, "/any/path")).toBeUndefined() + }) + + test("returns permission from pattern map when path matches", () => { + const permission = { + write: { + "/tmp/**": "allow" as const, + "*": "deny" as const, + }, + } + expect(Config.getExternalDirectoryWriteForPath(permission, "/tmp/file.txt")).toBe("allow") + expect(Config.getExternalDirectoryWriteForPath(permission, "/tmp/subdir/file")).toBe("allow") + expect(Config.getExternalDirectoryWriteForPath(permission, "/other/path")).toBe("deny") + }) + + test("expands ~ to home directory in patterns", () => { + const homeDir = os.homedir() + const permission = { + write: { + "~/temp/**": "allow" as const, + "*": "deny" as const, + }, + } + expect(Config.getExternalDirectoryWriteForPath(permission, `${homeDir}/temp/file.txt`)).toBe("allow") + expect(Config.getExternalDirectoryWriteForPath(permission, `${homeDir}/other/file.txt`)).toBe("deny") + }) + + test("mixed config: pattern map for write, simple value for read", () => { + const permission = { + read: "allow" as const, + write: { + "/protected/**": "deny" as const, + "*": "ask" as const, + }, + } + expect(Config.getExternalDirectoryWriteForPath(permission, "/protected/file.txt")).toBe("deny") + expect(Config.getExternalDirectoryWriteForPath(permission, "/other/file.txt")).toBe("ask") + }) +}) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 9ef7dfb9d8f..d8d22d4a0f1 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -386,6 +386,79 @@ describe("tool.bash permissions", () => { }) }) + test("denies external directory when split permission has write: deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: "allow", + write: "deny", + }, + bash: { + "*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // Should deny cd to parent directory (bash uses write permission) + await expect( + bash.execute( + { + command: "cd ../", + description: "Change to parent directory", + }, + ctx, + ), + ).rejects.toThrow() + }, + }) + }) + + test("allows external directory when split permission has write: allow", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: "deny", + write: "allow", + }, + bash: { + "*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // Should allow because write permission is allow + const result = await bash.execute( + { + command: "ls /tmp", + description: "List /tmp", + }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + }, + }) + }) + test("handles multiple commands in sequence", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts new file mode 100644 index 00000000000..b5cf687222a --- /dev/null +++ b/packages/opencode/test/tool/read.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { ReadTool } from "../../src/tool/read" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, +} + +describe("tool.read external_directory permissions", () => { + test("denies reading external file when external_directory is deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + await expect( + read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ), + ).rejects.toThrow("not in the current working directory") + }, + }) + }) + + test("denies reading external file when split permission has read: deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: "deny", + write: "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // Should deny because read permission is deny + await expect( + read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ), + ).rejects.toThrow("not in the current working directory") + }, + }) + }) + + test("allows reading external file when split permission has read: allow", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: "allow", + write: "deny", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // Should allow because read permission is allow + const result = await read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ) + expect(result.output).toContain("") + }, + }) + }) + + test("allows reading file inside project directory regardless of external_directory setting", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "hello world") + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // Should allow because file is inside project directory + const result = await read.execute( + { + filePath: path.join(tmp.path, "test.txt"), + }, + ctx, + ) + expect(result.output).toContain("hello world") + }, + }) + }) +}) + +describe("tool.read external_directory pattern-based permissions", () => { + test("allows reading when path matches allow pattern", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: { + "/etc/**": "allow", + "*": "deny", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // Should allow because /etc/hosts matches "/etc/**" pattern + const result = await read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ) + expect(result.output).toContain("") + }, + }) + }) + + test("denies reading when path matches deny pattern", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: { + "/etc/**": "deny", + "*": "allow", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // Should deny because /etc/hosts matches "/etc/**" pattern which is deny + await expect( + read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ), + ).rejects.toThrow("not in the current working directory") + }, + }) + }) + + test("falls back to * (catch-all) when no pattern matches", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: { + "/nonexistent/**": "allow", + "*": "deny", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // Should deny because /etc/hosts doesn't match any specific pattern, falls back to "*" which is deny + await expect( + read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ), + ).rejects.toThrow("not in the current working directory") + }, + }) + }) + + test("first matching pattern takes precedence (insertion order)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: { + "/etc/hosts": "allow", + "/etc/**": "deny", + "*": "deny", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // Should allow because "/etc/hosts" is matched first (before "/etc/**") + const result = await read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ) + expect(result.output).toContain("") + }, + }) + }) + + test("allows when no * pattern exists and no match (undefined = allow)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: { + "/nonexistent/**": "deny", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // Should allow because /etc/hosts doesn't match any pattern and no "*" fallback exists (undefined = allow) + const result = await read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ) + expect(result.output).toContain("") + }, + }) + }) + + test("mixed config: pattern map for read, simple value for write", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: { + read: { + "/etc/**": "allow", + "*": "deny", + }, + write: "deny", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + // read should allow because /etc/hosts matches "/etc/**" pattern + const result = await read.execute( + { + filePath: "/etc/hosts", + }, + ctx, + ) + expect(result.output).toContain("") + }, + }) + }) +}) + diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1b43d3f48a1..384131e7e97 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1124,6 +1124,21 @@ export type KeybindsConfig = { terminal_title_toggle?: string } +export type ExternalDirectoryPermission = + | "ask" + | "allow" + | "deny" + | { + /** + * Permission for reading files outside working directory + */ + read?: "ask" | "allow" | "deny" + /** + * Permission for writing files outside working directory + */ + write?: "ask" | "allow" | "deny" + } + export type AgentConfig = { model?: string temperature?: number @@ -1157,7 +1172,7 @@ export type AgentConfig = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: ExternalDirectoryPermission } [key: string]: | unknown @@ -1183,7 +1198,7 @@ export type AgentConfig = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: ExternalDirectoryPermission } | undefined } @@ -1502,7 +1517,7 @@ export type Config = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: ExternalDirectoryPermission } tools?: { [key: string]: boolean @@ -1782,7 +1797,7 @@ export type Agent = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: ExternalDirectoryPermission } model?: { modelID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f33d20069c4..597850d814f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7597,6 +7597,30 @@ }, "additionalProperties": false }, + "ExternalDirectoryPermission": { + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "read": { + "description": "Permission for reading files outside working directory", + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + "write": { + "description": "Permission for writing files outside working directory", + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "additionalProperties": false + } + ] + }, "AgentConfig": { "type": "object", "properties": { @@ -7677,8 +7701,7 @@ "enum": ["ask", "allow", "deny"] }, "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] + "$ref": "#/components/schemas/ExternalDirectoryPermission" } } } @@ -8385,8 +8408,7 @@ "enum": ["ask", "allow", "deny"] }, "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] + "$ref": "#/components/schemas/ExternalDirectoryPermission" } } }, @@ -9194,8 +9216,7 @@ "enum": ["ask", "allow", "deny"] }, "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] + "$ref": "#/components/schemas/ExternalDirectoryPermission" } }, "required": ["edit", "bash"] diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 1aea3ef740d..95c4a2b4ff6 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -193,6 +193,85 @@ This provides an additional safety layer to prevent unintended modifications to --- +#### Separate read and write permissions + +You can configure separate permissions for reading and writing files outside the working directory. This allows you to permit reading external reference files while blocking writes. + +```json title="opencode.json" {4-7} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "read": "allow", + "write": "deny" + } + } +} +``` + +- `read` — Controls the `read` tool when accessing external files +- `write` — Controls file writing tools (`write`, `edit`, `patch`) and `bash` when working with external directories + +:::tip +The `bash` tool uses the `write` permission when checking external directory access, since bash commands can potentially modify files. If you need to run read-only bash commands in external directories, consider allowing specific commands via `permission.bash` instead. +::: + +--- + +#### Glob patterns + +You can use glob patterns to manage permissions for specific external paths. This allows fine-grained control over which external directories are accessible. + +```json title="opencode.json" {4-11} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "read": { + "~/Documents/**": "allow", + "/tmp/**": "allow", + "*": "deny" + }, + "write": "deny" + } + } +} +``` + +For example, here the `read` permission allows reading files under `~/Documents` and `/tmp`, but denies access to all other external paths. + +--- + +##### Pattern evaluation + +Patterns are evaluated in insertion order; the first match wins. The `*` pattern is treated as a catch-all default and is always evaluated last, regardless of its position. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "read": { + "~/secrets/**": "deny", + "~/**": "allow", + "*": "ask" + } + } + } +} +``` + +In this example: +1. Files under `~/secrets/` are denied +2. Other files in the home directory are allowed +3. All other external paths require approval + +:::tip +Use `~` as a shorthand for the home directory. For example, `~/Documents/**` expands to `/Users/yourname/Documents/**`. +::: + +--- + ## Agents You can also configure permissions per agent. Where the agent specific config