From 376a84842e31672e425aaea1702cbe2d8ff44980 Mon Sep 17 00:00:00 2001 From: Maxime Date: Tue, 21 Apr 2026 15:04:02 -0700 Subject: [PATCH 1/5] docs(ai-sessions): document tool permissions and profile configuration Add a dedicated reference page covering how allowed_tools, denied_tools, and permission_mode work in both user Profiles and ai_agent Hive records. Explains the Claude Code tool-name grammar (built-ins, Bash(prefix:*) pipeline-stage semantics, MCP mcp__server__tool names, the lc_call_tool meta-tool scoping), allow/deny precedence, permission modes, session-scoped approvals, and the defaults shipped to new users. Cross-link from the index, user-sessions, dr-sessions, and CLI pages, and add it to the AI Sessions nav section. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/9-ai-sessions/cli.md | 1 + docs/9-ai-sessions/dr-sessions.md | 8 +- docs/9-ai-sessions/index.md | 1 + docs/9-ai-sessions/tool-permissions.md | 207 +++++++++++++++++++++++++ docs/9-ai-sessions/user-sessions.md | 6 +- mkdocs.yml | 1 + 6 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 docs/9-ai-sessions/tool-permissions.md diff --git a/docs/9-ai-sessions/cli.md b/docs/9-ai-sessions/cli.md index 9f7eec4d5..eb0e62107 100644 --- a/docs/9-ai-sessions/cli.md +++ b/docs/9-ai-sessions/cli.md @@ -388,4 +388,5 @@ Sessions you create with `ai chat` will appear in `chats list`. Sessions you cre - [User Sessions](user-sessions.md) — concepts, session states, profiles, the web UI. - [D&R-Driven Sessions](dr-sessions.md) — triggering the same `ai_agent` records automatically from Detection & Response rules. +- [Tool Permissions & Profiles](tool-permissions.md) — reference for `--allowed-tools`, `--denied-tools`, and `--permission-mode`. - [API Reference](api-reference.md) — the REST and WebSocket endpoints the CLI wraps. diff --git a/docs/9-ai-sessions/dr-sessions.md b/docs/9-ai-sessions/dr-sessions.md index ca5dd573e..ebed80696 100644 --- a/docs/9-ai-sessions/dr-sessions.md +++ b/docs/9-ai-sessions/dr-sessions.md @@ -191,11 +191,13 @@ Profiles let you configure Claude's behavior, available tools, and resource limi #### Profile Options +> The full pattern grammar for `allowed_tools` and `denied_tools` (built-in Claude Code tool names, `Bash(prefix:*)` scoping, MCP `mcp__server__tool` names, and the `lc_call_tool` meta-tool form), along with the precedence rules and `permission_mode` semantics, lives on the dedicated [Tool Permissions & Profiles](tool-permissions.md) page. Unattended D&R agents typically want `permission_mode: bypassPermissions` so tool calls don't block on approval prompts. + | Option | Type | Description | |--------|------|-------------| -| `allowed_tools` | list | Tools Claude can use. If empty, all tools are allowed. | -| `denied_tools` | list | Tools Claude cannot use. Takes precedence over `allowed_tools`. | -| `permission_mode` | string | `acceptEdits` (default), `plan`, or `bypassPermissions` | +| `allowed_tools` | list | Tools Claude can use. If empty, all tools are allowed. See [Tool Permissions & Profiles](tool-permissions.md#tool-name-grammar). | +| `denied_tools` | list | Tools Claude cannot use. Takes precedence over `allowed_tools`. See [Tool Permissions & Profiles](tool-permissions.md#allowed_tools-vs-denied_tools). | +| `permission_mode` | string | `acceptEdits` (default), `plan`, or `bypassPermissions`. See [Tool Permissions & Profiles](tool-permissions.md#permission_mode). | | `model` | string | Claude model to use (e.g., `claude-sonnet-4-20250514`) | | `max_turns` | integer | Maximum conversation turns before auto-termination | | `max_budget_usd` | float | Maximum spend limit in USD | diff --git a/docs/9-ai-sessions/index.md b/docs/9-ai-sessions/index.md index 7b5ab7fa5..8a8163d60 100644 --- a/docs/9-ai-sessions/index.md +++ b/docs/9-ai-sessions/index.md @@ -84,6 +84,7 @@ respond: - [D&R-Driven Sessions](dr-sessions.md) - Automated sessions triggered by D&R rules - [User Sessions](user-sessions.md) - Interactive sessions via web UI or API +- [Tool Permissions & Profiles](tool-permissions.md) - How `allowed_tools`, `denied_tools`, and `permission_mode` work - [API Reference](api-reference.md) - REST API and WebSocket protocol - [TypeScript SDK](sdk.md) - SDK for programmatic access diff --git a/docs/9-ai-sessions/tool-permissions.md b/docs/9-ai-sessions/tool-permissions.md new file mode 100644 index 000000000..4d9b7a64c --- /dev/null +++ b/docs/9-ai-sessions/tool-permissions.md @@ -0,0 +1,207 @@ +# Tool Permissions & Profiles + +Every AI Session runs a Claude Agent SDK process inside a managed sandbox. What that agent is actually allowed to do — which built-in Claude Code tools it can call, which shell commands it can run, which MCP servers it can reach — is controlled by three fields that appear in both **user Profiles** and **`ai_agent` Hive records**: + +- `allowed_tools` +- `denied_tools` +- `permission_mode` + +These three settings map directly to the corresponding options on `ClaudeAgentOptions` in the Claude Agent SDK, so the matching semantics are exactly those documented in the upstream [Claude Code permissions reference](https://code.claude.com/docs/en/permissions). This page explains how LimaCharlie surfaces them, the full tool-name grammar, and how the bridge evaluates patterns at tool-call time. + +## Where these fields live + +The same three fields show up in every place an AI Session can be configured: + +| Location | Who owns it | Used by | +|---|---|---| +| **User Profile** (`POST /v1/profiles`) | The authenticated LimaCharlie user | [User Sessions](user-sessions.md) created via the web UI, the [CLI](cli.md) `ai chat`, or the TypeScript [SDK](sdk.md). | +| **`ai_agent` Hive record** | The organization | [D&R-driven sessions](dr-sessions.md) and CLI `ai start-session --definition ` runs. | +| **Inline `profile:` block** in a D&R `start ai agent` action | The organization | One-off overrides inside a specific D&R rule. | +| **Per-session `allowed_tools` / `denied_tools`** in `POST /v1/sessions` | The authenticated user | Per-session override on top of the chosen Profile. | + +The field names and semantics are identical across all four surfaces — a `denied_tools: [Write]` rule means the same thing whether it sits in a user's default Profile or in an `ai_agent` record triggered by a detection. + +## Tool-name grammar + +Entries in `allowed_tools` and `denied_tools` are **tool-name patterns**, not free-form strings. Three shapes are recognised. + +### 1. Bare built-in tool name + +A bare identifier matches the entire Claude Code tool of that name. Common built-ins are: + +| Name | What it does | +|---|---| +| `Read` | Read a file from the session workspace. | +| `Write` | Create or overwrite a file. | +| `Edit` | Apply a targeted edit to an existing file. | +| `Bash` | Run a shell command. | +| `Grep` | Search file contents. | +| `Glob` | Match files by pattern. | +| `WebFetch` | Fetch an HTTP(S) URL. | +| `WebSearch` | Run a web search. | +| `TodoWrite` | Update the in-session task list. | +| `Task` / `Agent` | Spawn a subagent. | +| `AskUserQuestion` | Ask the human-in-the-loop a structured question (routed to the feedback UI in LimaCharlie sessions). | + +!!! note + The authoritative list of built-in tools is the one published by the Claude Code CLI — LimaCharlie does not add or remove tools from that set. Bare names are case-sensitive. + +### 2. Scoped Bash pattern — `Bash(prefix:*)` + +The `Bash` tool accepts a scoping specifier that restricts which commands are covered. Only the `prefix:*` form is recognised, mirroring the official Claude Code CLI syntax: + +``` +Bash(git:*) # any command starting with "git " +Bash(npm install:*) # any command starting with "npm install " +Bash(kubectl get:*) # read-only kubectl verbs +``` + +**Matching semantics** (mirrors the upstream CLI): + +- The command is split on shell stage operators (`|`, `||`, `&`, `&&`, `;`, `|&`) and on real newlines. **Every stage** of the pipeline must be covered by some stored pattern. `Bash(git:*)` alone does **not** approve `git status && rm -rf /`. +- Process wrappers (`timeout`, `time`, `nice`, `nohup`, `stdbuf`, bare `xargs`) and leading `VAR=value` env assignments are stripped iteratively from the front of each stage, so `nohup timeout 30 DEBUG=1 npm test` reduces to `npm test` before matching. +- Redirection operators (`>`, `>>`, `<`, `>&`, `&>`, fd-duplications) stay attached to their command — they are **not** stage separators. +- **Fail-closed** on command substitution, process substitution, backticks, and subshell/brace grouping (`` ` ``, `$(...)`, `<(...)`, `>(...)`, `(...)`, `{...}`). These can smuggle commands past a prefix check, so any stage containing them re-prompts regardless of the pattern list. +- Matching is literal prefix on the stripped stage: either the stage equals the prefix exactly, or it starts with `prefix + " "`. There is no flag-value allowlist and no alias resolution. + +### 3. MCP tool pattern + +MCP server tools are exposed to Claude under a mangled name of the form `mcp____`. You can deny or allow them with either the full name or a scoped pattern. + +```yaml +# Deny one specific MCP tool +denied_tools: + - mcp__virustotal__scan_file + +# Allow only a specific lc_call_tool RPC +allowed_tools: + - mcp__limacharlie__lc_call_tool(list_user_orgs:*) +``` + +There are two MCP-specific rules worth knowing: + +- **Suffix matching for server name drift.** When both the tool name reported by Claude and the pattern start with `mcp__`, only the `__` at the end has to match. A pattern written `mcp__limacharlie__lc_call_tool` will still match a tool Claude exposes as `mcp__claude_ai_LimaCharlie__lc_call_tool`. This keeps Profiles portable across MCP server name variants. +- **`lc_call_tool` meta-tool scoping.** The LimaCharlie MCP server exposes a meta-tool `lc_call_tool` that multiplexes over many LC API functions via its `tool_name` parameter. Patterns of the form `mcp__*__lc_call_tool(:*)` scope approval to a specific LC function (`list_user_orgs`, `run_lcql_query`, etc.) rather than the whole meta-tool. + +## `allowed_tools` vs `denied_tools` + +The two lists are independent Claude Agent SDK inputs and compose as follows: + +1. If `allowed_tools` is non-empty, it becomes the allowlist — tools not in the list are subject to approval prompts (or are denied outright, depending on `permission_mode`). +2. If `denied_tools` is non-empty, those patterns block the corresponding tools **regardless of whether they also appear in `allowed_tools`**. `denied_tools` always wins. +3. If both lists are empty, no filter is installed — the agent sees every tool, and the `permission_mode` decides what happens on each call. + +> A practical mental model: `allowed_tools` is the "positive" intent ("these are the things I expect this agent to do"); `denied_tools` is the "backstop" ("even if a looser rule sneaks through, never let it touch these"). For unattended D&R-driven agents this pair replaces the interactive approval flow entirely. + +## `permission_mode` + +`permission_mode` controls what happens **when a tool call is not auto-approved by the lists above**. Three values are valid: + +| Value | Behaviour | +|---|---| +| `acceptEdits` (default) | Edits (`Write`, `Edit`, `NotebookEdit`) are auto-approved; every other tool call triggers an approval prompt. Best for human-in-the-loop user sessions. | +| `plan` | Claude is kept in plan-only mode: it can read and reason but cannot execute any mutating tool without explicit approval. Useful for review/preview flows. | +| `bypassPermissions` | All tool calls are auto-approved (subject to `denied_tools` still taking effect). Required for unattended D&R-driven agents — without it, tool calls with no user to answer the prompt will time out after 5 minutes and the session will fail. | + +The runner defaults `permission_mode` to `acceptEdits` when the field is omitted. For D&R agents that need to execute tools without a human, explicitly set `permission_mode: bypassPermissions` in the `ai_agent` record or the inline profile. + +## Session-scoped approvals (interactive sessions) + +In user sessions that go through the approval prompt, the operator can answer `session` instead of `y` or `n`. That choice stores a **session-scoped pattern** derived from the actual tool call — typically a `Bash(:*)` for shell commands, or the plain tool name for everything else — and auto-approves future matching calls for the rest of the session without asking again. + +Session-scoped patterns use the same grammar as `allowed_tools`, so once a user approves a `Bash(git:*)` for the session, the Bash pipeline-stage coverage rules described above apply identically. These patterns are ephemeral: they vanish when the session ends and are never promoted into the Profile automatically (use `ai_agent.capture-profile` to snapshot a session's settings into a new Profile explicitly). + +## Defaults shipped to new users + +The first time a user registers for AI Sessions, two profiles are provisioned automatically: + +- **Default** — a read-only safe baseline. `permission_mode: acceptEdits`, no `denied_tools`, and `allowed_tools` limited to: + + ``` + Read + Bash(cat:*) Bash(head:*) Bash(tail:*) Bash(less:*) + Bash(grep:*) Bash(sed:*) Bash(awk:*) Bash(jq:*) + Bash(ls:*) Bash(find:*) Bash(wc:*) + ``` + +- **Full Permissions** — `permission_mode: bypassPermissions`, both lists empty. Lets Claude use any tool without prompting; use only when you're comfortable granting that blast radius. + +The Default profile is marked `is_default: true` and is what the web UI starts with unless the user picks another one. The Default profile cannot be deleted; you can edit it, mark another profile as default, or create additional profiles up to the 10-per-user limit. + +## Examples + +### Read-only investigation profile + +Good baseline for interactive triage — Claude can inspect workspace files and common read-only shell utilities, but never writes or edits, and any non-read tool still requires an approval prompt. + +```json +{ + "name": "Investigation (read-only)", + "permission_mode": "acceptEdits", + "allowed_tools": [ + "Read", "Grep", "Glob", + "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)", + "Bash(grep:*)", "Bash(jq:*)", "Bash(ls:*)", "Bash(find:*)" + ], + "denied_tools": ["Write", "Edit", "NotebookEdit"] +} +``` + +### Unattended D&R triage agent + +An `ai_agent` Hive record meant to run without a human. `bypassPermissions` is required so tool calls don't block on approval; `denied_tools` still prevents the agent from writing or fetching the web even though `allowed_tools` is empty. + +```yaml +ai_agent: + triage-agent: + data: + prompt: | + Investigate the triggering detection and produce a structured report. + anthropic_secret: hive://secret/anthropic-key + lc_api_key_secret: hive://secret/lc-api-key + permission_mode: bypassPermissions + one_shot: true + denied_tools: + - Write + - Edit + - WebFetch + mcp_servers: + limacharlie: + type: http + url: https://mcp.limacharlie.io + headers: + Authorization: hive://secret/lc-mcp-token +``` + +### Scoping a single MCP tool + +Allow the agent to call the LimaCharlie MCP server, but only the `run_lcql_query` function of its `lc_call_tool` meta-tool. Every other LC function re-prompts (or is denied if `permission_mode` is `plan`). + +```yaml +allowed_tools: + - mcp__limacharlie__lc_call_tool(run_lcql_query:*) +denied_tools: + - mcp__limacharlie__lc_call_tool(delete_org:*) +``` + +### Blocking destructive Bash verbs + +`denied_tools` patterns are evaluated the same way as `allowed_tools`, so you can block a specific prefix even when the rest of Bash is allowed: + +```yaml +allowed_tools: ["Bash"] +denied_tools: + - "Bash(rm:*)" + - "Bash(mv:*)" + - "Bash(kubectl delete:*)" +``` + +Because of the pipeline-stage coverage rule, `Bash(rm:*)` in `denied_tools` will also trip on `ls && rm -rf /` — the `rm -rf /` stage is seen in isolation. + +## Where to go next + +- [User Sessions](user-sessions.md#session-profiles) — creating and managing Profiles via the API and UI. +- [D&R-Driven Sessions](dr-sessions.md#session-profiles) — attaching these fields to an `ai_agent` Hive record or to an inline `profile:` block on a `start ai agent` action. +- [Command Line Interface](cli.md#limacharlie-ai-start-session) — per-run overrides for `--allowed-tools`, `--denied-tools`, and `--permission-mode` when starting a session from a Hive template. +- [API Reference](api-reference.md#profiles) — the REST shape of the Profile resource. +- [Claude Code permissions (upstream)](https://code.claude.com/docs/en/permissions) — the source of truth for the pattern grammar. diff --git a/docs/9-ai-sessions/user-sessions.md b/docs/9-ai-sessions/user-sessions.md index 56ecf62c3..8722a4451 100644 --- a/docs/9-ai-sessions/user-sessions.md +++ b/docs/9-ai-sessions/user-sessions.md @@ -134,9 +134,9 @@ curl -X POST https://ai-sessions.limacharlie.io/v1/profiles \ |--------|------|-------------| | `name` | string | Profile name (max 100 characters) | | `description` | string | Profile description (max 500 characters) | -| `allowed_tools` | list | Tools Claude can use | -| `denied_tools` | list | Tools Claude cannot use | -| `permission_mode` | string | `acceptEdits`, `plan`, or `bypassPermissions` | +| `allowed_tools` | list | Tools Claude can use. See [Tool Permissions & Profiles](tool-permissions.md) for the full pattern grammar. | +| `denied_tools` | list | Tools Claude cannot use. Always wins over `allowed_tools`. See [Tool Permissions & Profiles](tool-permissions.md). | +| `permission_mode` | string | `acceptEdits`, `plan`, or `bypassPermissions`. See [Tool Permissions & Profiles](tool-permissions.md#permission_mode). | | `model` | string | Claude model to use | | `max_turns` | integer | Maximum conversation turns | | `max_budget_usd` | float | Maximum spend limit in USD | diff --git a/mkdocs.yml b/mkdocs.yml index 049a957cf..643743b48 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -517,6 +517,7 @@ nav: - Overview: 9-ai-sessions/index.md - D&R-Driven Sessions: 9-ai-sessions/dr-sessions.md - User Sessions: 9-ai-sessions/user-sessions.md + - Tool Permissions & Profiles: 9-ai-sessions/tool-permissions.md - Command Line Interface: 9-ai-sessions/cli.md - Alternative Providers: 9-ai-sessions/alternative-providers.md - API Reference: 9-ai-sessions/api-reference.md From 693c1a44edd5a49f529c12969db4faf543fc2064 Mon Sep 17 00:00:00 2001 From: Maxime Date: Tue, 21 Apr 2026 15:48:48 -0700 Subject: [PATCH 2/5] docs(ai-sessions): drop LC-MCP example from tool permissions page Use a popular security MCP server (VirusTotal) in the MCP pattern examples instead of the LimaCharlie MCP server, since the recommended path for agents talking to LimaCharlie is now the CLI rather than the MCP server. Also drop the lc_call_tool meta-tool scoping section for the same reason, and trim the unattended-D&R example to not wire in an LC MCP server. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/9-ai-sessions/tool-permissions.md | 33 ++++++++++---------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/docs/9-ai-sessions/tool-permissions.md b/docs/9-ai-sessions/tool-permissions.md index 4d9b7a64c..8e9fee651 100644 --- a/docs/9-ai-sessions/tool-permissions.md +++ b/docs/9-ai-sessions/tool-permissions.md @@ -69,19 +69,16 @@ Bash(kubectl get:*) # read-only kubectl verbs MCP server tools are exposed to Claude under a mangled name of the form `mcp____`. You can deny or allow them with either the full name or a scoped pattern. ```yaml -# Deny one specific MCP tool -denied_tools: - - mcp__virustotal__scan_file - -# Allow only a specific lc_call_tool RPC +# Allow every tool exposed by the VirusTotal MCP server allowed_tools: - - mcp__limacharlie__lc_call_tool(list_user_orgs:*) -``` + - mcp__virustotal -There are two MCP-specific rules worth knowing: +# Deny one specific tool from the VirusTotal MCP server +denied_tools: + - mcp__virustotal__upload_file +``` -- **Suffix matching for server name drift.** When both the tool name reported by Claude and the pattern start with `mcp__`, only the `__` at the end has to match. A pattern written `mcp__limacharlie__lc_call_tool` will still match a tool Claude exposes as `mcp__claude_ai_LimaCharlie__lc_call_tool`. This keeps Profiles portable across MCP server name variants. -- **`lc_call_tool` meta-tool scoping.** The LimaCharlie MCP server exposes a meta-tool `lc_call_tool` that multiplexes over many LC API functions via its `tool_name` parameter. Patterns of the form `mcp__*__lc_call_tool(:*)` scope approval to a specific LC function (`list_user_orgs`, `run_lcql_query`, etc.) rather than the whole meta-tool. +**Suffix matching for server name drift.** When both the tool name reported by Claude and the pattern start with `mcp__`, only the `__` at the end has to match. A pattern written `mcp__virustotal__scan_url` will still match a tool Claude exposes as `mcp__claude_ai_VirusTotal__scan_url`. This keeps Profiles portable across MCP server name variants — the same rule covers registry-resolved servers, locally registered servers, and UI-installed servers even when they publish slightly different server identifiers. ## `allowed_tools` vs `denied_tools` @@ -149,7 +146,7 @@ Good baseline for interactive triage — Claude can inspect workspace files and ### Unattended D&R triage agent -An `ai_agent` Hive record meant to run without a human. `bypassPermissions` is required so tool calls don't block on approval; `denied_tools` still prevents the agent from writing or fetching the web even though `allowed_tools` is empty. +An `ai_agent` Hive record meant to run without a human. `bypassPermissions` is required so tool calls don't block on approval; `denied_tools` still prevents the agent from writing or from reaching arbitrary URLs even though `allowed_tools` is empty. ```yaml ai_agent: @@ -165,23 +162,17 @@ ai_agent: - Write - Edit - WebFetch - mcp_servers: - limacharlie: - type: http - url: https://mcp.limacharlie.io - headers: - Authorization: hive://secret/lc-mcp-token ``` -### Scoping a single MCP tool +### Scoping MCP tools to a single server -Allow the agent to call the LimaCharlie MCP server, but only the `run_lcql_query` function of its `lc_call_tool` meta-tool. Every other LC function re-prompts (or is denied if `permission_mode` is `plan`). +Let the agent call the VirusTotal MCP server for enrichment, but nothing else — and specifically block the one tool that would submit local files to the service. Any other MCP server the session inherits is still subject to the normal approval flow (or denied outright under `permission_mode: plan`). ```yaml allowed_tools: - - mcp__limacharlie__lc_call_tool(run_lcql_query:*) + - mcp__virustotal denied_tools: - - mcp__limacharlie__lc_call_tool(delete_org:*) + - mcp__virustotal__upload_file ``` ### Blocking destructive Bash verbs From f4318ee3f98c6c3ec91df9009e57509c7f4b068e Mon Sep 17 00:00:00 2001 From: Maxime Date: Tue, 21 Apr 2026 15:53:41 -0700 Subject: [PATCH 3/5] docs(ai-sessions): correct inaccurate claims on tool permissions page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the "Task / Agent" dual-naming — the Claude Agent SDK surfaces the subagent tool as Task. - Fix the AskUserQuestion row: the tool routes questions to the attached chat client (browser UI or `ai session attach`), not to the ext-feedback extension's UI as I'd erroneously implied. - Add MultiEdit to the list of tools acceptEdits auto-approves. - Replace the invented `ai_agent.capture-profile` name with the real `POST /v1/sessions/{sessionId}/capture-profile` API, and link it. - Remove the "suffix matching for server name drift" paragraph. That behaviour only lives in the bridge's session-scoped pattern matcher and does not extend to Profile-level allowed_tools/denied_tools, so stating it as a general Profile rule was wrong. Replace with a simple pointer that the server_name in the pattern must match the key in the mcp_servers map. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/9-ai-sessions/tool-permissions.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/9-ai-sessions/tool-permissions.md b/docs/9-ai-sessions/tool-permissions.md index 8e9fee651..255d995b7 100644 --- a/docs/9-ai-sessions/tool-permissions.md +++ b/docs/9-ai-sessions/tool-permissions.md @@ -40,8 +40,8 @@ A bare identifier matches the entire Claude Code tool of that name. Common built | `WebFetch` | Fetch an HTTP(S) URL. | | `WebSearch` | Run a web search. | | `TodoWrite` | Update the in-session task list. | -| `Task` / `Agent` | Spawn a subagent. | -| `AskUserQuestion` | Ask the human-in-the-loop a structured question (routed to the feedback UI in LimaCharlie sessions). | +| `Task` | Spawn a subagent. | +| `AskUserQuestion` | Ask the human-in-the-loop a structured question. In interactive sessions the question is surfaced to the attached client (browser chat UI or `ai session attach --interactive`); `one_shot` / unattended sessions time out on these after five minutes. | !!! note The authoritative list of built-in tools is the one published by the Claude Code CLI — LimaCharlie does not add or remove tools from that set. Bare names are case-sensitive. @@ -78,7 +78,7 @@ denied_tools: - mcp__virustotal__upload_file ``` -**Suffix matching for server name drift.** When both the tool name reported by Claude and the pattern start with `mcp__`, only the `__` at the end has to match. A pattern written `mcp__virustotal__scan_url` will still match a tool Claude exposes as `mcp__claude_ai_VirusTotal__scan_url`. This keeps Profiles portable across MCP server name variants — the same rule covers registry-resolved servers, locally registered servers, and UI-installed servers even when they publish slightly different server identifiers. +The `` segment is whatever the MCP server registers itself as when the session starts — the same identifier that appears as the key in the `mcp_servers` map of the Profile or `ai_agent` record. Use that exact name in your pattern. ## `allowed_tools` vs `denied_tools` @@ -96,7 +96,7 @@ The two lists are independent Claude Agent SDK inputs and compose as follows: | Value | Behaviour | |---|---| -| `acceptEdits` (default) | Edits (`Write`, `Edit`, `NotebookEdit`) are auto-approved; every other tool call triggers an approval prompt. Best for human-in-the-loop user sessions. | +| `acceptEdits` (default) | File-editing tools (`Write`, `Edit`, `NotebookEdit`, `MultiEdit`) are auto-approved; every other tool call triggers an approval prompt. Best for human-in-the-loop user sessions. | | `plan` | Claude is kept in plan-only mode: it can read and reason but cannot execute any mutating tool without explicit approval. Useful for review/preview flows. | | `bypassPermissions` | All tool calls are auto-approved (subject to `denied_tools` still taking effect). Required for unattended D&R-driven agents — without it, tool calls with no user to answer the prompt will time out after 5 minutes and the session will fail. | @@ -106,7 +106,7 @@ The runner defaults `permission_mode` to `acceptEdits` when the field is omitted In user sessions that go through the approval prompt, the operator can answer `session` instead of `y` or `n`. That choice stores a **session-scoped pattern** derived from the actual tool call — typically a `Bash(:*)` for shell commands, or the plain tool name for everything else — and auto-approves future matching calls for the rest of the session without asking again. -Session-scoped patterns use the same grammar as `allowed_tools`, so once a user approves a `Bash(git:*)` for the session, the Bash pipeline-stage coverage rules described above apply identically. These patterns are ephemeral: they vanish when the session ends and are never promoted into the Profile automatically (use `ai_agent.capture-profile` to snapshot a session's settings into a new Profile explicitly). +Session-scoped patterns use the same grammar as `allowed_tools`, so once a user approves a `Bash(git:*)` for the session, the Bash pipeline-stage coverage rules described above apply identically. These patterns are ephemeral: they vanish when the session ends and are never promoted into the Profile automatically — to persist a session's configuration, snapshot it with `POST /v1/sessions/{sessionId}/capture-profile` (see the [capture-profile endpoint](api-reference.md#profiles)). ## Defaults shipped to new users From 64ca84a179a124443d1ab64a5e80ba24d6abb6ae Mon Sep 17 00:00:00 2001 From: Maxime Date: Tue, 21 Apr 2026 16:20:56 -0700 Subject: [PATCH 4/5] docs(ai-sessions): split allowed/denied matching to reflect new bridge routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks ai-sessions PR route-allowed-tools-through-matcher: profile `allowed_tools` now seed into the bridge's session-approved pattern set and are evaluated by the hardened matcher (pipeline-stage coverage, wrapper stripping, fail-close on $() / backticks / subshell grouping / newlines). `denied_tools` still goes to the Claude Agent SDK as `disallowed_tools` and uses the SDK's simpler literal-prefix matcher. - Label the "Matching semantics" section as allow-list behaviour and add an explicit denied_tools section documenting the SDK-level literal-prefix matcher (no stage coverage, no fail-close). - Rewrite the allowed_tools vs denied_tools section around the two enforcement layers (SDK deny → bridge allow → permission_mode fall- through). - Unify session-scoped approvals with profile allowed_tools — same pattern store, same matcher. - Fix the destructive-Bash-verbs example: a denied `Bash(rm:*)` does NOT block `ls && rm -rf /`; the correct containment comes from the allow-list matcher refusing to cover the `rm -rf /` stage. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/9-ai-sessions/tool-permissions.md | 30 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/9-ai-sessions/tool-permissions.md b/docs/9-ai-sessions/tool-permissions.md index 255d995b7..1a84f00e8 100644 --- a/docs/9-ai-sessions/tool-permissions.md +++ b/docs/9-ai-sessions/tool-permissions.md @@ -56,14 +56,18 @@ Bash(npm install:*) # any command starting with "npm install " Bash(kubectl get:*) # read-only kubectl verbs ``` -**Matching semantics** (mirrors the upstream CLI): +**Matching semantics (allowed_tools)** — Profile-supplied `allowed_tools` patterns are evaluated by LimaCharlie's own hardened matcher, which is a strict superset of the upstream Claude Code rules: - The command is split on shell stage operators (`|`, `||`, `&`, `&&`, `;`, `|&`) and on real newlines. **Every stage** of the pipeline must be covered by some stored pattern. `Bash(git:*)` alone does **not** approve `git status && rm -rf /`. - Process wrappers (`timeout`, `time`, `nice`, `nohup`, `stdbuf`, bare `xargs`) and leading `VAR=value` env assignments are stripped iteratively from the front of each stage, so `nohup timeout 30 DEBUG=1 npm test` reduces to `npm test` before matching. - Redirection operators (`>`, `>>`, `<`, `>&`, `&>`, fd-duplications) stay attached to their command — they are **not** stage separators. -- **Fail-closed** on command substitution, process substitution, backticks, and subshell/brace grouping (`` ` ``, `$(...)`, `<(...)`, `>(...)`, `(...)`, `{...}`). These can smuggle commands past a prefix check, so any stage containing them re-prompts regardless of the pattern list. +- **Fail-closed** on command substitution, process substitution, backticks, and subshell/brace grouping (`` ` ``, `$(...)`, `<(...)`, `>(...)`, `(...)`, `{...}`). These can smuggle commands past a prefix check, so any stage containing them re-prompts regardless of the pattern list. A `cat $(rm -rf /)` invocation is **not** auto-approved by `Bash(cat:*)`. - Matching is literal prefix on the stripped stage: either the stage equals the prefix exactly, or it starts with `prefix + " "`. There is no flag-value allowlist and no alias resolution. +The same matcher is used for session-scoped approvals (the `session` answer in the interactive approval prompt) — both paths go through a single implementation so autonomous org-owned sessions get the same guarantees as interactive ones. + +**Matching semantics (denied_tools)** — `denied_tools` is enforced **by the Claude Agent SDK**, not by the LimaCharlie matcher. The SDK uses the upstream literal-prefix rule, which does not perform pipeline-stage splitting or fail-close on substitution. This means a deny-list pattern only blocks commands whose **leading** stage matches — `Bash(rm:*)` in `denied_tools` blocks `rm -rf /` but does **not** block `ls && rm -rf /`. Treat `denied_tools` as a coarse backstop; rely on a tight `allowed_tools` set (evaluated under the hardened rules above) for the real containment. + ### 3. MCP tool pattern MCP server tools are exposed to Claude under a mangled name of the form `mcp____`. You can deny or allow them with either the full name or a scoped pattern. @@ -82,13 +86,14 @@ The `` segment is whatever the MCP server registers itself as when ## `allowed_tools` vs `denied_tools` -The two lists are independent Claude Agent SDK inputs and compose as follows: +The two lists are evaluated at different layers and compose as follows for every tool call: -1. If `allowed_tools` is non-empty, it becomes the allowlist — tools not in the list are subject to approval prompts (or are denied outright, depending on `permission_mode`). -2. If `denied_tools` is non-empty, those patterns block the corresponding tools **regardless of whether they also appear in `allowed_tools`**. `denied_tools` always wins. -3. If both lists are empty, no filter is installed — the agent sees every tool, and the `permission_mode` decides what happens on each call. +1. **Deny list (SDK layer).** `denied_tools` is handed to the Claude Agent SDK as `disallowed_tools`. If a call matches any deny pattern it is blocked before the bridge's callback ever sees it — `denied_tools` always wins, regardless of anything else in `allowed_tools` or `permission_mode`. +2. **Allow list (bridge layer).** The bridge seeds `allowed_tools` into its session-approved pattern set and evaluates every surviving tool call through the hardened matcher described above. A match auto-approves the call without a prompt. +3. **Fallback on no match.** If neither list matches, `permission_mode` decides what happens: `acceptEdits` auto-approves file-edit tools and prompts the user for everything else, `plan` keeps the session read-only, and `bypassPermissions` auto-approves the call. +4. **Both lists empty.** Nothing is pre-authorised; every tool call falls through to `permission_mode`. -> A practical mental model: `allowed_tools` is the "positive" intent ("these are the things I expect this agent to do"); `denied_tools` is the "backstop" ("even if a looser rule sneaks through, never let it touch these"). For unattended D&R-driven agents this pair replaces the interactive approval flow entirely. +> A practical mental model: `allowed_tools` is the "positive" intent, evaluated under the hardened matcher ("these are the things this agent should be able to do without asking"); `denied_tools` is a coarser SDK-level backstop ("even if something slips through, never let the agent start a matching command"). For unattended D&R-driven agents, a tight `allowed_tools` plus `permission_mode: bypassPermissions` replaces the interactive approval flow entirely. ## `permission_mode` @@ -106,7 +111,7 @@ The runner defaults `permission_mode` to `acceptEdits` when the field is omitted In user sessions that go through the approval prompt, the operator can answer `session` instead of `y` or `n`. That choice stores a **session-scoped pattern** derived from the actual tool call — typically a `Bash(:*)` for shell commands, or the plain tool name for everything else — and auto-approves future matching calls for the rest of the session without asking again. -Session-scoped patterns use the same grammar as `allowed_tools`, so once a user approves a `Bash(git:*)` for the session, the Bash pipeline-stage coverage rules described above apply identically. These patterns are ephemeral: they vanish when the session ends and are never promoted into the Profile automatically — to persist a session's configuration, snapshot it with `POST /v1/sessions/{sessionId}/capture-profile` (see the [capture-profile endpoint](api-reference.md#profiles)). +Session-scoped patterns share a single pattern store with the Profile-supplied `allowed_tools`, so they share the hardened matcher and all its guarantees. These patterns are ephemeral: they vanish when the session ends and are never promoted into the Profile automatically — to persist a session's configuration, snapshot it with `POST /v1/sessions/{sessionId}/capture-profile` (see the [capture-profile endpoint](api-reference.md#profiles)). ## Defaults shipped to new users @@ -177,17 +182,20 @@ denied_tools: ### Blocking destructive Bash verbs -`denied_tools` patterns are evaluated the same way as `allowed_tools`, so you can block a specific prefix even when the rest of Bash is allowed: +You can deny a specific prefix even when the rest of Bash is allowed. Keep in mind that the deny list runs through the SDK's literal-prefix matcher, so it only blocks the leading stage of the command — a pattern like `Bash(rm:*)` catches `rm -rf /` but will not stop `ls && rm -rf /`. For containment you still want a tight allowlist under the hardened matcher. ```yaml -allowed_tools: ["Bash"] +allowed_tools: + - "Bash(ls:*)" + - "Bash(cat:*)" + - "Bash(grep:*)" denied_tools: - "Bash(rm:*)" - "Bash(mv:*)" - "Bash(kubectl delete:*)" ``` -Because of the pipeline-stage coverage rule, `Bash(rm:*)` in `denied_tools` will also trip on `ls && rm -rf /` — the `rm -rf /` stage is seen in isolation. +Under the hardened allowlist matcher the `ls && rm -rf /` case is already handled: the `rm -rf /` stage is not covered by any allowed pattern, so the whole command fails the pipeline-stage coverage check and never auto-approves. ## Where to go next From 747a0eaf3bb1adb6a6a1bb886c0e3ccaf543dbe0 Mon Sep 17 00:00:00 2001 From: Maxime Date: Tue, 21 Apr 2026 16:40:37 -0700 Subject: [PATCH 5/5] docs(ai-sessions): deny list also routes through hardened matcher Tracks ai-sessions commit 81b9969 "route denied_tools through the matcher; fix bare Bash allow regression": - denied_tools is now seeded into session_denied_patterns and evaluated by the bridge, not handed to the SDK's opts.disallowed_tools anymore. Both lists share the same pipeline splitting, wrapper stripping, env-assignment skip, and newline handling so an allow+deny pair can no longer be played against each other. - Restructure the Bash-pattern section into Common pre-processing + Allow semantics (all stages covered) + Deny semantics (any stage matching) + fail-closed-into-prompt on dangerous constructs. - Call out the bare-tool-name short-circuit (e.g. bare `Bash` means "every Bash invocation"; applies on both sides of the split). - Rewrite the allowed_tools vs denied_tools section around the deny-first / allow-second / permission_mode-fallback order, since both lists now live at the bridge layer. - Update the destructive-Bash-verbs example: a denied `Bash(rm:*)` now DOES block `ls && rm -rf /`, and `timeout 30 rm ...` is caught via the same wrapper-stripping as allow. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/9-ai-sessions/tool-permissions.md | 41 +++++++++++++++----------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/9-ai-sessions/tool-permissions.md b/docs/9-ai-sessions/tool-permissions.md index 1a84f00e8..c150360e7 100644 --- a/docs/9-ai-sessions/tool-permissions.md +++ b/docs/9-ai-sessions/tool-permissions.md @@ -56,17 +56,24 @@ Bash(npm install:*) # any command starting with "npm install " Bash(kubectl get:*) # read-only kubectl verbs ``` -**Matching semantics (allowed_tools)** — Profile-supplied `allowed_tools` patterns are evaluated by LimaCharlie's own hardened matcher, which is a strict superset of the upstream Claude Code rules: +**Common pre-processing.** Whether a pattern lives in `allowed_tools` or in `denied_tools`, it is evaluated by LimaCharlie's hardened matcher (not by the upstream Claude Code literal-prefix one). Before any matching happens the bridge normalises every Bash command the same way: -- The command is split on shell stage operators (`|`, `||`, `&`, `&&`, `;`, `|&`) and on real newlines. **Every stage** of the pipeline must be covered by some stored pattern. `Bash(git:*)` alone does **not** approve `git status && rm -rf /`. +- The command is split on shell stage operators (`|`, `||`, `&`, `&&`, `;`, `|&`) and on real newlines. - Process wrappers (`timeout`, `time`, `nice`, `nohup`, `stdbuf`, bare `xargs`) and leading `VAR=value` env assignments are stripped iteratively from the front of each stage, so `nohup timeout 30 DEBUG=1 npm test` reduces to `npm test` before matching. - Redirection operators (`>`, `>>`, `<`, `>&`, `&>`, fd-duplications) stay attached to their command — they are **not** stage separators. -- **Fail-closed** on command substitution, process substitution, backticks, and subshell/brace grouping (`` ` ``, `$(...)`, `<(...)`, `>(...)`, `(...)`, `{...}`). These can smuggle commands past a prefix check, so any stage containing them re-prompts regardless of the pattern list. A `cat $(rm -rf /)` invocation is **not** auto-approved by `Bash(cat:*)`. -- Matching is literal prefix on the stripped stage: either the stage equals the prefix exactly, or it starts with `prefix + " "`. There is no flag-value allowlist and no alias resolution. +- Matching is a literal prefix on the stripped stage: either the stage equals the prefix exactly or it starts with `prefix + " "`. There is no flag-value allowlist and no alias resolution. -The same matcher is used for session-scoped approvals (the `session` answer in the interactive approval prompt) — both paths go through a single implementation so autonomous org-owned sessions get the same guarantees as interactive ones. +**Allow semantics.** For an `allowed_tools` match to fire, **every** pipeline stage must be covered by some allow pattern. `Bash(git:*)` alone does **not** approve `git status && rm -rf /` — the `rm -rf /` stage is uncovered, so the command falls through to `permission_mode`. -**Matching semantics (denied_tools)** — `denied_tools` is enforced **by the Claude Agent SDK**, not by the LimaCharlie matcher. The SDK uses the upstream literal-prefix rule, which does not perform pipeline-stage splitting or fail-close on substitution. This means a deny-list pattern only blocks commands whose **leading** stage matches — `Bash(rm:*)` in `denied_tools` blocks `rm -rf /` but does **not** block `ls && rm -rf /`. Treat `denied_tools` as a coarse backstop; rely on a tight `allowed_tools` set (evaluated under the hardened rules above) for the real containment. +**Deny semantics.** For a `denied_tools` match to fire, **any** pipeline stage matching **any** deny pattern is enough to block the whole call. `Bash(rm:*)` in `denied_tools` catches both `rm -rf /` and the mixed-case `ls && rm -rf /` — deny is the mirror of allow, so the two sides can't be played against each other by splicing wrappers and compound operators. + +**Dangerous constructs fall through to `permission_mode`.** Command substitution, process substitution, backticks, and subshell/brace grouping (`` ` ``, `$(...)`, `<(...)`, `>(...)`, `(...)`, `{...}`) smuggle commands past both sides of the matcher. Neither allow nor deny fires automatically on a stage that contains them — the call takes the `permission_mode` fallback path instead, which for interactive sessions means a re-prompt. In particular, `cat $(rm -rf /)` is **not** auto-approved by `Bash(cat:*)`, and is **not** auto-denied by `Bash(rm:*)` either; it prompts. + +**Bare tool name.** A bare `Bash` entry (no `(prefix:*)` specifier) means "every Bash invocation" — in `allowed_tools` it short-circuits to auto-approve all shell commands, in `denied_tools` it blocks all of them. The same applies to every other tool name: a bare `Read` or `WebFetch` matches every invocation of that tool. + +**Deny wins.** When a call matches both lists, deny is checked first and the call is blocked. + +**Shared with session-scoped approvals.** The interactive `session` answer in the approval prompt seeds patterns into the same allow-pattern set, so everything above applies identically to those runtime-added rules. Autonomous org-owned sessions and interactive user sessions share a single matcher implementation. ### 3. MCP tool pattern @@ -86,14 +93,14 @@ The `` segment is whatever the MCP server registers itself as when ## `allowed_tools` vs `denied_tools` -The two lists are evaluated at different layers and compose as follows for every tool call: +Both lists are seeded into the bridge's own pattern sets at session start and evaluated by the same hardened matcher described above. For every tool call: -1. **Deny list (SDK layer).** `denied_tools` is handed to the Claude Agent SDK as `disallowed_tools`. If a call matches any deny pattern it is blocked before the bridge's callback ever sees it — `denied_tools` always wins, regardless of anything else in `allowed_tools` or `permission_mode`. -2. **Allow list (bridge layer).** The bridge seeds `allowed_tools` into its session-approved pattern set and evaluates every surviving tool call through the hardened matcher described above. A match auto-approves the call without a prompt. +1. **Deny check first.** If any `denied_tools` pattern matches under the deny semantics, the call is blocked and Claude receives a deny result. Deny always wins. +2. **Allow check second.** Otherwise, if the call is fully covered by an `allowed_tools` pattern under the allow semantics, it is auto-approved without a prompt. 3. **Fallback on no match.** If neither list matches, `permission_mode` decides what happens: `acceptEdits` auto-approves file-edit tools and prompts the user for everything else, `plan` keeps the session read-only, and `bypassPermissions` auto-approves the call. -4. **Both lists empty.** Nothing is pre-authorised; every tool call falls through to `permission_mode`. +4. **Both lists empty.** Nothing is pre-authorised and nothing is pre-blocked; every tool call falls through to `permission_mode`. -> A practical mental model: `allowed_tools` is the "positive" intent, evaluated under the hardened matcher ("these are the things this agent should be able to do without asking"); `denied_tools` is a coarser SDK-level backstop ("even if something slips through, never let the agent start a matching command"). For unattended D&R-driven agents, a tight `allowed_tools` plus `permission_mode: bypassPermissions` replaces the interactive approval flow entirely. +> A practical mental model: `allowed_tools` is the positive intent ("these are the things this agent should be able to do without asking"), `denied_tools` is the backstop ("even if a looser allow rule would cover it, never let the agent do this"). For unattended D&R-driven agents, a tight `allowed_tools` plus `permission_mode: bypassPermissions` replaces the interactive approval flow entirely. ## `permission_mode` @@ -182,20 +189,20 @@ denied_tools: ### Blocking destructive Bash verbs -You can deny a specific prefix even when the rest of Bash is allowed. Keep in mind that the deny list runs through the SDK's literal-prefix matcher, so it only blocks the leading stage of the command — a pattern like `Bash(rm:*)` catches `rm -rf /` but will not stop `ls && rm -rf /`. For containment you still want a tight allowlist under the hardened matcher. +Deny a specific prefix even when the rest of Bash is open. The deny-side matcher shares allow's wrapper-stripping and pipeline-splitting, so a single pattern catches both bare and compound forms. ```yaml -allowed_tools: - - "Bash(ls:*)" - - "Bash(cat:*)" - - "Bash(grep:*)" +allowed_tools: ["Bash"] denied_tools: - "Bash(rm:*)" - "Bash(mv:*)" - "Bash(kubectl delete:*)" ``` -Under the hardened allowlist matcher the `ls && rm -rf /` case is already handled: the `rm -rf /` stage is not covered by any allowed pattern, so the whole command fails the pipeline-stage coverage check and never auto-approves. +- `rm -rf /` → blocked by `Bash(rm:*)`. +- `ls && rm -rf /` → blocked too: the `rm -rf /` stage matches `Bash(rm:*)` and deny fires on any matching stage. +- `timeout 30 kubectl delete pod xyz` → blocked: the `timeout 30` wrapper is stripped before matching. +- `cat $(rm file)` → does **not** auto-deny, but does **not** auto-approve either; the dangerous construct routes the call back through `permission_mode` (a re-prompt in interactive sessions). ## Where to go next