Skip to content

Commit d88c8cb

Browse files
authored
Merge pull request #5313 from RaresKeY/fix/chat-web-search-explicit-deny
fix(chat): require explicit web search enable
2 parents a35384e + 54f1d01 commit d88c8cb

5 files changed

Lines changed: 266 additions & 69 deletions

File tree

routes/chat_routes.py

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@
4242
_enforce_chat_privileges,
4343
)
4444
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
45-
from src.tool_policy import build_effective_tool_policy
45+
from src.tool_policy import (
46+
WEB_TOOL_NAMES,
47+
build_effective_tool_policy,
48+
is_web_search_explicitly_denied,
49+
web_search_enabled_for_turn,
50+
)
4651

4752
logger = logging.getLogger(__name__)
4853

@@ -583,10 +588,7 @@ async def chat_stream(request: Request) -> StreamingResponse:
583588
# below). Skill extraction should only learn from real agent sessions,
584589
# not chats we quietly promoted for a notes/calendar intent.
585590
user_requested_agent = (chat_mode == "agent")
586-
_search_enabled = (
587-
str(allow_web_search).lower() == "true"
588-
or str(use_web).lower() == "true"
589-
)
591+
_search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
590592
# Intent auto-escalation: if the user is clearly asking the assistant
591593
# to create a todo, reminder, or calendar event, promote chat → agent
592594
# for this turn so the LLM has access to manage_notes / manage_calendar.
@@ -870,24 +872,20 @@ async def chat_stream(request: Request) -> StreamingResponse:
870872

871873
# Build disabled-tools set from frontend toggles + user privileges
872874
disabled_tools = set()
873-
# Only disable bash/web_search when the caller *explicitly* set them
874-
# to a falsy value. When unset (None), defer to per-user privilege
875-
# checks below — this lets admins with can_use_bash=True use bash
876-
# by default without having to send allow_bash in every request.
875+
# Only disable bash when the caller *explicitly* set it to a falsy
876+
# value. When unset (None), defer to per-user privilege checks below.
877+
# Web search is per-turn opt-in: either the chat pre-search setting
878+
# (`use_web=true`) or agent web toggle (`allow_web_search=true`) must
879+
# explicitly enable it.
877880
if allow_bash is not None and str(allow_bash).lower() != "true":
878881
disabled_tools.add("bash")
879882
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
880-
if (
881-
allow_web_search is not None
882-
and str(allow_web_search).lower() != "true"
883-
):
884-
disabled_tools.add("web_search")
885-
disabled_tools.add("web_fetch")
883+
if is_web_search_explicitly_denied(allow_web_search) or not _search_enabled:
884+
disabled_tools.update(WEB_TOOL_NAMES)
886885
if _explicit_web_intent:
887886
# A direct lookup/search request should not drift into personal
888-
# tools or shell fallbacks. We still keep web_search/web_fetch
889-
# available even when the frontend toggle is stale/falsy because
890-
# the user's words are the stronger signal.
887+
# tools or shell fallbacks. It can only use web_search/web_fetch
888+
# when the request's explicit web setting enabled them.
891889
disabled_tools.update({
892890
"bash", "python",
893891
"search_chats", "manage_skills", "manage_memory",
@@ -897,11 +895,12 @@ async def chat_stream(request: Request) -> StreamingResponse:
897895
"manage_notes", "manage_calendar", "manage_tasks",
898896
"api_call", "builtin_browser",
899897
})
900-
disabled_tools.discard("web_search")
901-
disabled_tools.discard("web_fetch")
898+
if _search_enabled:
899+
disabled_tools.difference_update(WEB_TOOL_NAMES)
900+
else:
901+
disabled_tools.update(WEB_TOOL_NAMES)
902902
elif _search_enabled:
903-
disabled_tools.discard("web_search")
904-
disabled_tools.discard("web_fetch")
903+
disabled_tools.difference_update(WEB_TOOL_NAMES)
905904

906905
# Nobody/incognito mode: deny tools that would expose the user's
907906
# persistent memory, past chats, or other identity-linked data.
@@ -952,14 +951,7 @@ async def chat_stream(request: Request) -> StreamingResponse:
952951
from src.settings import get_setting
953952
_global_disabled = get_setting("disabled_tools", [])
954953
if _global_disabled and isinstance(_global_disabled, list):
955-
explicit_web_allowed = (
956-
_explicit_web_intent
957-
or (allow_web_search is not None and str(allow_web_search).lower() == "true")
958-
)
959-
if explicit_web_allowed:
960-
disabled_tools.update(t for t in _global_disabled if t not in {"web_search", "web_fetch"})
961-
else:
962-
disabled_tools.update(_global_disabled)
954+
disabled_tools.update(_global_disabled)
963955

