Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ export namespace Config {
bash: PermissionRule.optional(),
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
tmpdir: PermissionAction.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,10 @@ export namespace SessionPrompt {
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
})
},
allowed(permission: string, pattern = "*") {
const ruleset = PermissionNext.merge(taskAgent.permission, session.permission ?? [])
return PermissionNext.evaluate(permission, pattern, ruleset).action === "allow"
},
}
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
executionError = error
Expand Down Expand Up @@ -673,6 +677,10 @@ export namespace SessionPrompt {
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
})
},
allowed(permission: string, pattern = "*") {
const ruleset = PermissionNext.merge(input.agent.permission, input.session.permission ?? [])
return PermissionNext.evaluate(permission, pattern, ruleset).action === "allow"
},
})

for (const item of await ToolRegistry.tools(input.model.providerID)) {
Expand Down Expand Up @@ -911,6 +919,7 @@ export namespace SessionPrompt {
extra: { bypassCwdCheck: true, model },
metadata: async () => {},
ask: async () => {},
allowed: () => true,
}
const result = await t.execute(args, readCtx)
pieces.push({
Expand Down Expand Up @@ -972,6 +981,7 @@ export namespace SessionPrompt {
extra: { bypassCwdCheck: true },
metadata: async () => {},
ask: async () => {},
allowed: () => true,
}
const result = await ListTool.init().then((t) => t.execute(args, listCtx))
return [
Expand Down
18 changes: 12 additions & 6 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,18 @@ export const BashTool = Tool.define("bash", async () => {
}

if (directories.size > 0) {
await ctx.ask({
permission: "external_directory",
patterns: Array.from(directories),
always: Array.from(directories).map((x) => path.dirname(x) + "*"),
metadata: {},
})
// Filter out tmpdir paths if tmpdir permission is allowed
const filtered = ctx.allowed("tmpdir")
? Array.from(directories).filter((dir) => !Filesystem.isInTmpdir(dir))
: Array.from(directories)
if (filtered.length > 0) {
await ctx.ask({
permission: "external_directory",
patterns: filtered,
always: filtered.map((x) => path.dirname(x) + "*"),
metadata: {},
})
}
}

if (patterns.size > 0) {
Expand Down
24 changes: 14 additions & 10 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,20 @@ 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)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath: filePath,
parentDir,
},
})
// Skip external_directory check for tmpdir paths when tmpdir permission is allowed
const skipCheck = ctx.allowed("tmpdir") && Filesystem.isInTmpdir(filePath)
if (!skipCheck) {
const parentDir = path.dirname(filePath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath: filePath,
parentDir,
},
})
}
}

let diff = ""
Expand Down
24 changes: 14 additions & 10 deletions packages/opencode/src/tool/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,20 @@ export const PatchTool = Tool.define("patch", {
const filePath = path.resolve(Instance.directory, hunk.path)

if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath: filePath,
parentDir,
},
})
// Skip external_directory check for tmpdir paths when tmpdir permission is allowed
const skipCheck = ctx.allowed("tmpdir") && Filesystem.isInTmpdir(filePath)
if (!skipCheck) {
const parentDir = path.dirname(filePath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath: filePath,
parentDir,
},
})
}
}

switch (hunk.type) {
Expand Down
24 changes: 14 additions & 10 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,20 @@ export const ReadTool = Tool.define("read", {
const title = path.relative(Instance.worktree, filepath)

if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir],
always: [parentDir + "/*"],
metadata: {
filepath,
parentDir,
},
})
// Skip external_directory check for tmpdir paths when tmpdir permission is allowed
const skipCheck = ctx.allowed("tmpdir") && Filesystem.isInTmpdir(filepath)
if (!skipCheck) {
const parentDir = path.dirname(filepath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir],
always: [parentDir + "/*"],
metadata: {
filepath,
parentDir,
},
})
}
}

