Enforce scoped tool access policies
Add one project/profile-aware resolver for canonical local, plugin, and MCP tool identities. Apply it to text prompts, Responses schemas, connector stubs, local execution, MCP invocation, and delegated agents while preserving the response and vision invariants owned by the runtime.
Alessandro committed
Aug 5, 2026 at 10:53 UTC
eec18ad839eedb5e92d389ca164abc192e19968f
23 files changed
+1116
-52
extensions/python/system_prompt/AGENTS.md
+2
@@ -14,6 +14,8 @@
14
- Preserve ordering where sections depend on earlier context.
15
- Keep secret-related prompt sections masked and scoped.
16
- Prompt additions must be bounded and compatible with tool-call contracts.
17
+- Discover local tool prompts through `helpers.subagents.get_paths` and apply
18
+ `helpers.tool_policy` before including their text.
19
20
## Work Guidance
21
extensions/python/system_prompt/_11_tools_prompt.py
+4
-2
@@ -2,7 +2,7 @@ import os
2
from typing import Any
3
4
from helpers.extension import Extension, extensible
5
-from helpers import files, subagents
5
+from helpers import files, subagents, tool_policy
6
from helpers.print_style import PrintStyle
7
from agent import Agent, LoopData
8
@@ -41,7 +41,9 @@ async def build_prompt(agent: Agent) -> str:
41
basename = os.path.basename(tool_file)
42
extra = all_tool_kwargs.get(basename, {})
43
tool = agent.read_prompt(basename, **extra)
44
- tools.append(tool)
44
+ tool = tool_policy.filter_tool_prompt(agent, basename, tool)
45
+ if tool:
46
+ tools.append(tool)
47
except Exception as e:
48
PrintStyle().error(f"Error loading tool '{tool_file}': {e}")
49
extensions/python/system_prompt/_12_mcp_prompt.py
+1
-1
@@ -28,6 +28,6 @@ async def build_prompt(agent: Agent) -> str:
28
29
pre_progress = agent.context.log.progress
30
agent.context.log.set_progress("Collecting MCP tools")
31
- tools = mcp_config.get_tools_prompt()
31
+ tools = mcp_config.get_tools_prompt(agent=agent)
32
agent.context.log.set_progress(pre_progress)
33
return tools
helpers/mcp_handler.py
+17
-3
@@ -434,6 +434,10 @@ class MCPTool(Tool):
434
return message, additional
435
436
async def execute(self, **kwargs: Any):
437
+ from helpers.tool_policy import ensure_tool_allowed
438
+
439
+ if "." in self.name:
440
+ ensure_tool_allowed(self.agent, self.name)
441
error = ""
442
additional: dict[str, Any] | None = None
443
try:
@@ -1141,7 +1145,7 @@ class MCPConfig(BaseModel):
1145
tools.append({f"{server.name}.{tool['name']}": tool_copy})
1146
return tools
1147
1144
- def get_tools_prompt(self, server_name: str = "") -> str:
1148
+ def get_tools_prompt(self, server_name: str = "", agent: Any | None = None) -> str:
1149
"""Get a prompt for all tools"""
1150
1151
# just to wait for pending initialization
@@ -1165,8 +1169,18 @@ class MCPConfig(BaseModel):
1169
tools = server.get_tools()
1170
1171
for tool in tools:
1172
+ qualified_name = f"{server_name}.{tool['name']}"
1173
+ if agent is not None:
1174
+ from helpers.tool_policy import canonical_mcp_id, resolve_tool
1175
+
1176
+ if not resolve_tool(
1177
+ agent,
1178
+ qualified_name,
1179
+ canonical_id=canonical_mcp_id(qualified_name),
1180
+ ).allowed:
1181
+ continue
1182
prompt += (
1169
- f"\n### {server_name}.{tool['name']}:\n"
1183
+ f"\n### {qualified_name}:\n"
1184
f"{tool['description']}\n\n"
1185
# f"#### Categories:\n"
1186
# f"* kind: MCP Server Tool\n"
@@ -1188,7 +1202,7 @@ class MCPConfig(BaseModel):
1202
# f' "observations": ["..."],\n' # TODO: this should be a prompt file with placeholders
1203
f' "thoughts": ["..."],\n'
1204
# f' "reflection": ["..."],\n' # TODO: this should be a prompt file with placeholders
1191
- f" \"tool_name\": \"{server_name}.{tool['name']}\",\n"
1205
+ f" \"tool_name\": \"{qualified_name}\",\n"
1206
f' "tool_args": !follow schema above\n'
1207
f"}}\n"
1208
)
helpers/mcp_handler.py.dox.md
+3
@@ -82,6 +82,8 @@
82
- Project-scoped MCP servers overlay global servers by normalized name. The resulting `MCPConfig` cache key is derived from both config strings so project instances refresh when either scope changes.
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.
87
- `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.
88
- 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.
89
- Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations.
@@ -110,6 +112,7 @@
112
- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
113
- Related tests observed by source search:
114
- `tests/test_mcp_handler_multimodal.py`
115
+ - `tests/test_tool_policy.py`
116
117
## Child DOX Index
118
helpers/plugins.py
+11
@@ -220,6 +220,17 @@ def get_plugin_roots(plugin_name: str = "") -> List[str]:
220
]
221
222
223
+def get_plugin_name_from_path(path: str | Path) -> str:
224
+ """Return the plugin directory name for a path under a canonical plugin root."""
225
+ candidate = Path(path).absolute()
226
+ for root in get_plugin_roots():
227
+ try:
228
+ return candidate.relative_to(Path(root).absolute()).parts[0]
229
+ except (IndexError, ValueError):
230
+ continue
231
+ return ""
232
+
233
+
234
def get_plugins_list():
235
if cached := cache.get(PLUGINS_LIST_CACHE_AREA, ""):
236
return cached
helpers/plugins.py.dox.md
+3
-1
@@ -21,6 +21,7 @@
21
- `refresh_plugin_modules(plugin_names: list[str] | None=...)`
22
- `clear_plugin_cache(plugin_names: list[str] | None=...)`
23
- `get_plugin_roots(plugin_name: str=...) -> List[str]`: Plugin root directories, ordered by priority (user first).
24
+- `get_plugin_name_from_path(path: str | Path) -> str`: Return the plugin directory name only for paths below a canonical user or bundled plugin root.
25
- `get_plugins_list()`
26
- `get_enhanced_plugins_list(custom: bool=..., builtin: bool=..., plugin_names: list[str] | None=...) -> List[PluginListItem]`: Discover plugins by directory convention. First root wins on ID conflict.
27
- `get_custom_plugins_updates(plugin_names: list[str] | None=...) -> List[PluginUpdateInfo]`
@@ -54,7 +55,7 @@
55
56
## Key Concepts
57
57
-- Important called helpers/classes observed in the source: `re.compile`, `Field`, `watchdog.add_watchdog`, `clear_plugin_cache`, `send_frontend_reload_notification`, `DeferredTask.start_task`, `get_plugin_roots`, `result.sort`, `cache.add`, `get_enhanced_plugins_list`, `find_plugin_dir`, `files.get_abs_path`, `files.exists`, `call_plugin_hook`, `delete_plugin`, `files.delete_dir`, `after_plugin_change`, `get_enabled_plugins`, `get_plugins_list`, `get_plugin_meta`.
58
+- Important called helpers/classes observed in the source: `re.compile`, `Field`, `watchdog.add_watchdog`, `clear_plugin_cache`, `send_frontend_reload_notification`, `DeferredTask.start_task`, `get_plugin_roots`, `get_plugin_name_from_path`, `result.sort`, `cache.add`, `get_enhanced_plugins_list`, `find_plugin_dir`, `files.get_abs_path`, `files.exists`, `call_plugin_hook`, `delete_plugin`, `files.delete_dir`, `after_plugin_change`, `get_enabled_plugins`, `get_plugins_list`, `get_plugin_meta`.
59
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
60
61
## Work Guidance
@@ -75,6 +76,7 @@
76
- `tests/test_document_query_plugin.py`
77
- `tests/test_error_retry_plugin.py`
78
- `tests/test_host_browser_connector.py`
79
+ - `tests/test_tool_policy.py`
80
81
## Child DOX Index
82
helpers/responses_tools.py
+21
-25
@@ -6,7 +6,7 @@ import os
6
import re
7
from typing import Any
8
9
-from helpers import files, subagents
9
+from helpers import files, subagents, tool_policy
10
11
12
FUNCTION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
@@ -35,18 +35,32 @@ def build_responses_function_tools(agent: Any) -> tuple[list[dict[str, Any]], di
35
name_map: dict[str, str] = {}
36
37
for tool_name, prompt in _local_tool_prompts(agent):
38
+ if not tool_policy.resolve_tool(agent, tool_name).allowed:
39
+ continue
40
native_name = _native_tool_name(tool_name)
41
name_map[native_name] = tool_name
42
tools.append(
43
{
44
"type": "function",
45
"name": native_name,
44
- "description": _description_from_prompt(prompt, fallback=tool_name),
46
+ "description": _truncate(
47
+ tool_policy.tool_prompt_description(
48
+ prompt,
49
+ tool_name,
50
+ fallback=tool_name,
51
+ )
52
+ ),
53
"parameters": _schema_from_prompt(prompt),
54
}
55
)
56
57
for tool_name, tool in _mcp_tools(agent):
58
+ if not tool_policy.resolve_tool(
59
+ agent,
60
+ tool_name,
61
+ canonical_id=tool_policy.canonical_mcp_id(tool_name),
62
+ ).allowed:
63
+ continue
64
native_name = _native_tool_name(tool_name)
65
name_map[native_name] = tool_name
66
tools.append(
@@ -124,7 +138,7 @@ def _mcp_tools(agent: Any) -> list[tuple[str, dict[str, Any]]]:
138
try:
139
import helpers.mcp_handler as mcp_helper
140
127
- raw_tools = mcp_helper.MCPConfig.get_instance().get_tools()
141
+ raw_tools = mcp_helper.MCPConfig.get_for_agent(agent).get_tools()
142
except Exception:
143
return []
144
@@ -139,7 +153,9 @@ def _mcp_tools(agent: Any) -> list[tuple[str, dict[str, Any]]]:
153
154
155
def _tool_name_from_prompt_basename(basename: str) -> str:
142
- if not basename.startswith(TOOL_PROMPT_PREFIX) or not basename.endswith(TOOL_PROMPT_SUFFIX):
156
+ if not basename.startswith(TOOL_PROMPT_PREFIX) or not basename.endswith(
157
+ TOOL_PROMPT_SUFFIX
158
+ ):
159
return ""
160
name = basename[len(TOOL_PROMPT_PREFIX) : -len(TOOL_PROMPT_SUFFIX)]
161
if not name or name in {"tools", "tools_vision"}:
@@ -187,25 +203,6 @@ def _native_tool_name(tool_name: str) -> str:
203
return native[:64]
204
205
190
-def _description_from_prompt(prompt: str, *, fallback: str) -> str:
191
- for match in TOOL_DECLARATION_PATTERN.finditer(prompt or ""):
192
- if match.group(1) == fallback:
193
- return _truncate(match.group(2))
194
-
195
- in_fence = False
196
- for raw_line in (prompt or "").splitlines():
197
- line = raw_line.strip()
198
- if line.startswith(("```", "~~~")):
199
- in_fence = not in_fence
200
- continue
201
- if in_fence or not line:
202
- continue
203
- if line.startswith("#"):
204
- continue
205
- return _truncate(line)
206
- return fallback
207
-
208
-
206
def _schema_from_prompt(prompt: str) -> dict[str, Any]:
207
schema = _schema_from_embedded_json(prompt)
208
if schema:
@@ -226,8 +223,7 @@ def _schema_from_embedded_json(prompt: str) -> dict[str, Any]:
223
if index == -1:
224
return {}
225
tail = prompt[index + len(marker) :].strip()
229
- match = re.search(r"\{(?:[^{}]|(?R))*\}", tail, flags=re.DOTALL) if hasattr(re, "VERSION1") else None
230
- candidate = match.group(0) if match else _balanced_json_object(tail)
226
+ candidate = _balanced_json_object(tail)
227
if not candidate:
228
return {}
229
try:
helpers/responses_tools.py.dox.md
+8
-1
@@ -13,12 +13,19 @@
13
## Local Contracts
14
15
- Build local function tools from enabled `agent.system.tool.*.md` prompt files and include `vision_load` only when the active chat model enables the matching vision prompt.
16
+- Discover local prompt files through `helpers.subagents.get_paths`; this module
17
+ owns the Responses-specific prompt-name compatibility rules.
18
- Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename.
19
- Apply registered tool-prompt render kwargs before deriving native metadata so descriptions never expose unresolved prompt templates.
20
- Use an explicitly embedded JSON input schema when present. Infer only an unambiguous single backticked argument on an otherwise empty `args:` line; all other local tools receive an honest permissive object schema instead of prose-guessed types.
19
-- Native local-tool descriptions use the matching compact multi-tool declaration or the first prose line, not a duplicate copy of the full tool manual or fenced examples.
21
+- Native local-tool descriptions reuse the tool catalog's compact prompt
22
+ description; Responses retains native-name mapping, schema derivation, and
23
+ provider description limits.
24
- Preserve original Agent Zero tool names through the native Responses name map.
25
- Keep MCP tool schemas merged after local prompt-derived tools.
26
+- Apply `helpers.tool_policy` before emitting local or MCP schemas; a blocked
27
+ capability is absent from provider-native tool definitions. Vision remains
28
+ controlled solely by the active chat model configuration.
29
- Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available.
30
31
## Work Guidance
helpers/tool_policy.py
new
+323
@@ -0,0 +1,323 @@
1
+from __future__ import annotations
2
+
3
+from dataclasses import dataclass
4
+import os
5
+import re
6
+from typing import Any
7
+
8
+from helpers import files, plugins, subagents
9
+from helpers.errors import RepairableException
10
+
11
+
12
+PLUGIN_NAME = "_tool_access"
13
+PROMPT_PREFIX = "agent.system.tool."
14
+PROMPT_SUFFIX = ".md"
15
+NON_CONFIGURABLE_TOOLS = frozenset({"response", "vision_load"})
16
+
17
+
18
+@dataclass(frozen=True)
19
+class ToolPolicyDecision:
20
+ allowed: bool
21
+ tool_id: str
22
+ source: str
23
+ mode: str
24
+ reason: str = ""
25
+
26
+
27
+def normalize_policy(config: Any) -> dict[str, Any]:
28
+ raw = dict(config) if isinstance(config, dict) else {}
29
+ mode = str(raw.get("mode") or "inherit").strip().lower()
30
+ default = str(raw.get("default") or "allow").strip().lower()
31
+ raw["mode"] = "custom" if mode == "custom" else "inherit"
32
+ raw["default"] = "block" if default == "block" else "allow"
33
+ raw["allowed"] = _normalize_ids(raw.get("allowed"))
34
+ raw["blocked"] = _normalize_ids(raw.get("blocked"))
35
+ return raw
36
+
37
+
38
+def get_policy(agent: Any) -> dict[str, Any]:
39
+ return normalize_policy(plugins.get_plugin_config(PLUGIN_NAME, agent=agent))
40
+
41
+
42
+def get_tool_catalog(agent: Any) -> list[dict[str, Any]]:
43
+ tool_paths = _local_tool_paths(agent)
44
+ descriptions = _tool_descriptions(agent, set(tool_paths))
45
+ catalog: list[dict[str, Any]] = []
46
+ seen: set[str] = set()
47
+ for name, tool_path in tool_paths.items():
48
+ if name in NON_CONFIGURABLE_TOOLS:
49
+ continue
50
+ tool_id, origin = _canonical_from_path(tool_path, name)
51
+ if tool_id in seen:
52
+ continue
53
+ seen.add(tool_id)
54
+ catalog.append(
55
+ {
56
+ "id": tool_id,
57
+ "name": name,
58
+ "label": name.replace("_", " ").title(),
59
+ "origin": origin,
60
+ "description": descriptions.get(name, ""),
61
+ "available": True,
62
+ }
63
+ )
64
+
65
+ try:
66
+ from helpers.mcp_handler import MCPConfig
67
+
68
+ for item in MCPConfig.get_for_agent(agent).get_tools():
69
+ qualified, tool = next(iter(item.items()))
70
+ tool_id = canonical_mcp_id(qualified)
71
+ if tool_id in seen:
72
+ continue
73
+ seen.add(tool_id)
74
+ catalog.append(
75
+ {
76
+ "id": tool_id,
77
+ "name": qualified,
78
+ "label": str(tool.get("name") or qualified),
79
+ "description": str(tool.get("description") or ""),
80
+ "origin": f"MCP · {str(tool.get('server') or '').strip()}",
81
+ "available": True,
82
+ }
83
+ )
84
+ except Exception:
85
+ pass
86
+
87
+ policy = get_policy(agent)
88
+ for tool_id in [*policy["allowed"], *policy["blocked"]]:
89
+ if (
90
+ tool_id in seen
91
+ or _tool_name_from_id(tool_id) in NON_CONFIGURABLE_TOOLS
92
+ ):
93
+ continue
94
+ seen.add(tool_id)
95
+ name = _tool_name_from_id(tool_id)
96
+ catalog.append(
97
+ {
98
+ "id": tool_id,
99
+ "name": name,
100
+ "label": name.replace("_", " ").title(),
101
+ "description": "",
102
+ "origin": "Unavailable",
103
+ "available": False,
104
+ }
105
+ )
106
+
107
+ catalog.sort(key=lambda item: (item["label"].casefold(), item["id"]))
108
+ return catalog
109
+
110
+
111
+def canonical_mcp_id(tool_name: str) -> str:
112
+ server, separator, name = str(tool_name or "").partition(".")
113
+ return f"mcp:{server}:{name}" if separator and server and name else ""
114
+
115
+
116
+def _canonical_tool_id(agent: Any, tool_name: str) -> str:
117
+ if mcp_id := canonical_mcp_id(tool_name):
118
+ try:
119
+ from helpers.mcp_handler import MCPConfig
120
+
121
+ if MCPConfig.get_for_agent(agent).has_tool(tool_name):
122
+ return mcp_id
123
+ except Exception:
124
+ pass
125
+
126
+ paths = subagents.get_paths(agent, "tools", f"{tool_name}.py")
127
+ path = next((candidate for candidate in paths if files.exists(candidate)), "")
128
+ return _canonical_from_path(path, tool_name)[0] if path else f"local:{tool_name}"
129
+
130
+
131
+def resolve_tool(
132
+ agent: Any,
133
+ tool_name: str,
134
+ *,
135
+ canonical_id: str = "",
136
+) -> ToolPolicyDecision:
137
+ tool_id = canonical_id or _canonical_tool_id(agent, tool_name)
138
+ requested = str(tool_name or "").strip()
139
+ name = _tool_name_from_id(tool_id) if requested == tool_id else requested
140
+ if name in NON_CONFIGURABLE_TOOLS:
141
+ source = "framework-required" if name == "response" else "runtime-config"
142
+ return ToolPolicyDecision(True, tool_id, source, "invariant")
143
+
144
+ policy = get_policy(agent)
145
+ if policy["mode"] != "custom":
146
+ return ToolPolicyDecision(True, tool_id, "inherited", "inherit")
147
+
148
+ if tool_id in policy["blocked"]:
149
+ return ToolPolicyDecision(
150
+ False, tool_id, "scoped-policy", "custom", "blocked explicitly"
151
+ )
152
+ if tool_id in policy["allowed"]:
153
+ return ToolPolicyDecision(True, tool_id, "scoped-policy", "custom")
154
+
155
+ is_allowed = policy["default"] == "allow"
156
+ return ToolPolicyDecision(
157
+ is_allowed,
158
+ tool_id,
159
+ "scoped-default",
160
+ "custom",
161
+ "blocked by default" if not is_allowed else "",
162
+ )
163
+
164
+
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
+ )
171
+ if decision.allowed:
172
+ return decision
173
+ profile = str(getattr(getattr(agent, "config", None), "profile", "") or "default")
174
+ raise RepairableException(
175
+ f'Tool "{tool_name}" is blocked for agent profile "{profile}".'
176
+ )
177
+
178
+
179
+def filter_tool_prompt(agent: Any, prompt_file: str, prompt: str) -> str:
180
+ known_names = _policy_tool_names(agent)
181
+ names = _prompt_tool_names(prompt_file, prompt, known_names)
182
+ if names and not any(resolve_tool(agent, name).allowed for name in names):
183
+ return ""
184
+
185
+ blocked_names = {
186
+ name
187
+ for name in known_names
188
+ if not resolve_tool(agent, name).allowed
189
+ }
190
+ if not blocked_names:
191
+ return prompt
192
+ patterns = [
193
+ re.compile(
194
+ rf"(?:`{re.escape(name)}`|[\"']{re.escape(name)}[\"']|"
195
+ rf"(?<![A-Za-z0-9_-]){re.escape(name)}\s+tool\b)",
196
+ re.IGNORECASE,
197
+ )
198
+ for name in sorted(blocked_names, key=len, reverse=True)
199
+ ]
200
+ return "".join(
201
+ line
202
+ for line in prompt.splitlines(keepends=True)
203
+ if not any(pattern.search(line) for pattern in patterns)
204
+ )
205
+
206
+
207
+def _local_tool_paths(agent: Any) -> dict[str, str]:
208
+ result: dict[str, str] = {}
209
+ for path in files.get_unique_filenames_in_dirs(
210
+ subagents.get_paths(agent, "tools"), "*.py"
211
+ ):
212
+ name = os.path.splitext(os.path.basename(path))[0]
213
+ if name not in {"__init__", "unknown"}:
214
+ result[name] = path
215
+ return result
216
+
217
+
218
+def _policy_tool_names(agent: Any) -> set[str]:
219
+ names = set(_local_tool_paths(agent))
220
+ policy = get_policy(agent)
221
+ names.update(
222
+ _tool_name_from_id(tool_id)
223
+ for tool_id in [*policy["allowed"], *policy["blocked"]]
224
+ if not tool_id.startswith("mcp:")
225
+ )
226
+ return names
227
+
228
+
229
+def _prompt_tool_names(
230
+ prompt_file: str, prompt: str, known_names: set[str]
231
+) -> list[str]:
232
+ fallback = _prompt_name(prompt_file)
233
+ declared = [
234
+ name for name in sorted(known_names) if _prompt_declares_tool(prompt, name)
235
+ ]
236
+ if fallback in known_names:
237
+ return list(dict.fromkeys([fallback, *declared]))
238
+ return declared or ([fallback] if fallback else [])
239
+
240
+
241
+def _prompt_declares_tool(prompt: str, name: str) -> bool:
242
+ escaped = re.escape(name)
243
+ return bool(
244
+ re.search(
245
+ rf"^\s{{0,3}}#{{1,6}}\s+`?{escaped}`?(?:\s|:|$)",
246
+ prompt or "",
247
+ re.IGNORECASE | re.MULTILINE,
248
+ )
249
+ or re.search(
250
+ rf"^\s*-\s+`{escaped}`\s*:",
251
+ prompt or "",
252
+ re.IGNORECASE | re.MULTILINE,
253
+ )
254
+ )
255
+
256
+
257
+def _prompt_name(prompt_file: str) -> str:
258
+ basename = os.path.basename(prompt_file)
259
+ if basename.startswith(PROMPT_PREFIX) and basename.endswith(PROMPT_SUFFIX):
260
+ return basename[len(PROMPT_PREFIX) : -len(PROMPT_SUFFIX)]
261
+ return ""
262
+
263
+
264
+def _tool_descriptions(agent: Any, tool_names: set[str]) -> dict[str, str]:
265
+ descriptions: dict[str, str] = {}
266
+ prompt_files = files.get_unique_filenames_in_dirs(
267
+ subagents.get_paths(agent, "prompts"), f"{PROMPT_PREFIX}*{PROMPT_SUFFIX}"
268
+ )
269
+ for prompt_file in prompt_files:
270
+ try:
271
+ prompt = agent.read_prompt(os.path.basename(prompt_file))
272
+ except Exception:
273
+ continue
274
+ for name in _prompt_tool_names(prompt_file, prompt, tool_names):
275
+ if name in tool_names and name not in descriptions:
276
+ descriptions[name] = tool_prompt_description(prompt, name)[:512]
277
+ return descriptions
278
+
279
+
280
+def _canonical_from_path(path: str, name: str) -> tuple[str, str]:
281
+ if plugin_id := plugins.get_plugin_name_from_path(path):
282
+ return f"plugin:{plugin_id}:{name}", f"Plugin · {plugin_id}"
283
+ return f"local:{name}", "Agent Zero"
284
+
285
+
286
+def _normalize_ids(raw: Any) -> list[str]:
287
+ if not isinstance(raw, list):
288
+ return []
289
+ result: list[str] = []
290
+ for value in raw:
291
+ tool_id = str(value or "").strip()
292
+ if tool_id and tool_id not in result:
293
+ result.append(tool_id)
294
+ return result
295
+
296
+
297
+def tool_prompt_description(
298
+ prompt: str,
299
+ name: str,
300
+ *,
301
+ fallback: str = "",
302
+) -> str:
303
+ declaration = re.search(
304
+ rf"^\s*-\s+`{re.escape(name)}`:\s+(args?\b.*)$",
305
+ prompt or "",
306
+ re.IGNORECASE | re.MULTILINE,
307
+ )
308
+ if declaration:
309
+ return declaration.group(1).strip()
310
+ in_fence = False
311
+ for raw_line in (prompt or "").splitlines():
312
+ line = raw_line.strip()
313
+ if line.startswith(("```", "~~~")):
314
+ in_fence = not in_fence
315
+ continue
316
+ if in_fence or not line or line.startswith("#"):
317
+ continue
318
+ return line
319
+ return fallback or name.replace("_", " ").strip().capitalize()
320
+
321
+
322
+def _tool_name_from_id(tool_id: str) -> str:
323
+ return str(tool_id or "").rsplit(":", 1)[-1]
helpers/tool_policy.py.dox.md
new
+52
@@ -0,0 +1,52 @@
1
+# tool_policy.py DOX
2
+
3
+## Purpose
4
+
5
+- Own the single project/profile-aware tool policy used by catalogs, prompts, native
6
+ schemas, local execution, MCP invocation, and delegated agents.
7
+
8
+## Ownership
9
+
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
15
+ allowed/required flags from the backend.
16
+- `tool_prompt_description` owns the shared compact description extracted for
17
+ the editor catalog and provider-native schemas; transport-specific names and
18
+ schemas remain with their transports.
19
+- `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.
23
+
24
+## Runtime Contracts
25
+
26
+- Scoped config resolution is delegated to `helpers.plugins`: active project
27
+ profile, active project, user profile, bundled/plugin profile, then default.
28
+- Missing policy inherits standard access; custom policy always records whether
29
+ future tools default to allowed or blocked.
30
+- The `response` capability is a framework-required invariant: profile policy
31
+ cannot disable it, and the editor does not list it as a configurable tool.
32
+- `vision_load` remains owned by the active chat model's vision configuration;
33
+ it is not exposed as a profile-policy choice and legacy policy IDs cannot
34
+ suppress the chat-configured capability.
35
+- Policy IDs are namespaced as `local:`, `plugin:<id>:`, or `mcp:<server>:`.
36
+- Plugin IDs are derived relative to the canonical roots from `helpers.plugins`,
37
+ not by independently parsing repository-relative path strings.
38
+- Each executable local tool has its own policy identity, including tools that
39
+ share one Markdown prompt.
40
+- Catalog descriptions call the supplied agent's prompt loader instead of
41
+ opening prompt files through a parallel path; the editor agent intentionally
42
+ keeps its existing raw, no-processor implementation.
43
+- Unknown policy IDs remain in the catalog as unavailable entries.
44
+- Resolution performs no model calls and logs no secrets.
45
+
46
+## Verification
47
+
48
+- Run `tests/test_tool_policy.py` and the prompt/Responses/MCP focused tests.
49
+
50
+## Child DOX Index
51
+
52
+No child DOX files.
plugins/AGENTS.md
+1
@@ -99,6 +99,7 @@ Direct child DOX files:
99
| [_telegram_integration/AGENTS.md](_telegram_integration/AGENTS.md) | Telegram bot integration and per-user chat sessions. |
100
| [_text_editor/AGENTS.md](_text_editor/AGENTS.md) | Native text read, write, and patch tool. |
101
| [_time_travel/AGENTS.md](_time_travel/AGENTS.md) | Workspace history, diff, travel, snapshot, and revert flows. |
102
+| [_tool_access/AGENTS.md](_tool_access/AGENTS.md) | Always-on project/profile tool-policy execution gate. |
103
| [_whatsapp_integration/AGENTS.md](_whatsapp_integration/AGENTS.md) | WhatsApp Baileys bridge integration. |
104
| [_whats_new/AGENTS.md](_whats_new/AGENTS.md) | Version-gated What's New showcase modal, card list, and startup trigger. |
105
| [_whisper_stt/AGENTS.md](_whisper_stt/AGENTS.md) | Whisper speech-to-text integration. |
plugins/_a0_connector/AGENTS.md
+2
@@ -20,6 +20,8 @@
20
prompts, remote file metadata enables `text_editor_remote`, F4-enabled remote
21
execution metadata enables `code_execution_remote`, and supported enabled
22
Computer Use that does not need re-arming enables `computer_use_remote`.
23
+- Never re-add a connector prompt that the effective project/profile tool policy
24
+ blocks.
25
- Do not bypass WebSocket authentication or leak connector session data.
26
- Advertise Launcher gateways additively through HTTP capability
27
`launcher_gateway` and WebSocket feature `launcher_gateway_control`. Older
plugins/_a0_connector/extensions/python/_functions/_11_tools_prompt/build_prompt/end/_70_include_remote_tool_stubs.py
+4
-16
@@ -4,17 +4,13 @@ import re
4
from typing import Any
5
6
from helpers.extension import Extension
7
+from helpers.tool_policy import resolve_tool
8
from plugins._a0_connector.helpers.remote_tool_prompts import (
9
REMOTE_TOOL_PROMPTS,
10
remote_tool_prompt_availability,
11
)
12
13
13
-_TOOL_MARKERS = {
14
- tool_name: f'"tool_name": "{tool_name}"' for tool_name in REMOTE_TOOL_PROMPTS
15
-}
16
-
17
-
14
class IncludeRemoteToolStubs(Extension):
15
def execute(self, data: dict[str, Any] = {}, **kwargs: Any) -> None:
16
if self.agent is None:
@@ -40,8 +36,8 @@ class IncludeRemoteToolStubs(Extension):
36
if not prompt:
37
continue
38
43
- if available.get(tool_name):
44
- marker = _TOOL_MARKERS[tool_name]
39
+ if available.get(tool_name) and resolve_tool(self.agent, tool_name).allowed:
40
+ marker = f'"tool_name": "{tool_name}"'
41
if marker not in result:
42
result = f"{result.rstrip()}\n\n{prompt}"
43
continue
@@ -55,12 +51,4 @@ def _remove_prompt(result: str, prompt: str) -> str:
51
if prompt not in result:
52
return result
53
58
- for needle, replacement in (
59
- (f"\n\n{prompt}\n\n", "\n\n"),
60
- (f"\n\n{prompt}", ""),
61
- (f"{prompt}\n\n", ""),
62
- (prompt, ""),
63
- ):
64
- result = result.replace(needle, replacement)
65
-
66
- return re.sub(r"\n{3,}", "\n\n", result).rstrip()
54
+ return re.sub(r"\n{3,}", "\n\n", result.replace(prompt, "")).rstrip()
plugins/_tool_access/AGENTS.md
new
+27
@@ -0,0 +1,27 @@
1
+# Tool Access Plugin DOX
2
+
3
+## Purpose
4
+
5
+- Own the always-enabled project/profile tool-policy configuration and execution gate.
6
+
7
+## Ownership
8
+
9
+- `helpers/tool_policy.py` owns shared resolution and catalog behavior.
10
+- `hooks.py` normalizes scoped configuration.
11
+- `extensions/python/tool_execute_before/` rejects blocked execution.
12
+
13
+## Local Contracts
14
+
15
+- This plugin has no independent settings UI; the Agent Editor writes sparse
16
+ profile `config.json` files, projects may own project or project-profile
17
+ configs through the standard plugin scope paths, and the runtime remains
18
+ authoritative.
19
+- Required final-response capability is never disabled.
20
+
21
+## Verification
22
+
23
+- Run `tests/test_tool_policy.py`.
24
+
25
+## Child DOX Index
26
+
27
+No child DOX files.
plugins/_tool_access/README.md
new
+11
@@ -0,0 +1,11 @@
1
+# Tool Access
2
+
3
+Tool Access is the always-enabled runtime owner for Agent Editor tool policy.
4
+Policies use the standard plugin precedence: active project profile, active
5
+project, user profile, bundled/plugin profile, then the default. Sparse project
6
+policy lives under `.a0proj/plugins/_tool_access/config.json`; profile policy
7
+lives under `usr/agents/<profile>/plugins/_tool_access/config.json`.
8
+
9
+One resolver filters textual prompts and provider-native schemas, rejects local
10
+and MCP execution, and keeps delegated agents bound to their own effective scope.
11
+The required final-response capability is always available.
plugins/_tool_access/default_config.yaml
new
+4
@@ -0,0 +1,4 @@
1
+mode: inherit
2
+default: allow
3
+allowed: []
4
+blocked: []
plugins/_tool_access/extensions/python/tool_execute_before/_10_enforce_tool_policy.py
new
+8
@@ -0,0 +1,8 @@
1
+from helpers.extension import Extension
2
+from helpers.tool_policy import ensure_tool_allowed
3
+
4
+
5
+class EnforceToolPolicy(Extension):
6
+ async def execute(self, tool_name: str = "", **kwargs) -> None:
7
+ if self.agent and tool_name:
8
+ ensure_tool_allowed(self.agent, tool_name)
plugins/_tool_access/hooks.py
new
+9
@@ -0,0 +1,9 @@
1
+from helpers.tool_policy import normalize_policy
2
+
3
+
4
+def get_plugin_config(default=None, **kwargs):
5
+ return normalize_policy(default)
6
+
7
+
8
+def save_plugin_config(settings=None, **kwargs):
9
+ return normalize_policy(settings)
plugins/_tool_access/plugin.yaml
new
+7
@@ -0,0 +1,7 @@
1
+name: _tool_access
2
+title: Tool Access
3
+description: Enforces sparse project and agent tool visibility and execution policy.
4
+version: 1.0.0
5
+always_enabled: true
6
+per_project_config: true
7
+per_agent_config: true
tests/test_a0_connector_prompt_gating.py
+28
-1
@@ -3,6 +3,7 @@ import sys
3
import time
4
import uuid
5
from pathlib import Path
6
+from types import SimpleNamespace
7
8
import yaml
9
@@ -13,7 +14,11 @@ if str(PROJECT_ROOT) not in sys.path:
14
15
def _restore_real_helpers_package() -> None:
16
helpers_module = sys.modules.get("helpers")
16
- if helpers_module is None or getattr(helpers_module, "__file__", ""):
17
+ if (
18
+ helpers_module is None
19
+ or getattr(helpers_module, "__file__", "")
20
+ or list(getattr(helpers_module, "__path__", []))
21
+ ):
22
return
23
24
for name in list(sys.modules):
@@ -65,10 +70,14 @@ class FakeContext:
70
def __init__(self, context_id: str):
71
self.id = context_id
72
73
+ def get_data(self, key: str, recursive: bool = True):
74
+ return None
75
+
76
77
class FakeAgent:
78
def __init__(self, context_id: str):
79
self.context = FakeContext(context_id)
80
+ self.config = SimpleNamespace(profile="default")
81
82
def read_prompt(self, file: str, **kwargs) -> str:
83
text = (PROMPT_ROOT / file).read_text(encoding="utf-8")
@@ -218,6 +227,24 @@ def test_remote_tool_gate_appends_available_prompt_when_standard_prompt_missing(
227
_assert_remote_tool_absent(prompt, "computer_use_remote")
228
229
230
+def test_remote_tool_gate_does_not_readd_a_policy_blocked_prompt(monkeypatch):
231
+ context_id = _context_id()
232
+ sid = _sid()
233
+ monkeypatch.setitem(
234
+ IncludeRemoteToolStubs.execute.__globals__,
235
+ "resolve_tool",
236
+ lambda _agent, name: SimpleNamespace(allowed=name != "text_editor_remote"),
237
+ )
238
+ ws_runtime.register_sid(sid)
239
+ ws_runtime.store_sid_remote_file_metadata(sid, {"enabled": True})
240
+ try:
241
+ prompt = _apply_gate(context_id, include_standard_remote_prompts=False)
242
+ finally:
243
+ ws_runtime.unregister_sid(sid)
244
+
245
+ _assert_remote_tool_absent(prompt, "text_editor_remote")
246
+
247
+
248
def test_responses_function_tools_follow_remote_prompt_gate(monkeypatch):
249
from helpers import responses_tools
250
tests/test_responses_tools.py
+20
-2
@@ -1,18 +1,21 @@
1
import sys
2
from pathlib import Path
3
+from types import SimpleNamespace
4
5
6
PROJECT_ROOT = Path(__file__).resolve().parents[1]
7
if str(PROJECT_ROOT) not in sys.path:
8
sys.path.insert(0, str(PROJECT_ROOT))
9
9
-from helpers import responses_tools
10
+from helpers import responses_tools, tool_policy
11
12
13
class FakeAgent:
14
def __init__(self, prompt_root: Path, data=None):
15
self.prompt_root = prompt_root
16
self.data = data or {}
17
+ self.config = SimpleNamespace(profile="default")
18
+ self.context = SimpleNamespace(get_data=lambda *args, **kwargs: None)
19
20
def read_prompt(self, file: str, **kwargs) -> str:
21
prompt = (self.prompt_root / file).read_text(encoding="utf-8")
@@ -170,7 +173,11 @@ def test_response_tool_native_contract_omits_wrapper_and_exposes_text():
173
encoding="utf-8"
174
)
175
173
- description = responses_tools._description_from_prompt(prompt, fallback="response")
176
+ description = tool_policy.tool_prompt_description(
177
+ prompt,
178
+ "response",
179
+ fallback="response",
180
+ )
181
schema = responses_tools._schema_from_prompt(prompt)
182
183
assert description == "final answer to user"
@@ -259,3 +266,14 @@ def test_local_tool_prompts_use_registered_render_kwargs(monkeypatch, tmp_path):
266
267
assert "{{default_line_count}}" not in prompts["text_editor"]
268
assert "read 200 lines by default" in prompts["text_editor"]
269
+
270
+
271
+def test_explicit_tool_name_precedes_a_generic_heading():
272
+ prompt = """## memory tools
273
+durable memory operations
274
+{"tool_name": "memory_load", "tool_args": {}}
275
+"""
276
+
277
+ assert responses_tools._tool_names_from_prompt(
278
+ prompt, fallback="memory"
279
+ ) == ["memory_load"]
tests/test_tool_policy.py
new
+550
@@ -0,0 +1,550 @@
1
+from __future__ import annotations
2
+
3
+from pathlib import Path
4
+from types import SimpleNamespace
5
+
6
+import pytest
7
+
8
+from extensions.python.system_prompt import _11_tools_prompt
9
+from helpers import mcp_handler, responses_tools, tool_policy
10
+from helpers.errors import RepairableException
11
+from plugins._tool_access.extensions.python.tool_execute_before._10_enforce_tool_policy import (
12
+ EnforceToolPolicy,
13
+)
14
+
15
+
16
+class _Context:
17
+ def get_data(self, key: str, recursive: bool = True):
18
+ return None
19
+
20
+
21
+class _Agent:
22
+ def __init__(self, prompt_root: Path, profile: str = "researcher") -> None:
23
+ self.prompt_root = prompt_root
24
+ self.config = SimpleNamespace(profile=profile)
25
+ self.context = _Context()
26
+ self.data: dict = {}
27
+
28
+ def read_prompt(self, basename: str, **kwargs) -> str:
29
+ content = (self.prompt_root / basename).read_text(encoding="utf-8")
30
+ for key, value in kwargs.items():
31
+ content = content.replace("{{" + key + "}}", str(value))
32
+ return content
33
+
34
+ def get_data(self, key: str):
35
+ return self.data.get(key)
36
+
37
+
38
+class _NoMCPTools:
39
+ def get_tools(self):
40
+ return []
41
+
42
+
43
+def _write_prompt(root: Path, basename: str, content: str) -> None:
44
+ (root / basename).write_text(content.strip() + "\n", encoding="utf-8")
45
+
46
+
47
+def _prompt_paths(root: Path):
48
+ def get_paths(agent, *parts, **kwargs):
49
+ return [str(root)] if parts and parts[0] == "prompts" else []
50
+
51
+ return get_paths
52
+
53
+
54
+def _custom_policy(*, default: str, allowed=(), blocked=()):
55
+ return {
56
+ "mode": "custom",
57
+ "default": default,
58
+ "allowed": list(allowed),
59
+ "blocked": list(blocked),
60
+ }
61
+
62
+
63
+@pytest.fixture
64
+def local_prompt_agent(monkeypatch, tmp_path: Path) -> _Agent:
65
+ _write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
66
+ _write_prompt(
67
+ tmp_path,
68
+ "agent.system.tool.allowed.md",
69
+ """### allowed
70
+Allowed description
71
+Keyboard input remains documented.
72
+Do not call the `blocked` tool from here.
73
+{"tool_name":"allowed","tool_args":{}}""",
74
+ )
75
+ _write_prompt(
76
+ tmp_path,
77
+ "agent.system.tool.blocked.md",
78
+ '### blocked\nBlocked description\n{"tool_name":"blocked","tool_args":{}}',
79
+ )
80
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
81
+ monkeypatch.setattr(
82
+ "plugins._model_config.helpers.model_config.get_chat_model_config",
83
+ lambda agent: {"vision": False},
84
+ )
85
+ monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
86
+ return _Agent(tmp_path)
87
+
88
+
89
+@pytest.mark.asyncio
90
+async def test_text_tool_prompt_omits_blocked_tool_and_description(
91
+ monkeypatch, local_prompt_agent: _Agent
92
+) -> None:
93
+ monkeypatch.setattr(
94
+ tool_policy,
95
+ "get_policy",
96
+ lambda agent: _custom_policy(default="allow", blocked=["local:blocked"]),
97
+ )
98
+
99
+ prompt = await _11_tools_prompt.build_prompt(local_prompt_agent)
100
+
101
+ assert "Allowed description" in prompt
102
+ assert "Keyboard input remains documented." in prompt
103
+ assert "Do not call" not in prompt
104
+ assert "blocked" not in prompt.lower()
105
+ assert "Blocked description" not in prompt
106
+
107
+
108
+def test_provider_native_schemas_omit_blocked_local_tool(
109
+ monkeypatch, local_prompt_agent: _Agent
110
+) -> None:
111
+ monkeypatch.setattr(
112
+ tool_policy,
113
+ "get_policy",
114
+ lambda agent: _custom_policy(default="allow", blocked=["local:blocked"]),
115
+ )
116
+
117
+ tools, _name_map = responses_tools.build_responses_function_tools(
118
+ local_prompt_agent
119
+ )
120
+
121
+ assert [tool["name"] for tool in tools] == ["allowed"]
122
+
123
+
124
+def test_required_response_survives_default_block(monkeypatch, tmp_path: Path) -> None:
125
+ _write_prompt(
126
+ tmp_path,
127
+ "agent.system.tool.response.md",
128
+ '### response\nfinal answer\n{"tool_name":"response","tool_args":{"text":"done"}}',
129
+ )
130
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
131
+ monkeypatch.setattr(
132
+ tool_policy,
133
+ "get_policy",
134
+ lambda agent: _custom_policy(default="block", blocked=["local:response"]),
135
+ )
136
+ monkeypatch.setattr(
137
+ mcp_handler.MCPConfig,
138
+ "get_for_agent",
139
+ lambda agent: _NoMCPTools(),
140
+ )
141
+ agent = _Agent(tmp_path)
142
+
143
+ decision = tool_policy.resolve_tool(agent, "response")
144
+
145
+ assert decision.allowed is True
146
+ assert decision.source == "framework-required"
147
+ assert tool_policy.get_tool_catalog(agent) == []
148
+
149
+
150
+def test_catalog_comes_from_executable_tools_not_prompt_names(
151
+ monkeypatch, tmp_path: Path
152
+) -> None:
153
+ prompt_root = tmp_path / "prompts"
154
+ tool_root = tmp_path / "tools"
155
+ prompt_root.mkdir()
156
+ tool_root.mkdir()
157
+ _write_prompt(
158
+ prompt_root,
159
+ "agent.system.tool.actual.md",
160
+ "### actual\nActual description",
161
+ )
162
+ _write_prompt(
163
+ prompt_root,
164
+ "agent.system.tool.prompt_only.md",
165
+ "### prompt_only\nNo executable implementation",
166
+ )
167
+ (tool_root / "actual.py").write_text("class Actual: pass\n", encoding="utf-8")
168
+ (tool_root / "response.py").write_text("class Response: pass\n", encoding="utf-8")
169
+
170
+ def get_paths(agent, *parts, **kwargs):
171
+ if parts[0] == "prompts":
172
+ return [str(prompt_root)]
173
+ if len(parts) == 1:
174
+ return [str(tool_root)]
175
+ return [str(tool_root / parts[1])]
176
+
177
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", get_paths)
178
+ monkeypatch.setattr(
179
+ mcp_handler.MCPConfig,
180
+ "get_for_agent",
181
+ lambda agent: _NoMCPTools(),
182
+ )
183
+ monkeypatch.setattr(
184
+ tool_policy,
185
+ "get_policy",
186
+ lambda agent: {
187
+ "mode": "inherit",
188
+ "default": "allow",
189
+ "allowed": [],
190
+ "blocked": [],
191
+ },
192
+ )
193
+
194
+ catalog = tool_policy.get_tool_catalog(_Agent(prompt_root))
195
+
196
+ assert [item["id"] for item in catalog] == ["local:actual"]
197
+ assert catalog[0]["description"] == "Actual description"
198
+
199
+
200
+def test_tool_prompt_description_skips_fenced_examples() -> None:
201
+ prompt = """### example
202
+~~~json
203
+{"tool_name":"example","tool_args":{}}
204
+~~~
205
+Visible summary
206
+"""
207
+
208
+ assert tool_policy.tool_prompt_description(prompt, "example") == "Visible summary"
209
+
210
+
211
+def test_plugin_tool_identity_uses_canonical_plugin_roots(
212
+ monkeypatch, tmp_path: Path
213
+) -> None:
214
+ plugin_root = tmp_path / "plugins" / "_example"
215
+ plugin_tool = plugin_root / "tools" / "actual.py"
216
+ plugin_tool.parent.mkdir(parents=True)
217
+ plugin_tool.write_text("class Actual: pass\n", encoding="utf-8")
218
+ monkeypatch.setattr(
219
+ tool_policy.plugins,
220
+ "get_plugin_roots",
221
+ lambda: [str(tmp_path / "usr" / "plugins"), str(tmp_path / "plugins")],
222
+ )
223
+ monkeypatch.setattr(
224
+ tool_policy,
225
+ "get_policy",
226
+ lambda agent: {
227
+ "mode": "inherit",
228
+ "default": "allow",
229
+ "allowed": [],
230
+ "blocked": [],
231
+ },
232
+ )
233
+ agent = _Agent(tmp_path)
234
+
235
+ monkeypatch.setattr(
236
+ tool_policy.subagents,
237
+ "get_paths",
238
+ lambda *args, **kwargs: [str(plugin_tool)],
239
+ )
240
+ assert tool_policy.resolve_tool(agent, "actual").tool_id == "plugin:_example:actual"
241
+
242
+ lookalike = tmp_path / "work" / "plugins" / "_example" / "tools" / "actual.py"
243
+ lookalike.parent.mkdir(parents=True)
244
+ lookalike.write_text("class Actual: pass\n", encoding="utf-8")
245
+ monkeypatch.setattr(
246
+ tool_policy.subagents,
247
+ "get_paths",
248
+ lambda *args, **kwargs: [str(lookalike)],
249
+ )
250
+ assert tool_policy.resolve_tool(agent, "actual").tool_id == "local:actual"
251
+
252
+
253
+def test_legacy_response_and_vision_policy_ids_stay_out_of_catalog(
254
+ monkeypatch, tmp_path: Path
255
+) -> None:
256
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
257
+ monkeypatch.setattr(
258
+ mcp_handler.MCPConfig,
259
+ "get_for_agent",
260
+ lambda agent: _NoMCPTools(),
261
+ )
262
+ monkeypatch.setattr(
263
+ tool_policy,
264
+ "get_policy",
265
+ lambda agent: _custom_policy(
266
+ default="allow",
267
+ blocked=[
268
+ "response",
269
+ "local:response",
270
+ "plugin:legacy:response",
271
+ "vision_load",
272
+ "local:vision_load",
273
+ "plugin:legacy:vision_load",
274
+ ],
275
+ ),
276
+ )
277
+
278
+ assert tool_policy.get_tool_catalog(_Agent(tmp_path)) == []
279
+
280
+
281
+@pytest.mark.asyncio
282
+async def test_vision_tool_follows_chat_config_not_profile_policy(
283
+ monkeypatch, tmp_path: Path
284
+) -> None:
285
+ _write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
286
+ _write_prompt(
287
+ tmp_path,
288
+ "agent.system.tools_vision.md",
289
+ '### vision_load\nload images\n{"tool_name":"vision_load","tool_args":{"paths":[]}}',
290
+ )
291
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
292
+ monkeypatch.setattr(
293
+ "plugins._model_config.helpers.model_config.get_chat_model_config",
294
+ lambda agent: {"vision": True},
295
+ )
296
+ monkeypatch.setattr(
297
+ tool_policy,
298
+ "get_policy",
299
+ lambda agent: _custom_policy(
300
+ default="block", blocked=["local:vision_load"]
301
+ ),
302
+ )
303
+ monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
304
+ agent = _Agent(tmp_path)
305
+
306
+ prompt = await _11_tools_prompt.build_prompt(agent)
307
+ schemas, _name_map = responses_tools.build_responses_function_tools(agent)
308
+
309
+ assert "vision_load" in prompt
310
+ assert [schema["name"] for schema in schemas] == ["vision_load"]
311
+ assert tool_policy.resolve_tool(agent, "vision_load").source == "runtime-config"
312
+
313
+
314
+def test_mcp_prompt_and_native_schema_omit_blocked_tool(
315
+ monkeypatch, tmp_path: Path
316
+) -> None:
317
+ class Server:
318
+ name = "docs"
319
+ description = "Documentation"
320
+
321
+ def get_tools(self):
322
+ return [
323
+ {
324
+ "name": "read",
325
+ "description": "Read docs",
326
+ "input_schema": {"type": "object"},
327
+ },
328
+ {
329
+ "name": "write",
330
+ "description": "Write docs",
331
+ "input_schema": {"type": "object"},
332
+ },
333
+ ]
334
+
335
+ config = mcp_handler.MCPConfig(servers_list=[])
336
+ config.servers = [Server()]
337
+ agent = _Agent(tmp_path)
338
+ monkeypatch.setattr(
339
+ tool_policy,
340
+ "get_policy",
341
+ lambda agent: _custom_policy(
342
+ default="allow", blocked=["mcp:docs:write"]
343
+ ),
344
+ )
345
+ monkeypatch.setattr(
346
+ responses_tools,
347
+ "_mcp_tools",
348
+ lambda agent: [
349
+ ("docs.read", Server().get_tools()[0]),
350
+ ("docs.write", Server().get_tools()[1]),
351
+ ],
352
+ )
353
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args: [])
354
+ monkeypatch.setattr(responses_tools, "_vision_tool_prompt", lambda agent: "")
355
+
356
+ prompt = config.get_tools_prompt(agent=agent)
357
+ schemas, name_map = responses_tools.build_responses_function_tools(agent)
358
+
359
+ assert "docs.read" in prompt
360
+ assert "docs.write" not in prompt
361
+ assert len(schemas) == 1
362
+ assert name_map[schemas[0]["name"]] == "docs.read"
363
+
364
+
365
+@pytest.mark.asyncio
366
+async def test_local_execution_gate_returns_stable_profile_error(
367
+ monkeypatch, tmp_path: Path
368
+) -> None:
369
+ agent = _Agent(tmp_path, profile="researcher")
370
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
371
+ monkeypatch.setattr(
372
+ tool_policy,
373
+ "get_policy",
374
+ lambda agent: _custom_policy(default="block"),
375
+ )
376
+
377
+ with pytest.raises(
378
+ RepairableException,
379
+ match='Tool "shell" is blocked for agent profile "researcher"',
380
+ ):
381
+ await EnforceToolPolicy(agent).execute(tool_name="shell")
382
+
383
+
384
+@pytest.mark.asyncio
385
+async def test_mcp_invocation_rechecks_policy_before_server_call(
386
+ monkeypatch, tmp_path: Path
387
+) -> None:
388
+ agent = _Agent(tmp_path, profile="researcher")
389
+ called = False
390
+
391
+ class Config:
392
+ async def call_tool(self, name, kwargs):
393
+ nonlocal called
394
+ called = True
395
+ raise AssertionError("blocked MCP call reached the server")
396
+
397
+ monkeypatch.setattr(mcp_handler.MCPConfig, "get_for_agent", lambda agent: Config())
398
+ monkeypatch.setattr(
399
+ tool_policy,
400
+ "get_policy",
401
+ lambda agent: _custom_policy(
402
+ default="allow", blocked=["mcp:docs:write"]
403
+ ),
404
+ )
405
+ tool = mcp_handler.MCPTool(
406
+ agent=agent,
407
+ name="docs.write",
408
+ method=None,
409
+ args={},
410
+ message="",
411
+ loop_data=None,
412
+ )
413
+
414
+ with pytest.raises(RepairableException, match='Tool "docs.write" is blocked'):
415
+ await tool.execute()
416
+ assert called is False
417
+
418
+
419
+@pytest.mark.asyncio
420
+async def test_delegated_agent_uses_its_own_profile_policy_at_execution_gate(
421
+ monkeypatch, tmp_path: Path
422
+) -> None:
423
+ parent = _Agent(tmp_path, profile="agent0")
424
+ child = _Agent(tmp_path, profile="researcher")
425
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
426
+
427
+ def config_for_profile(plugin_name, agent=None, **kwargs):
428
+ if agent.config.profile == "researcher":
429
+ return _custom_policy(default="block")
430
+ return {"mode": "inherit"}
431
+
432
+ monkeypatch.setattr(tool_policy.plugins, "get_plugin_config", config_for_profile)
433
+
434
+ assert tool_policy.resolve_tool(parent, "shell").allowed is True
435
+ assert tool_policy.resolve_tool(child, "shell").allowed is False
436
+ await EnforceToolPolicy(parent).execute(tool_name="shell")
437
+ with pytest.raises(
438
+ RepairableException,
439
+ match='Tool "shell" is blocked for agent profile "researcher"',
440
+ ):
441
+ await EnforceToolPolicy(child).execute(tool_name="shell")
442
+
443
+
444
+def test_project_policy_precedes_profile_policy(
445
+ monkeypatch: pytest.MonkeyPatch,
446
+ tmp_path: Path,
447
+) -> None:
448
+ class ProjectContext:
449
+ def get_data(self, key: str, recursive: bool = True):
450
+ return "demo" if key == "project" else None
451
+
452
+ monkeypatch.setattr(tool_policy.files, "_base_dir", str(tmp_path))
453
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
454
+ monkeypatch.setattr(
455
+ tool_policy.plugins,
456
+ "call_plugin_hook",
457
+ lambda _plugin, _hook, default=None, **_kwargs: default,
458
+ )
459
+ agent = _Agent(tmp_path)
460
+ agent.context = ProjectContext()
461
+
462
+ tool_policy.plugins.save_plugin_config(
463
+ tool_policy.PLUGIN_NAME,
464
+ "",
465
+ "researcher",
466
+ _custom_policy(default="block"),
467
+ )
468
+ tool_policy.plugins.save_plugin_config(
469
+ tool_policy.PLUGIN_NAME,
470
+ "demo",
471
+ "",
472
+ _custom_policy(default="allow"),
473
+ )
474
+ tool_policy.plugins.save_plugin_config(
475
+ tool_policy.PLUGIN_NAME,
476
+ "demo",
477
+ "researcher",
478
+ _custom_policy(default="allow", blocked=["local:shell"]),
479
+ )
480
+ profile_path = Path(
481
+ tool_policy.plugins.determine_plugin_asset_path(
482
+ tool_policy.PLUGIN_NAME,
483
+ "",
484
+ "researcher",
485
+ tool_policy.plugins.CONFIG_FILE_NAME,
486
+ )
487
+ )
488
+ project_path = Path(
489
+ tool_policy.plugins.determine_plugin_asset_path(
490
+ tool_policy.PLUGIN_NAME,
491
+ "demo",
492
+ "",
493
+ tool_policy.plugins.CONFIG_FILE_NAME,
494
+ )
495
+ )
496
+ project_profile_path = Path(
497
+ tool_policy.plugins.determine_plugin_asset_path(
498
+ tool_policy.PLUGIN_NAME,
499
+ "demo",
500
+ "researcher",
501
+ tool_policy.plugins.CONFIG_FILE_NAME,
502
+ )
503
+ )
504
+
505
+ decision = tool_policy.resolve_tool(agent, "shell")
506
+ assert decision.allowed is False
507
+ assert decision.source == "scoped-policy"
508
+
509
+ project_profile_path.unlink()
510
+ decision = tool_policy.resolve_tool(agent, "shell")
511
+ assert decision.allowed is True
512
+ assert decision.source == "scoped-default"
513
+
514
+ project_path.unlink()
515
+ decision = tool_policy.resolve_tool(agent, "shell")
516
+ assert decision.allowed is False
517
+ assert decision.source == "scoped-default"
518
+ assert profile_path.is_file()
519
+
520
+
521
+def test_unknown_policy_ids_are_retained_as_unavailable(
522
+ monkeypatch, tmp_path: Path
523
+) -> None:
524
+ agent = _Agent(tmp_path)
525
+ monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
526
+ monkeypatch.setattr(
527
+ mcp_handler.MCPConfig,
528
+ "get_for_agent",
529
+ lambda agent: _NoMCPTools(),
530
+ )
531
+ monkeypatch.setattr(
532
+ tool_policy,
533
+ "get_policy",
534
+ lambda agent: _custom_policy(
535
+ default="allow", blocked=["plugin:missing:ghost"]
536
+ ),
537
+ )
538
+
539
+ catalog = tool_policy.get_tool_catalog(agent)
540
+
541
+ assert catalog == [
542
+ {
543
+ "id": "plugin:missing:ghost",
544
+ "name": "ghost",
545
+ "label": "Ghost",
546
+ "description": "",
547
+ "origin": "Unavailable",
548
+ "available": False,
549
+ }
550
+ ]