Chio/Docs
LOGIN · JOIN

Tool Access Guards

Bound the tool calls no specialized guard claims, with a name block list, an optional allowlist, and a ceiling on argument size.

Concept chio@5f63e6e

On this page

Custom MCP-server tools and vendor-specific names fall outside the extractors that route calls to the filesystem, shell, network, and code guards. Those calls meet McpToolGuard, registered as mcp-tool: it refuses names on a block list, can admit only an allowlist, and refuses serialized arguments over a size ceiling.

It gates by tool name alone. McpToolConfig carries no server field and evaluate never reads ctx.server_id, so a per-server rule belongs in the capability grant. A HushSpec tool_access rule produces that capability scope as well as this guard's lists, and the scope check runs before any guard.

Arguments are measured before any name is compared

evaluate reads the switch, classifies the call, measures its arguments, and only then compares the name.

The tool access guard, in check orderChoose an input.
r = read_filerenabledg1classifyg2argssizeg3blockg4allowlistg5defaultg6vallowrread_filemcp-toolg1enabledg2classifyFileAccess: allowed hereg3args sizeg4blockg5allow listg6defaultvallow

A recognized name leaves this guard

The filesystem extractor claims read_file, so the call becomes FileAccess. McpToolGuard allows it, and the filesystem guards decide. Which tool calls reach this guard.

The five steps of evaluate drawn as a rail, with the enabled switch first. Tool names and configs are illustrative: each example names the config it assumes, and the rest use McpToolConfig::default(). The guards that evaluate classified actions are left out.

With enabled false the guard allows at once. Classification runs extract_action_checked: a recognized but malformed action shape is refused, and every classified variant (FileAccess, FileWrite, NetworkEgress, ShellCommand, Patch, CodeExecution, and the rest) passes this guard to the guard that owns it. A generic call then has its serialized arguments counted by a byte-counting JSON writer, which counts bytes as they are written, and is refused above max_args_size: 1 MiB (1,048,576 bytes) by default.

Only then does is_allowed compare the name: the block list first, then the allow list when it is non-empty, then default_action.

Only calls that no extractor claims reach the lists

ToolAction::McpTool is the fallback category; a tool cannot opt into it. crate::action::extract_action_checked tries a network extractor, then filesystem, shell, code execution, browser, database, memory write, memory read, and external API. Each reads the tool name, and sometimes the argument shape, for its own naming conventions. A call none of them claims falls through to:

crates/guards/chio-guards/src/action/extractor.rsrust
Ok(ToolAction::McpTool(
    self.tool_name.to_string(),
    self.arguments.clone(),
))

A custom MCP-server tool, a vendor-specific name, anything shaped unlike read_file, http_request, or apply_patch, lands here. Argument shape leaves a call where its name put it: custom_tool invoked with { "path": "/etc/shadow" } stays McpTool, so ForbiddenPathGuard never sees it and these lists decide.

The same routing explains the default block list. The direct name helper blocks shell_exec, but runtime extraction classifies a valid shell call as a shell action, which skips these lists. Capability scope and ShellCommandGuard constrain that request.

The block list answers first, and a non-empty allow list refuses every other name

The default block list is a starting set of four generic capability tools that many MCP servers expose: shell_exec, run_command, raw_file_write, and raw_file_delete. allow starts empty and default_action is Allow, so every other generic name passes. A non-empty allow switches the guard to allowlist mode, as in this policy:

policy.yamlyaml
hushspec: "0.1.0"
rules:
  tool_access:
    enabled: true
    allow:
      - read_file
      - list_directory
      - search_files
    block:
      - shell_exec
      - raw_file_delete
    default: block
    max_args_size: 524288

Unlisted generic tool actions now default to deny, and default_action matters only while allow stays empty. The compiled scope also leaves write_file out of authority. write_file is a file action, so the scope check refuses it before this guard's lists could.

Membership is exact string equality on a HashSet<String>. Blocking shell_exec leaves shell_exec_v2 and Shell_Exec open, so add every name and alias your MCP server exposes to block.

A case table makes the split between the two checks visible. An unlisted generic tool reaches this guard and is refused by name; write_file never does, because the scope refuses it first:

policy.test.yamlyaml
policy: ./policy.yaml
server: workspace
cases:
  - name: listed tool
    tool: search_files
    params: {query: "refund_window"}
    expect: allow
  - name: unlisted generic tool
    tool: export_prices
    params: {format: csv}
    expect: deny
    code: mcp-tool
  - name: file write outside the scope
    tool: write_file
    params: {path: /workspace/src/pricing.py}
    expect: deny
Terminalbash
chio policy test
Output
policy   ./policy.yaml
PASS  listed tool                    allow
PASS  unlisted generic tool          deny   guard mcp-tool
PASS  file write outside the scope   deny   outside the grant
3 cases: 3 passed

McpToolGuard runs fifth of seven in the default pipeline

GuardPipeline::default_pipeline() registers it after the path allowlist and before the secret scanner:

crates/guards/chio-guards/src/pipeline.rs39-49rust
pub fn default_pipeline() -> Self {
    let mut pipeline = Self::new();
    pipeline.add(Box::new(crate::ForbiddenPathGuard::new()));
    pipeline.add(Box::new(crate::ShellCommandGuard::new()));
    pipeline.add(Box::new(crate::EgressAllowlistGuard::new()));
    pipeline.add(Box::new(crate::PathAllowlistGuard::new()));
    pipeline.add(Box::new(crate::McpToolGuard::new()));
    pipeline.add(Box::new(crate::SecretLeakGuard::new()));
    pipeline.add(Box::new(crate::PatchIntegrityGuard::new()));
    pipeline
}

