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.
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.
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.
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:
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:
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: 524288Unlisted 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: ./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: denychio policy testpolicy ./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:
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
Try max_args_size, default_action, or KernelError.
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
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).
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
| Knob | Type | Default | Purpose |
|---|---|---|---|
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 | Mcp | 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.
- If
enabledis false, allow immediately. - Run
extract_action_checked. A recognized but malformed action shape denies (fail closed) instead of falling through toToolAction::Unknown. - 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. - 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. - Call
is_allowed(tool_name): block list first (deny on membership), then allowlist mode ifallowis non-empty (deny unless the name is a member), thendefault_action.
Failure modes
- Non-
McpToolactions returnVerdict::Allow. A call a more specific extractor already classified never reaches the lists. - A malformed action shape from
extract_action_checkeddenies 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,
evaluatereturnsErr(KernelError::GuardDenied(...))in place ofOk(GuardDecision::deny(...)). The pipeline treats anErrfromevaluateas 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 parsedserde_json::Value. - Construction cannot fail.
with_configtakesMcpToolConfigand returnsSelf: it movesallowandblockintoHashSet<String>and resolvesmax_args_sizeagainst the 1 MiB default. NoMcpToolConfigErrortype exists and nobuild_default_or_fail_closed()analog is needed. Sibling guards (ForbiddenPathGuard,EgressAllowlistGuard,ShellCommandGuard) compile operator-supplied globs or regexes, so theirwith_*constructors return aResultand 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 call | Verdict | Reason |
|---|---|---|
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 |