964956
# Light auto-escalation: the user is in chat mode and just expressed a
965957
# notes/calendar/email intent. Grant the relevant managers but withhold
@@ -1411,10 +1403,8 @@ def _on_research_done(_sid, _result, _sources, _findings):
14111403
_max_rounds = max(1, min(_max_rounds, 200))
14121404

14131405
_forced_tools = None
1414-
if _explicit_web_intent:
1415-
_forced_tools = {"web_search", "web_fetch"}
1416-
elif _search_enabled:
1417-
_forced_tools = {"web_search", "web_fetch"}
1406+
if _search_enabled:
1407+
_forced_tools = set(WEB_TOOL_NAMES)
14181408

14191409
async for chunk in stream_agent_loop(
14201410
sess.endpoint_url,

src/agent_loop.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from src.settings import get_setting
2525
from src.prompt_security import untrusted_context_message
2626
from src.tool_security import blocked_tools_for_owner, plan_mode_disabled_tools
27-
from src.tool_policy import GUIDE_ONLY_DIRECTIVE, ToolPolicy
27+
from src.tool_policy import GUIDE_ONLY_DIRECTIVE, WEB_TOOL_NAMES, ToolPolicy
2828
from src.tool_utils import _truncate, get_mcp_manager
2929
from src.agent_tools import (
3030
parse_tool_blocks,
@@ -321,7 +321,7 @@ def _load_mcp_disabled_map() -> Dict[str, set]:
321321
}
322322

323323
_DOMAIN_TOOL_MAP = {
324-
"web": {"web_search", "web_fetch"},
324+
"web": set(WEB_TOOL_NAMES),
325325
"documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"},
326326
"email": {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "archive_email", "delete_email", "mark_email_read", "resolve_contact", "manage_contact"},
327327
"cookbook": {"download_model", "serve_model", "serve_preset", "list_serve_presets", "list_served_models", "stop_served_model", "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "list_cached_models", "list_cookbook_servers", "adopt_served_model"},
@@ -2847,13 +2847,12 @@ async def stream_agent_loop(
28472847
if "email" in (_intent.get("domains") or set()):
28482848
_relevant_tools.add("ui_control")
28492849
if "web" in (_intent.get("domains") or set()):
2850-
_relevant_tools.update({"web_search", "web_fetch"})
2851-
_removed_web_blocks = sorted({"web_search", "web_fetch"} & disabled_tools)
2852-
if _removed_web_blocks:
2853-
disabled_tools.difference_update({"web_search", "web_fetch"})
2850+
_relevant_tools.update(WEB_TOOL_NAMES)
2851+
_blocked_web_tools = sorted(WEB_TOOL_NAMES & disabled_tools)
2852+
if _blocked_web_tools:
28542853
logger.info(
2855-
"[agent-intent] web turn forced search tools enabled; removed disabled=%s",
2856-
_removed_web_blocks,
2854+
"[agent-intent] web domain selected but search tools remain disabled=%s",
2855+
_blocked_web_tools,
28572856
)
28582857
if "ui" in (_intent.get("domains") or set()):
28592858
_relevant_tools.add("ui_control")
@@ -2887,9 +2886,9 @@ async def stream_agent_loop(
28872886
_relevant_tools = set(ALWAYS_AVAILABLE)
28882887
_relevant_tools.update({"read_file", "grep", "ls", "manage_documents"})
28892888

2890-
# Per-request forced tools are stronger than retrieval. Search toggles and
2891-
# explicit lookup turns must make web tools visible even when tool RAG
2892-
# misses them; route-level disabled_tools decides what else is allowed.
2889+
# Per-request forced tools are stronger than retrieval. Explicit search
2890+
# settings make web tools visible even when tool RAG misses them;
2891+
# route-level disabled_tools decides what remains allowed.
28932892
if not guide_only and forced_tools:
28942893
forced_set = {t for t in forced_tools if t not in disabled_tools}
28952894
if _relevant_tools is None:

src/tool_policy.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,39 @@
1616
"output they will produce locally."
1717
)
1818

19+
WEB_TOOL_NAMES = frozenset({"web_search", "web_fetch"})
20+
21+
22+
def tool_toggle_enabled(value: object) -> bool:
23+
"""Return true only for explicit true-like tool toggle values."""
24+
25+
return str(value).lower() == "true"
26+
27+
28+
def tool_toggle_explicitly_denied(value: object) -> bool:
29+
"""Return true when a caller explicitly supplied a non-true toggle value."""
30+
31+
return value is not None and not tool_toggle_enabled(value)
32+
33+
34+
def is_web_search_explicitly_denied(allow_web_search: object) -> bool:
35+
"""Whether the web-search agent toggle was explicitly set to false."""
36+
37+
return tool_toggle_explicitly_denied(allow_web_search)
38+
39+
40+
def web_search_enabled_for_turn(allow_web_search: object, use_web: object = None) -> bool:
41+
"""Return true only when this request explicitly enables web search.
42+
43+
Agent mode sends ``allow_web_search``; chat-mode pre-search sends
44+
``use_web``. If both are present, an explicit ``allow_web_search=false``
45+
wins so a stale or conflicting intent path cannot re-enable web tools.
46+
"""
47+
48+
if is_web_search_explicitly_denied(allow_web_search):
49+
return False
50+
return tool_toggle_enabled(allow_web_search) or tool_toggle_enabled(use_web)
51+
1952

2053
_COMMON_TOOL_NAMES = {
2154
"api_call",

tests/test_chat_route_tool_policy.py

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
"""Issue #3229 — allow_bash / allow_web_search must work for JSON API callers
2-
and admin users must get bash enabled by default.
1+
"""Issue #3229 and explicit web-toggle regressions.
32
43
Bug: allow_bash and allow_web_search were only read from form_data, so JSON
54
API callers (Content-Type: application/json) always had bash disabled.
65
76
Fix: (1) Read from JSON body as fallback.
8-
(2) Only add bash/web_search to disabled_tools when explicitly set to a
9-
falsy value; when unset (None), defer to per-user privilege checks.
7+
(2) Keep bash on the privilege fallback when unset.
8+
(3) Require an explicit per-turn web setting before exposing web tools.
109
"""
1110

1211
import ast
@@ -15,6 +14,11 @@
1514
import pytest
1615

1716
from src.action_intents import classify_tool_intent
17+
from src.tool_policy import (
18+
WEB_TOOL_NAMES,
19+
is_web_search_explicitly_denied,
20+
web_search_enabled_for_turn,
21+
)
1822

1923
_CHAT_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py"
2024

@@ -76,8 +80,7 @@ def test_allow_web_search_reads_from_body_as_fallback():
7680

7781

7882
def test_disabled_tools_respects_missing_vs_explicit_toggles():
79-
"""When allow_bash is not set (None), bash must NOT be unconditionally
80-
added to disabled_tools. The per-user privilege check handles it.
83+
"""Bash still defers to privileges, but web is an explicit per-turn opt-in.
8184
"""
8285
source = _CHAT_ROUTES.read_text(encoding="utf-8")
8386

@@ -88,11 +91,14 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
8891
assert "allow_bash is not None" in source, (
8992
"disabled_tools check must guard against allow_bash being None"
9093
)
91-
assert "allow_web_search is not None" in source, (
92-
"disabled_tools check must guard against allow_web_search being None"
94+
assert "web_search_enabled_for_turn(allow_web_search, use_web)" in source, (
95+
"web tools must be gated through the explicit per-turn web setting"
96+
)
97+
assert "disabled_tools.update(WEB_TOOL_NAMES)" in source, (
98+
"disabled_tools must add web_search/web_fetch when web is not explicitly enabled"
9399
)
94-
assert "and not _explicit_web_intent" not in source, (
95-
"explicit allow_web_search=false must not be overridden by prompt web intent"
100+
assert "_forced_tools = set(WEB_TOOL_NAMES)" in source, (
101+
"web tools should only be forced visible from the explicit web setting"
96102
)
97103

98104

@@ -102,31 +108,48 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
102108
def _build_disabled_tools(
103109
allow_bash=None,
104110
allow_web_search=None,
111+
use_web=None,
105112
can_use_bash=True,
106113
can_use_browser=True,
107114
explicit_web_intent=False,
115+
global_disabled=None,
108116
):
109117
"""Replicate the disabled-tools logic from chat_stream for unit testing.
110118
111119
Returns the set of tool names that would be disabled.
112120
"""
113121
disabled_tools = set()
114122

115-
# Issue #3229 fix: only disable when explicitly set to a falsy value.
123+
# Issue #3229 fix: only disable bash when explicitly set to a falsy value.
116124
if allow_bash is not None and str(allow_bash).lower() != "true":
117125
disabled_tools.add("bash")
118-
if (
119-
allow_web_search is not None
120-
and str(allow_web_search).lower() != "true"
121-
):
122-
disabled_tools.add("web_search")
123-
disabled_tools.add("web_fetch")
126+
search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
127+
if is_web_search_explicitly_denied(allow_web_search) or not search_enabled:
128+
disabled_tools.update(WEB_TOOL_NAMES)
129+
if explicit_web_intent:
130+
disabled_tools.update({
131+
"bash", "python",
132+
"search_chats", "manage_skills", "manage_memory",
133+
"read_file", "write_file", "edit_file",
134+
"create_document", "edit_document", "update_document",
135+
"send_email", "reply_to_email",
136+
"manage_notes", "manage_calendar", "manage_tasks",
137+
"api_call", "builtin_browser",
138+
})
139+
if search_enabled:
140+
disabled_tools.difference_update(WEB_TOOL_NAMES)
141+
else:
142+
disabled_tools.update(WEB_TOOL_NAMES)
143+
elif search_enabled:
144+
disabled_tools.difference_update(WEB_TOOL_NAMES)
124145

125146
# Enforce per-user privileges
126147
if not can_use_bash:
127148
disabled_tools.update({"bash", "python", "read_file", "write_file"})
128149
if not can_use_browser:
129150
disabled_tools.add("builtin_browser")
151+
if global_disabled and isinstance(global_disabled, list):
152+
disabled_tools.update(global_disabled)
130153

131154
return disabled_tools
132155

@@ -157,6 +180,20 @@ def test_json_body_allow_web_search_false_disables_web():
157180
assert "web_fetch" in disabled
158181

159182

183+
def test_chat_mode_use_web_true_enables_web():
184+
"""Chat pre-search sends use_web=true as the explicit web setting."""
185+
disabled = _build_disabled_tools(use_web="true")
186+
assert "web_search" not in disabled
187+
assert "web_fetch" not in disabled
188+
189+
190+
def test_allow_web_search_false_wins_over_use_web_true():
191+
"""The agent web toggle hard-denies web even if another path says use_web=true."""
192+
disabled = _build_disabled_tools(allow_web_search="false", use_web="true")
193+
assert "web_search" in disabled
194+
assert "web_fetch" in disabled
195+
196+
160197
@pytest.mark.parametrize(
161198
"message",
162199
[
@@ -180,6 +217,21 @@ def test_explicit_false_disables_web_despite_prompt_web_intent(message):
180217
assert "web_fetch" in disabled
181218

182219

220+
def test_prompt_web_intent_does_not_enable_web_without_setting():
221+
"""Prompt-derived web intent alone must not expose web tools."""
222+
intent = classify_tool_intent("look up the latest docs")
223+
assert intent is not None
224+
assert intent.category == "web"
225+
226+
disabled = _build_disabled_tools(
227+
allow_web_search=None,
228+
use_web=None,
229+
explicit_web_intent=True,
230+
)
231+
assert "web_search" in disabled
232+
assert "web_fetch" in disabled
233+
234+
183235
def test_admin_user_gets_bash_enabled_by_default():
184236
"""When allow_bash is not set and user has can_use_bash privilege,
185237
bash must NOT be disabled.
@@ -188,13 +240,11 @@ def test_admin_user_gets_bash_enabled_by_default():
188240
assert "bash" not in disabled
189241

190242

191-
def test_admin_user_gets_web_search_enabled_by_default():
192-
"""When allow_web_search is not set and user has normal privileges,
193-
web_search must NOT be disabled.
194-
"""
243+
def test_web_search_disabled_by_default_without_explicit_turn_setting():
244+
"""Missing web settings must not expose web tools by default."""
195245
disabled = _build_disabled_tools(allow_web_search=None)
196-
assert "web_search" not in disabled
197-
assert "web_fetch" not in disabled
246+
assert "web_search" in disabled
247+
assert "web_fetch" in disabled
198248

199249

200250
def test_non_privileged_user_without_explicit_flag_still_disabled():
@@ -213,6 +263,16 @@ def test_non_privileged_user_explicit_true_overridden_by_privilege():
213263
assert "bash" in disabled
214264

215265

266+
def test_global_disabled_web_wins_over_explicit_web_enable():
267+
"""Admin-level disabled tools are still a hard deny."""
268+
disabled = _build_disabled_tools(
269+
allow_web_search="true",
270+
global_disabled=["web_search", "web_fetch"],
271+
)
272+
assert "web_search" in disabled
273+
assert "web_fetch" in disabled
274+
275+
216276
def test_form_data_none_body_true_works():
217277
"""Simulates: form_data has no allow_bash, body has allow_bash=true.
218278
After the fallback (`form_data.get(...) or body.get(...)`), allow_bash

0 commit comments

Comments
 (0)