Skip to content

Commit 7f6180f

Browse files
committed
feat: add --tools flag to limit available tools
Add --tools flag to serve, web, and TUI commands to enable selective tool access control. The flag accepts comma-separated patterns to enable/disable tools. Usage examples: # Only allow read, write, and webfetch tools opencode serve --tools='-*,read,write,webfetch' # Disable specific tools opencode web --tools='-bash,-edit' # Use in TUI mode opencode --tools='-*,read,write,webfetch' Changes: - Add --tools flag to serve, web, and TUI thread commands - Implement Wildcard.parseToolsPattern() to parse tool enable/disable patterns - Update Server.listen() to accept tools option - Pass default tools to SessionPrompt.prompt() in server - Add tests for parseToolsPattern() Signed-off-by: Christian Stewart <[email protected]>
1 parent 31e6ed6 commit 7f6180f

File tree

8 files changed

+92
-2
lines changed

8 files changed

+92
-2
lines changed

packages/opencode/src/cli/cmd/serve.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Server } from "../../server/server"
22
import { cmd } from "./cmd"
3+
import { Wildcard } from "@/util/wildcard"
34

45
export const ServeCommand = cmd({
56
command: "serve",
@@ -15,14 +16,21 @@ export const ServeCommand = cmd({
1516
type: "string",
1617
describe: "hostname to listen on",
1718
default: "127.0.0.1",
19+
})
20+
.option("tools", {
21+
type: "string",
22+
describe:
23+
"comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)",
1824
}),
1925
describe: "starts a headless opencode server",
2026
handler: async (args) => {
2127
const hostname = args.hostname
2228
const port = args.port
29+
const tools = Wildcard.parseToolsPattern(args.tools)
2330
const server = Server.listen({
2431
port,
2532
hostname,
33+
tools,
2634
})
2735
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
2836
await new Promise(() => {})

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { createColors, createFrames } from "../../ui/spinner.ts"
2525
import { useDialog } from "@tui/ui/dialog"
2626
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
2727
import { useToast } from "../../ui/toast"
28+
import { useArgs } from "../../context/args"
29+
import { Wildcard } from "@/util/wildcard"
2830

2931
export type PromptProps = {
3032
sessionID?: string
@@ -58,6 +60,7 @@ export function Prompt(props: PromptProps) {
5860
const sync = useSync()
5961
const dialog = useDialog()
6062
const toast = useToast()
63+
const args = useArgs()
6164
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
6265
const history = usePromptHistory()
6366
const command = useCommandDialog()
@@ -477,12 +480,14 @@ export function Prompt(props: PromptProps) {
477480
messageID,
478481
})
479482
} else {
483+
const tools = Wildcard.parseToolsPattern(args.tools)
480484
sdk.client.session.prompt({
481485
sessionID,
482486
...selectedModel,
483487
messageID,
484488
agent: local.agent.current().name,
485489
model: selectedModel,
490+
...(tools && { tools }),
486491
parts: [
487492
{
488493
id: Identifier.ascending("part"),

packages/opencode/src/cli/cmd/tui/context/args.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface Args {
66
prompt?: string
77
continue?: boolean
88
sessionID?: string
9+
tools?: string
910
}
1011

1112
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({

packages/opencode/src/cli/cmd/tui/thread.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ export const TuiThreadCommand = cmd({
4444
type: "string",
4545
describe: "agent to use",
4646
})
47+
.option("tools", {
48+
type: "string",
49+
describe:
50+
"comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)",
51+
})
4752
.option("port", {
4853
type: "number",
4954
describe: "port to listen on",
@@ -105,6 +110,7 @@ export const TuiThreadCommand = cmd({
105110
agent: args.agent,
106111
model: args.model,
107112
prompt,
113+
tools: args.tools,
108114
},
109115
onExit: async () => {
110116
await client.call("shutdown", undefined)

packages/opencode/src/cli/cmd/web.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { UI } from "../ui"
33
import { cmd } from "./cmd"
44
import open from "open"
55
import { networkInterfaces } from "os"
6+
import { Wildcard } from "@/util/wildcard"
67

78
function getNetworkIPs() {
89
const nets = networkInterfaces()
@@ -40,14 +41,21 @@ export const WebCommand = cmd({
4041
type: "string",
4142
describe: "hostname to listen on",
4243
default: "127.0.0.1",
44+
})
45+
.option("tools", {
46+
type: "string",
47+
describe:
48+
"comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)",
4349
}),
4450
describe: "starts a headless opencode server",
4551
handler: async (args) => {
4652
const hostname = args.hostname
4753
const port = args.port
54+
const tools = Wildcard.parseToolsPattern(args.tools)
4855
const server = Server.listen({
4956
port,
5057
hostname,
58+
tools,
5159
})
5260
UI.empty()
5361
UI.println(UI.logo(" "))

packages/opencode/src/server/server.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export namespace Server {
5858
Connected: BusEvent.define("server.connected", z.object({})),
5959
}
6060

61+
let defaultTools: Record<string, boolean> | undefined
62+
6163
const app = new Hono()
6264
export const App = lazy(() =>
6365
app
@@ -1163,7 +1165,11 @@ export namespace Server {
11631165
return stream(c, async (stream) => {
11641166
const sessionID = c.req.valid("param").sessionID
11651167
const body = c.req.valid("json")
1166-
const msg = await SessionPrompt.prompt({ ...body, sessionID })
1168+
const msg = await SessionPrompt.prompt({
1169+
...body,
1170+
sessionID,
1171+
tools: { ...defaultTools, ...body.tools },
1172+
})
11671173
stream.write(JSON.stringify(msg))
11681174
})
11691175
},
@@ -2454,7 +2460,10 @@ export namespace Server {
24542460
return result
24552461
}
24562462

2457-
export function listen(opts: { port: number; hostname: string }) {
2463+
export function listen(opts: { port: number; hostname: string; tools?: Record<string, boolean> }) {
2464+
if (opts.tools) {
2465+
defaultTools = opts.tools
2466+
}
24582467
const server = Bun.serve({
24592468
port: opts.port,
24602469
hostname: opts.hostname,

packages/opencode/src/util/wildcard.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ export namespace Wildcard {
4040
return result
4141
}
4242

43+
export function parseToolsPattern(pattern: string | undefined): Record<string, boolean> | undefined {
44+
if (!pattern?.trim()) return undefined
45+
const result: Record<string, boolean> = {}
46+
const parts = pattern.split(",").map((x) => x.trim())
47+
for (const part of parts) {
48+
if (part.startsWith("-")) {
49+
result[part.slice(1)] = false
50+
} else {
51+
result[part] = true
52+
}
53+
}
54+
return result
55+
}
56+
4357
function matchSequence(items: string[], patterns: string[]): boolean {
4458
if (patterns.length === 0) return true
4559
const [pattern, ...rest] = patterns

packages/opencode/test/util/wildcard.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,42 @@ test("allStructured handles sed flags", () => {
5353
expect(Wildcard.allStructured({ head: "sed", tail: ["-n", "1p", "file"] }, rules)).toBe("allow")
5454
expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "-n", "/./p", "myfile.txt"] }, rules)).toBe("ask")
5555
})
56+
57+
test("parseToolsPattern handles empty and undefined", () => {
58+
expect(Wildcard.parseToolsPattern(undefined)).toBeUndefined()
59+
expect(Wildcard.parseToolsPattern("")).toBeUndefined()
60+
expect(Wildcard.parseToolsPattern(" ")).toBeUndefined()
61+
})
62+
63+
test("parseToolsPattern disables all tools", () => {
64+
expect(Wildcard.parseToolsPattern("-*")).toEqual({ "*": false })
65+
})
66+
67+
test("parseToolsPattern enables all tools", () => {
68+
expect(Wildcard.parseToolsPattern("*")).toEqual({ "*": true })
69+
})
70+
71+
test("parseToolsPattern disables all and enables specific tools", () => {
72+
expect(Wildcard.parseToolsPattern("-*,read,write,webfetch")).toEqual({
73+
"*": false,
74+
read: true,
75+
write: true,
76+
webfetch: true,
77+
})
78+
})
79+
80+
test("parseToolsPattern enables only specific tools", () => {
81+
expect(Wildcard.parseToolsPattern("read,write,webfetch")).toEqual({
82+
read: true,
83+
write: true,
84+
webfetch: true,
85+
})
86+
})
87+
88+
test("parseToolsPattern handles whitespace", () => {
89+
expect(Wildcard.parseToolsPattern(" -* , read , write ")).toEqual({
90+
"*": false,
91+
read: true,
92+
write: true,
93+
})
94+
})

0 commit comments

Comments
 (0)