From ccca36919a208a8531d91d4838d3be279d61e21e Mon Sep 17 00:00:00 2001 From: call-me-ram Date: Wed, 17 Jun 2026 12:19:51 +0000 Subject: [PATCH] feat(tools): gate network egress behind a web_fetch tool category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CAO's tool-restriction vocabulary did not map providers' network tools, so they stayed unrestricted even when allowedTools was set — a read-only reviewer or orchestration-only supervisor could still reach the network. Add a web_fetch category so network access is governable across providers. - claude_code: web_fetch -> [WebFetch, WebSearch]; gemini_cli: web_fetch -> [web_fetch, google_web_search]. A profile/role without web_fetch now blocks those tools. - developer role gains web_fetch (full-access role and the no-role default keep network access — no silent regression); supervisor/reviewer stay off the network, removing their egress channel entirely (they also lack execute_bash, i.e. curl). - subagent (Task) is intentionally NOT a separate category: it stays folded into execute_bash, since a Task subagent spawns with its own full toolset and can run shell — a standalone subagent grant would re-open that escape. - Launch-time guard: kimi_cli/codex have no native tool-blocking (soft/prompt enforcement only), so creating a restricted terminal on them logs a loud warning to route restricted/write-capable roles to hard-enforcement providers. - web_fetch is a no-op for providers without a network entry (copilot), keeping the vocabulary universal without changing their behavior. - Update docs/tool-restrictions.md vocabulary/translation tables and Known Limitation #1; extend test/utils/test_tool_mapping.py. Closes #310 --- docs/tool-restrictions.md | 12 ++-- src/cli_agent_orchestrator/constants.py | 7 +- .../services/terminal_service.py | 21 ++++++ .../utils/tool_mapping.py | 11 +++- test/e2e/test_allowed_tools.py | 2 +- test/utils/test_tool_mapping.py | 64 ++++++++++++++++++- 6 files changed, 105 insertions(+), 12 deletions(-) diff --git a/docs/tool-restrictions.md b/docs/tool-restrictions.md index d0c919db..d5f66b6c 100644 --- a/docs/tool-restrictions.md +++ b/docs/tool-restrictions.md @@ -22,7 +22,7 @@ CAO controls what tools an agent can use through a two-layer system: ## Default Behavior -**If you don't set `role` or `allowedTools`, the agent defaults to `developer` role permissions** (`@builtin`, `fs_*`, `execute_bash`, `@cao-mcp-server`). This gives full coding access while still going through the restriction system. The launch confirmation prompt will remind you to add `role` or `allowedTools` to your profile. +**If you don't set `role` or `allowedTools`, the agent defaults to `developer` role permissions** (`@builtin`, `fs_*`, `execute_bash`, `web_fetch`, `@cao-mcp-server`). This gives full coding access while still going through the restriction system. The launch confirmation prompt will remind you to add `role` or `allowedTools` to your profile. ## The Three Controls @@ -43,8 +43,8 @@ role: supervisor | Role | Default `allowedTools` | What the agent can do | |------|----------------------|----------------------| | `supervisor` | `@cao-mcp-server`, `fs_read`, `fs_list` | Orchestrate workers + read files for context | -| `developer` | `@builtin`, `fs_*`, `execute_bash`, `@cao-mcp-server` | Full access: read, write, execute, orchestrate | -| `reviewer` | `@builtin`, `fs_read`, `fs_list`, `@cao-mcp-server` | Read-only: review code, no writes or execution | +| `developer` | `@builtin`, `fs_*`, `execute_bash`, `web_fetch`, `@cao-mcp-server` | Full access: read, write, execute, fetch, orchestrate | +| `reviewer` | `@builtin`, `fs_read`, `fs_list`, `@cao-mcp-server` | Read-only: review code, no writes, execution, or network | #### Custom Roles @@ -106,6 +106,7 @@ No `role` is needed — `allowedTools` is the full specification of what tools t | `fs_write` | Write/edit files | `Edit`, `Write` | `write_file`, `replace` | | `fs_list` | Search/list files | `Glob`, `Grep` | `list_directory`, `glob` | | `fs_*` | All filesystem ops | All of the above | All of the above | +| `web_fetch` | Fetch URLs / search the web | `WebFetch`, `WebSearch` | `web_fetch`, `google_web_search` | | `@builtin` | Provider built-in capabilities | (internal) | (internal) | | `@cao-mcp-server` | CAO orchestration tools | `handoff`, `assign`, `send_message`, plus Hermes prompt answers via `answer_user_prompt` | Same | | `*` | Everything (unrestricted) | All tools | All tools | @@ -154,7 +155,7 @@ If no `role` or `allowedTools` is set in the profile, the prompt includes an add ``` Agent 'my_agent' launching on claude_code: Role: (not set — using developer defaults) - Allowed: @builtin, fs_*, execute_bash, @cao-mcp-server + Allowed: @builtin, fs_*, execute_bash, web_fetch, @cao-mcp-server Directory: /home/user/my-project Note: No role or allowedTools set — defaulting to 'developer'. @@ -195,6 +196,7 @@ CAO defines a universal tool vocabulary (`execute_bash`, `fs_read`, `fs_write`, | `fs_read` | `Read` | `read` | `read_file`, `list_directory`, `search_file_content`, `glob` | | `fs_write` | `Edit`, `Write` | `write` | `write_file`, `replace` | | `fs_list` | `Glob`, `Grep` | `list`, `grep` | `list_directory`, `glob`, `search_file_content` | +| `web_fetch` | `WebFetch`, `WebSearch` | (not mapped) | `web_fetch`, `google_web_search` | **Providers that accept CAO vocabulary directly** — Kiro CLI and Q CLI accept `allowedTools` in the agent JSON at install time, using the same vocabulary as CAO. No translation needed. Kimi CLI and Codex use system prompt instructions to enforce restrictions. For all four, CAO passes the `allowedTools` list directly without translation — so no `TOOL_MAPPING` entry exists for them, and none is needed. @@ -324,7 +326,7 @@ Each agent is restricted based on its own profile, not its parent's permissions. ## Known Limitations -1. **Claude Code tool mapping is incomplete.** The current mapping covers `Bash`, `Read`, `Edit`, `Write`, `Glob`, and `Grep`. Claude Code also has [`WebFetch`](https://code.claude.com/docs/en/permissions#webfetch), `Agent` (subagent), and MCP tools that are not yet mapped to CAO vocabulary. These tools remain **unrestricted** even when `allowedTools` is set — they cannot be blocked via `--disallowedTools`. Future versions will add `web_fetch` and `subagent` to the CAO vocabulary. +1. **Claude Code tool mapping is nearly complete, with MCP tools the remaining gap.** The current mapping covers `Bash` (and its `Task`/`Monitor`/`BashOutput`/`KillShell` execution family), `Read`, `Edit`, `Write`, `Glob`, `Grep`, and — via `web_fetch` — [`WebFetch`](https://code.claude.com/docs/en/permissions#webfetch) and `WebSearch`. The subagent tool (`Task`) is intentionally **not** a separate category: it is folded into `execute_bash`, because a `Task` subagent spawns with its own full toolset and can run shell, so exposing it standalone would let a profile grant subagent access without `execute_bash` and re-open that escape. Provider MCP tools remain unmapped (see limitation #2) — they cannot be blocked via `--disallowedTools`. 2. **`@cao-mcp-server` is a pass-through marker, not enforced at the provider level.** Including `@cao-mcp-server` in `allowedTools` signals intent (this agent should have orchestration tools), but it does **not** translate to any native `--disallowedTools` flag. MCP tools (`handoff`, `assign`, `send_message`, `answer_user_prompt`) are always available to the agent regardless of `allowedTools` — providers do not currently support blocking individual MCP tools. `answer_user_prompt` is exposed by the MCP server, but its structured prompt-navigation behavior is currently implemented for Hermes workers that report `waiting_user_answer`; other providers may only receive ordinary text input until they implement equivalent prompt states. Additionally, `@cao-mcp-server` is all-or-nothing: there is no way to allow only `send_message` while blocking `assign`. Future versions may support `@cao-mcp-server:send_message` syntax for per-tool MCP control. diff --git a/src/cli_agent_orchestrator/constants.py b/src/cli_agent_orchestrator/constants.py index fe081204..66fc715d 100644 --- a/src/cli_agent_orchestrator/constants.py +++ b/src/cli_agent_orchestrator/constants.py @@ -316,11 +316,14 @@ def add_local_cors_origins(host: str, port: int) -> None: # ============================================================================= # Built-in role defaults. A role is a named bundle of allowedTools. # Users can define custom roles in settings.json under "roles". -# CAO vocabulary: execute_bash, fs_read, fs_write, fs_list, fs_*, @builtin, @cao-mcp-server +# CAO vocabulary: execute_bash, fs_read, fs_write, fs_list, fs_*, web_fetch, +# @builtin, @cao-mcp-server. +# web_fetch is granted only to developer: supervisor/reviewer are intentionally +# kept off the network (no WebFetch/WebSearch), shrinking their exfiltration surface. ROLE_TOOL_DEFAULTS = { "supervisor": ["@cao-mcp-server", "fs_read", "fs_list"], "reviewer": ["@builtin", "fs_read", "fs_list", "@cao-mcp-server"], - "developer": ["@builtin", "fs_*", "execute_bash", "@cao-mcp-server"], + "developer": ["@builtin", "fs_*", "execute_bash", "web_fetch", "@cao-mcp-server"], } # Security constraints prepended to system prompts for providers without diff --git a/src/cli_agent_orchestrator/services/terminal_service.py b/src/cli_agent_orchestrator/services/terminal_service.py index d8640566..cd5dc2c5 100644 --- a/src/cli_agent_orchestrator/services/terminal_service.py +++ b/src/cli_agent_orchestrator/services/terminal_service.py @@ -120,6 +120,13 @@ class OutputMode(str, Enum): ProviderType.KIMI_CLI.value, } +# Providers whose tool restrictions are prompt-level text only (no native +# blocking mechanism) — a restricted policy on these is advisory, not enforced. +SOFT_ENFORCEMENT_PROVIDERS = { + ProviderType.KIMI_CLI.value, + ProviderType.CODEX.value, +} + async def create_terminal( provider: str, @@ -234,6 +241,20 @@ async def create_terminal( profile.allowedTools, profile.role, mcp_server_names ) + # Soft-enforcement guard: kimi_cli/codex have NO native tool-blocking + # mechanism (kimi runs --yolo; restrictions are prompt-level text + # only), so a restricted policy on them is advisory, not enforced. + # Surface that loudly at launch so operators route restricted or + # write-capable roles to hard-enforcement providers instead. + if provider in SOFT_ENFORCEMENT_PROVIDERS and allowed_tools and "*" not in allowed_tools: + logger.warning( + f"Terminal {terminal_id}: provider '{provider}' cannot enforce tool " + f"restrictions (soft/prompt-level only) but profile '{agent_profile}' " + f"requests {allowed_tools}. Treat this worker as unrestricted; for " + f"enforced restrictions use claude_code, kiro_cli, gemini_cli, or " + f"copilot_cli." + ) + # Step 3c: Persist terminal metadata to database after restrictions # are resolved so API reads and snapshots report the actual launch policy. db_create_terminal( diff --git a/src/cli_agent_orchestrator/utils/tool_mapping.py b/src/cli_agent_orchestrator/utils/tool_mapping.py index f4eceed9..708575d4 100644 --- a/src/cli_agent_orchestrator/utils/tool_mapping.py +++ b/src/cli_agent_orchestrator/utils/tool_mapping.py @@ -1,7 +1,7 @@ """Tool mapping from CAO vocabulary to provider-native tool names. CAO defines a universal tool vocabulary (execute_bash, fs_read, fs_write, fs_list, fs_*, -@builtin, @cao-mcp-server) that is translated to each provider's native tool names. +web_fetch, @builtin, @cao-mcp-server) that is translated to each provider's native tool names. This module provides the mapping and a function to compute which native tools to BLOCK given a set of allowed CAO tools. """ @@ -32,6 +32,14 @@ "fs_write": ["Edit", "Write", "NotebookEdit"], "fs_list": ["Glob", "Grep"], "fs_*": ["Read", "Edit", "Write", "NotebookEdit", "Glob", "Grep"], + # Network access. WebSearch gates here too: both reach the network and + # are the agent's exfiltration/SSRF surface, so a profile without + # web_fetch loses both. Note: the subagent tool (Task) is deliberately + # NOT a separate category — it folds into execute_bash above, because a + # Task subagent spawns with its own full toolset and can run shell; + # exposing it standalone would let a profile grant subagent without + # execute_bash and re-open that escape. + "web_fetch": ["WebFetch", "WebSearch"], }, "copilot_cli": { "execute_bash": ["shell"], @@ -53,6 +61,7 @@ "search_file_content", "glob", ], + "web_fetch": ["web_fetch", "google_web_search"], }, } diff --git a/test/e2e/test_allowed_tools.py b/test/e2e/test_allowed_tools.py index b1dc2c0c..4a5d1bd7 100644 --- a/test/e2e/test_allowed_tools.py +++ b/test/e2e/test_allowed_tools.py @@ -441,7 +441,7 @@ def test_allowed_tools_stored_in_metadata(self, require_claude): _run_allowed_tools_stored_test( provider="claude_code", agent_profile="developer", - allowed_tools="@builtin,fs_*,execute_bash,@cao-mcp-server", + allowed_tools="@builtin,fs_*,execute_bash,web_fetch,@cao-mcp-server", ) diff --git a/test/utils/test_tool_mapping.py b/test/utils/test_tool_mapping.py index a2f03ee1..5b6b34fc 100644 --- a/test/utils/test_tool_mapping.py +++ b/test/utils/test_tool_mapping.py @@ -33,6 +33,7 @@ def test_developer_role_defaults(self): result = resolve_allowed_tools(None, "developer") assert "execute_bash" in result assert "fs_*" in result + assert "web_fetch" in result def test_developer_default_when_no_role_no_tools(self): """No role + no allowedTools = developer defaults (secure default).""" @@ -81,9 +82,9 @@ def test_claude_code_supervisor_blocks_bash(self): assert "Write" in result def test_claude_code_developer_allows_all(self): - """Developer with fs_* and execute_bash should not block anything.""" + """Developer (fs_*, execute_bash, web_fetch) should not block anything.""" result = get_disallowed_tools( - "claude_code", ["@builtin", "fs_*", "execute_bash", "@cao-mcp-server"] + "claude_code", ["@builtin", "fs_*", "execute_bash", "web_fetch", "@cao-mcp-server"] ) assert result == [] @@ -118,6 +119,63 @@ def test_mcp_refs_ignored(self): assert len(result) > 0 +class TestClaudeCodeWebFetch: + """The web_fetch category gates Claude Code's network tools. + + Before this category existed, WebFetch/WebSearch were unmapped and therefore + never blocked — a read-only reviewer or orchestration-only supervisor could + still reach the network (an exfiltration/SSRF surface). web_fetch makes that + governable: only profiles that grant it keep network access. + """ + + def test_web_fetch_allows_network_tools(self): + """A profile granting web_fetch does not block WebFetch/WebSearch.""" + disallowed = get_disallowed_tools("claude_code", ["web_fetch"]) + assert "WebFetch" not in disallowed + assert "WebSearch" not in disallowed + + def test_supervisor_blocks_network_tools(self): + """Supervisor (no web_fetch) blocks both network tools.""" + disallowed = get_disallowed_tools("claude_code", ["@cao-mcp-server", "fs_read", "fs_list"]) + assert "WebFetch" in disallowed + assert "WebSearch" in disallowed + + def test_reviewer_blocks_network_tools(self): + """Reviewer (no web_fetch) blocks both network tools.""" + disallowed = get_disallowed_tools( + "claude_code", ["@builtin", "fs_read", "fs_list", "@cao-mcp-server"] + ) + assert "WebFetch" in disallowed + assert "WebSearch" in disallowed + + def test_execute_bash_does_not_grant_network(self): + """Network access is its own category — execute_bash alone blocks it. + + Guards against folding the network tools into execute_bash: an agent + allowed only shell should not silently gain web access. + """ + disallowed = get_disallowed_tools("claude_code", ["execute_bash"]) + assert "WebFetch" in disallowed + assert "WebSearch" in disallowed + + def test_gemini_web_fetch_mapping(self): + """Gemini has the equivalent network category (web_fetch, google_web_search).""" + disallowed = get_disallowed_tools("gemini_cli", ["fs_read"]) + assert "web_fetch" in disallowed + assert "google_web_search" in disallowed + # Granting it unblocks both. + granted = get_disallowed_tools("gemini_cli", ["fs_read", "web_fetch"]) + assert "web_fetch" not in granted + assert "google_web_search" not in granted + + def test_web_fetch_noop_for_unmapped_provider(self): + """For a provider with no network entry (copilot), web_fetch is a + harmless no-op — it maps to nothing and blocks nothing extra.""" + assert get_disallowed_tools("copilot_cli", ["web_fetch", "fs_read"]) == sorted( + {"shell", "write", "list", "grep"} + ) + + class TestFormatToolSummary: """Tests for format_tool_summary.""" @@ -158,7 +216,7 @@ def test_reviewer_blocks_task_and_notebook_write(self): def test_developer_with_bash_keeps_task(self): disallowed = get_disallowed_tools( - "claude_code", ["@builtin", "fs_*", "execute_bash", "@cao-mcp-server"] + "claude_code", ["@builtin", "fs_*", "execute_bash", "web_fetch", "@cao-mcp-server"] ) assert "Task" not in disallowed assert disallowed == []