detached 964ab58

fix(web): harden exploration policy

commit
964ab58beb4f603223e5a661e9769596bcc8e8d4
author
brice · · 2026-08-19 06:06 UTC
parent
8dff8a1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +65 -16
M packages/agent/build.go +4 -2
@@ -1266,8 +1266,10 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
12661266 if orchestratorExplorationToolsAllowed(args) {
12671267 reg["read"] = all["read"]
12681268 reg["grep"] = all["grep"]
1269- for name, tool := range tools.NewWebTools() {
1270- reg[name] = tool
1269+ if args.PermissionSet == nil {
1270+ for name, tool := range tools.NewWebTools() {
1271+ reg[name] = tool
1272+ }
12711273 }
12721274 }
12731275 return reg
@@ -1266,8 +1266,10 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1266 if orchestratorExplorationToolsAllowed(args) {1266 if orchestratorExplorationToolsAllowed(args) {
1267 reg["read"] = all["read"]1267 reg["read"] = all["read"]
1268 reg["grep"] = all["grep"]1268 reg["grep"] = all["grep"]
1269- for name, tool := range tools.NewWebTools() {1269+ if args.PermissionSet == nil {
1270- reg[name] = tool1270+ for name, tool := range tools.NewWebTools() {
1271+ reg[name] = tool
1272+ }
1271 }1273 }
1272 }1274 }
1273 return reg1275 return reg
M packages/agent/build_orchestrator_test.go +14 -2
@@ -12,30 +12,42 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) {
1212 name string
1313 args Args
1414 wantResearch bool
15+ wantWeb bool
1516 wantWriteTool bool
1617 }{
1718 {
1819 name: "requested allowlist",
1920 args: Args{Orchestrate: true, ToolsSet: true, Tools: []string{"subagent_spawn"}},
2021 wantResearch: true,
22+ wantWeb: true,
2123 wantWriteTool: false,
2224 },
2325 {
2426 name: "web search policy disabled",
2527 args: Args{Orchestrate: true, WebSearchPolicy: subagents.WebSearchDeny},
2628 wantResearch: true,
29+ wantWeb: true,
30+ wantWriteTool: true,
31+ },
32+ {
33+ name: "permission ceiling",
34+ args: Args{Orchestrate: true, PermissionSet: &tools.PermissionSet{}},
35+ wantResearch: true,
36+ wantWeb: false,
2737 wantWriteTool: true,
2838 },
2939 {
3040 name: "tools disabled",
3141 args: Args{Orchestrate: true, NoTools: true, ToolsSet: true, Tools: []string{"write"}},
3242 wantResearch: false,
43+ wantWeb: false,
3344 wantWriteTool: false,
3445 },
3546 {
3647 name: "normal session",
3748 args: Args{ToolsSet: true, Tools: []string{"write"}},
3849 wantResearch: false,
50+ wantWeb: false,
3951 wantWriteTool: true,
4052 },
4153 } {
@@ -54,8 +66,8 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) {
5466 }
5567 for _, name := range tools.WebCapabilityNames {
5668 _, gotWebTool := registry[name]
57- if gotWebTool != tc.wantResearch {
58- t.Fatalf("%s present = %v, want research tool present = %v", name, gotWebTool, tc.wantResearch)
69+ if gotWebTool != tc.wantWeb {
70+ t.Fatalf("%s present = %v, want web tool present = %v", name, gotWebTool, tc.wantWeb)
5971 }
6072 }
6173 _, gotWrite := registry["write"]
@@ -12,30 +12,42 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) {
12 name string12 name string
13 args Args13 args Args
14 wantResearch bool14 wantResearch bool
15+ wantWeb bool
15 wantWriteTool bool16 wantWriteTool bool
16 }{17 }{
17 {18 {
18 name: "requested allowlist",19 name: "requested allowlist",
19 args: Args{Orchestrate: true, ToolsSet: true, Tools: []string{"subagent_spawn"}},20 args: Args{Orchestrate: true, ToolsSet: true, Tools: []string{"subagent_spawn"}},
20 wantResearch: true,21 wantResearch: true,
22+ wantWeb: true,
21 wantWriteTool: false,23 wantWriteTool: false,
22 },24 },
23 {25 {
24 name: "web search policy disabled",26 name: "web search policy disabled",
25 args: Args{Orchestrate: true, WebSearchPolicy: subagents.WebSearchDeny},27 args: Args{Orchestrate: true, WebSearchPolicy: subagents.WebSearchDeny},
26 wantResearch: true,28 wantResearch: true,
29+ wantWeb: true,
30+ wantWriteTool: true,
31+ },
32+ {
33+ name: "permission ceiling",
34+ args: Args{Orchestrate: true, PermissionSet: &tools.PermissionSet{}},
35+ wantResearch: true,
36+ wantWeb: false,
27 wantWriteTool: true,37 wantWriteTool: true,
28 },38 },
29 {39 {
30 name: "tools disabled",40 name: "tools disabled",
31 args: Args{Orchestrate: true, NoTools: true, ToolsSet: true, Tools: []string{"write"}},41 args: Args{Orchestrate: true, NoTools: true, ToolsSet: true, Tools: []string{"write"}},
32 wantResearch: false,42 wantResearch: false,
43+ wantWeb: false,
33 wantWriteTool: false,44 wantWriteTool: false,
34 },45 },
35 {46 {
36 name: "normal session",47 name: "normal session",
37 args: Args{ToolsSet: true, Tools: []string{"write"}},48 args: Args{ToolsSet: true, Tools: []string{"write"}},
38 wantResearch: false,49 wantResearch: false,
50+ wantWeb: false,
39 wantWriteTool: true,51 wantWriteTool: true,
40 },52 },
41 } {53 } {
@@ -54,8 +66,8 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) {
54 }66 }
55 for _, name := range tools.WebCapabilityNames {67 for _, name := range tools.WebCapabilityNames {
56 _, gotWebTool := registry[name]68 _, gotWebTool := registry[name]
57- if gotWebTool != tc.wantResearch {69+ if gotWebTool != tc.wantWeb {
58- t.Fatalf("%s present = %v, want research tool present = %v", name, gotWebTool, tc.wantResearch)70+ t.Fatalf("%s present = %v, want web tool present = %v", name, gotWebTool, tc.wantWeb)
59 }71 }
60 }72 }
61 _, gotWrite := registry["write"]73 _, gotWrite := registry["write"]
M packages/agent/subagent_runtime.go +7 -5
@@ -366,14 +366,14 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
366366 }
367367
368368 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
369- hasSearch := false
369+ hasWebTool := false
370370 for _, name := range selected {
371- if name == "web_search" {
372- hasSearch = true
371+ if tools.IsWebCapabilityName(name) {
372+ hasWebTool = true
373373 break
374374 }
375375 }
376- if !hasSearch {
376+ if !hasWebTool {
377377 return selected
378378 }
379379 available := make(map[string]struct{}, len(catalogue))
@@ -382,7 +382,9 @@ func expandWebCapabilityTools(selected, catalogue []string, permitted func(strin
382382 available[name] = struct{}{}
383383 }
384384 for _, name := range selected {
385- selectedSet[name] = struct{}{}
385+ if permitted == nil || permitted(name) {
386+ selectedSet[name] = struct{}{}
387+ }
386388 }
387389 for _, name := range tools.WebCapabilityNames {
388390 if _, ok := available[name]; ok && (permitted == nil || permitted(name)) {
@@ -366,14 +366,14 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
366 }366 }
367 367
368 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {368 func expandWebCapabilityTools(selected, catalogue []string, permitted func(string) bool) []string {
369- hasSearch := false369+ hasWebTool := false
370 for _, name := range selected {370 for _, name := range selected {
371- if name == "web_search" {371+ if tools.IsWebCapabilityName(name) {
372- hasSearch = true372+ hasWebTool = true
373 break373 break
374 }374 }
375 }375 }
376- if !hasSearch {376+ if !hasWebTool {
377 return selected377 return selected
378 }378 }
379 available := make(map[string]struct{}, len(catalogue))379 available := make(map[string]struct{}, len(catalogue))
@@ -382,7 +382,9 @@ func expandWebCapabilityTools(selected, catalogue []string, permitted func(strin
382 available[name] = struct{}{}382 available[name] = struct{}{}
383 }383 }
384 for _, name := range selected {384 for _, name := range selected {
385- selectedSet[name] = struct{}{}385+ if permitted == nil || permitted(name) {
386+ selectedSet[name] = struct{}{}
387+ }
386 }388 }
387 for _, name := range tools.WebCapabilityNames {389 for _, name := range tools.WebCapabilityNames {
388 if _, ok := available[name]; ok && (permitted == nil || permitted(name)) {390 if _, ok := available[name]; ok && (permitted == nil || permitted(name)) {
M packages/agent/subagent_runtime_test.go +25 -0
@@ -25,6 +25,31 @@ func TestExpandWebCapabilityTools(t *testing.T) {
2525 }
2626 }
2727
28+func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T) {
29+ catalogue := append([]string{"read"}, tools.WebCapabilityNames...)
30+ partial := expandWebCapabilityTools([]string{"web_open"}, catalogue, func(string) bool { return true })
31+ for _, name := range tools.WebCapabilityNames {
32+ found := false
33+ for _, candidate := range partial {
34+ if candidate == name {
35+ found = true
36+ break
37+ }
38+ }
39+ if !found {
40+ t.Fatalf("partial web selection did not expand: %v", partial)
41+ }
42+ }
43+ denied := expandWebCapabilityTools([]string{"read", "web_search"}, catalogue, func(name string) bool { return name == "read" })
44+ if len(denied) != 1 || denied[0] != "read" {
45+ t.Fatalf("denied web capability = %v, want [read]", denied)
46+ }
47+ nonWeb := expandWebCapabilityTools([]string{"read"}, catalogue, func(string) bool { return true })
48+ if len(nonWeb) != 1 || nonWeb[0] != "read" {
49+ t.Fatalf("non-web selection changed to %v", nonWeb)
50+ }
51+}
52+
2853 func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {
2954 runtime := newSubagentRuntime(subagentRuntimeConfig{
3055 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
@@ -25,6 +25,31 @@ func TestExpandWebCapabilityTools(t *testing.T) {
25 }25 }
26 }26 }
27 27
28+func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T) {
29+ catalogue := append([]string{"read"}, tools.WebCapabilityNames...)
30+ partial := expandWebCapabilityTools([]string{"web_open"}, catalogue, func(string) bool { return true })
31+ for _, name := range tools.WebCapabilityNames {
32+ found := false
33+ for _, candidate := range partial {
34+ if candidate == name {
35+ found = true
36+ break
37+ }
38+ }
39+ if !found {
40+ t.Fatalf("partial web selection did not expand: %v", partial)
41+ }
42+ }
43+ denied := expandWebCapabilityTools([]string{"read", "web_search"}, catalogue, func(name string) bool { return name == "read" })
44+ if len(denied) != 1 || denied[0] != "read" {
45+ t.Fatalf("denied web capability = %v, want [read]", denied)
46+ }
47+ nonWeb := expandWebCapabilityTools([]string{"read"}, catalogue, func(string) bool { return true })
48+ if len(nonWeb) != 1 || nonWeb[0] != "read" {
49+ t.Fatalf("non-web selection changed to %v", nonWeb)
50+ }
51+}
52+
28 func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {53 func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {
29 runtime := newSubagentRuntime(subagentRuntimeConfig{54 runtime := newSubagentRuntime(subagentRuntimeConfig{
30 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),55 Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
M packages/agent/tools/web_page.go +3 -3
@@ -153,6 +153,9 @@ func (s *BrowsingStore) add(doc *webDocument, generation uint64, checkGeneration
153153 for _, link := range doc.links {
154154 doc.size += len(link.label) + len(link.url)
155155 }
156+ if doc.size > webPageMaxStoreBytes {
157+ return ""
158+ }
156159 for len(s.docs) >= webPageMaxDocuments || (len(s.docs) > 0 && s.used+uint64(doc.size) > webPageMaxStoreBytes) {
157160 var victim *webDocument
158161 for _, candidate := range s.docs {
@@ -166,9 +169,6 @@ func (s *BrowsingStore) add(doc *webDocument, generation uint64, checkGeneration
166169 delete(s.docs, victim.id)
167170 s.used -= uint64(victim.size)
168171 }
169- if doc.size > webPageMaxStoreBytes {
170- return ""
171- }
172172 s.next++
173173 doc.id = fmt.Sprintf("web-%d", s.next)
174174 doc.touched = s.next
@@ -153,6 +153,9 @@ func (s *BrowsingStore) add(doc *webDocument, generation uint64, checkGeneration
153 for _, link := range doc.links {153 for _, link := range doc.links {
154 doc.size += len(link.label) + len(link.url)154 doc.size += len(link.label) + len(link.url)
155 }155 }
156+ if doc.size > webPageMaxStoreBytes {
157+ return ""
158+ }
156 for len(s.docs) >= webPageMaxDocuments || (len(s.docs) > 0 && s.used+uint64(doc.size) > webPageMaxStoreBytes) {159 for len(s.docs) >= webPageMaxDocuments || (len(s.docs) > 0 && s.used+uint64(doc.size) > webPageMaxStoreBytes) {
157 var victim *webDocument160 var victim *webDocument
158 for _, candidate := range s.docs {161 for _, candidate := range s.docs {
@@ -166,9 +169,6 @@ func (s *BrowsingStore) add(doc *webDocument, generation uint64, checkGeneration
166 delete(s.docs, victim.id)169 delete(s.docs, victim.id)
167 s.used -= uint64(victim.size)170 s.used -= uint64(victim.size)
168 }171 }
169- if doc.size > webPageMaxStoreBytes {
170- return ""
171- }
172 s.next++172 s.next++
173 doc.id = fmt.Sprintf("web-%d", s.next)173 doc.id = fmt.Sprintf("web-%d", s.next)
174 doc.touched = s.next174 doc.touched = s.next
M packages/agent/tools/web_page_test.go +12 -4
@@ -139,9 +139,9 @@ func TestWebPageFetcherRedirectsWithFreshValidation(t *testing.T) {
139139 }
140140
141141 func TestWebPageFetcherPreservesHTTPSHostnameForSNI(t *testing.T) {
142- var serverName string
142+ var serverName atomic.Value
143143 server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144- serverName = r.TLS.ServerName
144+ serverName.Store(r.TLS.ServerName)
145145 w.Header().Set("Content-Type", "text/plain")
146146 _, _ = w.Write([]byte("secure"))
147147 }))
@@ -153,8 +153,9 @@ func TestWebPageFetcherPreservesHTTPSHostnameForSNI(t *testing.T) {
153153 // InsecureSkipVerify.
154154 fetcher.tlsConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test-only local certificate
155155 body, _, _, message := fetcher.fetch(context.Background(), "https://public.test/")
156- if message != "" || string(body) != "secure" || serverName != "public.test" {
157- t.Fatalf("HTTPS body=%q message=%q SNI=%q", body, message, serverName)
156+ got, _ := serverName.Load().(string)
157+ if message != "" || string(body) != "secure" || got != "public.test" {
158+ t.Fatalf("HTTPS body=%q message=%q SNI=%q", body, message, got)
158159 }
159160 }
160161
@@ -254,7 +255,14 @@ func TestBrowsingStoreConcurrentAccess(t *testing.T) {
254255
255256 func TestWebPageStoreGenerationAndSchemaFailures(t *testing.T) {
256257 store := NewBrowsingStore()
258+ retained := store.addSource("http://public.test/")
257259 generation := store.snapshotGeneration()
260+ if id, committed := store.addPage(&webDocument{url: "http://public.test", lines: []string{strings.Repeat("x", webPageMaxStoreBytes+1)}}, generation); id != "" || committed {
261+ t.Fatalf("oversized page commit = %q, %v", id, committed)
262+ }
263+ if _, ok := store.get(retained, false); !ok {
264+ t.Fatal("oversized page evicted an existing document")
265+ }
258266 store.Clear()
259267 if id, committed := store.addPage(&webDocument{url: "http://public.test", lines: []string{"text"}}, generation); id != "" || committed {
260268 t.Fatalf("stale page commit = %q, %v", id, committed)
@@ -139,9 +139,9 @@ func TestWebPageFetcherRedirectsWithFreshValidation(t *testing.T) {
139 }139 }
140 140
141 func TestWebPageFetcherPreservesHTTPSHostnameForSNI(t *testing.T) {141 func TestWebPageFetcherPreservesHTTPSHostnameForSNI(t *testing.T) {
142- var serverName string142+ var serverName atomic.Value
143 server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {143 server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144- serverName = r.TLS.ServerName144+ serverName.Store(r.TLS.ServerName)
145 w.Header().Set("Content-Type", "text/plain")145 w.Header().Set("Content-Type", "text/plain")
146 _, _ = w.Write([]byte("secure"))146 _, _ = w.Write([]byte("secure"))
147 }))147 }))
@@ -153,8 +153,9 @@ func TestWebPageFetcherPreservesHTTPSHostnameForSNI(t *testing.T) {
153 // InsecureSkipVerify.153 // InsecureSkipVerify.
154 fetcher.tlsConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test-only local certificate154 fetcher.tlsConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test-only local certificate
155 body, _, _, message := fetcher.fetch(context.Background(), "https://public.test/")155 body, _, _, message := fetcher.fetch(context.Background(), "https://public.test/")
156- if message != "" || string(body) != "secure" || serverName != "public.test" {156+ got, _ := serverName.Load().(string)
157- t.Fatalf("HTTPS body=%q message=%q SNI=%q", body, message, serverName)157+ if message != "" || string(body) != "secure" || got != "public.test" {
158+ t.Fatalf("HTTPS body=%q message=%q SNI=%q", body, message, got)
158 }159 }
159 }160 }
160 161
@@ -254,7 +255,14 @@ func TestBrowsingStoreConcurrentAccess(t *testing.T) {
254 255
255 func TestWebPageStoreGenerationAndSchemaFailures(t *testing.T) {256 func TestWebPageStoreGenerationAndSchemaFailures(t *testing.T) {
256 store := NewBrowsingStore()257 store := NewBrowsingStore()
258+ retained := store.addSource("http://public.test/")
257 generation := store.snapshotGeneration()259 generation := store.snapshotGeneration()
260+ if id, committed := store.addPage(&webDocument{url: "http://public.test", lines: []string{strings.Repeat("x", webPageMaxStoreBytes+1)}}, generation); id != "" || committed {
261+ t.Fatalf("oversized page commit = %q, %v", id, committed)
262+ }
263+ if _, ok := store.get(retained, false); !ok {
264+ t.Fatal("oversized page evicted an existing document")
265+ }
258 store.Clear()266 store.Clear()
259 if id, committed := store.addPage(&webDocument{url: "http://public.test", lines: []string{"text"}}, generation); id != "" || committed {267 if id, committed := store.addPage(&webDocument{url: "http://public.test", lines: []string{"text"}}, generation); id != "" || committed {
260 t.Fatalf("stale page commit = %q, %v", id, committed)268 t.Fatalf("stale page commit = %q, %v", id, committed)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close