mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Merge 79fb960f58 into 83397ebde2
This commit is contained in:
commit
6cf231373e
18 changed files with 1179 additions and 40 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
packages/opencode/src/util/external-permission.ts
Normal file
47
packages/opencode/src/util/external-permission.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
326
packages/opencode/test/util/external-permission.test.ts
Normal file
326
packages/opencode/test/util/external-permission.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue