Skip to content
Open
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: 1 addition & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ export namespace Config {
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
question: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
webfetch: PermissionRule.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),
lsp: PermissionRule.optional(),
Expand Down
17 changes: 16 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,12 +593,19 @@ export namespace SessionPrompt {

await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages })

const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
const webfetch = formatWebfetchRules(ruleset)

const result = await processor.process({
user: lastUser,
agent,
abort,
sessionID,
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
system: [
...(await SystemPrompt.environment()),
...(await SystemPrompt.custom()),
...(webfetch ? [webfetch] : []),
],
messages: [
...MessageV2.toModelMessages(sessionMessages, model),
...(isLastStep
Expand Down Expand Up @@ -1812,4 +1819,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ touch: false },
)
}

export function formatWebfetchRules(ruleset: PermissionNext.Ruleset): string | undefined {
const rules = ruleset.filter((r) => r.permission === "webfetch")
if (!rules.length) return
if (rules.length === 1 && rules[0].pattern === "*" && rules[0].action === "allow") return
const lines = rules.map((r) => ` ${r.action}: ${r.pattern}`)
return ["<webfetch-url-permissions>", ...lines, "</webfetch-url-permissions>"].join("\n")
}
}
3 changes: 2 additions & 1 deletion packages/opencode/src/tool/webfetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ export const WebFetchTool = Tool.define("webfetch", {
throw new Error("URL must start with http:// or https://")
}

const host = new URL(params.url).host
await ctx.ask({
permission: "webfetch",
patterns: [params.url],
always: ["*"],
always: [`*://${host}*`],
metadata: {
url: params.url,
format: params.format,
Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,35 @@ test("migrates legacy tools config to permissions - deny", async () => {
})
})

test("permission config allows webfetch rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
webfetch: {
"https://example.com/*": "allow",
"*.example.com/*": "ask",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.permission?.webfetch).toEqual({
"https://example.com/*": "allow",
"*.example.com/*": "ask",
})
},
})
})

test("migrates legacy write tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
Expand Down
30 changes: 30 additions & 0 deletions packages/opencode/test/permission/next.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,36 @@ test("evaluate - glob pattern match", () => {
expect(result.action).toBe("allow")
})

test("evaluate - url pattern match", () => {
const result = PermissionNext.evaluate("webfetch", "example.com/path", [
{ permission: "webfetch", pattern: "example.com/*", action: "allow" },
])
expect(result.action).toBe("allow")
})

test("evaluate - protocol specific wins when ordered last", () => {
const result = PermissionNext.evaluate("webfetch", "https://example.com/path", [
{ permission: "webfetch", pattern: "example.com/*", action: "allow" },
{ permission: "webfetch", pattern: "https://example.com/*", action: "deny" },
])
expect(result.action).toBe("deny")
})

test("evaluate - webfetch deny all except specific host", () => {
const ruleset: PermissionNext.Ruleset = [
{ permission: "webfetch", pattern: "*", action: "deny" },
{ permission: "webfetch", pattern: "*://github.com*", action: "allow" },
]
// github.com should be allowed (full URLs)
expect(PermissionNext.evaluate("webfetch", "https://github.com", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("webfetch", "https://github.com/", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("webfetch", "https://github.com/user/repo", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("webfetch", "http://github.com/foo", ruleset).action).toBe("allow")
// other hosts should be denied
expect(PermissionNext.evaluate("webfetch", "https://example.com", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("webfetch", "https://api.github.com/user", ruleset).action).toBe("deny")
})

test("evaluate - last matching glob wins", () => {
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/*", action: "deny" },
Expand Down
82 changes: 82 additions & 0 deletions packages/opencode/test/tool/webfetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import { WebFetchTool } from "../../src/tool/webfetch"
import type { PermissionNext } from "../../src/permission/next"

function formatWebfetchRules(ruleset: PermissionNext.Ruleset): string | undefined {
const rules = ruleset.filter((r) => r.permission === "webfetch")
if (!rules.length) return
if (rules.length === 1 && rules[0].pattern === "*" && rules[0].action === "allow") return
const lines = rules.map((r) => ` ${r.action}: ${r.pattern}`)
return ["<webfetch-url-permissions>", ...lines, "</webfetch-url-permissions>"].join("\n")
}

const ctx = {
sessionID: "test",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}

describe("formatWebfetchRules", () => {
test("returns undefined when no webfetch rules", () => {
const ruleset: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
expect(formatWebfetchRules(ruleset)).toBeUndefined()
})

test("returns undefined when only default allow", () => {
const ruleset: PermissionNext.Ruleset = [{ permission: "webfetch", pattern: "*", action: "allow" }]
expect(formatWebfetchRules(ruleset)).toBeUndefined()
})

test("formats mixed rules", () => {
const ruleset: PermissionNext.Ruleset = [
{ permission: "webfetch", pattern: "*", action: "deny" },
{ permission: "webfetch", pattern: "https://docs.example.com/*", action: "allow" },
{ permission: "webfetch", pattern: "*.internal.com/*", action: "ask" },
]
const result = formatWebfetchRules(ruleset)
expect(result).toBe(
[
"<webfetch-url-permissions>",
" deny: *",
" allow: https://docs.example.com/*",
" ask: *.internal.com/*",
"</webfetch-url-permissions>",
].join("\n"),
)
})

test("preserves rule order", () => {
const ruleset: PermissionNext.Ruleset = [
{ permission: "webfetch", pattern: "https://allowed.com/*", action: "allow" },
{ permission: "webfetch", pattern: "*", action: "deny" },
]
const result = formatWebfetchRules(ruleset)
expect(result).toBe(
["<webfetch-url-permissions>", " allow: https://allowed.com/*", " deny: *", "</webfetch-url-permissions>"].join(
"\n",
),
)
})

test("formats deny all except specific host", () => {
const ruleset: PermissionNext.Ruleset = [
{ permission: "webfetch", pattern: "*", action: "deny" },
{ permission: "webfetch", pattern: "github.com", action: "allow" },
{ permission: "webfetch", pattern: "github.com/*", action: "allow" },
]
const result = formatWebfetchRules(ruleset)
expect(result).toBe(
[
"<webfetch-url-permissions>",
" deny: *",
" allow: github.com",
" allow: github.com/*",
"</webfetch-url-permissions>",
].join("\n"),
)
})
})
2 changes: 1 addition & 1 deletion packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8917,7 +8917,7 @@
"$ref": "#/components/schemas/PermissionActionConfig"
},
"webfetch": {
"$ref": "#/components/schemas/PermissionActionConfig"
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"websearch": {
"$ref": "#/components/schemas/PermissionActionConfig"
Expand Down