The second default set, default_runtime_guard_profile(), leaves it out: that constructor's pre_invocation_guards list holds InternalNetworkGuard, AgentVelocityGuard, and an AdvisoryPipeline.

Pair it with the guards for classified actions. ShellCommandGuard and CodeExecutionGuard read shell and code actions, and this guard reads the remaining names and their argument sizes. An innocently named custom tool can still have internal effects, which the host mediates.

Read more in Default Pipelines, which covers both default sets.

Tool access guard reference

McpToolGuard

Source: crates/guards/chio-guards/src/mcp_tool.rs. Guard name: mcp-tool. Operates on ToolAction::McpTool(tool_name, arguments). Gating is by tool name only: McpToolConfig has no server field, and evaluate never reads ctx.server_id.

Struct

crates/guards/chio-guards/src/mcp_tool.rsrust
pub enum McpDefaultAction {
    #[default]
    Allow,
    Block,
}

pub struct McpToolConfig {
    pub enabled: bool,
    pub allow: Vec<String>,
    pub block: Vec<String>,
    pub default_action: McpDefaultAction,
    pub max_args_size: Option<usize>,
}

// ...

pub enum ToolDecision {
    Allow,
    Block,
}

pub struct McpToolGuard {
    enabled: bool,
    allow_set: HashSet<String>,
    block_set: HashSet<String>,
    default_action: McpDefaultAction,
    max_args_size: usize,
}

impl McpToolGuard {
    pub fn new() -> Self;
    // ...
    pub fn with_config(config: McpToolConfig) -> Self;
    // ...
    pub fn is_allowed(&self, tool_name: &str) -> ToolDecision;
}

Default block list

From McpToolConfig::default(). default_max_args_size() returns 1 MiB (1,048,576 bytes).

crates/guards/chio-guards/src/mcp_tool.rs63-78rust
impl Default for McpToolConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            allow: vec![],
            block: vec![
                "shell_exec".to_string(),
                "run_command".to_string(),
                "raw_file_write".to_string(),
                "raw_file_delete".to_string(),
            ],
            default_action: McpDefaultAction::Allow,
            max_args_size: Some(default_max_args_size()),
        }
    }
}

Configuration

KnobTypeDefaultPurpose
enabled
bool
true
Master switch. When off, the guard allows calls before checking either list.
allow
Vec<String>
empty
Allowlist-mode switch. A non-empty list permits these tool names and denies the rest.
block
Vec<String>
4 names
Denylist. Checked before the allowlist; a name here always denies.
default_action
McpDefaultAction
Allow
Fallback outcome when the tool is in neither list and the allowlist is empty.
max_args_size
Option<usize>
Some(1024 * 1024)
Ceiling on serialized argument bytes: 1 MiB (1,048,576 bytes). A None resolves to the same default inside with_config.

Algorithm

evaluate runs five checks in order.

  1. If enabled is false, allow immediately.
  2. Run extract_action_checked. A recognized but malformed action shape denies (fail closed) instead of falling through to ToolAction::Unknown.
  3. Match the resulting action. Only ToolAction::McpTool(name, args) continues; every other variant (FileAccess, FileWrite, NetworkEgress, ShellCommand, Patch, CodeExecution, and the rest) allows immediately, leaving calls another extractor classified to their own guards.
  4. Compute the serialized argument size with a byte-counting JSON writer, which counts bytes as they are written without materializing the full string. If the size exceeds max_args_size, deny.
  5. Call is_allowed(tool_name): block list first (deny on membership), then allowlist mode if allow is non-empty (deny unless the name is a member), then default_action.

Failure modes

  • Non-McpTool actions return Verdict::Allow. A call a more specific extractor already classified never reaches the lists.
  • A malformed action shape from extract_action_checked denies with empty evidence, GuardDecision::deny(Vec::new()), as a block-list hit and an oversized-argument hit do. None of this guard's deny paths attach a detail message; the pipeline's wrapper records the guard name and a generic fail-closed reason on the receipt.
  • If the argument-size writer fails to serialize the arguments, evaluate returns Err(KernelError::GuardDenied(...)) in place of Ok(GuardDecision::deny(...)). The pipeline treats an Err from evaluate as a deny, so the outcome matches, but the receipt's evidence entry comes from the pipeline's generic error handler. The path is unlikely: the arguments already arrived as a parsed serde_json::Value.
  • Construction cannot fail. with_config takes McpToolConfig and returns Self: it moves allow and block into HashSet<String> and resolves max_args_size against the 1 MiB default. No McpToolConfigError type exists and no build_default_or_fail_closed() analog is needed. Sibling guards (ForbiddenPathGuard, EgressAllowlistGuard, ShellCommandGuard) compile operator-supplied globs or regexes, so their with_* constructors return a Result and can fail closed on a bad pattern; this guard's entries are plain strings.

Name-helper and argument-size scenarios

These name decisions exercise the guard's direct helper. At runtime, classified file, shell, and code actions use their own guards; the serialized-argument limit applies to generic tool actions.

Tool callVerdictReason
shell_exec (default config)
Deny
In the default block list
read_file (default config)
Allow
Not blocked; allow list empty; default_action is Allow
safe_tool, with allow: ["safe_tool"], default_action: Block
Allow
Member of a non-empty allow list
other_tool, same config
Deny
allow is non-empty and other_tool is not a member
tool_a, with allow: ["tool_a"] and block: ["tool_a"]
Deny
Block list is checked first and always wins
any_tool, empty allow and block, default_action: Block
Deny
Falls through to default_action
shell_exec, enabled: false
Allow
Guard short-circuits before any list is checked
Any tool with a 200-byte payload, max_args_size: 100
Deny
Serialized arguments exceed max_args_size

Next: govern the calls that leave this guard