feat(core): add a websearch.use policy action (#61557)

This commit is contained in:
Adam 2026-11-04 04:13:25 -06:00 • committed by GitHub
parent ffe6fed583
commit baeebc2f93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 108 additions and 10 deletions

View file

@ -2387,6 +2387,7 @@ export type ConfigEntry =
| "mcp.use"
| "skill.use"
| "plugin.use"
| "websearch.use"
resource: string
effect: "allow" | "deny"
}>
@ -2558,6 +2559,7 @@ export type ConfigEntry =
| "mcp.use"
| "skill.use"
| "plugin.use"
| "websearch.use"
resource: string
effect: "allow" | "deny"
}>

View file

@ -76,7 +76,11 @@ export const Plugin = define({
yield* websearch.transform((editor) => {
const statements = policies()
for (const provider of editor.list()) {
if (integrationDenied(statements, provider.id)) editor.remove(provider.id)
if (
ManagedPolicy.decision(statements, "websearch.use", provider.id) === "deny" ||
integrationDenied(statements, provider.id)
)
editor.remove(provider.id)
}
})
yield* ctx.mcp.transform((servers) => {

View file

@ -185,7 +185,8 @@ export const Plugin = {
Effect.as(false),
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
)
if (disabled) delete event.tools[name]
// Policy can remove every provider, and the model shouldn't be offered a tool that can't search.
if (disabled || (yield* websearch.providers()).length === 0) delete event.tools[name]
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)

View file

@ -14,7 +14,10 @@ import { Session } from "@opencode/core/session"
import { Credential } from "@opencode/core/credential"
import { ConfigIntegration } from "@opencode/core/config/integration"
import { WebSearch } from "@opencode/core/websearch"
import { WebSearchExa } from "@opencode/core/plugin/websearch/exa"
import { WebSearchTinyFish } from "@opencode/core/plugin/websearch/tinyfish"
import { Effect, Schema } from "effect"
import { FetchHttpClient } from "effect/http"
import { waitUntil } from "../lib/eventually"
import { testEffect } from "../lib/effect"
import { organizationPolicy } from "../fixture/policy"
@ -385,6 +388,27 @@ describe("integration policy", () => {
}),
)
it.effect("removes built-in and plugin web search providers that websearch.use denies", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const host = yield* PluginHost.make(yield* Plugin.Service, "test")
// TinyFish needs no API key, so it is offered until a policy removes it.
yield* Effect.all([WebSearchExa.Plugin.effect(host), WebSearchTinyFish.Plugin.effect(host)]).pipe(
Effect.provide(FetchHttpClient.layer),
)
yield* host.websearch.transform((editor) =>
editor.add({ id: WebSearch.ID.make("acme-search"), name: "Acme Search", execute: () => Effect.succeed([]) }),
)
yield* addPlugin([
document(
{ action: "websearch.use", resource: "*", effect: "deny" },
{ action: "websearch.use", resource: "exa", effect: "allow" },
),
])
expect((yield* websearch.providers()).map((item): string => item.id)).toEqual(["exa"])
}),
)
it.effect("converts legacy statements in Console config before enforcement sees them", () =>
Effect.sync(() => {
const remote = Schema.decodeUnknownSync(ConfigIntegration.Remote)({

View file

@ -19,13 +19,25 @@ import { permissionLayer } from "./lib/permission"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
import { webSearchHost } from "./plugin/host"
import { TestWebSearch } from "./lib/websearch"
import type { SessionContext } from "@opencode/plugin/effect/session"
// The plugin's `context` hook, which edits the tools offered to the model.
const contextHooks: ((event: SessionContext) => Effect.Effect<void>)[] = []
const webSearchToolNode = makeLocationNode({
name: "test/websearch-tool-plugin",
layer: Layer.effectDiscard(
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
yield* registerToolPlugin(WebSearchTool.Plugin, {
websearch: webSearchHost(websearch),
session: {
hook: (name, callback) =>
Effect.sync(() => {
if (name === "context") contextHooks.push(callback as (event: SessionContext) => Effect.Effect<void>)
return { dispose: Effect.void }
}),
},
})
}),
),
deps: [Tool.node, Permission.node, WebSearch.node, Form.node],
@ -348,6 +360,23 @@ describe("WebSearchTool registration", () => {
}),
)
it.effect("offers the tool to the model only while a provider remains", () =>
Effect.gen(function* () {
contextHooks.length = 0
const fixture = yield* setup
const offered = Effect.fnUntraced(function* () {
const event = { tools: { [WebSearchTool.name]: { description: "", input: {} } } }
yield* Effect.forEach(contextHooks, (hook) => hook(event as unknown as SessionContext))
return WebSearchTool.name in event.tools
})
expect(yield* offered()).toBe(true)
// As when policy denies every provider.
yield* fixture.websearch.transform((editor) => editor.list().forEach((provider) => editor.remove(provider.id)))
expect(yield* offered()).toBe(false)
}),
)
it.effect("persists the choice to disable web search", () =>
Effect.gen(function* () {
const fixture = yield* setup

View file

@ -16376,7 +16376,8 @@
"integration.use",
"mcp.use",
"skill.use",
"plugin.use"
"plugin.use",
"websearch.use"
]
},
"resource": {
@ -16739,7 +16740,8 @@
"integration.use",
"mcp.use",
"skill.use",
"plugin.use"
"plugin.use",
"websearch.use"
]
},
"resource": {

View file

@ -13,6 +13,7 @@ export type Effect = typeof Effect.Type
* web search, MCP servers, or config an integration supplies come from no account (env keys, local config, the
* signed-out free tier).
* - `mcp.use`, `skill.use`, `plugin.use`: resource is an MCP server name, skill ID, or plugin target.
* - `websearch.use`: resource is a web search provider ID, such as `opencode` or `exa`.
*/
export const Action = Schema.Literals([
"provider.use",
@ -22,6 +23,7 @@ export const Action = Schema.Literals([
"mcp.use",
"skill.use",
"plugin.use",
"websearch.use",
])
export type Action = typeof Action.Type

View file

@ -30,6 +30,16 @@ describe("Config.Entry", () => {
).toThrow()
})
test("round-trips websearch.use statements", () => {
const input = {
policies: [
{ action: "websearch.use", resource: "*", effect: "deny" },
{ action: "websearch.use", resource: "opencode", effect: "allow" },
],
} as const
expect(Schema.encodeSync(Config.Info)(Schema.decodeUnknownSync(Config.Info)(input))).toEqual(input)
})
test("normalizes legacy prefixed integration.use statements into mcp.use, skill.use and plugin.use", () => {
const decode = Schema.decodeUnknownSync(Schema.Array(ConfigPolicy.Info))
expect(

View file

@ -40,11 +40,11 @@ merges into `policies`:
Each statement has three fields:
| Field | Values | Meaning |
| ---------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `action` | `provider.use`, `tool.use`, `integration.connect`, `integration.use`, `mcp.use`, `skill.use`, `plugin.use` | The operation being controlled |
| `resource` | string or wildcard pattern | What the statement applies to; depends on the action |
| `effect` | `allow`, `deny` | The decision when this statement matches |
| Field | Values | Meaning |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `action` | `provider.use`, `tool.use`, `integration.connect`, `integration.use`, `mcp.use`, `skill.use`, `plugin.use`, `websearch.use` | The operation being controlled |
| `resource` | string or wildcard pattern | What the statement applies to; depends on the action |
| `effect` | `allow`, `deny` | The decision when this statement matches |
Statements that fail validation are dropped with a warning in the server log and
the rest of the list still applies. Check the log after editing a `deny`.
@ -247,6 +247,26 @@ Clients older than 2.2.3 ignore `integration.connect`, `integration.use` (in its
meaning), `mcp.use`, `skill.use`, and `plugin.use`, so require 2.2.3 or newer
wherever you rely on them.
## Web search
`websearch.use` controls the web search providers the `websearch` tool can use. The
resource is the provider ID: `opencode` for OpenCode Web Search from the Console, a
built-in provider such as `exa`, `firecrawl`, `parallel`, `tavily`, or `tinyfish`, or
the ID of a provider a plugin adds.
```jsonc
{
"policies": [
{ "action": "websearch.use", "resource": "*", "effect": "deny" },
{ "action": "websearch.use", "resource": "opencode", "effect": "allow" },
],
}
```
Denied providers are removed even when they work without an API key, such as Exa and
TinyFish. When every provider is denied, the `websearch` tool is no longer offered.
Clients older than 2.2.6 ignore `websearch.use`.
## Precedence
Ordinary settings let the nearest configuration win. Policies reverse that: statements

View file

@ -24,6 +24,9 @@ resource: <integration ID>#<account>, or the bare integration ID without an acco
action: mcp.use | skill.use | plugin.use
resource: MCP server name, skill ID, or unversioned package / absolute local path
action: websearch.use
resource: web search provider ID, such as opencode, exa, or tinyfish
Legacy integration.use statements with an mcp:, skill:, or plugin: prefix are
converted to the matching *.use action when ConfigPolicy.Info decodes them.
```
@ -78,6 +81,7 @@ interface PolicyInfo {
| "mcp.use"
| "skill.use"
| "plugin.use"
| "websearch.use"
resource: string
}
```