Skip to content

Commit 960db96

Browse files
GautamBytesGautam Manchandani
andauthored
fix(tools): require permission before web_search requests (#382)
* fix(tools): gate web_search behind network permission * chore(tools): clarify web_search permission reason --------- Co-authored-by: Gautam Manchandani <gautammanch@Gautams-MacBook-Air.local>
1 parent b1ccb6d commit 960db96

5 files changed

Lines changed: 138 additions & 23 deletions

File tree

‎internal/agent/loop_test.go‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,35 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
3737
return ch, nil
3838
}
3939

40+
type recordingWebSearchTool struct {
41+
calls []map[string]any
42+
}
43+
44+
func (tool *recordingWebSearchTool) Name() string { return "web_search" }
45+
func (tool *recordingWebSearchTool) Description() string { return "test web search tool" }
46+
func (tool *recordingWebSearchTool) Parameters() tools.Schema {
47+
return tools.Schema{
48+
Type: "object",
49+
Properties: map[string]tools.PropertySchema{
50+
"query": {Type: "string"},
51+
},
52+
Required: []string{"query"},
53+
AdditionalProperties: false,
54+
}
55+
}
56+
func (tool *recordingWebSearchTool) Safety() tools.Safety {
57+
return tools.Safety{
58+
SideEffect: tools.SideEffectNetwork,
59+
Permission: tools.PermissionPrompt,
60+
Reason: "Sends model-provided search query text to the configured web search backend.",
61+
AdvertiseInAuto: true,
62+
}
63+
}
64+
func (tool *recordingWebSearchTool) Run(_ context.Context, args map[string]any) tools.Result {
65+
tool.calls = append(tool.calls, cloneArgs(args))
66+
return tools.Result{Status: tools.StatusOK, Output: "1. T — https://x.test"}
67+
}
68+
4069
type sandboxDeniedRetryTool struct {
4170
calls []map[string]any
4271
}
@@ -865,7 +894,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
865894
}
866895
}
867896

868-
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
897+
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
869898
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
870899
registry := tools.NewRegistry()
871900
for _, tool := range tools.CoreNetworkTools() {
@@ -896,6 +925,52 @@ func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
896925
}
897926
}
898927

