diff --git a/bun.lock b/bun.lock index 9cda088153c..9181ee85ea8 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "opencode", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 730da20c265..f315ec9c737 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -782,6 +782,18 @@ export function Prompt(props: PromptProps) { e.preventDefault() return } + if (keybind.match("input_copy", e)) { + const selection = input.visualCursor.selection + if (selection) { + const start = Math.min(selection.start, selection.end) + const end = Math.max(selection.start, selection.end) + const text = input.plainText.slice(start, end) + if (text) { + await Clipboard.write(text) + return + } + } + } // Handle clipboard paste (Ctrl+V) - check for images first on Windows // This is needed because Windows terminal doesn't properly send image data // through bracketed paste, so we need to intercept the keypress and diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ddb3af4b0a8..af7f270e07a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -645,7 +645,7 @@ export namespace Config { session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), - model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), + model_provider_list: z.string().optional().default("ctrl+alt+m").describe("Open provider list from model dialog"), model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), @@ -690,7 +690,8 @@ export namespace Config { agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), - input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), + input_copy: z.string().optional().default("ctrl+c,super+c").describe("Copy from input"), + input_paste: z.string().optional().default("ctrl+v,super+v").describe("Paste from clipboard"), input_submit: z.string().optional().default("return").describe("Submit input"), input_newline: z .string() @@ -699,8 +700,8 @@ export namespace Config { .describe("Insert newline in input"), input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), - input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), - input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), + input_move_up: z.string().optional().default("up,ctrl+p").describe("Move cursor up in input"), + input_move_down: z.string().optional().default("down,ctrl+n").describe("Move cursor down in input"), input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), @@ -770,8 +771,8 @@ export namespace Config { .optional() .default("ctrl+w,ctrl+backspace,alt+backspace") .describe("Delete word backward in input"), - history_previous: z.string().optional().default("up").describe("Previous history item"), - history_next: z.string().optional().default("down").describe("Next history item"), + history_previous: z.string().optional().default("up,ctrl+p").describe("Previous history item"), + history_next: z.string().optional().default("down,ctrl+n").describe("Next history item"), session_child_cycle: z.string().optional().default("right").describe("Next child session"), session_child_cycle_reverse: z.string().optional().default("left").describe("Previous child session"), session_parent: z.string().optional().default("up").describe("Go to parent session"), diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 76b7be4b72b..a527d8306cf 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -277,8 +277,7 @@ export namespace File { const project = Instance.project const full = path.join(Instance.directory, file) - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + // Check if path is lexically contained first (fast check) if (!Instance.containsPath(full)) { throw new Error(`Access denied: path escapes project directory`) } @@ -289,6 +288,11 @@ export namespace File { return { type: "text", content: "" } } + const real = await fs.promises.realpath(full) + if (!Instance.containsPath(real)) { + throw new Error(`Access denied: path escapes project directory`) + } + const encode = await shouldEncode(bunFile) if (encode) { @@ -337,12 +341,20 @@ export namespace File { } const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + // Check if path is lexically contained first (fast check) if (!Instance.containsPath(resolved)) { throw new Error(`Access denied: path escapes project directory`) } + try { + const real = await fs.promises.realpath(resolved) + if (!Instance.containsPath(real)) { + throw new Error(`Access denied: path escapes project directory`) + } + } catch (err: any) { + if (err.message && err.message.startsWith("Access denied")) throw err + } + const nodes: Node[] = [] for (const entry of await fs.promises .readdir(resolved, { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 170d4448088..76c89fd5fc6 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -12,6 +12,52 @@ import { defer } from "@/util/defer" import { Config } from "../config/config" import { PermissionNext } from "@/permission/next" +// Check if caller has Plan mode restrictions (edit="deny" globally) +function hasPlanRestrictions(permissions: PermissionNext.Ruleset): boolean { + const rule = permissions.findLast((r) => r.permission === "edit" && r.pattern === "*") + return rule?.action === "deny" +} + +// Read-only bash commands allowed in Plan mode +const PLAN_BASH_WHITELIST = [ + "cat *", + "head *", + "tail *", + "grep *", + "ls *", + "find *", + "wc *", + "diff *", + "git status*", + "git log*", + "git diff*", + "git show*", + "git branch*", + "git remote*", + "git rev-parse*", + "echo *", + "pwd", + "which *", + "env", + "printenv*", +] + +// Build permission rules for Plan mode inheritance +function planPermissions(): PermissionNext.Ruleset { + return [ + { permission: "edit", pattern: "*", action: "deny" as const }, + { permission: "write", pattern: "*", action: "deny" as const }, + { permission: "patch", pattern: "*", action: "deny" as const }, + { permission: "multiedit", pattern: "*", action: "deny" as const }, + { permission: "bash", pattern: "*", action: "deny" as const }, + ...PLAN_BASH_WHITELIST.map((pattern) => ({ + permission: "bash" as const, + pattern, + action: "allow" as const, + })), + ] +} + const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), prompt: z.string().describe("The task for the agent to perform"), @@ -23,15 +69,17 @@ const parameters = z.object({ export const TaskTool = Tool.define("task", async (ctx) => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) - // Filter agents by permissions if agent provided const caller = ctx?.agent - const accessibleAgents = caller + const accessible = caller ? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny") : agents + // Security: sub-agents must inherit Plan mode restrictions + const restricted = caller ? hasPlanRestrictions(caller.permission) : false + const description = DESCRIPTION.replace( "{agents}", - accessibleAgents + accessible .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) .join("\n"), ) @@ -41,7 +89,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { async execute(params: z.infer, ctx) { const config = await Config.get() - // Skip permission check when user explicitly invoked via @ or command subtask if (!ctx.extra?.bypassAgentCheck) { await ctx.ask({ permission: "task", @@ -54,10 +101,21 @@ export const TaskTool = Tool.define("task", async (ctx) => { }) } - const agent = await Agent.get(params.subagent_type) - if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + // Force plan agent when parent has Plan mode restrictions + let name = params.subagent_type + if (restricted) { + const plan = await Agent.get("plan") + if (!plan) { + throw new Error("Cannot spawn sub-agent from Plan mode parent - 'plan' agent is not available") + } + name = "plan" + } + + const agent = await Agent.get(name) + if (!agent) throw new Error(`Unknown agent type: ${name} is not a valid agent type`) - const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") + const hasTask = agent.permission.some((rule) => rule.permission === "task") + const inherited = restricted ? planPermissions() : [] const session = await iife(async () => { if (params.session_id) { @@ -67,32 +125,17 @@ export const TaskTool = Tool.define("task", async (ctx) => { return await Session.create({ parentID: ctx.sessionID, - title: params.description + ` (@${agent.name} subagent)`, + title: params.description + ` (@${agent.name} subagent${restricted ? ", Plan mode" : ""})`, permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "todoread", - pattern: "*", - action: "deny", - }, - ...(hasTaskPermission - ? [] - : [ - { - permission: "task" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), + { permission: "todowrite", pattern: "*", action: "deny" }, + { permission: "todoread", pattern: "*", action: "deny" }, + ...(hasTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), ...(config.experimental?.primary_tools?.map((t) => ({ pattern: "*", action: "allow" as const, permission: t, })) ?? []), + ...inherited, ], }) }) @@ -142,6 +185,10 @@ export const TaskTool = Tool.define("task", async (ctx) => { using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) + const disabled: Record = restricted + ? { edit: false, write: false, patch: false, multiedit: false } + : {} + const result = await SessionPrompt.prompt({ messageID, sessionID: session.id, @@ -153,8 +200,9 @@ export const TaskTool = Tool.define("task", async (ctx) => { tools: { todowrite: false, todoread: false, - ...(hasTaskPermission ? {} : { task: false }), + ...(hasTask ? {} : { task: false }), ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), + ...disabled, }, parts: promptParts, }) diff --git a/packages/opencode/test/security/symlink.test.ts b/packages/opencode/test/security/symlink.test.ts new file mode 100644 index 00000000000..80bd452a66c --- /dev/null +++ b/packages/opencode/test/security/symlink.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import os from "os" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { File } from "../../src/file" + +describe("security", () => { + test("prevents reading files outside project via symlink", async () => { + // Create a "secret" file outside the project + const secretDir = path.join(os.tmpdir(), "secret-" + Math.random().toString(36).slice(2)) + await fs.mkdir(secretDir, { recursive: true }) + const secretFile = path.join(secretDir, "passwd") + await Bun.write(secretFile, "secret-data") + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a symlink to the secret file + await fs.symlink(secretFile, path.join(dir, "link-to-secret")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Try to read the symlink + // This should FAIL (throw error) if security check works + // We expect "Access denied: path escapes project directory" + try { + await File.read("link-to-secret") + // If we get here, it failed to throw + throw new Error("Security check failed: File.read succeeded but should have failed") + } catch (err: any) { + expect(err.message).toContain("Access denied") + } + }, + }) + } finally { + // Clean up secret dir + await fs.rm(secretDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts new file mode 100644 index 00000000000..a800dc43664 --- /dev/null +++ b/packages/opencode/test/tool/task.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { PermissionNext } from "../../src/permission/next" +import { Agent } from "../../src/agent/agent" + +const root = path.join(__dirname, "../..") + +function planPerms(): PermissionNext.Ruleset { + return PermissionNext.fromConfig({ + "*": "allow", + edit: { "*": "deny" }, + }) +} + +function buildPerms(): PermissionNext.Ruleset { + return PermissionNext.fromConfig({ + "*": "allow", + edit: { "*": "allow" }, + }) +} + +describe("tool.task Plan Mode Security", () => { + describe("Plan mode detection", () => { + test("detects Plan mode when edit='deny' globally", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const perms = planPerms() + const rule = perms.findLast((r) => r.permission === "edit" && r.pattern === "*") + expect(rule).toBeDefined() + expect(rule?.action).toBe("deny") + }, + }) + }) + + test("does not detect Plan mode when edit='allow'", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const perms = buildPerms() + const rule = perms.findLast((r) => r.permission === "edit" && r.pattern === "*") + expect(rule?.action).not.toBe("deny") + }, + }) + }) + + test("plan agent has edit deny restriction", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("plan") + expect(agent).toBeDefined() + if (agent) { + const rule = agent.permission.findLast((r) => r.permission === "edit" && r.pattern === "*") + expect(rule?.action).toBe("deny") + } + }, + }) + }) + + test("build agent does not have edit deny restriction", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("build") + expect(agent).toBeDefined() + if (agent) { + const rule = agent.permission.findLast((r) => r.permission === "edit" && r.pattern === "*") + expect(rule?.action).not.toBe("deny") + } + }, + }) + }) + }) + + describe("Permission rules", () => { + test("edit tools are denied in Plan mode permissions", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const tools = ["edit", "write", "patch", "multiedit"] + const perms = PermissionNext.fromConfig({ + edit: { "*": "deny" }, + write: { "*": "deny" }, + patch: { "*": "deny" }, + multiedit: { "*": "deny" }, + }) + for (const tool of tools) { + const rule = perms.find((r) => r.permission === tool && r.pattern === "*" && r.action === "deny") + expect(rule).toBeDefined() + } + }, + }) + }) + }) + + describe("Agent availability", () => { + test("plan agent is available", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("plan") + expect(agent).toBeDefined() + expect(agent?.name).toBe("plan") + }, + }) + }) + + test("plan agent has correct mode", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("plan") + expect(agent?.mode).toBe("primary") + }, + }) + }) + + test("general agent is subagent", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("general") + expect(agent?.name).toBe("general") + expect(agent?.mode).toBe("subagent") + }, + }) + }) + + test("explore agent is subagent", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("explore") + expect(agent?.name).toBe("explore") + expect(agent?.mode).toBe("subagent") + }, + }) + }) + }) + + describe("Permission evaluation", () => { + test("evaluate returns deny for edit in Plan mode", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const perms = planPerms() + const result = PermissionNext.evaluate("edit", "test.ts", perms) + expect(result.action).toBe("deny") + }, + }) + }) + + test("evaluate returns allow for edit in Build mode", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const perms = buildPerms() + const result = PermissionNext.evaluate("edit", "test.ts", perms) + expect(result.action).toBe("allow") + }, + }) + }) + + test("disabled identifies edit tools as disabled", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const perms = PermissionNext.fromConfig({ + edit: { "*": "deny" }, + write: { "*": "deny" }, + patch: { "*": "deny" }, + multiedit: { "*": "deny" }, + }) + const disabled = PermissionNext.disabled(["edit", "write", "patch", "multiedit", "read", "bash"], perms) + expect(disabled.has("edit")).toBe(true) + expect(disabled.has("write")).toBe(true) + expect(disabled.has("patch")).toBe(true) + expect(disabled.has("multiedit")).toBe(true) + expect(disabled.has("read")).toBe(false) + expect(disabled.has("bash")).toBe(false) + }, + }) + }) + }) + + describe("Security", () => { + test("plan agent denies file edits", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("plan") + expect(agent).toBeDefined() + if (agent) { + const result = PermissionNext.evaluate("edit", "any.ts", agent.permission) + expect(result.action).toBe("deny") + } + }, + }) + }) + + test("plan agent allows reading files", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("plan") + expect(agent).toBeDefined() + if (agent) { + const result = PermissionNext.evaluate("read", "any.ts", agent.permission) + expect(result.action).toBe("allow") + } + }, + }) + }) + + test("build agent allows file edits", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("build") + expect(agent).toBeDefined() + if (agent) { + const result = PermissionNext.evaluate("edit", "any.ts", agent.permission) + expect(result.action).not.toBe("deny") + } + }, + }) + }) + + test("explore agent allows grep", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agent = await Agent.get("explore") + expect(agent).toBeDefined() + if (agent) { + const result = PermissionNext.evaluate("grep", "*", agent.permission) + expect(result.action).toBe("allow") + } + }, + }) + }) + }) +}) + +describe("tool.task permission filtering", () => { + test("subagents exist", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await Agent.list() + const subs = agents.filter((a) => a.mode !== "primary") + expect(subs.length).toBeGreaterThan(0) + expect(subs.find((a) => a.name === "general")).toBeDefined() + expect(subs.find((a) => a.name === "explore")).toBeDefined() + }, + }) + }) + + test("agent list includes core agents", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const agents = await Agent.list() + const names = agents.map((a) => a.name) + expect(names).toContain("build") + expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + }, + }) + }) +})