#8855 · @robertfall · opened Jan 16, 2026 at 9:57 AM UTC · last updated Mar 21, 2026 at 10:47 AM UTC

feat(webfetch): Allow granular URL permissions

appfeat
66
+20647 files

Score breakdown

Impact

9.0

Clarity

8.0

Urgency

4.0

Ease Of Review

7.0

Guidelines

8.0

Readiness

7.0

Size

2.0

Trust

5.0

Traction

9.0

Summary

This PR introduces granular URL permission rules for the webfetch tool, significantly enhancing security by allowing specific allow/block patterns. It expands existing permission configuration and includes dedicated tests for the new functionality.

Open in GitHub

Description

Summary

  • allow granular URL permission rules for webfetch
  • expand permission patterns to include protocol/host/path variants
  • add tests for config + webfetch patterns

Testing

  • bun test ./test/tool/webfetch.test.ts ./test/permission/next.test.ts ./test/config/config.test.ts

Issue

  • Fixes #7445

Linked Issues

#7445 [FEATURE]: webfetch allowed/blocked URLs

View issue

Comments

PR comments

wolffberg

Would it make sense to add the rules to the context so the LLM doesn't have to bruteforce fetches?

robertfall

Done. Another look?

wolffberg

LGTM 🎉

wolffberg

I just did a few more tests. Can you elaborate on how the permissions should work if you want to allow only specific URLs?

Say I want to allow github.com but block everything else, how can this be achieved?

robertfall

Say I want to allow github.com but block everything else, how can this be achieved?

If I understand the rules engine correctly, this should be:

{
  "permissions": {
    "webfetch": {
      "*": "deny",
      "github.com": "allow"
    }
  }
}

I've added tests here. I hope I've understood everything.

wolffberg

I can't get it to work. I tested the following:

webfetch tool is not available at all

{
  "permission": {
    "webfetch": {
      "github.com": "allow",
      "*": "deny"
    }
  }
}

no URL's work

{
  "permission": {
    "webfetch": {
      "*": "deny",
      "github.com": "allow"
    }
  }
}

all URL's work

{
  "permission": {
    "webfetch": {
      "github.com": "allow"
    }
  }
}

Could it be model specific? I'm only testing with the free models available by default.

robertfall

I can't get it to work. I tested the following:

What does not working look like? The tool is completely ignored?

I've only been driving it from the tests... I'll figure out how to run it locally.

wolffberg

Yes, all the models I try do not list the webfetch tool as available at all.

If you have Docker installed you can test by:

  1. Check out your branch locally and cd to the root of the repo
  2. Run docker run --rm -it -v $(pwd):/home/bun/app oven/bun bash
  3. Add a working config file to ~/.config/opencode/opencode.json
  4. Run bun install && bun dev

robertfall

@wolffberg I've fixed this. I had misunderstood the permissions system.

This simplifies the feature and uses only the existing globbing from permissions.

Worth noting that if "deny": "*" is the last rule for a tool, the tool is disabled. This seems to be the current pattern. eg.

{
  "permission": {
    "webfetch": {
      "github.com": "allow",
      "*": "deny"
    }
  }
}

I have this working with the following config now:

    "webfetch": {
      "*": "ask",
      "*://github.com*": "allow"
    },

Because of the simplified globbing we need a * for protocol if we want to support all protocols. The ask command currently adds the *://${host}* when always is chosen.

I think trying to be too smart here means this gets complex quickly. This approach is backwards compatible and allows users to allow specific URLs using globbing with ask and deny available.

w0rp

I would love to see a tested and working version of this! I will watch the PR. This is a very useful security feature to have, which has an equivalent in Claude Code.

robertfall

@w0rp this PR has tests, but has been pretty much ignored since being opened. I'm not sure how to get attention on it and I stopped trying to get it picket up and over the line because there has genuinely been no movement on it in months.

Changed Files

packages/opencode/src/config/config.ts

+11
@@ -675,7 +675,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(),

packages/opencode/src/session/prompt.ts

+121
@@ -664,13 +664,16 @@ export namespace SessionPrompt {
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
}
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
const webfetch = formatWebfetchRules(ruleset)
const result = await processor.process({
user: lastUser,
agent,
permission: session.permission,
abort,
sessionID,
system,
system: [...system, ...(webfetch ? [webfetch] : [])],
messages: [
...MessageV2.toModelMessages(msgs, model),
...(isLastStep
@@ -1995,4 +1998,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
}
}
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")
}
}

packages/opencode/src/tool/webfetch.ts

+21
@@ -24,10 +24,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,

packages/opencode/test/config/config.test.ts

+290
@@ -1124,6 +1124,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) => {

packages/opencode/test/permission/next.test.ts

+300
@@ -224,6 +224,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/", rule

packages/opencode/test/tool/webfetch.test.ts

+1310
@@ -3,6 +3,15 @@ import path from "path"
import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
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 projectRoot = path.join(import.meta.dir, "../..")
@@ -99,3 +108,125 @@ describe("tool.webfetch", () => {
)
})
})
describe("tool.webfetch permission patterns", () => {
const stubFetch = async () =>
new Response("ok", {
status: 200,
headers: { "content-type": "text/plain" },
})
test("includes portless host patterns", async () => {
const webfetch = await WebFetchTool.init()
let patterns: string[] = []
const as

packages/sdk/openapi.json

+11
@@ -9873,7 +9873,7 @@
"$ref": "#/components/schemas/PermissionActionConfig"
},
"webfetch": {
"$ref": "#/components/schemas/PermissionActionConfig"
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"websearch": {
"$ref": "#/components/schemas/PermissionActionConfig"