928+
func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
929+
search := &recordingWebSearchTool{}
930+
registry := tools.NewRegistry()
931+
registry.Register(search)
932+
provider := &mockProvider{
933+
turns: [][]zeroruntime.StreamEvent{
934+
{
935+
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "web_search"},
936+
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"query":"private workspace detail"}`},
937+
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
938+
{Type: zeroruntime.StreamEventDone},
939+
},
940+
{
941+
{Type: zeroruntime.StreamEventText, Content: "done"},
942+
{Type: zeroruntime.StreamEventDone},
943+
},
944+
},
945+
}
946+
var requests []PermissionRequest
947+
948+
result, err := Run(context.Background(), "search", provider, Options{
949+
Registry: registry,
950+
PermissionMode: PermissionModeAsk,
951+
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
952+
requests = append(requests, request)
953+
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "network not approved"}, nil
954+
},
955+
})
956+
957+
if err != nil {
958+
t.Fatal(err)
959+
}
960+
if result.FinalAnswer != "done" {
961+
t.Fatalf("expected final answer after denied tool call, got %q", result.FinalAnswer)
962+
}
963+
if len(requests) != 1 {
964+
t.Fatalf("expected one permission request, got %#v", requests)
965+
}
966+
if requests[0].ToolName != "web_search" || requests[0].Permission != string(tools.PermissionPrompt) || requests[0].SideEffect != string(tools.SideEffectNetwork) {
967+
t.Fatalf("unexpected permission request: %#v", requests[0])
968+
}
969+
if len(search.calls) != 0 {
970+
t.Fatalf("web_search backend must not run when permission is denied, got calls %#v", search.calls)
971+
}
972+
}
973+
899974
func TestRunFiltersAdvertisedTools(t *testing.T) {
900975
root := t.TempDir()
901976
registry := tools.NewRegistry()

‎internal/sandbox/engine_test.go‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,25 @@ import (
99
"time"
1010
)
1111

12-
// TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy verifies that the
13-
// sandbox network policy gates sandboxed shell egress, not first-party
14-
// in-process web tools.
15-
func TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy(t *testing.T) {
12+
// TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy verifies
13+
// that first-party in-process network tools are governed by their permission
14+
// metadata, not by the sandboxed shell egress policy.
15+
func TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy(t *testing.T) {
1616
base := Policy{Mode: ModeEnforce, Network: NetworkDeny}
1717

1818
engine := NewEngine(EngineOptions{Policy: base})
1919
d := engine.Evaluate(context.Background(), Request{
20-
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
20+
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
2121
})
22-
if d.Action != ActionAllow || d.Block != nil {
23-
t.Fatalf("web_search must be allowed by the sandbox gate, got %#v", d)
22+
if d.Action != ActionPrompt || d.Block != nil {
23+
t.Fatalf("web_search must prompt before permission is granted, got %#v", d)
24+
}
25+
if d.Reason == ReasonNetworkBlocked {
26+
t.Fatalf("web_search prompt should come from tool permission, not shell network policy: %#v", d)
2427
}
2528

2629
allowed := engine.Evaluate(context.Background(), Request{
27-
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
30+
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
2831
})
2932
if allowed.Action != ActionAllow || allowed.Block != nil {
3033
t.Fatalf("granted web_search must be allowed under deny, got %#v", allowed)

‎internal/tools/registry_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ func TestCoreNetworkToolsExposeSafetyMetadata(t *testing.T) {
104104
if property, ok := search.Parameters().Properties["query"]; !ok || property.Type != "string" {
105105
t.Fatalf("web_search must expose a string query property, got %#v", search.Parameters().Properties["query"])
106106
}
107-
if safety := search.Safety(); safety.Permission != PermissionAllow || safety.AdvertiseInAuto {
108-
t.Fatalf("web_search safety = %#v, want allow without prompt-advertise override", safety)
107+
if safety := search.Safety(); safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
108+
t.Fatalf("web_search safety = %#v, want prompt and advertised in auto", safety)
109109
}
110110
}
111111

‎internal/tools/web_search.go‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,21 +81,23 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
8181
Required: []string{"query"},
8282
AdditionalProperties: false,
8383
},
84-
// Hosted search is a read-only discovery action. It remains marked as
85-
// network for metadata, but sandbox network policy is the shell egress
86-
// boundary; web_search keeps its own result-domain filtering safeguards.
84+
// Hosted search sends model-provided query text to a configured network
85+
// backend. Keep it visible in auto mode, but guard execution through the
86+
// normal permission flow like web_fetch.
8787
safety: Safety{
88-
SideEffect: SideEffectNetwork,
89-
Permission: PermissionAllow,
90-
Reason: "Performs a web search over the network.",
88+
SideEffect: SideEffectNetwork,
89+
Permission: PermissionPrompt,
90+
Reason: "Sends model-provided search query text to the configured web search backend.",
91+
AdvertiseInAuto: true,
9192
},
9293
},
9394
backend: backend,
9495
}
9596
}
9697

9798
// RunWithSandbox follows the normal web_search path. The sandbox network policy
98-
// gates sandboxed shell egress, not this in-process hosted search tool.
99+
// gates sandboxed shell egress; this in-process hosted search tool is guarded by
100+
// the permission flow plus backend and result-domain safeguards.
99101
func (tool webSearchTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
100102
return tool.Run(ctx, args)
101103
}

‎internal/tools/web_search_test.go‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,52 @@ func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
116116
}
117117
}
118118

119-
func TestWebSearchSafetyAllowsHostedSearchWithoutPrompt(t *testing.T) {
119+
func TestWebSearchSafetyPromptsForHostedSearchButAdvertisesInAuto(t *testing.T) {
120120
tool := newWebSearchToolWithBackend(&fakeSearchBackend{})
121121
safety := tool.Safety()
122122
if safety.SideEffect != SideEffectNetwork {
123123
t.Fatalf("side effect = %s, want network", safety.SideEffect)
124124
}
125-
if safety.Permission != PermissionAllow {
126-
t.Fatalf("permission = %s, want allow", safety.Permission)
125+
if safety.Permission != PermissionPrompt {
126+
t.Fatalf("permission = %s, want prompt", safety.Permission)
127127
}
128-
if safety.AdvertiseInAuto {
129-
t.Fatal("web_search should not need the prompt-tool auto advertisement override")
128+
if !safety.AdvertiseInAuto {
129+
t.Fatal("web_search should be advertised in auto mode while still requiring permission")
130+
}
131+
}
132+
133+
func TestWebSearchRegistryRequiresPermissionBeforeBackendCall(t *testing.T) {
134+
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
135+
registry := NewRegistry()
136+
registry.Register(newWebSearchToolWithBackend(backend))
137+
138+
res := registry.Run(context.Background(), "web_search", map[string]any{"query": "private workspace detail"})
139+
140+
if res.Status != StatusError {
141+
t.Fatalf("expected permission error, got %s: %s", res.Status, res.Output)
142+
}
143+
if !strings.Contains(res.Output, "Permission required for web_search") {
144+
t.Fatalf("expected permission-required output, got %q", res.Output)
145+
}
146+
if backend.gotQuery != "" {
147+
t.Fatalf("backend must not be called before permission, got query %q", backend.gotQuery)
148+
}
149+
}
150+
151+
func TestWebSearchRegistryRunsAfterPermissionGranted(t *testing.T) {
152+
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
153+
registry := NewRegistry()
154+
registry.Register(newWebSearchToolWithBackend(backend))
155+
156+
res := registry.RunWithOptions(context.Background(), "web_search", map[string]any{"query": "go errors"}, RunOptions{
157+
PermissionGranted: true,
158+
})
159+
160+
if res.Status != StatusOK {
161+
t.Fatalf("expected ok, got %s: %s", res.Status, res.Output)
162+
}
163+
if backend.gotQuery != "go errors" {
164+
t.Fatalf("backend query = %q, want %q", backend.gotQuery, "go errors")
130165
}
131166
}
132167

0 commit comments

Comments
 (0)