await ctx.ask({
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export namespace Tool {
extra?: { [key: string]: any }
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
/** Check if a permission is allowed for the given pattern */
allowed(permission: string, pattern?: string): boolean
}
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
Expand Down
18 changes: 14 additions & 4 deletions packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,22 @@ export const WriteTool = Tool.define("write", {
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
/* TODO
if (!Filesystem.contains(Instance.directory, filepath)) {
const parentDir = path.dirname(filepath)
...
// Skip external_directory check for tmpdir paths when tmpdir permission is allowed
const skipCheck = ctx.allowed("tmpdir") && Filesystem.isInTmpdir(filepath)
if (!skipCheck) {
const parentDir = path.dirname(filepath)
await ctx.ask({
permission: "external_directory",
patterns: [parentDir, path.join(parentDir, "*")],
always: [parentDir + "/*"],
metadata: {
filepath,
parentDir,
},
})
}
}
*/

const file = Bun.file(filepath)
const exists = await file.exists()
Expand Down
60 changes: 60 additions & 0 deletions packages/opencode/src/util/filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { realpathSync } from "fs"
import { exists } from "fs/promises"
import { dirname, join, relative } from "path"
import os from "os"

export namespace Filesystem {
/**
Expand Down Expand Up @@ -80,4 +81,63 @@ export namespace Filesystem {
}
return result
}

/**
* Get the system's temporary directory with symlink resolution.
* Handles platform differences:
* - Linux: Usually /tmp
* - macOS: /var/folders/.../T/ (symlinked from /tmp -> /private/tmp)
* - Windows: C:\Users\<user>\AppData\Local\Temp
*/
export function tmpdir(): string {
const tmp = os.tmpdir()
try {
return realpathSync(tmp)
} catch {
return tmp
}
}

/**
* Check if child path is within parent, with symlink resolution.
* Prevents symlink traversal attacks.
*/
export function containsResolved(parent: string, child: string): boolean {
try {
const resolvedParent = realpathSync(parent)
const resolvedChild = realpathSync(child)
return !relative(resolvedParent, resolvedChild).startsWith("..")
} catch {
// Path doesn't exist yet (e.g., file being created)
// Walk up the child path to find the first existing ancestor and resolve from there
try {
const resolvedParent = realpathSync(parent)
let current = child
let suffix = ""
while (current !== dirname(current)) {
try {
const resolvedCurrent = realpathSync(current)
const resolvedChild = suffix ? join(resolvedCurrent, suffix) : resolvedCurrent
return !relative(resolvedParent, resolvedChild).startsWith("..")
} catch {
// This level doesn't exist, move up
const base = current.split(/[/\\]/).pop()!
suffix = suffix ? join(base, suffix) : base
current = dirname(current)
}
}
// No ancestor exists, fall back to unresolved comparison
return !relative(parent, child).startsWith("..")
} catch {
return !relative(parent, child).startsWith("..")
}
}
}

/**
* Check if path is within the system tmpdir
*/
export function isInTmpdir(filepath: string): boolean {
return containsResolved(tmpdir(), filepath)
}
}
87 changes: 87 additions & 0 deletions packages/opencode/test/tool/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ctx = {
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
allowed: () => false,
}

const projectRoot = path.join(__dirname, "../..")
Expand Down Expand Up @@ -229,4 +230,90 @@ describe("tool.bash permissions", () => {
},
})
})

test("allows tmpdir access when allow_tmpdir is true", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// Create context that allows tmpdir
const ctxWithTmpdir = {
...ctx,
allowed: (permission: string) => permission === "tmpdir",
}
// Should allow workdir in system tmpdir when allow_tmpdir is true
const result = await bash.execute(
{
command: "pwd",
workdir: require("os").tmpdir(),
description: "Print working directory in tmpdir",
},
ctxWithTmpdir,
)
expect(result.metadata.exit).toBe(0)
},
})
})

test("denies tmpdir access when allow_tmpdir is false", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// Create context that denies tmpdir but asks for external_directory
const ctxDenyTmpdir = {
...ctx,
allowed: () => false,
ask: async (req: { permission: string }) => {
if (req.permission === "external_directory") {
throw new Error("external_directory denied")
}
},
}
// Should deny workdir in system tmpdir when allow_tmpdir is false
await expect(
bash.execute(
{
command: "pwd",
workdir: require("os").tmpdir(),
description: "Print working directory in tmpdir",
},
ctxDenyTmpdir,
),
).rejects.toThrow()
},
})
})

test("allow_tmpdir does not affect non-tmpdir external paths", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// Create context that allows tmpdir but denies other external dirs
const ctxWithTmpdir = {
...ctx,
allowed: (permission: string) => permission === "tmpdir",
ask: async (req: { permission: string }) => {
if (req.permission === "external_directory") {
throw new Error("external_directory denied")
}
},
}
// Should still deny access to non-tmpdir external paths
await expect(
bash.execute(
{
command: "cd /usr",
description: "Change to /usr directory",
},
ctxWithTmpdir,
),
).rejects.toThrow()
},
})
})
})
1 change: 1 addition & 0 deletions packages/opencode/test/tool/grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const ctx = {
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
allowed: () => false,
}

const projectRoot = path.join(__dirname, "../..")
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/tool/patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const ctx = {
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
allowed: () => false,
}

const patchTool = await PatchTool.init()
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/tool/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ctx = {
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
allowed: () => false,
}

describe("tool.read external_directory permission", () => {
Expand Down
Loading