Skip to content

Commit fe5d884

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() Fixes: anomalyco#9386 Signed-off-by: Christian Stewart <[email protected]>
1 parent 644f0d4 commit fe5d884

9 files changed

Lines changed: 106 additions & 7 deletions

File tree

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@ import { Server } from "../../server/server"
22
import { cmd } from "./cmd"
33
import { withNetworkOptions, resolveNetworkOptions } from "../network"
44
import { Flag } from "../../flag/flag"
5+
import { Wildcard } from "@/util/wildcard"
56

67
export const ServeCommand = cmd({
78
command: "serve",
8-
builder: (yargs) => withNetworkOptions(yargs),
9+
builder: (yargs) =>
10+
withNetworkOptions(yargs).option("tools", {
11+
type: "string",
12+
describe:
13+
"comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)",
14+
}),
915
describe: "starts a headless opencode server",
1016
handler: async (args) => {
1117
if (!Flag.OPENCODE_SERVER_PASSWORD) {
1218
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
1319
}
1420
const opts = await resolveNetworkOptions(args)
15-
const server = Server.listen(opts)
21+
const tools = Wildcard.parseToolsPattern(args.tools)
22+
const server = Server.listen({ ...opts, tools })
1623
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
1724
await new Promise(() => {})
1825
await server.stop()

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { DialogAlert } from "../../ui/dialog-alert"
3131
import { useToast } from "../../ui/toast"
3232
import { useKV } from "../../context/kv"
3333
import { useTextareaKeybindings } from "../textarea-keybindings"
34+
import { useArgs } from "../../context/args"
35+
import { Wildcard } from "@/util/wildcard"
3436

3537
export type PromptProps = {
3638
sessionID?: string
@@ -66,6 +68,7 @@ export function Prompt(props: PromptProps) {
6668
const sync = useSync()
6769
const dialog = useDialog()
6870
const toast = useToast()
71+
const args = useArgs()
6972
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
7073
const history = usePromptHistory()
7174
const stash = usePromptStash()
@@ -577,6 +580,7 @@ export function Prompt(props: PromptProps) {
577580
})),
578581
})
579582
} else {
583+
const tools = Wildcard.parseToolsPattern(args.tools)
580584
sdk.client.session
581585
.prompt({
582586
sessionID,
@@ -585,6 +589,7 @@ export function Prompt(props: PromptProps) {
585589
agent: local.agent.current().name,
586590
model: selectedModel,
587591
variant,
592+
...(tools && { tools }),
588593
parts: [
589594
{
590595
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
@@ -71,6 +71,11 @@ export const TuiThreadCommand = cmd({
7171
.option("agent", {
7272
type: "string",
7373
describe: "agent to use",
74+
})
75+
.option("tools", {
76+
type: "string",
77+
describe:
78+
"comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)",
7479
}),
7580
handler: async (args) => {
7681
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
@@ -150,6 +155,7 @@ export const TuiThreadCommand = cmd({
150155
agent: args.agent,
151156
model: args.model,
152157
prompt,
158+
tools: args.tools,
153159
},
154160
onExit: async () => {
155161
await client.call("shutdown", undefined)

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
55
import { Flag } from "../../flag/flag"
66
import open from "open"
77
import { networkInterfaces } from "os"
8+
import { Wildcard } from "@/util/wildcard"
89

910
function getNetworkIPs() {
1011
const nets = networkInterfaces()
@@ -30,14 +31,20 @@ function getNetworkIPs() {
3031

3132
export const WebCommand = cmd({
3233
command: "web",
33-
builder: (yargs) => withNetworkOptions(yargs),
34+
builder: (yargs) =>
35+
withNetworkOptions(yargs).option("tools", {
36+
type: "string",
37+
describe:
38+
"comma-separated tool patterns to enable/disable (e.g., '-*,read,write,webfetch' to only enable those three)",
39+
}),
3440
describe: "start opencode server and open web interface",
3541
handler: async (args) => {
3642
if (!Flag.OPENCODE_SERVER_PASSWORD) {
3743
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
3844
}
3945
const opts = await resolveNetworkOptions(args)
40-
const server = Server.listen(opts)
46+
const tools = Wildcard.parseToolsPattern(args.tools)
47+
const server = Server.listen({ ...opts, tools })
4148
UI.empty()
4249
UI.println(UI.logo(" "))
4350
UI.empty()

‎packages/opencode/src/server/routes/session.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Log } from "../../util/log"
1616
import { PermissionNext } from "@/permission/next"
1717
import { errors } from "../error"
1818
import { lazy } from "../../util/lazy"
19+
import { Server } from "../server"
1920

2021
const log = Log.create({ service: "server" })
2122

@@ -730,7 +731,9 @@ export const SessionRoutes = lazy(() =>
730731
return stream(c, async (stream) => {
731732
const sessionID = c.req.valid("param").sessionID
732733
const body = c.req.valid("json")
733-
const msg = await SessionPrompt.prompt({ ...body, sessionID })
734+
const defaults = Server.getDefaultTools()
735+
const tools = defaults || body.tools ? { ...defaults, ...body.tools } : undefined
736+
const msg = await SessionPrompt.prompt({ ...body, sessionID, tools })
734737
stream.write(JSON.stringify(msg))
735738
})
736739
},
@@ -762,7 +765,9 @@ export const SessionRoutes = lazy(() =>
762765
return stream(c, async () => {
763766
const sessionID = c.req.valid("param").sessionID
764767
const body = c.req.valid("json")
765-
SessionPrompt.prompt({ ...body, sessionID })
768+
const defaults = Server.getDefaultTools()
769+
const tools = defaults || body.tools ? { ...defaults, ...body.tools } : undefined
770+
SessionPrompt.prompt({ ...body, sessionID, tools })
766771
})
767772
},
768773
)

‎packages/opencode/src/server/server.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export namespace Server {
5454
return _url ?? new URL("http://localhost:4096")
5555
}
5656

57+
let defaultTools: Record<string, boolean> | undefined
58+
59+
export function getDefaultTools() {
60+
return defaultTools
61+
}
62+
5763
const app = new Hono()
5864
export const App: () => Hono = lazy(
5965
() =>
@@ -560,8 +566,17 @@ export namespace Server {
560566
return result
561567
}
562568

563-
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
569+
export function listen(opts: {
570+
port: number
571+
hostname: string
572+
mdns?: boolean
573+
cors?: string[]
574+
tools?: Record<string, boolean>
575+
}) {
564576
_corsWhitelist = opts.cors ?? []
577+
if (opts.tools) {
578+
defaultTools = opts.tools
579+
}
565580

566581
const args = {
567582
hostname: opts.hostname,

‎packages/opencode/src/util/wildcard.ts‎

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

45+
export function parseToolsPattern(pattern: string | undefined): Record<string, boolean> | undefined {
46+
if (!pattern?.trim()) return undefined
47+
const result: Record<string, boolean> = {}
48+
const parts = pattern.split(",").map((x) => x.trim())
49+
for (const part of parts) {
50+
if (part.startsWith("-")) {
51+
result[part.slice(1)] = false
52+
} else {
53+
result[part] = true
54+
}
55+
}
56+
return result
57+
}
58+
4559
function matchSequence(items: string[], patterns: string[]): boolean {
4660
if (patterns.length === 0) return true
4761
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
@@ -73,3 +73,42 @@ test("allStructured handles sed flags", () => {
7373
expect(Wildcard.allStructured({ head: "sed", tail: ["-n", "1p", "file"] }, rules)).toBe("allow")
7474
expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "-n", "/./p", "myfile.txt"] }, rules)).toBe("ask")
7575
})
76+
77+
test("parseToolsPattern handles empty and undefined", () => {
78+
expect(Wildcard.parseToolsPattern(undefined)).toBeUndefined()
79+
expect(Wildcard.parseToolsPattern("")).toBeUndefined()
80+
expect(Wildcard.parseToolsPattern(" ")).toBeUndefined()
81+
})
82+
83+
test("parseToolsPattern disables all tools", () => {
84+
expect(Wildcard.parseToolsPattern("-*")).toEqual({ "*": false })
85+
})
86+
87+
test("parseToolsPattern enables all tools", () => {
88+
expect(Wildcard.parseToolsPattern("*")).toEqual({ "*": true })
89+
})
90+
91+
test("parseToolsPattern disables all and enables specific tools", () => {
92+
expect(Wildcard.parseToolsPattern("-*,read,write,webfetch")).toEqual({
93+
"*": false,
94+
read: true,
95+
write: true,
96+
webfetch: true,
97+
})
98+
})
99+
100+
test("parseToolsPattern enables only specific tools", () => {
101+
expect(Wildcard.parseToolsPattern("read,write,webfetch")).toEqual({
102+
read: true,
103+
write: true,
104+
webfetch: true,
105+
})
106+
})
107+
108+
test("parseToolsPattern handles whitespace", () => {
109+
expect(Wildcard.parseToolsPattern(" -* , read , write ")).toEqual({
110+
"*": false,
111+
read: true,
112+
write: true,
113+
})
114+
})

0 commit comments

Comments
 (0)