Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
26a5258
feat(config): add polymorphic external_directory permission schema (#…
jgordijn Dec 21, 2025
dd5464f
feat(permission): add ExternalPermission resolver with path-based rul…
jgordijn Dec 21, 2025
80d6d25
fix(security): add external_directory permission checks to search too…
jgordijn Dec 21, 2025
936fc92
fix(permission): implement proper glob semantics for path matching (#…
jgordijn Dec 21, 2025
388e4dd
fix(permission): use path.join for cross-platform tilde expansion (#5…
jgordijn Dec 21, 2025
035d2e1
refactor(permission): simplify code by removing verbose comments (#5395)
jgordijn Dec 21, 2025
6cf5c6c
fix(permission): treat plain directory paths as prefixes matching all…
jgordijn Dec 21, 2025
f45fcf3
fix(permission): append /** to glob patterns not ending with wildcard…
jgordijn Dec 21, 2025
9dfd36b
fix(permission): improve denied access error message (#5395)
jgordijn Dec 21, 2025
8fff42e
docs: add external_directory permission configuration options (#5395)
jgordijn Dec 21, 2025
b1f7f24
fix(security): add permission check for patch move_path destination (…
jgordijn Dec 21, 2025
611d02b
fix(permission): resolve grep searchPath to absolute before check (#5…
jgordijn Dec 21, 2025
92d720f
fix(wildcard): require path boundary for **/ glob patterns (#5395)
jgordijn Dec 21, 2025
af407e2
docs: fix external_directory limitations section (#5395)
jgordijn Dec 21, 2025
e45b789
refactor: apply style guide fixes (#5395)
jgordijn Dec 21, 2025
fd3a1f6
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 21, 2025
79fb960
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 23, 2025
9432103
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 23, 2025
76611ba
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 23, 2025
0ad9251
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 24, 2025
c8d8930
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 24, 2025
724336a
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 25, 2025
66437fd
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 26, 2025
62b6f4d
Merge branch 'dev' into feat/external-permission-utility
jgordijn Dec 27, 2025
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 @@ -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({
Expand Down
26 changes: 24 additions & 2 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,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(),
Expand Down Expand Up @@ -425,7 +447,7 @@ export namespace Config {
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
external_directory: ExternalDirectoryPermission.optional(),
})
.optional(),
})
Expand Down Expand Up @@ -790,7 +812,7 @@ export namespace Config {
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
external_directory: ExternalDirectoryPermission.optional(),
})
.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
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 { 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
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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, dir, "write")
if (externalPerm === "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 (externalPerm === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand Down
8 changes: 5 additions & 3 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 { ExternalPermission } from "@/util/external-permission"

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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, filePath, "write")
if (externalPerm === "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 (externalPerm === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand All @@ -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`,
)
}
}
Expand Down
37 changes: 34 additions & 3 deletions packages/opencode/src/tool/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = []
Expand Down
37 changes: 35 additions & 2 deletions packages/opencode/src/tool/grep.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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]
Expand Down
30 changes: 29 additions & 1 deletion packages/opencode/src/tool/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down Expand Up @@ -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 = []
Expand Down
45 changes: 40 additions & 5 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 { ExternalPermission } from "@/util/external-permission"

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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, filePath, "write")
if (externalPerm === "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 (externalPerm === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand All @@ -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`,
)
}
}
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 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 { ExternalPermission } from "@/util/external-permission"

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 externalPerm = ExternalPermission.resolve(agent.permission.external_directory, filepath, "read")
if (externalPerm === "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 (externalPerm === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
Expand All @@ -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`,
)
}
}
Expand Down
Loading