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
3 changes: 3 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export namespace Agent {
webfetch: Config.Permission.optional(),
doom_loop: Config.Permission.optional(),
external_directory: Config.Permission.optional(),
allow_tmpdir: z.boolean().optional(),
}),
model: z
.object({
Expand Down Expand Up @@ -65,6 +66,7 @@ export namespace Agent {
webfetch: "allow",
doom_loop: "ask",
external_directory: "ask",
allow_tmpdir: false,
}
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})

Expand Down Expand Up @@ -392,6 +394,7 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
skill: mergedSkill ?? { "*": "allow" },
doom_loop: merged.doom_loop,
external_directory: merged.external_directory,
allow_tmpdir: merged.allow_tmpdir,
}

return result
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ export namespace Config {
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
allow_tmpdir: z.boolean().optional(),
})
.optional(),
})
Expand Down Expand Up @@ -770,6 +771,7 @@ export namespace Config {
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
allow_tmpdir: z.boolean().optional(),
})
.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export const BashTool = Tool.define("bash", async () => {

const checkExternalDirectory = async (dir: string) => {
if (Filesystem.contains(Instance.directory, dir)) return
const inTmpdir = agent.permission.allow_tmpdir && Filesystem.isInTmpdir(dir)
if (inTmpdir) return
const title = `This command references paths outside of ${Instance.directory}`
if (agent.permission.external_directory === "ask") {
await Permission.ask({
Expand Down
51 changes: 27 additions & 24 deletions packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,30 +46,33 @@ 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") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Edit file outside working directory: ${filePath}`,
metadata: {
filepath: filePath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filePath,
parentDir,
},
`File ${filePath} is not in the current working directory`,
)
const inTmpdir = agent.permission.allow_tmpdir && Filesystem.isInTmpdir(filePath)
if (!inTmpdir) {
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Edit file outside working directory: ${filePath}`,
metadata: {
filepath: filePath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filePath,
parentDir,
},
`File ${filePath} is not in the current working directory`,
)
}
}
}

Expand Down
51 changes: 27 additions & 24 deletions packages/opencode/src/tool/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,30 +55,33 @@ export const PatchTool = Tool.define("patch", {

if (!Filesystem.contains(Instance.directory, filePath)) {
const parentDir = path.dirname(filePath)
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Patch file outside working directory: ${filePath}`,
metadata: {
filepath: filePath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filePath,
parentDir,
},
`File ${filePath} is not in the current working directory`,
)
const inTmpdir = agent.permission.allow_tmpdir && Filesystem.isInTmpdir(filePath)
if (!inTmpdir) {
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Patch file outside working directory: ${filePath}`,
metadata: {
filepath: filePath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filePath,
parentDir,
},
`File ${filePath} is not in the current working directory`,
)
}
}
}

Expand Down
51 changes: 27 additions & 24 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,33 @@ 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") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Access file outside working directory: ${filepath}`,
metadata: {
filepath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filepath,
parentDir,
},
`File ${filepath} is not in the current working directory`,
)
const inTmpdir = agent.permission.allow_tmpdir && Filesystem.isInTmpdir(filepath)
if (!inTmpdir) {
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Access file outside working directory: ${filepath}`,
metadata: {
filepath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filepath,
parentDir,
},
`File ${filepath} is not in the current working directory`,
)
}
}
}

Expand Down
51 changes: 27 additions & 24 deletions packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,33 @@ 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") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Write file outside working directory: ${filepath}`,
metadata: {
filepath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filepath,
parentDir,
},
`File ${filepath} is not in the current working directory`,
)
const inTmpdir = agent.permission.allow_tmpdir && Filesystem.isInTmpdir(filepath)
if (!inTmpdir) {
if (agent.permission.external_directory === "ask") {
await Permission.ask({
type: "external_directory",
pattern: [parentDir, path.join(parentDir, "*")],
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: `Write file outside working directory: ${filepath}`,
metadata: {
filepath,
parentDir,
},
})
} else if (agent.permission.external_directory === "deny") {
throw new Permission.RejectedError(
ctx.sessionID,
"external_directory",
ctx.callID,
{
filepath: filepath,
parentDir,
},
`File ${filepath} is not in the current working directory`,
)
}
}
}

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)
}
}
Loading