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
43Bug: allow_bash and allow_web_search were only read from form_data, so JSON
54API callers (Content-Type: application/json) always had bash disabled.
65
76Fix: (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
1211import ast
1514import pytest
1615
1716from 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
7882def 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():
102108def _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+
183235def 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
200250def 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+
216276def 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