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/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 }) + } + }) +})