diff --git a/STATS.md b/STATS.md index 242ca4c1c53..acaf0cb105d 100644 --- a/STATS.md +++ b/STATS.md @@ -5,3 +5,5 @@ | 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | | 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) | | 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) | +| 2025-07-02 | 24,840 (+2,732) | 46,168 (+2,423) | 71,008 (+5,155) | +| 2025-07-03 | 27,855 (+3,015) | 49,955 (+3,787) | 77,810 (+6,802) | diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 3ef44bd5f03..f7edc067cb0 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -48,6 +48,28 @@ export const BashTool = Tool.define({ if (BANNED_COMMANDS.some((item) => params.command.startsWith(item))) throw new Error(`Command '${params.command}' is not allowed`) + // Check if command requires sudo + const trimmedCommand = params.command.trim() + if (trimmedCommand.startsWith("sudo ") || trimmedCommand === "sudo") { + throw new Error( + `Sudo commands are not supported as they require interactive password input. ` + + `Consider alternative approaches that don't require elevated privileges.` + ) + } + + // Also check for sudo after common command separators + const sudoPatterns = [ + /(?:^|;|&&|\|\|)\s*sudo(?:\s|$)/, // sudo at start or after ;, &&, || + /\|\s*sudo(?:\s|$)/, // sudo after pipe + ] + + if (sudoPatterns.some(pattern => pattern.test(params.command))) { + throw new Error( + `Sudo commands are not supported as they require interactive password input. ` + + `Consider alternative approaches that don't require elevated privileges.` + ) + } + const process = Bun.spawn({ cmd: ["bash", "-c", params.command], cwd: App.info().path.cwd, diff --git a/packages/opencode/test/tool/__snapshots__/bash.test.ts.snap b/packages/opencode/test/tool/__snapshots__/bash.test.ts.snap new file mode 100644 index 00000000000..1740cc40014 --- /dev/null +++ b/packages/opencode/test/tool/__snapshots__/bash.test.ts.snap @@ -0,0 +1,3 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`tool.bash sudo detection error message format 1`] = `"Sudo commands are not supported as they require interactive password input. Consider alternative approaches that don't require elevated privileges."`; diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts new file mode 100644 index 00000000000..8ac88f28066 --- /dev/null +++ b/packages/opencode/test/tool/bash.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test" +import { App } from "../../src/app/app" +import { BashTool } from "../../src/tool/bash" + +const ctx = { + sessionID: "test", + messageID: "", + abort: AbortSignal.any([]), + metadata: () => {}, +} + +describe("tool.bash", () => { + describe("sudo detection", () => { + test("error message format", async () => { + await App.provide({ cwd: process.cwd() }, async () => { + try { + await BashTool.execute( + { command: "sudo apt update", description: "Test sudo error" }, + ctx, + ) + } catch (error) { + expect((error as Error).message).toMatchSnapshot() + } + }) + }) + + test("rejects commands starting with sudo", async () => { + await App.provide({ cwd: process.cwd() }, async () => { + const sudoCommands = [ + "sudo apt update", + "sudo -u user command", + "sudo", + " sudo command", + "sudo -i", + "sudo su", + ] + + for (const command of sudoCommands) { + expect( + BashTool.execute({ command, description: "Test sudo" }, ctx), + ).rejects.toThrow(/Sudo commands are not supported/) + } + }) + }) + + test("rejects sudo in command chains", async () => { + await App.provide({ cwd: process.cwd() }, async () => { + const chainedSudoCommands = [ + "echo test; sudo apt update", + "echo test && sudo command", + "echo test || sudo command", + "cat file | sudo tee output", + ] + + for (const command of chainedSudoCommands) { + expect( + BashTool.execute( + { command, description: "Test chained sudo" }, + ctx, + ), + ).rejects.toThrow(/Sudo commands are not supported/) + } + }) + }) + + test("allows commands with sudo in non-command context", async () => { + await App.provide({ cwd: process.cwd() }, async () => { + const validCommands = [ + { + command: "echo 'sudo is mentioned but not a command'", + expectExit: 0, + }, + { command: "echo sudo | cat", expectExit: 0 }, + { command: "echo test && echo sudo", expectExit: 0 }, + { command: "echo mysudo", expectExit: 0 }, + { command: "echo test | grep test", expectExit: 0 }, + ] + + for (const { command, expectExit } of validCommands) { + const result = await BashTool.execute( + { command, description: "Test non-sudo" }, + ctx, + ) + expect(result).toBeDefined() + expect(result.metadata.exit).toBe(expectExit) + } + }) + }) + }) + + describe("basic functionality", () => { + test("executes simple commands", async () => { + await App.provide({ cwd: process.cwd() }, async () => { + const result = await BashTool.execute( + { command: "echo 'Hello, World!'", description: "Test echo" }, + ctx, + ) + expect(result.output).toContain("Hello, World!") + expect(result.metadata.exit).toBe(0) + }) + }) + + test("handles command errors", async () => { + await App.provide({ cwd: process.cwd() }, async () => { + const result = await BashTool.execute( + { command: "exit 1", description: "Test error" }, + ctx, + ) + expect(result.metadata.exit).toBe(1) + }) + }) + + test("respects timeout", async () => { + await App.provide({ cwd: process.cwd() }, async () => { + const result = await BashTool.execute( + { + command: "sleep 0.1 && echo done", + description: "Test timeout", + timeout: 200, // 200ms timeout, sleep is 100ms so should succeed + }, + ctx, + ) + expect(result.output).toContain("done") + }) + }) + }) +})