diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ad665e5d6ee..d2333b8909f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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({ @@ -65,6 +66,7 @@ export namespace Agent { webfetch: "allow", doom_loop: "ask", external_directory: "ask", + allow_tmpdir: false, } const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {}) @@ -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 diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ba9d1973025..d34498edfc3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -418,6 +418,7 @@ export namespace Config { webfetch: Permission.optional(), doom_loop: Permission.optional(), external_directory: Permission.optional(), + allow_tmpdir: z.boolean().optional(), }) .optional(), }) @@ -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(), diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 115d8f8b29d..b5763c69d37 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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({ diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index b49bd7abe00..a76cd6ec624 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -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`, + ) + } } } diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 93888f60bd2..97a68c40e57 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -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`, + ) + } } } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index fd81c4864a4..88e7836aef3 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -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`, + ) + } } } diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 6b8fd3dd111..c7f4bdd2c25 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -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`, + ) + } } } diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 98fbe533de3..cf6e6e8c609 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -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 { /** @@ -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\\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) + } } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 9ef7dfb9d8f..a21080762a5 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -431,4 +431,110 @@ describe("tool.bash permissions", () => { }, }) }) + + test("allows tmpdir access when allow_tmpdir is true", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + allow_tmpdir: true, + external_directory: "deny", + bash: { + "*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // 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", + }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + }, + }) + }) + + test("denies tmpdir access when allow_tmpdir is false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + allow_tmpdir: false, + external_directory: "deny", + bash: { + "*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // 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", + }, + ctx, + ), + ).rejects.toThrow() + }, + }) + }) + + test("allow_tmpdir does not affect non-tmpdir external paths", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + allow_tmpdir: true, + external_directory: "deny", + bash: { + "*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // Should still deny access to non-tmpdir external paths + await expect( + bash.execute( + { + command: "cd /usr", + description: "Change to /usr directory", + }, + ctx, + ), + ).rejects.toThrow() + }, + }) + }) }) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts new file mode 100644 index 00000000000..41026bae2d5 --- /dev/null +++ b/packages/opencode/test/util/filesystem.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import fs from "fs" +import { Filesystem } from "../../src/util/filesystem" + +describe("Filesystem.tmpdir", () => { + test("returns a resolved path", () => { + const tmp = Filesystem.tmpdir() + // Should be an absolute path + expect(path.isAbsolute(tmp)).toBe(true) + // Should exist + expect(fs.existsSync(tmp)).toBe(true) + }) + + test("resolves symlinks", () => { + const tmp = Filesystem.tmpdir() + // On macOS, /tmp is a symlink to /private/tmp + // The resolved path should not be /tmp if it's a symlink + if (process.platform === "darwin") { + expect(tmp).not.toBe("/tmp") + expect(tmp).toContain("/private") + } + }) +}) + +describe("Filesystem.isInTmpdir", () => { + test("returns true for paths in tmpdir", () => { + const tmp = Filesystem.tmpdir() + expect(Filesystem.isInTmpdir(path.join(tmp, "test.txt"))).toBe(true) + expect(Filesystem.isInTmpdir(path.join(tmp, "subdir", "test.txt"))).toBe(true) + }) + + test("returns false for paths outside tmpdir", () => { + expect(Filesystem.isInTmpdir("/home/user/file.txt")).toBe(false) + expect(Filesystem.isInTmpdir("/etc/passwd")).toBe(false) + expect(Filesystem.isInTmpdir("/usr/local/bin")).toBe(false) + }) + + test("handles symlinked tmpdir paths on macOS", () => { + if (process.platform === "darwin") { + // os.tmpdir() on macOS returns /var/folders/.../T which resolves to /private/var/folders/.../T + // We should be able to use either the resolved or unresolved form + const unresolved = require("os").tmpdir() + expect(Filesystem.isInTmpdir(path.join(unresolved, "test.txt"))).toBe(true) + } + }) + + test("returns true for nested non-existent paths in tmpdir", () => { + const tmp = Filesystem.tmpdir() + const deepPath = path.join(tmp, "nonexistent", "deeply", "nested", "file.txt") + expect(Filesystem.isInTmpdir(deepPath)).toBe(true) + }) +}) + +describe("Filesystem.containsResolved", () => { + test("returns true when child is within parent", () => { + const tmp = Filesystem.tmpdir() + expect(Filesystem.containsResolved(tmp, path.join(tmp, "child.txt"))).toBe(true) + expect(Filesystem.containsResolved(tmp, path.join(tmp, "subdir", "child.txt"))).toBe(true) + }) + + test("returns false when child is outside parent", () => { + const tmp = Filesystem.tmpdir() + expect(Filesystem.containsResolved(tmp, "/etc/passwd")).toBe(false) + expect(Filesystem.containsResolved(tmp, "/usr/local")).toBe(false) + }) + + test("handles non-existent child paths", () => { + const tmp = Filesystem.tmpdir() + const nonExistent = path.join(tmp, "does-not-exist-" + Date.now(), "file.txt") + expect(Filesystem.containsResolved(tmp, nonExistent)).toBe(true) + }) + + test("handles deeply nested non-existent paths", () => { + const tmp = Filesystem.tmpdir() + const deepPath = path.join(tmp, "a", "b", "c", "d", "e", "file.txt") + expect(Filesystem.containsResolved(tmp, deepPath)).toBe(true) + }) + + test("resolves symlinks in macOS /tmp", () => { + if (process.platform === "darwin") { + // /tmp -> /private/tmp on macOS + const privateTmp = "/private/tmp" + expect(Filesystem.containsResolved(privateTmp, "/tmp/test.txt")).toBe(true) + } + }) +}) diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 754a6c875dd..ad2d2b7f048 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -226,6 +226,34 @@ This provides an additional safety layer to prevent unintended modifications to --- +### allow_tmpdir + +Use the `permission.allow_tmpdir` key to allow file operations in the system's temporary directory without requiring `external_directory` approval. + +This is useful for: + +- CI/CD environments where permission prompts cause hangs +- Tools that need to write temporary files +- Scoped access without allowing all external directories + +```json title="opencode.json" {4-5} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "allow_tmpdir": true, + "external_directory": "ask" + } +} +``` + +When `allow_tmpdir` is `true`, file operations in the OS tmpdir (e.g., `/tmp` on Linux, `/var/folders/.../T/` on macOS, `%TEMP%` on Windows) are permitted without triggering `external_directory` prompts. + +:::note +This only affects the tmpdir. Access to other external directories still follows `external_directory` settings. +::: + +--- + ## Agents You can also configure permissions per agent. Where the agent specific config