Skip to content
Draft
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
2 changes: 2 additions & 0 deletions STATS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
22 changes: 22 additions & 0 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/test/tool/__snapshots__/bash.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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."`;
127 changes: 127 additions & 0 deletions packages/opencode/test/tool/bash.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
})