Harden scoped tool policy enforcement

Resolve effective policy through inherited layers without letting empty upper configs shadow restrictions, and carry canonical tool identities through local and MCP execution. Enforce required policy hooks and parent delegation checks at runtime, keep installed connector capabilities configurable while disconnected, and remove complete blocked-tool examples from rendered prompts. Use the canonical tool prompts for concise catalog descriptions and add regressions for plugin activation, dotted local names, MCP calls, layered policies, and parallel subordinate paths.

Alessandro committed Aug 10, 2026 at 05:08 UTC afff2e3c055dd9f304a37516a31c5f325e09c3bf
19 files changed +380 -32
api/plugins.py
+6 -3
@@ -248,9 +248,12 @@ class Plugins(ApiHandler):
248 if enabled is None:
249 return Response(status=400, response="Missing enabled state")
250
251 - plugins.toggle_plugin(
252 - plugin_name, bool(enabled), project_name, agent_profile, clear_overrides
253 - )
251 + try:
252 + plugins.toggle_plugin(
253 + plugin_name, bool(enabled), project_name, agent_profile, clear_overrides
254 + )
255 + except ValueError as exc:
256 + return Response(status=400, response=str(exc))
257 return {"ok": True}
258
259 @extension.extensible
api/plugins.py.dox.md
+2
@@ -20,6 +20,8 @@
20 - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
21 - `Plugins` is an `ApiHandler`.
22 - `Plugins` defines `process(...)`.
23 +- Toggle requests that try to disable an `always_enabled` plugin return HTTP
24 + 400 without changing plugin state.
25 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, subprocess/runtime control, plugin state, settings/state persistence.
26 - Imported dependency areas include: `helpers`, `helpers.api`, `helpers.localization`, `json`, `os`, `subprocess`, `sys`.
27
helpers/mcp_handler.py
+6 -2
@@ -434,10 +434,14 @@ class MCPTool(Tool):
434 return message, additional
435
436 async def execute(self, **kwargs: Any):
437 - from helpers.tool_policy import ensure_tool_allowed
437 + from helpers.tool_policy import canonical_mcp_id, ensure_tool_allowed
438
439 if "." in self.name:
440 - ensure_tool_allowed(self.agent, self.name)
440 + ensure_tool_allowed(
441 + self.agent,
442 + self.name,
443 + canonical_id=canonical_mcp_id(self.name),
444 + )
445 error = ""
446 additional: dict[str, Any] | None = None
447 try:
helpers/mcp_handler.py.dox.md
+2 -1
@@ -83,7 +83,8 @@
83 - Server status and detail responses include `scope`, and MCP tools resolve through `MCPConfig.get_for_agent(agent)` before execution.
84 - MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots.
85 - Agent-facing MCP prompt descriptions filter through the central profile tool
86 - policy, and `MCPTool.execute()` rechecks the same policy before invocation.
86 + policy, and `MCPTool.execute()` rechecks the same policy with the explicit MCP
87 + canonical ID before invocation.
88 - `MCPConfig.get_tool()` tries the supplied qualified name first, then restores an advertised Responses alias from the calling agent's name map; names that still do not identify an MCP tool return `None` unchanged for downstream local-tool resolution.
89 - Servers may define `disabled_tools` as a list of MCP tool names. Disabled tools are omitted from agent-facing prompts, status counts, `has_tool`, and calls, while detail views can still retrieve them through `get_all_tools()` with a `disabled` flag so users can re-enable them.
90 - Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations.
helpers/parallel_tools.py
+7 -1
@@ -412,17 +412,23 @@ async def _run_parallel_job(parent_context_id: str, job_id: str) -> None:
412 async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str:
413 from agent import AgentContext, AgentContextType, UserMessage
414 from helpers import message_queue, persist_chat
415 + from helpers.tool_policy import ensure_tool_allowed
416 + from tools.call_subordinate import _validate_subordinate_profile
417
418 parent_context = AgentContext.get(parent_context_id)
419 if not parent_context:
420 raise ValueError("Parent context not found.")
421 + ensure_tool_allowed(parent_context.agent0, "call_subordinate")
422
423 args = job.tool_args
424 message = str(args.get("message") or "").strip()
425 if not message:
426 raise ValueError("call_subordinate requires `tool_args.message`.")
427
425 - profile = str(args.get("profile") or args.get("agent_profile") or "").strip()
428 + profile = _validate_subordinate_profile(
429 + parent_context.agent0,
430 + str(args.get("profile") or args.get("agent_profile") or ""),
431 + )
432 attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else []
433 attachments = [str(item) for item in attachments]
434
helpers/parallel_tools.py.dox.md
+5 -1
@@ -26,7 +26,11 @@
26 - Normalization accepts full agent-reply-shaped objects when `tool_name` and `tool_args` are present; non-contract planning fields such as `thoughts` or `headline` are ignored.
27 - `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification.
28 - Normalization rejects `document_query` and `response` inside `parallel`: document parsing and Q&A must run sequentially, while `response` must remain top-level so it can end the message loop.
29 -- `call_subordinate` jobs run in isolated child chat contexts tagged with parent-chat metadata; they must not be added to the scheduler task list and may use normal child-chat tools, including `parallel`.
29 +- `call_subordinate` jobs first enforce the parent profile's delegation policy
30 + and validate the requested profile through the sequential delegation owner,
31 + then run in isolated child chat contexts tagged with parent-chat metadata;
32 + they must not be added to the scheduler task list and may use normal child-chat
33 + tools, including `parallel`.
34 - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
35 - Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
36 - Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only.
helpers/plugins.py
+9
@@ -475,6 +475,11 @@ def get_enabled_plugins(agent: Agent | None):
475 active = []
476
477 for plugin in plugins:
478 + meta = get_plugin_meta(plugin)
479 + if meta and meta.always_enabled:
480 + active.append(plugin)
481 + continue
482 +
483 # plugins are toggled via .enabled / .disabled files
484 # every plugin is on by default, unless disabled in usr dir
485 enabled = True
@@ -546,6 +551,10 @@ def toggle_plugin(
551 agent_profile: str = "",
552 clear_overrides: bool = False,
553 ):
554 + meta = get_plugin_meta(plugin_name)
555 + if meta and meta.always_enabled and not enabled:
556 + raise ValueError(f'Plugin "{plugin_name}" is always enabled.')
557 +
558 if clear_overrides:
559 all_toggles = find_plugin_assets(
560 TOGGLE_FILE_PATTERN,
helpers/plugins.py.dox.md
+2
@@ -49,6 +49,8 @@
49 ## Runtime Contracts
50
51 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
52 +- Plugins marked `always_enabled` remain in runtime discovery regardless of
53 + stale global or scoped disable files, and disable attempts are rejected.
54 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
55 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling.
56 - Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`.
helpers/tool_policy.py
+39 -8
@@ -36,7 +36,26 @@ def normalize_policy(config: Any) -> dict[str, Any]:
36
37
38 def get_policy(agent: Any) -> dict[str, Any]:
39 - return normalize_policy(plugins.get_plugin_config(PLUGIN_NAME, agent=agent))
39 + from helpers import projects
40 +
41 + project_name = projects.get_context_project_name(agent.context) or ""
42 + profile = str(getattr(agent.config, "profile", "") or "")
43 + for asset in plugins.find_plugin_assets(
44 + plugins.CONFIG_FILE_NAME,
45 + plugin_name=PLUGIN_NAME,
46 + project_name=project_name,
47 + agent_profile=profile,
48 + only_first=False,
49 + ):
50 + config = files.read_file_json(asset["path"])
51 + if not isinstance(config, dict) or not any(
52 + key in config for key in ("mode", "default", "allowed", "blocked")
53 + ):
54 + continue
55 + policy = normalize_policy(config)
56 + if policy["mode"] == "custom":
57 + return policy
58 + return normalize_policy(plugins.get_default_plugin_config(PLUGIN_NAME))
59
60
61 def get_tool_catalog(agent: Any) -> list[dict[str, Any]]:
@@ -162,12 +181,13 @@ def resolve_tool(
181 )
182
183
165 -def ensure_tool_allowed(agent: Any, tool_name: str) -> ToolPolicyDecision:
166 - decision = resolve_tool(
167 - agent,
168 - tool_name,
169 - canonical_id=canonical_mcp_id(tool_name),
170 - )
184 +def ensure_tool_allowed(
185 + agent: Any,
186 + tool_name: str,
187 + *,
188 + canonical_id: str = "",
189 +) -> ToolPolicyDecision:
190 + decision = resolve_tool(agent, tool_name, canonical_id=canonical_id)
191 if decision.allowed:
192 return decision
193 profile = str(getattr(getattr(agent, "config", None), "profile", "") or "default")
@@ -197,6 +217,17 @@ def filter_tool_prompt(agent: Any, prompt_file: str, prompt: str) -> str:
217 )
218 for name in sorted(blocked_names, key=len, reverse=True)
219 ]
220 + prompt = re.sub(
221 + r"^[ \t]*(?P<fence>`{3,}|~{3,})[ \t]*json\b[^\r\n]*\r?\n"
222 + r".*?^[ \t]*(?P=fence)[ \t]*(?:\r?\n|$)",
223 + lambda match: (
224 + ""
225 + if any(pattern.search(match.group(0)) for pattern in patterns)
226 + else match.group(0)
227 + ),
228 + prompt,
229 + flags=re.IGNORECASE | re.MULTILINE | re.DOTALL,
230 + )
231 return "".join(
232 line
233 for line in prompt.splitlines(keepends=True)
@@ -301,7 +332,7 @@ def tool_prompt_description(
332 fallback: str = "",
333 ) -> str:
334 declaration = re.search(
304 - rf"^\s*-\s+`{re.escape(name)}`:\s+(args?\b.*)$",
335 + rf"^\s*-\s+`{re.escape(name)}`:\s+(.+)$",
336 prompt or "",
337 re.IGNORECASE | re.MULTILINE,
338 )
helpers/tool_policy.py.dox.md
+15 -7
@@ -10,21 +10,27 @@
10 - `normalize_policy` owns the sparse allow/block configuration shape.
11 - `get_tool_catalog` owns canonical local, plugin, and MCP identities plus
12 unavailable-policy retention; local entries come from executable `tools/*.py`
13 - files in the runtime path hierarchy. Catalog entries describe tools; the
14 - editor applies the current draft policy instead of receiving duplicated
13 + files in the runtime path hierarchy. The catalog describes installed
14 + capabilities independently of transient transport availability; connector
15 + prompt/schema extensions remain responsible for live remote-tool exposure.
16 + The editor applies the current draft policy instead of receiving duplicated
17 allowed/required flags from the backend.
18 - `tool_prompt_description` owns the shared compact description extracted for
19 the editor catalog and provider-native schemas; transport-specific names and
20 schemas remain with their transports.
21 - `resolve_tool` returns the effective decision and provenance.
20 -- `ensure_tool_allowed` raises the stable repairable runtime policy error.
21 -- `filter_tool_prompt` removes denied local capabilities from the text protocol
22 - without taking ownership of provider-native naming rules.
22 +- `ensure_tool_allowed` raises the stable repairable runtime policy error and
23 + accepts an explicit canonical ID from transports that already resolved one.
24 +- `filter_tool_prompt` removes denied local capabilities from the text protocol,
25 + including complete fenced JSON examples that reference them, without taking
26 + ownership of provider-native naming rules.
27
28 ## Runtime Contracts
29
26 -- Scoped config resolution is delegated to `helpers.plugins`: active project
27 - profile, active project, user profile, bundled/plugin profile, then default.
30 +- Scoped asset precedence comes from `helpers.plugins`: active project profile,
31 + active project, user profile, bundled/plugin profile, then default.
32 + `get_policy` selects the first custom policy; unknown-only and
33 + explicit-inherit files remain on disk but defer to the next lower layer.
34 - Missing policy inherits standard access; custom policy always records whether
35 future tools default to allowed or blocked.
36 - The `response` capability is a framework-required invariant: profile policy
@@ -33,6 +39,8 @@
39 it is not exposed as a profile-policy choice and legacy policy IDs cannot
40 suppress the chat-configured capability.
41 - Policy IDs are namespaced as `local:`, `plugin:<id>:`, or `mcp:<server>:`.
42 + Generic execution resolves canonical IDs from executable paths; MCP
43 + invocation supplies its explicit namespaced ID.
44 - Plugin IDs are derived relative to the canonical roots from `helpers.plugins`,
45 not by independently parsing repository-relative path strings.
46 - Each executable local tool has its own policy identity, including tools that
plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md
+1
@@ -1,5 +1,6 @@
1 # code_execution_remote tool
2
3 +Run shell-backed commands on a connected A0 CLI host.
4 Shown when a connected A0 CLI advertises remote execution, which the user enables
5 with F4 in the CLI. Runs shell-backed execution on the machine where that CLI is
6 running. Use this tool, not `code_execution_tool`, when the user asks for the
plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md
+1
@@ -1,5 +1,6 @@
1 ### computer_use_remote
2
3 +Control the desktop on a connected A0 CLI or Launcher host.
4 Shown when a connected A0 CLI or Launcher host gateway advertises enabled Computer Use (`/computer-use on`) and does not need re-arming. Runtime-gated beta desktop control through that host bridge on the user's machine. Availability, backend support, and trust mode are checked when the tool runs, together with host-bridge presence, local enablement, and re-arm state. Computer Use enablement is scoped to the current CLI session or Launcher Host access lease, not scoped to a single chat context.
5
6 Use this for native host desktop UI inspection, screenshots, background-safe window/element actions when supported, clicking, scrolling, typing, key presses, and status checks. Do not use it for ordinary web-page navigation or host-browser control; use the browser tool for web pages unless browser automation cannot express the task. For complex desktop workflows, load and follow skill `host-computer-use` before proceeding.
plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md
+1
@@ -1,5 +1,6 @@
1 # text_editor_remote tool
2
3 +Read, write, and patch files on a connected A0 CLI host.
4 Shown when a connected A0 CLI advertises remote file access. Reads, writes, and
5 patches files on the machine where that CLI is running. Use this tool, not
6 server-side file tools, when the user asks for files on the connected local
plugins/_code_execution/prompts/agent.system.tool.input.md
+1
@@ -1,4 +1,5 @@
1 ### input:
2 +send keyboard input to an interactive terminal program
3 use keyboard arg for terminal program input
4 use session arg for terminal session number
5 answer dialogues enter passwords etc
plugins/_memory/prompts/agent.system.tool.memory.md
+5 -4
@@ -1,9 +1,10 @@
1 ## memory tools
2 use when durable recall or storage is useful
3 -- `memory_load`: args `query`, optional `threshold`, `limit`, `filter`
4 -- `memory_save`: args `text`, optional `area` and metadata kwargs
5 -- `memory_delete`: arg `ids` comma-separated ids
6 -- `memory_forget`: args `query`, optional `threshold`, `filter`
3 +- `memory_load`: search stored memories by meaning and metadata
4 +- `memory_save`: store durable information for future recall
5 +- `memory_delete`: delete memories by exact ID
6 +- `memory_forget`: find and remove memories matching a query
7 +args: load uses `query`, optional `threshold`, `limit`, `filter`; save uses `text`, optional `area` and metadata; delete uses comma-separated `ids`; forget uses `query`, optional `threshold`, `filter`
8
9 notes:
10 - `threshold` is similarity from `0` to `1`
prompts/agent.system.tool.behaviour.md
+1 -1
@@ -1,6 +1,6 @@
1 ### behaviour_adjustment
2 -exact tool name uses british spelling: `behaviour_adjustment`
2 update persistent behavioral rules
3 +exact tool name uses british spelling: `behaviour_adjustment`
4 arg: `adjustments` text describing what to add or remove
5 use for durable behavior, personality, style, response-format, greeting, and exact-response rules
6 when the user asks for an exact word, phrase, token, or casing, preserve it verbatim in `adjustments`
tests/test_parallel_tool.py
+91 -1
@@ -384,6 +384,97 @@ async def test_parallel_subordinate_jobs_are_visible_child_logs_not_scheduler_ta
384 assert "parallel_child" not in agent.context.log.items[0].kvps
385
386
387 +@pytest.mark.asyncio
388 +async def test_parallel_subordinate_enforces_parent_delegation_policy(
389 + monkeypatch,
390 +) -> None:
391 + from agent import AgentContext
392 + from helpers import tool_policy
393 + from helpers.errors import RepairableException
394 +
395 + parent_agent = SimpleNamespace(
396 + config=SimpleNamespace(profile="restricted"),
397 + context=_FakeContext(),
398 + )
399 + parent_context = SimpleNamespace(agent0=parent_agent)
400 + monkeypatch.setattr(
401 + AgentContext,
402 + "get",
403 + staticmethod(lambda _context_id: parent_context),
404 + )
405 + monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
406 + monkeypatch.setattr(
407 + tool_policy,
408 + "get_policy",
409 + lambda agent: {
410 + "mode": "custom",
411 + "default": "allow",
412 + "allowed": [],
413 + "blocked": ["local:call_subordinate"],
414 + },
415 + )
416 + job = parallel_tools.ParallelJob(
417 + id="callsubordin-blocked",
418 + parent_context_id="ctx",
419 + index=0,
420 + tool_name="call_subordinate",
421 + tool_args={"profile": "developer", "message": "Work"},
422 + kind="subordinate",
423 + )
424 +
425 + with pytest.raises(
426 + RepairableException,
427 + match='Tool "call_subordinate" is blocked for agent profile "restricted"',
428 + ):
429 + await parallel_tools._run_subordinate_context_job("ctx", job)
430 +
431 +
432 +@pytest.mark.asyncio
433 +async def test_parallel_subordinate_reuses_profile_validation(monkeypatch) -> None:
434 + from agent import AgentContext
435 + from helpers import tool_policy
436 + from helpers.errors import RepairableException
437 + from tools import call_subordinate
438 +
439 + parent_agent = SimpleNamespace(
440 + config=SimpleNamespace(profile="agent0"),
441 + context=_FakeContext(),
442 + )
443 + parent_context = SimpleNamespace(agent0=parent_agent)
444 + monkeypatch.setattr(
445 + AgentContext,
446 + "get",
447 + staticmethod(lambda _context_id: parent_context),
448 + )
449 + monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
450 + monkeypatch.setattr(
451 + tool_policy,
452 + "get_policy",
453 + lambda agent: {
454 + "mode": "inherit",
455 + "default": "allow",
456 + "allowed": [],
457 + "blocked": [],
458 + },
459 + )
460 + monkeypatch.setattr(
461 + call_subordinate.subagents,
462 + "get_available_agents_dict",
463 + lambda project_name: {"developer": SimpleNamespace(title="Developer")},
464 + )
465 + job = parallel_tools.ParallelJob(
466 + id="callsubordin-invalid",
467 + parent_context_id="ctx",
468 + index=0,
469 + tool_name="call_subordinate",
470 + tool_args={"profile": "ghost", "message": "Work"},
471 + kind="subordinate",
472 + )
473 +
474 + with pytest.raises(RepairableException, match="Agent profile 'ghost' not found"):
475 + await parallel_tools._run_subordinate_context_job("ctx", job)
476 +
477 +
478 @pytest.mark.asyncio
479 async def test_parallel_direct_tool_jobs_fallback_to_generic_tool_log_type(monkeypatch) -> None:
480 class FakeDeferredTask:
@@ -704,4 +795,3 @@ def test_chats_sidebar_projects_parallel_children_as_indented_accordion() -> Non
795 assert "left: 2px" in html
796 assert "padding-left: 24px" in html
797 assert "color: var(--color-text-muted)" in html
707 - assert "padding: 8px;" in html
tests/test_plugin_activation_ui.py
+46
@@ -2,11 +2,13 @@ import sys
2 from pathlib import Path
3 from types import SimpleNamespace
4
5 +import pytest
6
7 PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 if str(PROJECT_ROOT) not in sys.path:
9 sys.path.insert(0, str(PROJECT_ROOT))
10
11 +from api.plugins import Plugins
12 from helpers import files, plugins
13
14
@@ -57,6 +59,50 @@ def test_list_toggle_state_is_global_even_when_scoped_rules_exist(monkeypatch):
59 assert plugins.get_toggle_state("example") == "disabled"
60
61
62 +def test_always_enabled_plugin_ignores_disable_files_at_runtime(monkeypatch):
63 + monkeypatch.setattr(plugins.cache, "get", lambda *args, **kwargs: None)
64 + monkeypatch.setattr(plugins.cache, "add", lambda *args, **kwargs: None)
65 + monkeypatch.setattr(plugins, "get_plugins_list", lambda: ["_required"])
66 + monkeypatch.setattr(
67 + plugins,
68 + "get_plugin_meta",
69 + lambda _plugin_name: SimpleNamespace(always_enabled=True),
70 + )
71 +
72 + def fail_on_toggle_lookup(*_args, **_kwargs):
73 + raise AssertionError("always-enabled plugins must ignore toggle files")
74 +
75 + monkeypatch.setattr(plugins, "determined_toggle_from_paths", fail_on_toggle_lookup)
76 +
77 + assert plugins.get_enabled_plugins(None) == ["_required"]
78 +
79 +
80 +def test_always_enabled_plugin_rejects_disable_attempt(monkeypatch):
81 + monkeypatch.setattr(
82 + plugins,
83 + "get_plugin_meta",
84 + lambda _plugin_name: SimpleNamespace(always_enabled=True),
85 + )
86 +
87 + with pytest.raises(ValueError, match="always enabled"):
88 + plugins.toggle_plugin.__wrapped__("_required", False)
89 +
90 +
91 +def test_plugin_api_returns_bad_request_for_rejected_disable(monkeypatch):
92 + def reject(*_args, **_kwargs):
93 + raise ValueError('Plugin "_required" is always enabled.')
94 +
95 + monkeypatch.setattr(plugins, "toggle_plugin", reject)
96 +
97 + response = Plugins._toggle_plugin.__wrapped__(
98 + object.__new__(Plugins),
99 + {"plugin_name": "_required", "enabled": False},
100 + )
101 +
102 + assert response.status_code == 400
103 + assert "always enabled" in response.get_data(as_text=True)
104 +
105 +
106 def test_config_scope_activation_toggle_saves_immediately_for_selected_scope():
107 html = (PROJECT_ROOT / "webui/components/plugins/plugin-settings.html").read_text(
108 encoding="utf-8"
tests/test_tool_policy.py
+140 -3
@@ -1,6 +1,8 @@
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
@@ -60,6 +62,14 @@ def _custom_policy(*, default: str, allowed=(), blocked=()):
62 }
63
64
65 +def test_agent_import_does_not_cycle_through_tool_policy() -> None:
66 + subprocess.run(
67 + [sys.executable, "-c", "import agent"],
68 + cwd=Path(__file__).parents[1],
69 + check=True,
70 + )
71 +
72 +
73 @pytest.fixture
74 def local_prompt_agent(monkeypatch, tmp_path: Path) -> _Agent:
75 _write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
@@ -197,6 +207,39 @@ def test_catalog_comes_from_executable_tools_not_prompt_names(
207 assert catalog[0]["description"] == "Actual description"
208
209
210 +def test_catalog_keeps_installed_remote_tools_without_live_connector(
211 + monkeypatch, tmp_path: Path
212 +) -> None:
213 + tool_root = tmp_path / "tools"
214 + tool_root.mkdir()
215 + (tool_root / "code_execution_remote.py").write_text("", encoding="utf-8")
216 + (tool_root / "shell.py").write_text("", encoding="utf-8")
217 + monkeypatch.setattr(
218 + tool_policy.subagents,
219 + "get_paths",
220 + lambda *args, **kwargs: [str(tool_root)],
221 + )
222 + monkeypatch.setattr(
223 + mcp_handler.MCPConfig,
224 + "get_for_agent",
225 + lambda agent: _NoMCPTools(),
226 + )
227 + monkeypatch.setattr(
228 + tool_policy,
229 + "get_policy",
230 + lambda agent: {
231 + "mode": "inherit",
232 + "default": "allow",
233 + "allowed": [],
234 + "blocked": [],
235 + },
236 + )
237 +
238 + catalog = tool_policy.get_tool_catalog(_Agent(tmp_path))
239 +
240 + assert [item["name"] for item in catalog] == ["code_execution_remote", "shell"]
241 +
242 +
243 def test_tool_prompt_description_skips_fenced_examples() -> None:
244 prompt = """### example
245 ~~~json
@@ -208,6 +251,62 @@ Visible summary
251 assert tool_policy.tool_prompt_description(prompt, "example") == "Visible summary"
252
253
254 +def test_tool_prompt_description_prefers_declared_summary() -> None:
255 + prompt = """## tools
256 +- `memory_load`: search stored memories by meaning and metadata
257 + args: `query`, optional `limit`
258 +"""
259 +
260 + assert (
261 + tool_policy.tool_prompt_description(prompt, "memory_load")
262 + == "search stored memories by meaning and metadata"
263 + )
264 +
265 +
266 +def test_prompt_filter_removes_complete_blocked_json_example(
267 + monkeypatch, tmp_path: Path
268 +) -> None:
269 + monkeypatch.setattr(
270 + tool_policy,
271 + "_policy_tool_names",
272 + lambda agent: {"memory_load", "memory_save"},
273 + )
274 + monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
275 + monkeypatch.setattr(
276 + tool_policy,
277 + "get_policy",
278 + lambda agent: _custom_policy(
279 + default="allow", blocked=["local:memory_load"]
280 + ),
281 + )
282 + prompt = """## memory tools
283 +- `memory_load`: load memory
284 +- `memory_save`: save memory
285 +~~~json
286 +{
287 + "tool_name": "memory_load",
288 + "tool_args": {"query": "blocked example"}
289 +}
290 +~~~
291 +~~~json
292 +{
293 + "tool_name": "memory_save",
294 + "tool_args": {"text": "allowed example"}
295 +}
296 +~~~
297 +"""
298 +
299 + filtered = tool_policy.filter_tool_prompt(
300 + _Agent(tmp_path), "agent.system.tool.memory.md", prompt
301 + )
302 +
303 + assert "blocked example" not in filtered
304 + assert "memory_load" not in filtered
305 + assert "allowed example" in filtered
306 + assert filtered.count("~~~json") == 1
307 + assert filtered.count("~~~") == 2
308 +
309 +
310 def test_plugin_tool_identity_uses_canonical_plugin_roots(
311 monkeypatch, tmp_path: Path
312 ) -> None:
@@ -250,6 +349,34 @@ def test_plugin_tool_identity_uses_canonical_plugin_roots(
349 assert tool_policy.resolve_tool(agent, "actual").tool_id == "local:actual"
350
351
352 +def test_dotted_local_tool_keeps_local_identity_at_execution_gate(
353 + monkeypatch, tmp_path: Path
354 +) -> None:
355 + tool_path = tmp_path / "docs.read.py"
356 + tool_path.write_text("class Tool: pass\n", encoding="utf-8")
357 + monkeypatch.setattr(
358 + tool_policy.subagents,
359 + "get_paths",
360 + lambda *args, **kwargs: [str(tool_path)],
361 + )
362 + monkeypatch.setattr(
363 + mcp_handler.MCPConfig,
364 + "get_for_agent",
365 + lambda agent: _NoMCPTools(),
366 + )
367 + monkeypatch.setattr(
368 + tool_policy,
369 + "get_policy",
370 + lambda agent: _custom_policy(
371 + default="allow", blocked=["local:docs.read"]
372 + ),
373 + )
374 + agent = _Agent(tmp_path)
375 +
376 + with pytest.raises(RepairableException, match='Tool "docs.read" is blocked'):
377 + tool_policy.ensure_tool_allowed(agent, "docs.read")
378 +
379 +
380 def test_legacy_response_and_vision_policy_ids_stay_out_of_catalog(
381 monkeypatch, tmp_path: Path
382 ) -> None:
@@ -424,12 +551,12 @@ async def test_delegated_agent_uses_its_own_profile_policy_at_execution_gate(
551 child = _Agent(tmp_path, profile="researcher")
552 monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
553
427 - def config_for_profile(plugin_name, agent=None, **kwargs):
554 + def policy_for_profile(agent):
555 if agent.config.profile == "researcher":
556 return _custom_policy(default="block")
557 return {"mode": "inherit"}
558
432 - monkeypatch.setattr(tool_policy.plugins, "get_plugin_config", config_for_profile)
559 + monkeypatch.setattr(tool_policy, "get_policy", policy_for_profile)
560
561 assert tool_policy.resolve_tool(parent, "shell").allowed is True
562 assert tool_policy.resolve_tool(child, "shell").allowed is False
@@ -506,7 +633,17 @@ def test_project_policy_precedes_profile_policy(
633 assert decision.allowed is False
634 assert decision.source == "scoped-policy"
635
509 - project_profile_path.unlink()
636 + project_profile_path.write_text('{"manual": true}\n', encoding="utf-8")
637 + decision = tool_policy.resolve_tool(agent, "shell")
638 + assert decision.allowed is True
639 + assert decision.source == "scoped-default"
640 + assert project_profile_path.read_text(encoding="utf-8") == '{"manual": true}\n'
641 +
642 + project_profile_path.write_text(
643 + '{"manual": true, "mode": "inherit", "default": "block", '
644 + '"allowed": [], "blocked": ["local:shell"]}\n',
645 + encoding="utf-8",
646 + )
647 decision = tool_policy.resolve_tool(agent, "shell")
648 assert decision.allowed is True
649 assert decision.source == "scoped-default"