Chio/Docs

PlatformGuard Catalognew

Tool Access Guards

McpToolGuard applies name-based allow and block lists plus a serialized argument-size limit to generic tool calls.


McpToolGuard

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

Struct

crates/guards/chio-guards/src/mcp_tool.rs
pub enum McpDefaultAction {
    Allow,  // #[default]
    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;
}

No config-validation failure path

Sibling guards elsewhere in this catalog, ForbiddenPathGuard, EgressAllowlistGuard, ShellCommandGuard, compile operator-supplied globs or regexes at construction time, so their with_* constructors return a Result and can fail closed on a bad pattern. McpToolGuard::with_config takes McpToolConfig and returns Self directly: no Result, no McpToolConfigError type exists at all. Allow and block entries are plain strings collected into a HashSet<String>, not compiled patterns, so there is nothing that construction could fail to compile.

Default block list

Verified from McpToolConfig::default():

crates/guards/chio-guards/src/mcp_tool.rs
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(1024 * 1024), // 1 MB

That is the entire default block list: four names, chosen because they read as generic capability tools that many MCP servers might expose, not because they are the only dangerous names possible. Everything else is allowed, since allow starts empty and default_action is Allow. A tool literally named shell_exec is blocked by this guard regardless of what ShellCommandGuard would have decided about the command line inside it: the two guards inspect different action variants, and neither substitutes for the other.

Configuration

KnobTypeDefaultPurpose
enabledbooltrueMaster switch. When off, the guard allows calls before checking either list.
allowVec<String>emptyAllowlist-mode switch. A non-empty list permits these tool names and denies the rest.
blockVec<String>4 names (above)Denylist. Checked before the allowlist; a name here always denies.
default_actionMcpDefaultActionAllowFallback outcome when the tool is in neither list and the allowlist is empty.
max_args_sizeOption<usize>Some(1024 * 1024)Ceiling on serialized argument bytes: 1 MiB (1,048,576 bytes). A bare None at the config level still resolves to this same default inside with_config.

Which tool calls reach this guard

ToolAction::McpTool is the fallback category; a tool does not opt into it. crate::action::extract_action_checked tries, in order, a network extractor, a filesystem extractor, a shell extractor, a code-execution extractor, a browser extractor, a database extractor, a memory-write extractor, a memory-read extractor, and an external-API extractor. Each inspects the tool name (and sometimes the argument shape) for its own naming conventions and returns a match when it recognizes one. If none of them claim the call, extraction falls through to:

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

so any tool named outside those conventions, a custom MCP-server tool, a vendor-specific name, anything that does not look like read_file or http_request or apply_patch, lands in this guard's scope. Argument shape alone does not reclassify a call: a tool named custom_tool invoked with { "path": "/etc/shadow" } still becomes McpTool, not FileAccess, because its name does not match a filesystem naming pattern. That means ForbiddenPathGuard never sees that call; only the allow/block lists on this page do.

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. This guard does not evaluate calls another extractor has classified.
  4. Compute the serialized argument size with a byte-counting JSON writer (it counts bytes as they are written, it does not materialize the full string first). 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.

Block-list membership is an exact string match

allow_set and block_set are plain HashSet<String> equality checks against the tool name the request carries, not a glob or prefix match. Blocking shell_exec does not block shell_exec_v2 or a differently cased Shell_Exec. An operator who renames or aliases a dangerous tool on their MCP server must add the new name to block explicitly; nothing here generalizes across name variants.

Failure modes

  • Non-McpTool actions return Verdict::Allow. A tool call that a more specific extractor already classified never reaches the allow/block lists on this page.
  • A malformed action shape from extract_action_checked denies with empty evidence, GuardDecision::deny(Vec::new()), the same call the guard makes for a block-list hit or an oversized-argument hit. None of this guard's deny paths attach a detail message of their own; the pipeline's wrapper still records the guard name and a generic fail-closed reason on the receipt.
  • If the argument-size writer itself fails to serialize the arguments, evaluate returns Err(KernelError::GuardDenied(...)) rather than Ok(GuardDecision::deny(...)). Per the guard contract, an Err from evaluate is treated as a deny by the pipeline, so the outcome matches, but the receipt's evidence entry comes from the pipeline's generic error handler rather than from this guard. This path is unlikely: the arguments already arrived as a parsed serde_json::Value.
  • There is no construction-time failure mode. with_config is infallible: it moves allow and block into HashSet<String> and unwraps max_args_size against the 1 MiB default. There is no build_default_or_fail_closed() analog here, because there is no fallible compilation step to fall back from.

Example scenarios

Verified against the guard's own test module:

Tool callVerdictReason
shell_exec (default config)DenyIn the default block list
read_file (default config)AllowNot blocked; allow list empty; default_action is Allow
safe_tool, with allow: ["safe_tool"], default_action: BlockAllowMember of a non-empty allow list
other_tool, same configDenyallow is non-empty and other_tool is not a member
tool_a, with allow: ["tool_a"] and block: ["tool_a"]DenyBlock list is checked first and always wins
any_tool, empty allow and block, default_action: BlockDenyFalls through to default_action
shell_exec, enabled: falseAllowGuard short-circuits before any list is checked
Any tool with a 200-byte payload, max_args_size: 100DenySerialized arguments exceed max_args_size

Example

chio.yaml
guards:
  mcp_tool:
    enabled: true
    allow:
      - read_file
      - list_directory
      - search_files
    block:
      - shell_exec
      - raw_file_delete
    default_action: block
    max_args_size: 524288

Supplying a non-empty allow changes the behavior from default allow to default deny for unlisted tools. Under the configuration above, write_file is denied even though it appears in neither list, because allow is non-empty and write_file is not in it. The default_action key only matters while allow stays empty.


Composition in the default pipeline

McpToolGuard ships in GuardPipeline::default_pipeline(), fifth of seven guards, registered after the path allowlist and before the secret scanner:

crates/guards/chio-guards/src/pipeline.rs
pub fn default_pipeline() -> Self {
    let mut pipeline = Self::new();
    pipeline.add(Box::new(ForbiddenPathGuard::new()));
    pipeline.add(Box::new(ShellCommandGuard::new()));     // see /shell-code
    pipeline.add(Box::new(EgressAllowlistGuard::new()));  // see /network
    pipeline.add(Box::new(PathAllowlistGuard::new()));    // disabled by default
    pipeline.add(Box::new(McpToolGuard::new()));          // this page
    pipeline.add(Box::new(SecretLeakGuard::new()));
    pipeline.add(Box::new(PatchIntegrityGuard::new()));
    pipeline
}

It is not part of the second default set, default_runtime_guard_profile(): that constructor's pre_invocation_guards list holds only InternalNetworkGuard, AgentVelocityGuard, and an AdvisoryPipeline. See Default Pipeline for both default sets.

Use with shell and code guards

Blocking shell_exec by tool name stops a server that exposes a literal shell-execution tool. It does nothing about a tool named run_python that shells out internally, and nothing about dangerous arguments passed to an otherwise ordinary tool name. Those are ShellCommandGuard's and CodeExecutionGuard's job (see Shell & Code Guards), and they run on entirely different action variants, so all three should remain enabled together; they are not alternatives.

Next Steps

  • Shell & Code Guards :: the command-line and interpreter checks that run on the actions this guard never sees.
  • Rate Limit Guards :: throttle invocations of a tool this guard already let through.
  • Default Pipeline :: full registration order and the two-default-sets split.