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
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
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():
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 MBThat 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
| 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 (above) | 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 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:
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:
- 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. This guard does not evaluate calls another extractor has classified. - 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. - 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.
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-
McpToolactions returnVerdict::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_checkeddenies 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,
evaluatereturnsErr(KernelError::GuardDenied(...))rather thanOk(GuardDecision::deny(...)). Per the guard contract, anErrfromevaluateis 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 parsedserde_json::Value. - There is no construction-time failure mode.
with_configis infallible: it movesallowandblockintoHashSet<String>and unwrapsmax_args_sizeagainst the 1 MiB default. There is nobuild_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 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 |
Example
guards:
mcp_tool:
enabled: true
allow:
- read_file
- list_directory
- search_files
block:
- shell_exec
- raw_file_delete
default_action: block
max_args_size: 524288Supplying 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:
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
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.