Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
111 changes: 109 additions & 2 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,113 @@ export namespace Config {
export const Permission = z.enum(["ask", "allow", "deny"])
export type Permission = z.infer<typeof Permission>

export const PermissionPatternMap = z.record(z.string(), Permission).describe(
"Map of glob patterns to permissions. Patterns are evaluated in insertion order; first match wins. Use '*' as a catch-all default.",
)
export type PermissionPatternMap = z.infer<typeof PermissionPatternMap>

export const ExternalDirectoryPermission = z
.union([
Permission,
z
.object({
read: z
.union([Permission, PermissionPatternMap])
.optional()
.describe("Permission for reading files outside working directory"),
write: z
.union([Permission, PermissionPatternMap])
.optional()
.describe("Permission for writing files outside working directory"),
})
.strict(),
])
.meta({
ref: "ExternalDirectoryPermission",
})
export type ExternalDirectoryPermission = z.infer<typeof ExternalDirectoryPermission>

export function getExternalDirectoryRead(
permission: ExternalDirectoryPermission | undefined,
): Permission | undefined {
if (permission === undefined) return undefined
if (typeof permission === "string") return permission
if (typeof permission.read === "object") return undefined // Pattern map requires filepath
return permission.read
}

export function getExternalDirectoryWrite(
permission: ExternalDirectoryPermission | undefined,
): Permission | undefined {
if (permission === undefined) return undefined
if (typeof permission === "string") return permission
if (typeof permission.write === "object") return undefined // Pattern map requires filepath
return permission.write
}

/**
* Resolves the read permission for a specific file path.
* When permission is a pattern map, patterns are evaluated in insertion order (first match wins).
* The "*" pattern is treated as a catch-all default and only evaluated if no other pattern matches.
*/
export function getExternalDirectoryReadForPath(
permission: ExternalDirectoryPermission | undefined,
filepath: string,
): Permission | undefined {
if (permission === undefined) return undefined
if (typeof permission === "string") return permission

const readPerm = permission.read
if (readPerm === undefined) return undefined
if (typeof readPerm === "string") return readPerm

return resolvePermissionFromPatternMap(readPerm, filepath)
}

/**
* Resolves the write permission for a specific file path.
* When permission is a pattern map, patterns are evaluated in insertion order (first match wins).
* The "*" pattern is treated as a catch-all default and only evaluated if no other pattern matches.
*/
export function getExternalDirectoryWriteForPath(
permission: ExternalDirectoryPermission | undefined,
filepath: string,
): Permission | undefined {
if (permission === undefined) return undefined
if (typeof permission === "string") return permission

const writePerm = permission.write
if (writePerm === undefined) return undefined
if (typeof writePerm === "string") return writePerm

return resolvePermissionFromPatternMap(writePerm, filepath)
}

/**
* Resolves permission from a pattern map for a given filepath.
* Patterns are evaluated in insertion order; "*" is skipped and used as fallback.
*/
function resolvePermissionFromPatternMap(
patternMap: PermissionPatternMap,
filepath: string,
): Permission | undefined {
for (const [pattern, perm] of Object.entries(patternMap)) {
// Skip "*" (catch-all default) - evaluate it last
if (pattern === "*") continue

// Expand ~ to home directory
const normalizedPattern = pattern.startsWith("~/") ? pattern.replace("~", os.homedir()) : pattern

const glob = new Bun.Glob(normalizedPattern)
if (glob.match(filepath)) {
return perm
}
}

// Return catch-all default if present
return patternMap["*"]
}

export const Command = z.object({
template: z.string(),
description: z.string().optional(),
Expand Down Expand Up @@ -427,7 +534,7 @@ export namespace Config {
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
external_directory: ExternalDirectoryPermission.optional(),
})
.optional(),
})
Expand Down Expand Up @@ -774,7 +881,7 @@ export namespace Config {
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
external_directory: ExternalDirectoryPermission.optional(),
})
.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export namespace Plugin {
const hooks = await state().then((x) => x.hooks)
const config = await Config.get()
for (const hook of hooks) {
await hook.config?.(config)
// Since `@hey-api/openapi-ts` doesn't generate the union type for ExternalDirectoryPermission correctly,
// a type-assertion workaround is used here (cf. Config.ExternalDirectoryPermission)
await hook.config?.(config as Parameters<NonNullable<Hooks["config"]>>[0])
}
Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
import path from "path"
import { Shell } from "@/shell/shell"
import { Config } from "@/config/config"

const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
Expand Down Expand Up @@ -86,7 +87,8 @@ export const BashTool = Tool.define("bash", async () => {
const checkExternalDirectory = async (dir: string) => {
if (Filesystem.contains(Instance.directory, dir)) return
const title = `This command references paths outside of ${Instance.directory}`
if (agent.permission.external_directory === "ask") {
const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, dir)
if (writePermission === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [dir, path.join(dir, "*")],
Expand All @@ -98,7 +100,7 @@ export const BashTool = Tool.define("bash", async () => {
command: params.command,
},
})
} else if (agent.permission.external_directory === "deny") {
} else if (writePermission === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { Snapshot } from "@/snapshot"
import { Config } from "@/config/config"

const MAX_DIAGNOSTICS_PER_FILE = 20

Expand Down Expand Up @@ -46,7 +47,8 @@ export const EditTool = Tool.define("edit", {
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
if (agent.permission.external_directory === "ask") {
const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filePath)
if (writePermission === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
Expand All @@ -59,7 +61,7 @@ export const EditTool = Tool.define("edit", {
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
} else if (writePermission === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/tool/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Agent } from "../agent/agent"
import { Patch } from "../patch"
import { Filesystem } from "../util/filesystem"
import { createTwoFilesPatch } from "diff"
import { Config } from "../config/config"

const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
Expand Down Expand Up @@ -55,7 +56,8 @@ export const PatchTool = Tool.define("patch", {

if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
if (agent.permission.external_directory === "ask") {
const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filePath)
if (writePermission === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
Expand All @@ -68,7 +70,7 @@ export const PatchTool = Tool.define("patch", {
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
} else if (writePermission === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Identifier } from "../id/id"
import { Permission } from "../permission"
import { Agent } from "@/agent/agent"
import { iife } from "@/util/iife"
import { Config } from "@/config/config"

const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
Expand All @@ -32,7 +33,8 @@ export const ReadTool = Tool.define("read", {

if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
if (agent.permission.external_directory === "ask") {
const readPermission = Config.getExternalDirectoryReadForPath(agent.permission.external_directory, filepath)
if (readPermission === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
Expand All @@ -45,7 +47,7 @@ export const ReadTool = Tool.define("read", {
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
} else if (readPermission === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { Config } from "../config/config"

const MAX_DIAGNOSTICS_PER_FILE = 20
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
Expand All @@ -26,7 +27,8 @@ export const WriteTool = Tool.define("write", {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
if (!Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
if (agent.permission.external_directory === "ask") {
const writePermission = Config.getExternalDirectoryWriteForPath(agent.permission.external_directory, filepath)
if (writePermission === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
Expand All @@ -39,7 +41,7 @@ export const WriteTool = Tool.define("write", {
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
} else if (writePermission === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand Down
Loading