diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ad665e5d6ee..b390f53a873 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -33,7 +33,7 @@ export namespace Agent { skill: 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 c94a34be0e6..59041d1b159 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -388,6 +388,28 @@ export namespace Config { export const Permission = z.enum(["ask", "allow", "deny"]) export type Permission = z.infer + export const DirectoryRulesObject = z + .object({ + directories: z.record(z.string(), Permission).optional(), + default: Permission.optional(), + }) + .strict() + export type DirectoryRulesObject = z.infer + + export const OperationPermission = z.union([Permission, DirectoryRulesObject]) + export type OperationPermission = z.infer + + export const ExternalDirectoryPermission = z.union([ + Permission, + z + .object({ + read: OperationPermission.optional(), + write: OperationPermission.optional(), + }) + .strict(), + ]) + export type ExternalDirectoryPermission = z.infer + export const Command = z.object({ template: z.string(), description: z.string().optional(), @@ -425,7 +447,7 @@ export namespace Config { skill: 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(), }) @@ -790,7 +812,7 @@ export namespace Config { skill: 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/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 115d8f8b29d..a56f35228aa 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 { ExternalPermission } from "@/util/external-permission" 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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, dir, "write") + if (externalPerm === "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 (externalPerm === "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 62679974648..4f0b8851bdd 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 { ExternalPermission } from "@/util/external-permission" 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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, filePath, "write") + if (externalPerm === "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 (externalPerm === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", @@ -68,7 +70,7 @@ export const EditTool = Tool.define("edit", { filepath: filePath, parentDir, }, - `File ${filePath} is not in the current working directory`, + `Access to ${filePath} is denied by external_directory permission`, ) } } diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 11c12f19ac4..4a45388edff 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -4,6 +4,10 @@ import { Tool } from "./tool" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import { Permission } from "../permission" +import { Agent } from "../agent/agent" +import { ExternalPermission } from "../util/external-permission" export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -16,9 +20,36 @@ export const GlobTool = Tool.define("glob", { `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, ), }), - async execute(params) { - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + async execute(params, ctx) { + const search = params.path + ? path.isAbsolute(params.path) + ? params.path + : path.resolve(Instance.directory, params.path) + : Instance.directory + const agent = await Agent.get(ctx.agent) + + if (!Filesystem.contains(Instance.directory, search)) { + const externalPerm = ExternalPermission.resolve(agent.permission.external_directory, search, "read") + if (externalPerm === "ask") { + await Permission.ask({ + type: "external_directory", + pattern: [search, path.join(search, "*")], + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Search directory outside working directory: ${search}`, + metadata: { searchPath: search }, + }) + } else if (externalPerm === "deny") { + throw new Permission.RejectedError( + ctx.sessionID, + "external_directory", + ctx.callID, + { searchPath: search }, + `Access to ${search} is denied by external_directory permission`, + ) + } + } const limit = 100 const files = [] diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index d73bc161683..454c6ba93db 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,9 +1,14 @@ import z from "zod" +import path from "path" import { Tool } from "./tool" import { Ripgrep } from "../file/ripgrep" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import { Permission } from "../permission" +import { Agent } from "../agent/agent" +import { ExternalPermission } from "../util/external-permission" const MAX_LINE_LENGTH = 2000 @@ -14,12 +19,40 @@ export const GrepTool = Tool.define("grep", { path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), }), - async execute(params) { + async execute(params, ctx) { if (!params.pattern) { throw new Error("pattern is required") } - const searchPath = params.path || Instance.directory + const searchPath = params.path + ? path.isAbsolute(params.path) + ? params.path + : path.resolve(Instance.directory, params.path) + : Instance.directory + const agent = await Agent.get(ctx.agent) + + if (!Filesystem.contains(Instance.directory, searchPath)) { + const externalPerm = ExternalPermission.resolve(agent.permission.external_directory, searchPath, "read") + if (externalPerm === "ask") { + await Permission.ask({ + type: "external_directory", + pattern: [searchPath, path.join(searchPath, "*")], + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Search directory outside working directory: ${searchPath}`, + metadata: { searchPath }, + }) + } else if (externalPerm === "deny") { + throw new Permission.RejectedError( + ctx.sessionID, + "external_directory", + ctx.callID, + { searchPath }, + `Access to ${searchPath} is denied by external_directory permission`, + ) + } + } const rgPath = await Ripgrep.filepath() const args = ["-nH", "--field-match-separator=|", "--regexp", params.pattern] diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index 95c36e74593..29583b56c65 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -4,6 +4,10 @@ import * as path from "path" import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" +import { Filesystem } from "../util/filesystem" +import { Permission } from "../permission" +import { Agent } from "../agent/agent" +import { ExternalPermission } from "../util/external-permission" export const IGNORE_PATTERNS = [ "node_modules/", @@ -40,8 +44,32 @@ export const ListTool = Tool.define("list", { path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(), ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), }), - async execute(params) { + async execute(params, ctx) { const searchPath = path.resolve(Instance.directory, params.path || ".") + const agent = await Agent.get(ctx.agent) + + if (!Filesystem.contains(Instance.directory, searchPath)) { + const externalPerm = ExternalPermission.resolve(agent.permission.external_directory, searchPath, "read") + if (externalPerm === "ask") { + await Permission.ask({ + type: "external_directory", + pattern: [searchPath, path.join(searchPath, "*")], + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `List directory outside working directory: ${searchPath}`, + metadata: { searchPath }, + }) + } else if (externalPerm === "deny") { + throw new Permission.RejectedError( + ctx.sessionID, + "external_directory", + ctx.callID, + { searchPath }, + `Access to ${searchPath} is denied by external_directory permission`, + ) + } + } const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) const files = [] diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 93888f60bd2..cd66ad5a26a 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 { ExternalPermission } from "@/util/external-permission" 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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, filePath, "write") + if (externalPerm === "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 (externalPerm === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", @@ -77,7 +79,7 @@ export const PatchTool = Tool.define("patch", { filepath: filePath, parentDir, }, - `File ${filePath} is not in the current working directory`, + `Access to ${filePath} is denied by external_directory permission`, ) } } @@ -122,12 +124,45 @@ export const PatchTool = Tool.define("patch", { const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) + const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + + // Check permission for move destination + if (movePath && !Filesystem.contains(Instance.directory, movePath)) { + const moveParentDir = path.dirname(movePath) + const moveExternalPerm = ExternalPermission.resolve(agent.permission.external_directory, movePath, "write") + if (moveExternalPerm === "ask") { + await Permission.ask({ + type: "external_directory", + pattern: [moveParentDir, path.join(moveParentDir, "*")], + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + title: `Move file to outside working directory: ${movePath}`, + metadata: { + filepath: movePath, + parentDir: moveParentDir, + }, + }) + } else if (moveExternalPerm === "deny") { + throw new Permission.RejectedError( + ctx.sessionID, + "external_directory", + ctx.callID, + { + filepath: movePath, + parentDir: moveParentDir, + }, + `Access to ${movePath} is denied by external_directory permission`, + ) + } + } + fileChanges.push({ filePath, oldContent, newContent, - type: hunk.move_path ? "move" : "update", - movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined, + type: movePath ? "move" : "update", + movePath, }) totalDiff += diff + "\n" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index fd81c4864a4..e93e962d57a 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 { ExternalPermission } from "@/util/external-permission" 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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, filepath, "read") + if (externalPerm === "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 (externalPerm === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", @@ -54,7 +56,7 @@ export const ReadTool = Tool.define("read", { filepath: filepath, parentDir, }, - `File ${filepath} is not in the current working directory`, + `Access to ${filepath} is denied by external_directory permission`, ) } } diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index a0e87299a44..108abcd0454 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 { ExternalPermission } from "../util/external-permission" 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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, filepath, "write") + if (externalPerm === "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 (externalPerm === "deny") { throw new Permission.RejectedError( ctx.sessionID, "external_directory", @@ -48,7 +50,7 @@ export const WriteTool = Tool.define("write", { filepath: filepath, parentDir, }, - `File ${filepath} is not in the current working directory`, + `Access to ${filepath} is denied by external_directory permission`, ) } } diff --git a/packages/opencode/src/util/external-permission.ts b/packages/opencode/src/util/external-permission.ts new file mode 100644 index 00000000000..15542934c3d --- /dev/null +++ b/packages/opencode/src/util/external-permission.ts @@ -0,0 +1,47 @@ +import path from "path" +import { Config } from "../config/config" +import { Wildcard } from "./wildcard" +import { Global } from "../global" + +export namespace ExternalPermission { + type Permission = Config.Permission + type ExternalDirectoryConfig = Config.ExternalDirectoryPermission + + function expandTilde(pattern: string): string { + if (pattern.startsWith("~/")) { + return path.join(Global.Path.home, pattern.slice(2)) + } + return pattern + } + + /** Resolve permission for a filepath. Checks directory rules, then falls back to default. */ + export function resolve( + config: ExternalDirectoryConfig | undefined, + filepath: string, + operation: "read" | "write", + ): Permission { + if (config === undefined) return "ask" + if (typeof config === "string") return config + + const operationConfig = config[operation] + if (operationConfig === undefined) return "ask" + if (typeof operationConfig === "string") return operationConfig + + if (operationConfig.directories) { + // Expand patterns: add /** suffix unless pattern already ends with wildcard + const expanded: Record = {} + for (const [pattern, permission] of Object.entries(operationConfig.directories)) { + const p = expandTilde(pattern) + expanded[p] = permission + // Add /** variant unless pattern already ends with * or ** + if (!p.endsWith("*")) { + expanded[p + (p.endsWith("/") ? "**" : "/**")] = permission + } + } + const match = Wildcard.pathAll(filepath, expanded) + if (match !== undefined) return match + } + + return operationConfig.default ?? "ask" + } +} diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index 9b595a0a9ec..200c090684a 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -14,6 +14,72 @@ export namespace Wildcard { return regex.test(str) } + /** + * Match a file path against a glob pattern with proper path semantics. + * - `*` matches any characters except path separators (/ or \) + * - `**` matches any characters including path separators (recursive) + * - `?` matches a single character except path separators + */ + export function pathMatch(filepath: string, pattern: string): boolean { + // Normalize separators to forward slash for matching + const normalizedPath = filepath.replace(/\\/g, "/") + const normalizedPattern = pattern.replace(/\\/g, "/") + + // Build regex from pattern + let regex = "^" + let i = 0 + while (i < normalizedPattern.length) { + const char = normalizedPattern[i] + const next = normalizedPattern[i + 1] + + if (char === "*" && next === "*") { + // ** matches zero or more path segments + i += 2 + if (normalizedPattern[i] === "/") { + // **/ means "zero or more directories followed by /" + regex += "(?:.*/)?" + i++ + } else { + // ** at end or before non-slash matches anything + regex += ".*" + } + } else if (char === "*") { + // * matches anything except slashes + regex += "[^/]*" + i++ + } else if (char === "?") { + // ? matches single char except slash + regex += "[^/]" + i++ + } else if (".+^${}()|[]\\".includes(char)) { + // Escape regex special chars + regex += "\\" + char + i++ + } else { + regex += char + i++ + } + } + regex += "$" + + return new RegExp(regex).test(normalizedPath) + } + + /** + * Find the best matching pattern for a file path. + * Uses pathMatch() for proper glob semantics. Longer patterns take precedence. + */ + export function pathAll(filepath: string, patterns: Record): T | undefined { + const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) + let result: T | undefined = undefined + for (const [pattern, value] of sorted) { + if (pathMatch(filepath, pattern)) { + result = value + } + } + return result + } + export function all(input: string, patterns: Record) { const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) let result = undefined diff --git a/packages/opencode/test/util/external-permission.test.ts b/packages/opencode/test/util/external-permission.test.ts new file mode 100644 index 00000000000..19e75ff0239 --- /dev/null +++ b/packages/opencode/test/util/external-permission.test.ts @@ -0,0 +1,326 @@ +import { test, expect, describe } from "bun:test" +import { ExternalPermission } from "../../src/util/external-permission" +import { Global } from "../../src/global" + +describe("ExternalPermission.resolve", () => { + const homedir = Global.Path.home + + describe("Type 1 - Simple string config", () => { + test("returns string value for read operation", () => { + expect(ExternalPermission.resolve("allow", "/etc/hosts", "read")).toBe("allow") + expect(ExternalPermission.resolve("ask", "/etc/hosts", "read")).toBe("ask") + expect(ExternalPermission.resolve("deny", "/etc/hosts", "read")).toBe("deny") + }) + + test("returns string value for write operation", () => { + expect(ExternalPermission.resolve("allow", "/etc/hosts", "write")).toBe("allow") + expect(ExternalPermission.resolve("ask", "/etc/hosts", "write")).toBe("ask") + expect(ExternalPermission.resolve("deny", "/etc/hosts", "write")).toBe("deny") + }) + }) + + describe("Type 2 - Object with read/write split (simple strings)", () => { + test("returns operation-specific permission", () => { + const config = { read: "allow" as const, write: "deny" as const } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/etc/hosts", "write")).toBe("deny") + }) + + test("defaults to ask when operation not specified", () => { + const configReadOnly = { read: "allow" as const } + expect(ExternalPermission.resolve(configReadOnly, "/etc/hosts", "read")).toBe("allow") + expect(ExternalPermission.resolve(configReadOnly, "/etc/hosts", "write")).toBe("ask") + + const configWriteOnly = { write: "deny" as const } + expect(ExternalPermission.resolve(configWriteOnly, "/etc/hosts", "read")).toBe("ask") + expect(ExternalPermission.resolve(configWriteOnly, "/etc/hosts", "write")).toBe("deny") + }) + + test("handles empty object config", () => { + const config = {} + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("ask") + expect(ExternalPermission.resolve(config, "/etc/hosts", "write")).toBe("ask") + }) + }) + + describe("Type 3 - Object with directory rules", () => { + test("matches directory patterns", () => { + const config = { + read: { + directories: { "/etc/*": "deny" as const, "/tmp/*": "allow" as const }, + default: "ask" as const, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("deny") + expect(ExternalPermission.resolve(config, "/tmp/file.txt", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/var/log/syslog", "read")).toBe("ask") + }) + + test("uses default when no pattern matches", () => { + const config = { + read: { + directories: { "/etc/*": "deny" as const }, + default: "allow" as const, + }, + } + expect(ExternalPermission.resolve(config, "/var/log/syslog", "read")).toBe("allow") + }) + + test("defaults to ask when default not specified", () => { + const config = { + read: { + directories: { "/etc/*": "deny" as const }, + }, + } + expect(ExternalPermission.resolve(config, "/var/log/syslog", "read")).toBe("ask") + }) + + test("longer patterns take precedence", () => { + const config = { + read: { + directories: { + "/etc/*": "deny" as const, + "/etc/hosts": "allow" as const, + }, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/etc/passwd", "read")).toBe("deny") + }) + }) + + describe("Type 4 - Mixed configurations", () => { + test("simple read with complex write", () => { + const config = { + read: "allow" as const, + write: { + directories: { "/etc/*": "deny" as const }, + default: "ask" as const, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/etc/hosts", "write")).toBe("deny") + expect(ExternalPermission.resolve(config, "/tmp/file", "write")).toBe("ask") + }) + + test("complex read with simple write", () => { + const config = { + read: { + directories: { "/var/log/*": "allow" as const }, + default: "deny" as const, + }, + write: "deny" as const, + } + expect(ExternalPermission.resolve(config, "/var/log/syslog", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/etc/passwd", "read")).toBe("deny") + expect(ExternalPermission.resolve(config, "/var/log/syslog", "write")).toBe("deny") + }) + }) + + describe("Tilde expansion", () => { + test("expands tilde in directory patterns", () => { + const config = { + read: { + directories: { "~/.ssh/*": "deny" as const }, + default: "allow" as const, + }, + } + expect(ExternalPermission.resolve(config, `${homedir}/.ssh/id_rsa`, "read")).toBe("deny") + expect(ExternalPermission.resolve(config, `${homedir}/.config/settings`, "read")).toBe("allow") + }) + + test("handles multiple tilde patterns", () => { + const config = { + write: { + directories: { + "~/.ssh/*": "deny" as const, + "~/.config/*": "ask" as const, + "~/Documents/*": "allow" as const, + }, + default: "deny" as const, + }, + } + expect(ExternalPermission.resolve(config, `${homedir}/.ssh/id_rsa`, "write")).toBe("deny") + expect(ExternalPermission.resolve(config, `${homedir}/.config/settings`, "write")).toBe("ask") + expect(ExternalPermission.resolve(config, `${homedir}/Documents/file.txt`, "write")).toBe("allow") + expect(ExternalPermission.resolve(config, `/etc/passwd`, "write")).toBe("deny") + }) + + test("non-tilde patterns still work", () => { + const config = { + read: { + directories: { + "/etc/*": "deny" as const, + "~/.config/*": "allow" as const, + }, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("deny") + expect(ExternalPermission.resolve(config, `${homedir}/.config/settings`, "read")).toBe("allow") + }) + }) + + describe("Default behavior", () => { + test("returns ask when config is undefined", () => { + expect(ExternalPermission.resolve(undefined, "/etc/hosts", "read")).toBe("ask") + expect(ExternalPermission.resolve(undefined, "/etc/hosts", "write")).toBe("ask") + }) + + test("handles undefined gracefully", () => { + expect(ExternalPermission.resolve(undefined, "/any/path", "read")).toBe("ask") + expect(ExternalPermission.resolve(undefined, "/any/path", "write")).toBe("ask") + }) + }) + + describe("Wildcard patterns", () => { + test("* does not cross directory boundaries", () => { + const config = { + read: { + directories: { "/etc/*": "deny" as const }, + default: "allow" as const, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("deny") + expect(ExternalPermission.resolve(config, "/etc/ssh/config", "read")).toBe("allow") // * doesn't cross / + expect(ExternalPermission.resolve(config, "/var/log/syslog", "read")).toBe("allow") + }) + + test("** matches across directory boundaries", () => { + const config = { + read: { + directories: { "/etc/**": "deny" as const }, + default: "allow" as const, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("deny") + expect(ExternalPermission.resolve(config, "/etc/ssh/config", "read")).toBe("deny") + expect(ExternalPermission.resolve(config, "/var/log/syslog", "read")).toBe("allow") + }) + + test("**/ requires path boundary - does not match partial names", () => { + const config = { + read: { + directories: { "/a/**/docs": "allow" as const }, + default: "deny" as const, + }, + } + expect(ExternalPermission.resolve(config, "/a/docs/file.txt", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/a/x/docs/file.txt", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/a/x/y/docs/file.txt", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/a/xdocs/file.txt", "read")).toBe("deny") // xdocs != docs + expect(ExternalPermission.resolve(config, "/a/mydocs/file.txt", "read")).toBe("deny") // mydocs != docs + }) + + test("handles ? single character wildcard", () => { + const config = { + read: { + directories: { "/tmp/file?.txt": "allow" as const }, + default: "deny" as const, + }, + } + expect(ExternalPermission.resolve(config, "/tmp/file1.txt", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/tmp/fileA.txt", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/tmp/file12.txt", "read")).toBe("deny") + }) + }) + + describe("Directory pattern normalization", () => { + test("plain directory paths match files inside", () => { + const config = { + read: { + directories: { "/Users/test/projects/myapp": "allow" as const }, + default: "deny" as const, + }, + } + expect(ExternalPermission.resolve(config, "/Users/test/projects/myapp/src/main.ts", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/Users/test/projects/myapp/package.json", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/Users/test/projects/other/file.ts", "read")).toBe("deny") + }) + + test("plain directory paths match nested subdirectories", () => { + const config = { + read: { + directories: { "/home/user/code": "allow" as const }, + default: "deny" as const, + }, + } + expect(ExternalPermission.resolve(config, "/home/user/code/project/src/deep/file.ts", "read")).toBe("allow") + }) + + test("tilde directory paths match contents", () => { + const config = { + read: { + directories: { "~/projects/spring-petclinic": "allow" as const }, + default: "deny" as const, + }, + } + expect(ExternalPermission.resolve(config, `${homedir}/projects/spring-petclinic/Pet.java`, "read")).toBe("allow") + expect(ExternalPermission.resolve(config, `${homedir}/projects/other/File.java`, "read")).toBe("deny") + }) + + test("patterns ending with * are not modified", () => { + const config = { + read: { + directories: { + "/etc/*": "deny" as const, + "/var/**": "allow" as const, + }, + default: "ask" as const, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("deny") + expect(ExternalPermission.resolve(config, "/etc/ssh/config", "read")).toBe("ask") // * doesn't match / + expect(ExternalPermission.resolve(config, "/var/log/deep/file.log", "read")).toBe("allow") + }) + + test("glob patterns with ** in middle match directory contents", () => { + const config = { + read: { + directories: { + "/projects/**/spring-petclinic": "allow" as const, + }, + default: "deny" as const, + }, + } + expect(ExternalPermission.resolve(config, "/projects/ahold/playground/spring-petclinic/Pet.java", "read")).toBe( + "allow", + ) + expect( + ExternalPermission.resolve(config, "/projects/ahold/playground/spring-petclinic/src/App.java", "read"), + ).toBe("allow") + expect(ExternalPermission.resolve(config, "/projects/ahold/playground/spring-petclinic", "read")).toBe("allow") + expect(ExternalPermission.resolve(config, "/projects/ahold/other-project/File.java", "read")).toBe("deny") + }) + }) + + describe("Edge cases", () => { + test("handles empty directories object", () => { + const config = { + read: { + directories: {}, + default: "allow" as const, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("allow") + }) + + test("handles config with only directories (no default)", () => { + const config = { + read: { + directories: { "/etc/*": "deny" as const }, + }, + } + expect(ExternalPermission.resolve(config, "/etc/hosts", "read")).toBe("deny") + expect(ExternalPermission.resolve(config, "/var/log/syslog", "read")).toBe("ask") + }) + + test("handles config with only default (no directories)", () => { + const config = { + read: { + default: "allow" as const, + }, + } + expect(ExternalPermission.resolve(config, "/any/path", "read")).toBe("allow") + }) + }) +}) diff --git a/packages/opencode/test/util/wildcard.test.ts b/packages/opencode/test/util/wildcard.test.ts index f7f1e15457b..abee048bd74 100644 --- a/packages/opencode/test/util/wildcard.test.ts +++ b/packages/opencode/test/util/wildcard.test.ts @@ -1,6 +1,63 @@ -import { test, expect } from "bun:test" +import { test, expect, describe } from "bun:test" import { Wildcard } from "../../src/util/wildcard" +describe("pathMatch", () => { + test("* does not cross directory boundaries", () => { + expect(Wildcard.pathMatch("/etc/hosts", "/etc/*")).toBe(true) + expect(Wildcard.pathMatch("/etc/ssh/config", "/etc/*")).toBe(false) + expect(Wildcard.pathMatch("/tmp/file.txt", "/tmp/*")).toBe(true) + expect(Wildcard.pathMatch("/tmp/subdir/file.txt", "/tmp/*")).toBe(false) + }) + + test("** matches across directory boundaries", () => { + expect(Wildcard.pathMatch("/etc/hosts", "/etc/**")).toBe(true) + expect(Wildcard.pathMatch("/etc/ssh/config", "/etc/**")).toBe(true) + expect(Wildcard.pathMatch("/etc/ssh/keys/id_rsa", "/etc/**")).toBe(true) + }) + + test("? matches single character but not separator", () => { + expect(Wildcard.pathMatch("/tmp/a.txt", "/tmp/?.txt")).toBe(true) + expect(Wildcard.pathMatch("/tmp/ab.txt", "/tmp/?.txt")).toBe(false) + expect(Wildcard.pathMatch("/t/p/a.txt", "/tmp/?.txt")).toBe(false) + }) + + test("exact matches work", () => { + expect(Wildcard.pathMatch("/etc/hosts", "/etc/hosts")).toBe(true) + expect(Wildcard.pathMatch("/etc/passwd", "/etc/hosts")).toBe(false) + }) + + test("handles Windows-style paths", () => { + expect(Wildcard.pathMatch("C:\\Users\\john\\file.txt", "C:/Users/john/*")).toBe(true) + expect(Wildcard.pathMatch("C:\\Users\\john\\docs\\file.txt", "C:/Users/john/*")).toBe(false) + expect(Wildcard.pathMatch("C:\\Users\\john\\docs\\file.txt", "C:/Users/john/**")).toBe(true) + }) +}) + +describe("pathAll", () => { + test("picks the most specific matching pattern", () => { + const rules = { + "/etc/*": "deny", + "/etc/hosts": "allow", + } + expect(Wildcard.pathAll("/etc/hosts", rules)).toBe("allow") + expect(Wildcard.pathAll("/etc/passwd", rules)).toBe("deny") + }) + + test("returns undefined when no match", () => { + const rules = { "/etc/*": "deny" } + expect(Wildcard.pathAll("/var/log/syslog", rules)).toBeUndefined() + }) + + test("** patterns match subdirectories", () => { + const rules = { + "/tmp/*": "allow", + "/etc/**": "deny", + } + expect(Wildcard.pathAll("/etc/ssh/config", rules)).toBe("deny") + expect(Wildcard.pathAll("/tmp/subdir/file", rules)).toBeUndefined() + }) +}) + test("match handles glob tokens", () => { expect(Wildcard.match("file1.txt", "file?.txt")).toBe(true) expect(Wildcard.match("file12.txt", "file?.txt")).toBe(false) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 32f33f66219..67591d3d9b2 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -995,7 +995,32 @@ export type AgentConfig = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: + | "ask" + | "allow" + | "deny" + | { + read?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + write?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + } } [key: string]: | unknown @@ -1016,7 +1041,32 @@ export type AgentConfig = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: + | "ask" + | "allow" + | "deny" + | { + read?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + write?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + } } | undefined } @@ -1324,7 +1374,32 @@ export type Config = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: + | "ask" + | "allow" + | "deny" + | { + read?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + write?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + } } tools?: { [key: string]: boolean @@ -1589,7 +1664,32 @@ export type Agent = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: + | "ask" + | "allow" + | "deny" + | { + read?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + write?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + } } model?: { modelID: string diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90b2154e18a..7333a4cf949 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1214,7 +1214,32 @@ export type AgentConfig = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: + | "ask" + | "allow" + | "deny" + | { + read?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + write?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + } } [key: string]: | unknown @@ -1247,7 +1272,32 @@ export type AgentConfig = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: + | "ask" + | "allow" + | "deny" + | { + read?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + write?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + } } | undefined } @@ -1575,7 +1625,32 @@ export type Config = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: + | "ask" + | "allow" + | "deny" + | { + read?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + write?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + } } tools?: { [key: string]: boolean @@ -1868,7 +1943,32 @@ export type Agent = { } webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" + external_directory?: + | "ask" + | "allow" + | "deny" + | { + read?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + write?: + | "ask" + | "allow" + | "deny" + | { + directories?: { + [key: string]: "ask" | "allow" | "deny" + } + default?: "ask" | "allow" | "deny" + } + } } model?: { modelID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c3658a90c50..2e4f70c0b71 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7811,8 +7811,74 @@ "enum": ["ask", "allow", "deny"] }, "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "read": { + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "directories": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "default": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "additionalProperties": false + } + ] + }, + "write": { + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "directories": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "default": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ] } } } @@ -8543,8 +8609,74 @@ "enum": ["ask", "allow", "deny"] }, "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "read": { + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "directories": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "default": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "additionalProperties": false + } + ] + }, + "write": { + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "directories": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "default": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ] } } }, @@ -9375,8 +9507,74 @@ "enum": ["ask", "allow", "deny"] }, "external_directory": { - "type": "string", - "enum": ["ask", "allow", "deny"] + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "read": { + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "directories": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "default": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "additionalProperties": false + } + ] + }, + "write": { + "anyOf": [ + { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + { + "type": "object", + "properties": { + "directories": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "default": { + "type": "string", + "enum": ["ask", "allow", "deny"] + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + ] } }, "required": ["edit", "bash", "skill"] diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 754a6c875dd..7415284338b 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -226,6 +226,94 @@ This provides an additional safety layer to prevent unintended modifications to --- +#### Separate read and write + +You can configure different permissions for read and write operations. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "read": "allow", + "write": "ask" + } + } +} +``` + +--- + +#### Directory rules + +For more granular control, specify permissions per directory using glob patterns. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "read": { + "directories": { + "~/projects/docs": "allow", + "~/.ssh": "deny", + "/etc": "deny" + }, + "default": "ask" + }, + "write": { + "directories": { + "~/projects/docs": "allow" + }, + "default": "deny" + } + } + } +} +``` + +Directory paths are matched as prefixes, so `~/projects/docs` matches all files inside that directory. Use `~` for your home directory. + +--- + +#### Glob patterns + +You can use glob patterns to match multiple directories. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": { + "read": { + "directories": { + "~/projects/**/docs": "allow", + "~/projects/**/node_modules": "deny" + }, + "default": "ask" + } + } + } +} +``` + +- `*` matches any characters except `/` +- `**` matches any characters including `/` + +--- + +#### Limitations + +The `external_directory` permission applies to OpenCode's built-in file tools: `read`, `write`, `edit`, `patch`, `ls`, `glob`, and `grep`. + +The `bash` tool performs best-effort detection of external paths and may prompt or deny based on your configuration. However, it cannot guarantee confinement because arbitrary commands can access files in ways that aren't reliably detectable. + +:::caution[Symlinks] +Path checks are lexical. Symlinks inside allowed directories that point outside are not currently blocked. +::: + +--- + ## Agents You can also configure permissions per agent. Where the agent specific config