From 26a5258147825a9af9d778cf58e7a1c588ce99b5 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 10:51:23 +0100 Subject: [PATCH 01/15] feat(config): add polymorphic external_directory permission schema (#5395) - Support simple string and object-based permission configs - Enable read/write operation split with directory rules - Maintain backward compatibility with existing configs --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/config/config.ts | 26 ++- packages/sdk/js/src/gen/types.gen.ts | 108 +++++++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 108 +++++++++++- packages/sdk/openapi.json | 210 +++++++++++++++++++++++- 5 files changed, 437 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 90c8594cd77..f5f2e2e19e1 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 031bdd31bab..aafce68d8a9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -380,6 +380,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(), @@ -416,7 +438,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(), }) @@ -763,7 +785,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/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 964112d8125..efd77ac78ce 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -987,7 +987,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 @@ -1008,7 +1033,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 } @@ -1312,7 +1362,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 @@ -1577,7 +1652,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 1b43d3f48a1..eaaa44afe6b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1157,7 +1157,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 @@ -1183,7 +1208,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 } @@ -1502,7 +1552,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 @@ -1782,7 +1857,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 f33d20069c4..e21d4d09a69 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7677,8 +7677,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 + } + ] } } } @@ -8385,8 +8451,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 + } + ] } } }, @@ -9194,8 +9326,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"] From dd5464f1b328c6e1db0ba511ec36e0e67b00e6b7 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 11:44:10 +0100 Subject: [PATCH 02/15] feat(permission): add ExternalPermission resolver with path-based rules (#5395) - Create ExternalPermission.resolve() for polymorphic permission handling - Support read/write split with directory-specific rules - Implement tilde expansion for home directory patterns - Update all tools to use operation-specific permission checks - Add comprehensive test coverage (21 tests) --- packages/opencode/src/tool/bash.ts | 6 +- packages/opencode/src/tool/edit.ts | 6 +- packages/opencode/src/tool/patch.ts | 6 +- packages/opencode/src/tool/read.ts | 6 +- packages/opencode/src/tool/write.ts | 6 +- .../opencode/src/util/external-permission.ts | 82 +++++++ .../test/util/external-permission.test.ts | 231 ++++++++++++++++++ 7 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/src/util/external-permission.ts create mode 100644 packages/opencode/test/util/external-permission.test.ts 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 b49bd7abe00..e726a7b0fd9 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", diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 93888f60bd2..611a344a307 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", diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 27426ad2412..2549a0f65da 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", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 6b8fd3dd111..af8c73948d1 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", diff --git a/packages/opencode/src/util/external-permission.ts b/packages/opencode/src/util/external-permission.ts new file mode 100644 index 00000000000..1c338d2ae2b --- /dev/null +++ b/packages/opencode/src/util/external-permission.ts @@ -0,0 +1,82 @@ +import { Config } from "../config/config" +import { Wildcard } from "./wildcard" +import { Global } from "../global" + +export namespace ExternalPermission { + type Permission = Config.Permission + type ExternalDirectoryConfig = Config.ExternalDirectoryPermission + + /** + * Expands tilde (~) in a pattern to the user's home directory. + * Examples: + * ~/.ssh/** → /Users/username/.ssh/** + * ~/Documents/* → /Users/username/Documents/* + */ + function expandTilde(pattern: string): string { + if (pattern.startsWith("~/")) { + return Global.Path.home + pattern.slice(1) + } + return pattern + } + + /** + * Resolve external directory permission for a given filepath and operation. + * + * Resolution order: + * 1. If config is absent → "ask" + * 2. If config is string → return that string for both operations + * 3. If config is object → check operation-specific config + * a. If operation config is absent → "ask" + * b. If operation config is string → return that string + * c. If operation config is object → check directories, then default + * + * @param config The external_directory configuration + * @param filepath Absolute file path to check + * @param operation Operation type ('read' or 'write') + * @returns Permission level to apply + */ + export function resolve( + config: ExternalDirectoryConfig | undefined, + filepath: string, + operation: "read" | "write", + ): Permission { + // Config absent = default to "ask" + if (config === undefined) { + return "ask" + } + + // Type 1: Simple string - applies to both read and write + if (typeof config === "string") { + return config + } + + // Type 2/3: Object with read/write + const operationConfig = config[operation] + + // Operation not specified = default to "ask" + if (operationConfig === undefined) { + return "ask" + } + + // Simple string for this operation + if (typeof operationConfig === "string") { + return operationConfig + } + + // Object with directories for this operation + if (operationConfig.directories) { + // Expand tilde in all directory patterns + const expandedDirectories: Record = {} + for (const [pattern, permission] of Object.entries(operationConfig.directories)) { + expandedDirectories[expandTilde(pattern)] = permission + } + + const match = Wildcard.all(filepath, expandedDirectories) + if (match !== undefined) { + return match as Permission + } + } + + return operationConfig.default ?? "ask" + } +} 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..e610a663eea --- /dev/null +++ b/packages/opencode/test/util/external-permission.test.ts @@ -0,0 +1,231 @@ +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("handles ** glob pattern", () => { + 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("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("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") + }) + }) +}) From 80d6d2554ae6a9f3851b56a8272453b56bdff26b Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 12:57:20 +0100 Subject: [PATCH 03/15] fix(security): add external_directory permission checks to search tools (#5395) - Add permission checks to ls, glob, and grep tools - Check Filesystem.contains() before accessing external paths - Use ExternalPermission.resolve() with 'read' operation - Prompt or deny access consistently with read.ts behavior --- packages/opencode/src/tool/glob.ts | 30 ++++++++++++++++++++++++++++- packages/opencode/src/tool/grep.ts | 31 +++++++++++++++++++++++++++++- packages/opencode/src/tool/ls.ts | 30 ++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 11c12f19ac4..a32bed1fc55 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,33 @@ 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) { + async execute(params, ctx) { let search = params.path ?? Instance.directory search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + 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 }, + `Directory ${search} is not in the current working directory`, + ) + } + } const limit = 100 const files = [] diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 99af448ba68..b05e2f8dfd4 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,36 @@ 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 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 }, + `Directory ${searchPath} is not in the current working directory`, + ) + } + } 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..2df6815bea9 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 }, + `Directory ${searchPath} is not in the current working directory`, + ) + } + } const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) const files = [] From 936fc92a224f9982fa8db6db3967e06b4ad31fd2 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 13:00:27 +0100 Subject: [PATCH 04/15] fix(permission): implement proper glob semantics for path matching (#5395) - Add Wildcard.pathMatch() where * doesn't cross directory boundaries - Add Wildcard.pathAll() for file path pattern matching - ** now correctly matches across directories, * matches within - Update ExternalPermission to use pathAll() for directory rules - Add comprehensive tests for new path matching behavior --- .../opencode/src/util/external-permission.ts | 2 +- packages/opencode/src/util/wildcard.ts | 61 +++++++++++++++++++ .../test/util/external-permission.test.ts | 14 ++++- packages/opencode/test/util/wildcard.test.ts | 59 +++++++++++++++++- 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/util/external-permission.ts b/packages/opencode/src/util/external-permission.ts index 1c338d2ae2b..d674d3c4306 100644 --- a/packages/opencode/src/util/external-permission.ts +++ b/packages/opencode/src/util/external-permission.ts @@ -71,7 +71,7 @@ export namespace ExternalPermission { expandedDirectories[expandTilde(pattern)] = permission } - const match = Wildcard.all(filepath, expandedDirectories) + const match = Wildcard.pathAll(filepath, expandedDirectories) if (match !== undefined) { return match as Permission } diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index 9b595a0a9ec..752c4407f9c 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -14,6 +14,67 @@ 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 anything including slashes + regex += ".*" + i += 2 + // Skip trailing slash after ** + if (normalizedPattern[i] === "/") i++ + } 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) { + const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"])) + let result = 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 index e610a663eea..3c2cdaeb7ef 100644 --- a/packages/opencode/test/util/external-permission.test.ts +++ b/packages/opencode/test/util/external-permission.test.ts @@ -173,7 +173,19 @@ describe("ExternalPermission.resolve", () => { }) describe("Wildcard patterns", () => { - test("handles ** glob pattern", () => { + 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 }, 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) From 388e4dda4c2b7600534a92cd80acdd0223824e9a Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 13:05:03 +0100 Subject: [PATCH 05/15] fix(permission): use path.join for cross-platform tilde expansion (#5395) - Use path.join() instead of string concat for Windows compatibility - Correctly handles path separators on all platforms --- packages/opencode/src/util/external-permission.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/util/external-permission.ts b/packages/opencode/src/util/external-permission.ts index d674d3c4306..2706b28af0c 100644 --- a/packages/opencode/src/util/external-permission.ts +++ b/packages/opencode/src/util/external-permission.ts @@ -1,3 +1,4 @@ +import path from "path" import { Config } from "../config/config" import { Wildcard } from "./wildcard" import { Global } from "../global" @@ -6,15 +7,10 @@ export namespace ExternalPermission { type Permission = Config.Permission type ExternalDirectoryConfig = Config.ExternalDirectoryPermission - /** - * Expands tilde (~) in a pattern to the user's home directory. - * Examples: - * ~/.ssh/** → /Users/username/.ssh/** - * ~/Documents/* → /Users/username/Documents/* - */ + // Expand ~/ to home directory using path.join for cross-platform support function expandTilde(pattern: string): string { if (pattern.startsWith("~/")) { - return Global.Path.home + pattern.slice(1) + return path.join(Global.Path.home, pattern.slice(2)) } return pattern } From 035d2e128e121a854ae12dc42b5f00cd01bc7155 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 13:07:28 +0100 Subject: [PATCH 06/15] refactor(permission): simplify code by removing verbose comments (#5395) --- .../opencode/src/util/external-permission.ts | 54 ++++--------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/util/external-permission.ts b/packages/opencode/src/util/external-permission.ts index 2706b28af0c..0b4c030dffe 100644 --- a/packages/opencode/src/util/external-permission.ts +++ b/packages/opencode/src/util/external-permission.ts @@ -15,62 +15,26 @@ export namespace ExternalPermission { return pattern } - /** - * Resolve external directory permission for a given filepath and operation. - * - * Resolution order: - * 1. If config is absent → "ask" - * 2. If config is string → return that string for both operations - * 3. If config is object → check operation-specific config - * a. If operation config is absent → "ask" - * b. If operation config is string → return that string - * c. If operation config is object → check directories, then default - * - * @param config The external_directory configuration - * @param filepath Absolute file path to check - * @param operation Operation type ('read' or 'write') - * @returns Permission level to apply - */ + /** 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 { - // Config absent = default to "ask" - if (config === undefined) { - return "ask" - } - - // Type 1: Simple string - applies to both read and write - if (typeof config === "string") { - return config - } + if (config === undefined) return "ask" + if (typeof config === "string") return config - // Type 2/3: Object with read/write const operationConfig = config[operation] + if (operationConfig === undefined) return "ask" + if (typeof operationConfig === "string") return operationConfig - // Operation not specified = default to "ask" - if (operationConfig === undefined) { - return "ask" - } - - // Simple string for this operation - if (typeof operationConfig === "string") { - return operationConfig - } - - // Object with directories for this operation if (operationConfig.directories) { - // Expand tilde in all directory patterns - const expandedDirectories: Record = {} + const expanded: Record = {} for (const [pattern, permission] of Object.entries(operationConfig.directories)) { - expandedDirectories[expandTilde(pattern)] = permission - } - - const match = Wildcard.pathAll(filepath, expandedDirectories) - if (match !== undefined) { - return match as Permission + expanded[expandTilde(pattern)] = permission } + const match = Wildcard.pathAll(filepath, expanded) + if (match !== undefined) return match as Permission } return operationConfig.default ?? "ask" From 6cf5c6c4ab4496479cfe27c0bcefa8c2ce1a73d6 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 13:23:19 +0100 Subject: [PATCH 07/15] fix(permission): treat plain directory paths as prefixes matching all contents (#5395) --- .../opencode/src/util/external-permission.ts | 16 +++++- .../test/util/external-permission.test.ts | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/external-permission.ts b/packages/opencode/src/util/external-permission.ts index 0b4c030dffe..a0a75d81feb 100644 --- a/packages/opencode/src/util/external-permission.ts +++ b/packages/opencode/src/util/external-permission.ts @@ -7,7 +7,6 @@ export namespace ExternalPermission { type Permission = Config.Permission type ExternalDirectoryConfig = Config.ExternalDirectoryPermission - // Expand ~/ to home directory using path.join for cross-platform support function expandTilde(pattern: string): string { if (pattern.startsWith("~/")) { return path.join(Global.Path.home, pattern.slice(2)) @@ -15,6 +14,10 @@ export namespace ExternalPermission { return pattern } + function hasWildcard(pattern: string): boolean { + return pattern.includes("*") || pattern.includes("?") + } + /** Resolve permission for a filepath. Checks directory rules, then falls back to default. */ export function resolve( config: ExternalDirectoryConfig | undefined, @@ -29,9 +32,18 @@ export namespace ExternalPermission { if (typeof operationConfig === "string") return operationConfig if (operationConfig.directories) { + // Expand patterns: plain paths get both exact and /** variants for directory matching const expanded: Record = {} for (const [pattern, permission] of Object.entries(operationConfig.directories)) { - expanded[expandTilde(pattern)] = permission + const expandedPattern = expandTilde(pattern) + if (hasWildcard(expandedPattern)) { + expanded[expandedPattern] = permission + } else { + // Plain path: match exact path AND treat as directory prefix + expanded[expandedPattern] = permission + const suffix = expandedPattern.endsWith("/") ? "**" : "/**" + expanded[expandedPattern + suffix] = permission + } } const match = Wildcard.pathAll(filepath, expanded) if (match !== undefined) return match as Permission diff --git a/packages/opencode/test/util/external-permission.test.ts b/packages/opencode/test/util/external-permission.test.ts index 3c2cdaeb7ef..38e9e429016 100644 --- a/packages/opencode/test/util/external-permission.test.ts +++ b/packages/opencode/test/util/external-permission.test.ts @@ -210,6 +210,56 @@ describe("ExternalPermission.resolve", () => { }) }) + 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 with existing wildcards 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") + }) + }) + describe("Edge cases", () => { test("handles empty directories object", () => { const config = { From f45fcf37d1e471df201f0088a6e747506fcf01d2 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 14:48:29 +0100 Subject: [PATCH 08/15] fix(permission): append /** to glob patterns not ending with wildcard (#5395) Patterns like /path/**/subdir now correctly match files inside subdir by automatically appending /** unless pattern already ends with * --- .../opencode/src/util/external-permission.ts | 19 ++++++----------- .../test/util/external-permission.test.ts | 21 ++++++++++++++++++- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/util/external-permission.ts b/packages/opencode/src/util/external-permission.ts index a0a75d81feb..b6de5f6c6c8 100644 --- a/packages/opencode/src/util/external-permission.ts +++ b/packages/opencode/src/util/external-permission.ts @@ -14,10 +14,6 @@ export namespace ExternalPermission { return pattern } - function hasWildcard(pattern: string): boolean { - return pattern.includes("*") || pattern.includes("?") - } - /** Resolve permission for a filepath. Checks directory rules, then falls back to default. */ export function resolve( config: ExternalDirectoryConfig | undefined, @@ -32,17 +28,14 @@ export namespace ExternalPermission { if (typeof operationConfig === "string") return operationConfig if (operationConfig.directories) { - // Expand patterns: plain paths get both exact and /** variants for directory matching + // Expand patterns: add /** suffix unless pattern already ends with wildcard const expanded: Record = {} for (const [pattern, permission] of Object.entries(operationConfig.directories)) { - const expandedPattern = expandTilde(pattern) - if (hasWildcard(expandedPattern)) { - expanded[expandedPattern] = permission - } else { - // Plain path: match exact path AND treat as directory prefix - expanded[expandedPattern] = permission - const suffix = expandedPattern.endsWith("/") ? "**" : "/**" - expanded[expandedPattern + suffix] = permission + 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) diff --git a/packages/opencode/test/util/external-permission.test.ts b/packages/opencode/test/util/external-permission.test.ts index 38e9e429016..cdc7ba61b94 100644 --- a/packages/opencode/test/util/external-permission.test.ts +++ b/packages/opencode/test/util/external-permission.test.ts @@ -244,7 +244,7 @@ describe("ExternalPermission.resolve", () => { expect(ExternalPermission.resolve(config, `${homedir}/projects/other/File.java`, "read")).toBe("deny") }) - test("patterns with existing wildcards are not modified", () => { + test("patterns ending with * are not modified", () => { const config = { read: { directories: { @@ -258,6 +258,25 @@ describe("ExternalPermission.resolve", () => { 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", () => { From 9dfd36beec32632df93c3b9915b3f62c4806fba7 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 14:48:44 +0100 Subject: [PATCH 09/15] fix(permission): improve denied access error message (#5395) Changed from 'is not in the current working directory' to 'is denied by external_directory permission' for clarity --- packages/opencode/src/tool/edit.ts | 2 +- packages/opencode/src/tool/glob.ts | 2 +- packages/opencode/src/tool/grep.ts | 2 +- packages/opencode/src/tool/ls.ts | 2 +- packages/opencode/src/tool/patch.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index e726a7b0fd9..47471219544 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -70,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 a32bed1fc55..8f32db0c60c 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -43,7 +43,7 @@ export const GlobTool = Tool.define("glob", { "external_directory", ctx.callID, { searchPath: search }, - `Directory ${search} is not in the current working directory`, + `Access to ${search} is denied by external_directory permission`, ) } } diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index b05e2f8dfd4..7e3070890f2 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -45,7 +45,7 @@ export const GrepTool = Tool.define("grep", { "external_directory", ctx.callID, { searchPath }, - `Directory ${searchPath} is not in the current working directory`, + `Access to ${searchPath} is denied by external_directory permission`, ) } } diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index 2df6815bea9..29583b56c65 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -66,7 +66,7 @@ export const ListTool = Tool.define("list", { "external_directory", ctx.callID, { searchPath }, - `Directory ${searchPath} is not in the current working directory`, + `Access to ${searchPath} is denied by external_directory permission`, ) } } diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 611a344a307..71cbc1d0d14 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -79,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`, ) } } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 2549a0f65da..13da76dd753 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -56,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 af8c73948d1..09ae10bf7b6 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -50,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`, ) } } From 8fff42ebc7e8492ddb8a550086899163ea4ce1dc Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 14:54:10 +0100 Subject: [PATCH 10/15] docs: add external_directory permission configuration options (#5395) Document read/write split, directory rules, glob patterns, and limitations --- packages/web/src/content/docs/permissions.mdx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 1aea3ef740d..7be28ec6601 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -193,6 +193,88 @@ 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 only applies to OpenCode's built-in file tools (`read`, `write`, `edit`, `ls`, `glob`, `grep`). It does not restrict access through the `bash` tool, which can run arbitrary commands. + +--- + ## Agents You can also configure permissions per agent. Where the agent specific config From b1f7f2419b1d0109e4117f479658724c329a748d Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 15:10:59 +0100 Subject: [PATCH 11/15] fix(security): add permission check for patch move_path destination (#5395) --- packages/opencode/src/tool/patch.ts | 37 +++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 71cbc1d0d14..cd66ad5a26a 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -124,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" From 611d02bd6db989a129baa6752c9598bb013aaac2 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 15:11:36 +0100 Subject: [PATCH 12/15] fix(permission): resolve grep searchPath to absolute before check (#5395) --- packages/opencode/src/tool/grep.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 7e3070890f2..9fb6b1d24e6 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -24,7 +24,8 @@ export const GrepTool = Tool.define("grep", { throw new Error("pattern is required") } - const searchPath = params.path || Instance.directory + let searchPath = params.path ?? Instance.directory + searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) const agent = await Agent.get(ctx.agent) if (!Filesystem.contains(Instance.directory, searchPath)) { From 92d720f9c9594e63ba7474f9af9db10493c6da9a Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 15:12:02 +0100 Subject: [PATCH 13/15] fix(wildcard): require path boundary for **/ glob patterns (#5395) **/ now uses (?:.*/)? regex to ensure path segment boundaries. /a/**/docs no longer incorrectly matches /a/xdocs --- packages/opencode/src/util/wildcard.ts | 13 +++++++++---- .../opencode/test/util/external-permission.test.ts | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/util/wildcard.ts b/packages/opencode/src/util/wildcard.ts index 752c4407f9c..d517006cb5a 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -33,11 +33,16 @@ export namespace Wildcard { const next = normalizedPattern[i + 1] if (char === "*" && next === "*") { - // ** matches anything including slashes - regex += ".*" + // ** matches zero or more path segments i += 2 - // Skip trailing slash after ** - if (normalizedPattern[i] === "/") i++ + 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 += "[^/]*" diff --git a/packages/opencode/test/util/external-permission.test.ts b/packages/opencode/test/util/external-permission.test.ts index cdc7ba61b94..19e75ff0239 100644 --- a/packages/opencode/test/util/external-permission.test.ts +++ b/packages/opencode/test/util/external-permission.test.ts @@ -197,6 +197,20 @@ describe("ExternalPermission.resolve", () => { 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: { From af407e251553a9f6e2ee048a639be1d6e787a980 Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 15:26:07 +0100 Subject: [PATCH 14/15] docs: fix external_directory limitations section (#5395) - Correct bash behavior (best-effort detection, not unrestricted) - Add patch to the list of covered tools - Add symlink limitation warning --- packages/web/src/content/docs/permissions.mdx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 7be28ec6601..2c82fad2b35 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -271,7 +271,13 @@ You can use glob patterns to match multiple directories. #### Limitations -The `external_directory` permission only applies to OpenCode's built-in file tools (`read`, `write`, `edit`, `ls`, `glob`, `grep`). It does not restrict access through the `bash` tool, which can run arbitrary commands. +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. +::: --- From e45b789589117c908c7bddbd57c413a14b8d057c Mon Sep 17 00:00:00 2001 From: Jeroen Gordijn Date: Sun, 21 Dec 2025 15:31:53 +0100 Subject: [PATCH 15/15] refactor: apply style guide fixes (#5395) - Make Wildcard.pathAll generic to avoid 'any' type - Replace 'let' with 'const' in glob.ts and grep.ts --- packages/opencode/src/tool/glob.ts | 7 +++++-- packages/opencode/src/tool/grep.ts | 7 +++++-- packages/opencode/src/util/external-permission.ts | 4 ++-- packages/opencode/src/util/wildcard.ts | 4 ++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 8f32db0c60c..4a45388edff 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -21,8 +21,11 @@ export const GlobTool = Tool.define("glob", { ), }), async execute(params, ctx) { - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + 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)) { diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 9fb6b1d24e6..7372df9fa69 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -24,8 +24,11 @@ export const GrepTool = Tool.define("grep", { throw new Error("pattern is required") } - let searchPath = params.path ?? Instance.directory - searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) + 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)) { diff --git a/packages/opencode/src/util/external-permission.ts b/packages/opencode/src/util/external-permission.ts index b6de5f6c6c8..15542934c3d 100644 --- a/packages/opencode/src/util/external-permission.ts +++ b/packages/opencode/src/util/external-permission.ts @@ -38,8 +38,8 @@ export namespace ExternalPermission { expanded[p + (p.endsWith("/") ? "**" : "/**")] = permission } } - const match = Wildcard.pathAll(filepath, expanded) - if (match !== undefined) return match as 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 d517006cb5a..200c090684a 100644 --- a/packages/opencode/src/util/wildcard.ts +++ b/packages/opencode/src/util/wildcard.ts @@ -69,9 +69,9 @@ export namespace Wildcard { * 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) { + 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 = undefined + let result: T | undefined = undefined for (const [pattern, value] of sorted) { if (pathMatch(filepath, pattern)) { result = value