This commit is contained in:
Jeroen Gordijn 2025-12-23 09:14:52 +01:00 committed by GitHub
commit 6cf231373e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1179 additions and 40 deletions

View file

@ -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({

View file

@ -380,6 +380,28 @@ export namespace Config {
export const Permission = z.enum(["ask", "allow", "deny"])
export type Permission = z.infer<typeof Permission>
export const DirectoryRulesObject = z
.object({
directories: z.record(z.string(), Permission).optional(),
default: Permission.optional(),
})
.strict()
export type DirectoryRulesObject = z.infer<typeof DirectoryRulesObject>
export const OperationPermission = z.union([Permission, DirectoryRulesObject])
export type OperationPermission = z.infer<typeof OperationPermission>
export const ExternalDirectoryPermission = z.union([
Permission,
z
.object({
read: OperationPermission.optional(),
write: OperationPermission.optional(),
})
.strict(),
])
export type ExternalDirectoryPermission = z.infer<typeof ExternalDirectoryPermission>
export const Command = z.object({
template: z.string(),
description: z.string().optional(),
@ -417,7 +439,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(),
})
@ -768,7 +790,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(),

View file

@ -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",

View file

@ -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`,
)
}
}

View file

@ -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 = []

View file

@ -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]

View file

@ -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 = []

View file

@ -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"

View file

@ -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`,
)
}
}

View file

@ -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`,
)
}
}

View file

@ -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<string, Permission> = {}
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<Permission>(filepath, expanded)
if (match !== undefined) return match
}
return operationConfig.default ?? "ask"
}
}

View file

@ -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<T>(filepath: string, patterns: Record<string, T>): 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<string, any>) {
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
let result = undefined

View file

@ -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")
})
})
})

View file

@ -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)

View file

@ -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
}
@ -1320,7 +1370,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
@ -1585,7 +1660,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

View file

@ -1176,7 +1176,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
@ -1209,7 +1234,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
}
@ -1535,7 +1585,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
@ -1818,7 +1893,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

View file

@ -7749,8 +7749,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
}
]
}
}
}
@ -8475,8 +8541,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
}
]
}
}
},
@ -9294,8 +9426,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"]

View file

@ -193,6 +193,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