Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/tool-restrictions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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'.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
7 changes: 5 additions & 2 deletions src/cli_agent_orchestrator/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/cli_agent_orchestrator/services/terminal_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion src/cli_agent_orchestrator/utils/tool_mapping.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand Down Expand Up @@ -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"],
Expand All @@ -53,6 +61,7 @@
"search_file_content",
"glob",
],
"web_fetch": ["web_fetch", "google_web_search"],
},
}

Expand Down
2 changes: 1 addition & 1 deletion test/e2e/test_allowed_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
64 changes: 61 additions & 3 deletions test/utils/test_tool_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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 == []

Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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 == []
Expand Down
Loading