main
py 870 lines 26.3 KB
Raw
1 from __future__ import annotations
2
3 from pathlib import Path
4 import subprocess
5 import sys
6 from types import SimpleNamespace
7
8 import pytest
9
10 from extensions.python.system_prompt import _11_tools_prompt, _13_skills_prompt
11 from helpers import files, mcp_handler, responses_tools, tool_policy
12 from helpers.errors import RepairableException
13 from plugins._tool_access.extensions.python.tool_execute_before._10_enforce_tool_policy import (
14 EnforceToolPolicy,
15 )
16
17
18 class _Context:
19 def get_data(self, key: str, recursive: bool = True):
20 return None
21
22
23 class _Agent:
24 def __init__(self, prompt_root: Path, profile: str = "researcher") -> None:
25 self.prompt_root = prompt_root
26 self.config = SimpleNamespace(profile=profile)
27 self.context = _Context()
28 self.data: dict = {}
29
30 def read_prompt(self, basename: str, **kwargs) -> str:
31 content = (self.prompt_root / basename).read_text(encoding="utf-8")
32 for key, value in kwargs.items():
33 content = content.replace("{{" + key + "}}", str(value))
34 return content
35
36 def get_data(self, key: str):
37 return self.data.get(key)
38
39
40 class _NoMCPTools:
41 def get_tools(self):
42 return []
43
44
45 def _write_prompt(root: Path, basename: str, content: str) -> None:
46 (root / basename).write_text(content.strip() + "\n", encoding="utf-8")
47
48
49 def _prompt_paths(root: Path):
50 def get_paths(agent, *parts, **kwargs):
51 return [str(root)] if parts and parts[0] == "prompts" else []
52
53 return get_paths
54
55
56 def _custom_policy(*, default: str, mcp_default: str = "allow", allowed=(), blocked=()):
57 return {
58 "mode": "custom",
59 "default": default,
60 "mcp_default": mcp_default,
61 "allowed": list(allowed),
62 "blocked": list(blocked),
63 }
64
65
66 def test_agent_import_does_not_cycle_through_tool_policy() -> None:
67 subprocess.run(
68 [sys.executable, "-c", "import agent"],
69 cwd=Path(__file__).parents[1],
70 check=True,
71 )
72
73
74 @pytest.fixture
75 def local_prompt_agent(monkeypatch, tmp_path: Path) -> _Agent:
76 _write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
77 _write_prompt(
78 tmp_path,
79 "agent.system.tool.allowed.md",
80 """### allowed
81 Allowed description
82 Keyboard input remains documented.
83 Do not call the `blocked` tool from here.
84 {"tool_name":"allowed","tool_args":{}}""",
85 )
86 _write_prompt(
87 tmp_path,
88 "agent.system.tool.blocked.md",
89 '### blocked\nBlocked description\n{"tool_name":"blocked","tool_args":{}}',
90 )
91 monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
92 monkeypatch.setattr(
93 "plugins._model_config.helpers.model_config.get_chat_model_config",
94 lambda agent: {"vision": False},
95 )
96 monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
97 return _Agent(tmp_path)
98
99
100 @pytest.mark.asyncio
101 async def test_text_tool_prompt_omits_blocked_tool_and_description(
102 monkeypatch, local_prompt_agent: _Agent
103 ) -> None:
104 monkeypatch.setattr(
105 tool_policy,
106 "get_policy",
107 lambda agent: _custom_policy(default="allow", blocked=["local:blocked"]),
108 )
109
110 prompt = await _11_tools_prompt.build_prompt(local_prompt_agent)
111
112 assert "Allowed description" in prompt
113 assert "Keyboard input remains documented." in prompt
114 assert "Do not call" not in prompt
115 assert "blocked" not in prompt.lower()
116 assert "Blocked description" not in prompt
117
118
119 def test_provider_native_schemas_omit_blocked_local_tool(
120 monkeypatch, local_prompt_agent: _Agent
121 ) -> None:
122 monkeypatch.setattr(
123 tool_policy,
124 "get_policy",
125 lambda agent: _custom_policy(default="allow", blocked=["local:blocked"]),
126 )
127
128 tools, _name_map = responses_tools.build_responses_function_tools(
129 local_prompt_agent
130 )
131
132 assert [tool["name"] for tool in tools] == ["allowed"]
133
134
135 def test_inherited_prompt_filter_skips_tool_inventory(monkeypatch, tmp_path: Path):
136 agent = _Agent(tmp_path)
137 prompt = "### shell\nRun a command."
138 policy_reads = 0
139
140 def inherited_policy(_agent):
141 nonlocal policy_reads
142 policy_reads += 1
143 return {
144 "mode": "inherit",
145 "default": "allow",
146 "mcp_default": "allow",
147 "allowed": [],
148 "blocked": [],
149 }
150
151 monkeypatch.setattr(tool_policy, "get_policy", inherited_policy)
152 monkeypatch.setattr(
153 tool_policy,
154 "_policy_tool_names",
155 lambda _agent: pytest.fail("inherited policy inventoried tools"),
156 )
157
158 assert tool_policy.filter_tool_prompt(
159 agent, "agent.system.tool.shell.md", prompt
160 ) == prompt
161 assert policy_reads == 1
162
163
164 def test_required_response_survives_default_block(monkeypatch, tmp_path: Path) -> None:
165 _write_prompt(
166 tmp_path,
167 "agent.system.tool.response.md",
168 '### response\nfinal answer\n{"tool_name":"response","tool_args":{"text":"done"}}',
169 )
170 monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
171 monkeypatch.setattr(
172 tool_policy,
173 "get_policy",
174 lambda agent: _custom_policy(default="block", blocked=["local:response"]),
175 )
176 monkeypatch.setattr(
177 mcp_handler.MCPConfig,
178 "get_for_agent",
179 lambda agent: _NoMCPTools(),
180 )
181 agent = _Agent(tmp_path)
182
183 decision = tool_policy.resolve_tool(agent, "response")
184
185 assert decision.allowed is True
186 assert decision.source == "framework-required"
187 assert tool_policy.get_tool_catalog(agent) == []
188
189
190 def test_tool_and_mcp_defaults_are_independent(monkeypatch, tmp_path: Path) -> None:
191 agent = _Agent(tmp_path)
192 monkeypatch.setattr(
193 tool_policy,
194 "get_policy",
195 lambda _agent: _custom_policy(
196 default="block",
197 mcp_default="allow",
198 allowed=["local:pinned"],
199 blocked=["mcp:docs:delete"],
200 ),
201 )
202
203 assert tool_policy.resolve_tool(agent, "shell", canonical_id="local:shell").allowed is False
204 assert tool_policy.resolve_tool(agent, "read", canonical_id="mcp:docs:read").allowed is True
205 assert tool_policy.resolve_tool(agent, "pinned", canonical_id="local:pinned").allowed is True
206 assert tool_policy.resolve_tool(agent, "delete", canonical_id="mcp:docs:delete").allowed is False
207
208
209 def test_catalog_comes_from_executable_tools_not_prompt_names(
210 monkeypatch, tmp_path: Path
211 ) -> None:
212 prompt_root = tmp_path / "prompts"
213 tool_root = tmp_path / "tools"
214 prompt_root.mkdir()
215 tool_root.mkdir()
216 _write_prompt(
217 prompt_root,
218 "agent.system.tool.actual.md",
219 "### actual\nActual description",
220 )
221 _write_prompt(
222 prompt_root,
223 "agent.system.tool.prompt_only.md",
224 "### prompt_only\nNo executable implementation",
225 )
226 (tool_root / "actual.py").write_text("class Actual: pass\n", encoding="utf-8")
227 (tool_root / "response.py").write_text("class Response: pass\n", encoding="utf-8")
228
229 def get_paths(agent, *parts, **kwargs):
230 if parts[0] == "prompts":
231 return [str(prompt_root)]
232 if len(parts) == 1:
233 return [str(tool_root)]
234 return [str(tool_root / parts[1])]
235
236 monkeypatch.setattr(tool_policy.subagents, "get_paths", get_paths)
237 monkeypatch.setattr(
238 mcp_handler.MCPConfig,
239 "get_for_agent",
240 lambda agent: _NoMCPTools(),
241 )
242 monkeypatch.setattr(
243 tool_policy,
244 "get_policy",
245 lambda agent: {
246 "mode": "inherit",
247 "default": "allow",
248 "allowed": [],
249 "blocked": [],
250 },
251 )
252
253 catalog = tool_policy.get_tool_catalog(_Agent(prompt_root))
254
255 assert [item["id"] for item in catalog] == ["local:actual"]
256 assert catalog[0]["description"] == "Actual description"
257
258
259 def test_catalog_keeps_installed_remote_tools_without_live_connector(
260 monkeypatch, tmp_path: Path
261 ) -> None:
262 tool_root = tmp_path / "tools"
263 tool_root.mkdir()
264 (tool_root / "code_execution_remote.py").write_text("", encoding="utf-8")
265 (tool_root / "shell.py").write_text("", encoding="utf-8")
266 monkeypatch.setattr(
267 tool_policy.subagents,
268 "get_paths",
269 lambda *args, **kwargs: [str(tool_root)],
270 )
271 monkeypatch.setattr(
272 mcp_handler.MCPConfig,
273 "get_for_agent",
274 lambda agent: _NoMCPTools(),
275 )
276 monkeypatch.setattr(
277 tool_policy,
278 "get_policy",
279 lambda agent: {
280 "mode": "inherit",
281 "default": "allow",
282 "allowed": [],
283 "blocked": [],
284 },
285 )
286
287 catalog = tool_policy.get_tool_catalog(_Agent(tmp_path))
288
289 assert [item["name"] for item in catalog] == ["code_execution_remote", "shell"]
290
291
292 def test_mcp_catalog_labels_include_humanized_server_and_tool(
293 monkeypatch, tmp_path: Path
294 ) -> None:
295 class MCPTools:
296 def get_tools(self):
297 return [
298 {
299 "deep_wiki.ask_question": {
300 "name": "ask_question",
301 "description": "Ask DeepWiki",
302 "server": "deep_wiki",
303 }
304 }
305 ]
306
307 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
308 monkeypatch.setattr(mcp_handler.MCPConfig, "get_for_agent", lambda agent: MCPTools())
309 monkeypatch.setattr(
310 tool_policy,
311 "get_policy",
312 lambda agent: {
313 "mode": "inherit",
314 "default": "allow",
315 "allowed": [],
316 "blocked": [],
317 },
318 )
319
320 assert tool_policy.get_tool_catalog(_Agent(tmp_path)) == [
321 {
322 "id": "mcp:deep_wiki:ask_question",
323 "name": "deep_wiki.ask_question",
324 "label": "Deep Wiki · Ask Question",
325 "description": "Ask DeepWiki",
326 "origin": "MCP · deep_wiki",
327 "available": True,
328 }
329 ]
330
331
332 @pytest.mark.asyncio
333 async def test_skills_catalog_prompt_is_absent_when_skills_tool_is_blocked(
334 monkeypatch, tmp_path: Path
335 ) -> None:
336 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
337 monkeypatch.setattr(
338 tool_policy,
339 "get_policy",
340 lambda agent: _custom_policy(default="allow", blocked=["local:skills_tool"]),
341 )
342 monkeypatch.setattr(
343 _13_skills_prompt.skills_helper,
344 "list_skills",
345 lambda **kwargs: pytest.fail("blocked skill discovery ran"),
346 )
347
348 assert await _13_skills_prompt.build_prompt(_Agent(tmp_path)) == ""
349
350
351 def test_tool_prompt_description_skips_fenced_examples() -> None:
352 prompt = """### example
353 ~~~json
354 {"tool_name":"example","tool_args":{}}
355 ~~~
356 Visible summary
357 """
358
359 assert tool_policy.tool_prompt_description(prompt, "example") == "Visible summary"
360
361
362 def test_tool_prompt_description_prefers_declared_summary() -> None:
363 prompt = """## tools
364 - `memory_load`: search stored memories by meaning and metadata
365 args: `query`, optional `limit`
366 """
367
368 assert (
369 tool_policy.tool_prompt_description(prompt, "memory_load")
370 == "search stored memories by meaning and metadata"
371 )
372
373
374 def test_prompt_filter_removes_complete_blocked_json_example(
375 monkeypatch, tmp_path: Path
376 ) -> None:
377 monkeypatch.setattr(
378 tool_policy,
379 "_policy_tool_names",
380 lambda agent: {"memory_load", "memory_save"},
381 )
382 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
383 monkeypatch.setattr(
384 tool_policy,
385 "get_policy",
386 lambda agent: _custom_policy(
387 default="allow", blocked=["local:memory_load"]
388 ),
389 )
390 prompt = """## memory tools
391 - `memory_load`: load memory
392 - `memory_save`: save memory
393 ~~~json
394 {
395 "tool_name": "memory_load",
396 "tool_args": {"query": "blocked example"}
397 }
398 ~~~
399 ~~~json
400 {
401 "tool_name": "memory_save",
402 "tool_args": {"text": "allowed example"}
403 }
404 ~~~
405 """
406
407 filtered = tool_policy.filter_tool_prompt(
408 _Agent(tmp_path), "agent.system.tool.memory.md", prompt
409 )
410
411 assert "blocked example" not in filtered
412 assert "memory_load" not in filtered
413 assert "allowed example" in filtered
414 assert filtered.count("~~~json") == 1
415 assert filtered.count("~~~") == 2
416
417
418 def test_plugin_tool_identity_uses_canonical_plugin_roots(
419 monkeypatch, tmp_path: Path
420 ) -> None:
421 plugin_root = tmp_path / "plugins" / "_example"
422 plugin_tool = plugin_root / "tools" / "actual.py"
423 plugin_tool.parent.mkdir(parents=True)
424 plugin_tool.write_text("class Actual: pass\n", encoding="utf-8")
425 monkeypatch.setattr(
426 tool_policy.plugins,
427 "get_plugin_roots",
428 lambda: [str(tmp_path / "usr" / "plugins"), str(tmp_path / "plugins")],
429 )
430 monkeypatch.setattr(
431 tool_policy,
432 "get_policy",
433 lambda agent: {
434 "mode": "inherit",
435 "default": "allow",
436 "allowed": [],
437 "blocked": [],
438 },
439 )
440 agent = _Agent(tmp_path)
441
442 monkeypatch.setattr(
443 tool_policy.subagents,
444 "get_paths",
445 lambda *args, **kwargs: [str(plugin_tool)],
446 )
447 assert tool_policy.resolve_tool(agent, "actual").tool_id == "plugin:_example:actual"
448
449 lookalike = tmp_path / "work" / "plugins" / "_example" / "tools" / "actual.py"
450 lookalike.parent.mkdir(parents=True)
451 lookalike.write_text("class Actual: pass\n", encoding="utf-8")
452 monkeypatch.setattr(
453 tool_policy.subagents,
454 "get_paths",
455 lambda *args, **kwargs: [str(lookalike)],
456 )
457 assert tool_policy.resolve_tool(agent, "actual").tool_id == "local:actual"
458
459
460 def test_dotted_local_tool_keeps_local_identity_at_execution_gate(
461 monkeypatch, tmp_path: Path
462 ) -> None:
463 tool_path = tmp_path / "docs.read.py"
464 tool_path.write_text("class Tool: pass\n", encoding="utf-8")
465 monkeypatch.setattr(
466 tool_policy.subagents,
467 "get_paths",
468 lambda *args, **kwargs: [str(tool_path)],
469 )
470 monkeypatch.setattr(
471 mcp_handler.MCPConfig,
472 "get_for_agent",
473 lambda agent: _NoMCPTools(),
474 )
475 monkeypatch.setattr(
476 tool_policy,
477 "get_policy",
478 lambda agent: _custom_policy(
479 default="allow", blocked=["local:docs.read"]
480 ),
481 )
482 agent = _Agent(tmp_path)
483
484 with pytest.raises(RepairableException, match='Tool "docs.read" is blocked'):
485 tool_policy.ensure_tool_allowed(agent, "docs.read")
486
487
488 def test_legacy_response_and_vision_policy_ids_stay_out_of_catalog(
489 monkeypatch, tmp_path: Path
490 ) -> None:
491 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
492 monkeypatch.setattr(
493 mcp_handler.MCPConfig,
494 "get_for_agent",
495 lambda agent: _NoMCPTools(),
496 )
497 monkeypatch.setattr(
498 tool_policy,
499 "get_policy",
500 lambda agent: _custom_policy(
501 default="allow",
502 blocked=[
503 "response",
504 "local:response",
505 "plugin:legacy:response",
506 "vision_load",
507 "local:vision_load",
508 "plugin:legacy:vision_load",
509 ],
510 ),
511 )
512
513 assert tool_policy.get_tool_catalog(_Agent(tmp_path)) == []
514
515
516 @pytest.mark.asyncio
517 async def test_vision_tool_follows_chat_config_not_profile_policy(
518 monkeypatch, tmp_path: Path
519 ) -> None:
520 _write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
521 _write_prompt(
522 tmp_path,
523 "agent.system.tools_vision.md",
524 '### vision_load\nload images\n{"tool_name":"vision_load","tool_args":{"paths":[]}}',
525 )
526 monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
527 monkeypatch.setattr(
528 "plugins._model_config.helpers.model_config.get_chat_model_config",
529 lambda agent: {"vision": True},
530 )
531 monkeypatch.setattr(
532 "plugins._model_config.helpers.model_config.get_vision_model_config",
533 lambda agent: {},
534 )
535 monkeypatch.setattr(
536 tool_policy,
537 "get_policy",
538 lambda agent: _custom_policy(
539 default="block", blocked=["local:vision_load"]
540 ),
541 )
542 monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
543 agent = _Agent(tmp_path)
544
545 prompt = await _11_tools_prompt.build_prompt(agent)
546 schemas, _name_map = responses_tools.build_responses_function_tools(agent)
547
548 assert "vision_load" in prompt
549 assert [schema["name"] for schema in schemas] == ["vision_load"]
550 assert tool_policy.resolve_tool(agent, "vision_load").source == "runtime-config"
551
552
553 @pytest.mark.asyncio
554 async def test_active_vision_model_uses_canonical_vision_prompt(
555 monkeypatch, tmp_path: Path
556 ) -> None:
557 _write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
558 _write_prompt(
559 tmp_path,
560 "agent.system.tools_vision.md",
561 "### vision_load\ncanonical vision\nargs: `paths`, `query`",
562 )
563 monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
564 monkeypatch.setattr(
565 "plugins._model_config.helpers.model_config.get_chat_model_config",
566 lambda agent: {"vision": False},
567 )
568 monkeypatch.setattr(
569 "plugins._model_config.helpers.model_config.get_vision_model_config",
570 lambda agent: {"provider": "test", "name": "vision"},
571 )
572 monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
573 agent = _Agent(tmp_path)
574
575 prompt = await _11_tools_prompt.build_prompt(agent)
576 schemas, _name_map = responses_tools.build_responses_function_tools(agent)
577
578 assert prompt.count("canonical vision") == 1
579 assert schemas[0]["name"] == "vision_load"
580 assert schemas[0]["description"] == "canonical vision"
581
582
583 def test_vision_prompt_stays_route_agnostic_and_batches_paths() -> None:
584 source = (
585 Path(__file__).resolve().parents[1]
586 / "prompts"
587 / "agent.system.tools_vision.md"
588 ).read_text(encoding="utf-8")
589 schema = responses_tools._schema_from_prompt(source)
590
591 assert schema == {
592 "type": "object",
593 "properties": {},
594 "additionalProperties": True,
595 }
596 assert "load all relevant images in one call" in source
597 assert "Input schema for tool_args" not in source
598 assert "Vision Model" not in source
599 assert "sidecar" not in source.lower()
600 assert "optional `query`" in source
601
602
603 def test_vision_framework_prompt_renders_optional_query() -> None:
604 prompt_dir = str(Path(__file__).resolve().parents[1] / "prompts")
605
606 without_query = files.read_prompt_file(
607 "fw.vision_load.md",
608 _directories=[prompt_dir],
609 request="Review these screenshots.",
610 query="",
611 )
612 with_query = files.read_prompt_file(
613 "fw.vision_load.md",
614 _directories=[prompt_dir],
615 request="Review these screenshots.",
616 query="Compare the error banners.",
617 )
618
619 assert "Visual query:" not in without_query
620 assert "Current request:\nReview these screenshots." in with_query
621 assert "Visual query:\nCompare the error banners." in with_query
622
623
624 def test_mcp_prompt_and_native_schema_omit_blocked_tool(
625 monkeypatch, tmp_path: Path
626 ) -> None:
627 class Server:
628 name = "docs"
629 description = "Documentation"
630
631 def get_tools(self):
632 return [
633 {
634 "name": "read",
635 "description": "Read docs",
636 "input_schema": {"type": "object"},
637 },
638 {
639 "name": "write",
640 "description": "Write docs",
641 "input_schema": {"type": "object"},
642 },
643 ]
644
645 config = mcp_handler.MCPConfig(servers_list=[])
646 config.servers = [Server()]
647 agent = _Agent(tmp_path)
648 monkeypatch.setattr(
649 tool_policy,
650 "get_policy",
651 lambda agent: _custom_policy(
652 default="allow", blocked=["mcp:docs:write"]
653 ),
654 )
655 monkeypatch.setattr(
656 responses_tools,
657 "_mcp_tools",
658 lambda agent: [
659 ("docs.read", Server().get_tools()[0]),
660 ("docs.write", Server().get_tools()[1]),
661 ],
662 )
663 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args: [])
664 monkeypatch.setattr(responses_tools, "_vision_tool_prompt", lambda agent: "")
665
666 prompt = config.get_tools_prompt(agent=agent)
667 schemas, name_map = responses_tools.build_responses_function_tools(agent)
668
669 assert "docs.read" in prompt
670 assert "docs.write" not in prompt
671 assert len(schemas) == 1
672 assert name_map[schemas[0]["name"]] == "docs.read"
673
674
675 @pytest.mark.asyncio
676 async def test_local_execution_gate_returns_stable_profile_error(
677 monkeypatch, tmp_path: Path
678 ) -> None:
679 agent = _Agent(tmp_path, profile="researcher")
680 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
681 monkeypatch.setattr(
682 tool_policy,
683 "get_policy",
684 lambda agent: _custom_policy(default="block"),
685 )
686
687 with pytest.raises(
688 RepairableException,
689 match='Tool "shell" is blocked for agent profile "researcher"',
690 ):
691 await EnforceToolPolicy(agent).execute(tool_name="shell")
692
693
694 @pytest.mark.asyncio
695 async def test_mcp_invocation_rechecks_policy_before_server_call(
696 monkeypatch, tmp_path: Path
697 ) -> None:
698 agent = _Agent(tmp_path, profile="researcher")
699 called = False
700
701 class Config:
702 async def call_tool(self, name, kwargs):
703 nonlocal called
704 called = True
705 raise AssertionError("blocked MCP call reached the server")
706
707 monkeypatch.setattr(mcp_handler.MCPConfig, "get_for_agent", lambda agent: Config())
708 monkeypatch.setattr(
709 tool_policy,
710 "get_policy",
711 lambda agent: _custom_policy(
712 default="allow", blocked=["mcp:docs:write"]
713 ),
714 )
715 tool = mcp_handler.MCPTool(
716 agent=agent,
717 name="docs.write",
718 method=None,
719 args={},
720 message="",
721 loop_data=None,
722 )
723
724 with pytest.raises(RepairableException, match='Tool "docs.write" is blocked'):
725 await tool.execute()
726 assert called is False
727
728
729 @pytest.mark.asyncio
730 async def test_delegated_agent_uses_its_own_profile_policy_at_execution_gate(
731 monkeypatch, tmp_path: Path
732 ) -> None:
733 parent = _Agent(tmp_path, profile="agent0")
734 child = _Agent(tmp_path, profile="researcher")
735 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
736
737 def policy_for_profile(agent):
738 if agent.config.profile == "researcher":
739 return _custom_policy(default="block")
740 return {"mode": "inherit"}
741
742 monkeypatch.setattr(tool_policy, "get_policy", policy_for_profile)
743
744 assert tool_policy.resolve_tool(parent, "shell").allowed is True
745 assert tool_policy.resolve_tool(child, "shell").allowed is False
746 await EnforceToolPolicy(parent).execute(tool_name="shell")
747 with pytest.raises(
748 RepairableException,
749 match='Tool "shell" is blocked for agent profile "researcher"',
750 ):
751 await EnforceToolPolicy(child).execute(tool_name="shell")
752
753
754 def test_project_policy_precedes_profile_policy(
755 monkeypatch: pytest.MonkeyPatch,
756 tmp_path: Path,
757 ) -> None:
758 class ProjectContext:
759 def get_data(self, key: str, recursive: bool = True):
760 return "demo" if key == "project" else None
761
762 monkeypatch.setattr(tool_policy.files, "_base_dir", str(tmp_path))
763 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
764 monkeypatch.setattr(
765 tool_policy.plugins,
766 "call_plugin_hook",
767 lambda _plugin, _hook, default=None, **_kwargs: default,
768 )
769 agent = _Agent(tmp_path)
770 agent.context = ProjectContext()
771
772 tool_policy.plugins.save_plugin_config(
773 tool_policy.PLUGIN_NAME,
774 "",
775 "researcher",
776 _custom_policy(default="block"),
777 )
778 tool_policy.plugins.save_plugin_config(
779 tool_policy.PLUGIN_NAME,
780 "demo",
781 "",
782 _custom_policy(default="allow"),
783 )
784 tool_policy.plugins.save_plugin_config(
785 tool_policy.PLUGIN_NAME,
786 "demo",
787 "researcher",
788 _custom_policy(default="allow", blocked=["local:shell"]),
789 )
790 profile_path = Path(
791 tool_policy.plugins.determine_plugin_asset_path(
792 tool_policy.PLUGIN_NAME,
793 "",
794 "researcher",
795 tool_policy.plugins.CONFIG_FILE_NAME,
796 )
797 )
798 project_path = Path(
799 tool_policy.plugins.determine_plugin_asset_path(
800 tool_policy.PLUGIN_NAME,
801 "demo",
802 "",
803 tool_policy.plugins.CONFIG_FILE_NAME,
804 )
805 )
806 project_profile_path = Path(
807 tool_policy.plugins.determine_plugin_asset_path(
808 tool_policy.PLUGIN_NAME,
809 "demo",
810 "researcher",
811 tool_policy.plugins.CONFIG_FILE_NAME,
812 )
813 )
814
815 decision = tool_policy.resolve_tool(agent, "shell")
816 assert decision.allowed is False
817 assert decision.source == "scoped-policy"
818
819 project_profile_path.write_text('{"manual": true}\n', encoding="utf-8")
820 decision = tool_policy.resolve_tool(agent, "shell")
821 assert decision.allowed is True
822 assert decision.source == "scoped-default"
823 assert project_profile_path.read_text(encoding="utf-8") == '{"manual": true}\n'
824
825 project_profile_path.write_text(
826 '{"manual": true, "mode": "inherit", "default": "block", '
827 '"allowed": [], "blocked": ["local:shell"]}\n',
828 encoding="utf-8",
829 )
830 decision = tool_policy.resolve_tool(agent, "shell")
831 assert decision.allowed is True
832 assert decision.source == "scoped-default"
833
834 project_path.unlink()
835 decision = tool_policy.resolve_tool(agent, "shell")
836 assert decision.allowed is False
837 assert decision.source == "scoped-default"
838 assert profile_path.is_file()
839
840
841 def test_unknown_policy_ids_are_retained_as_unavailable(
842 monkeypatch, tmp_path: Path
843 ) -> None:
844 agent = _Agent(tmp_path)
845 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
846 monkeypatch.setattr(
847 mcp_handler.MCPConfig,
848 "get_for_agent",
849 lambda agent: _NoMCPTools(),
850 )
851 monkeypatch.setattr(
852 tool_policy,
853 "get_policy",
854 lambda agent: _custom_policy(
855 default="allow", blocked=["plugin:missing:ghost"]
856 ),
857 )
858
859 catalog = tool_policy.get_tool_catalog(agent)
860
861 assert catalog == [
862 {
863 "id": "plugin:missing:ghost",
864 "name": "ghost",
865 "label": "Ghost",
866 "description": "",
867 "origin": "Unavailable",
868 "available": False,
869 }
870 ]