Tool Gate — is this tool blocked, permitted, or analysed?
The first checkpoint. Looks at the tool name only — no command, no file content. Decides whether to deny outright (blocked or not on a non-empty allowlist), pass through without further analysis (permitted but not in the native-tool mapping), or hand the call to Phase 1+ (mapped to an action type for deeper analysis).
| Property | Value |
|---|---|
| Latency | <1ms |
| Scope | All tools |
| Type | Gate (no score emitted) |
| Weight | — |
| Short-circuit | blocked → deny · permitted (no native mapping) → allow |
How the gate decides
- Look up the tool name in
guard.blocked_toolsfor the active platform (andmcpif it's an MCP tool). Match → deny. - If
guard.permitted_toolsfor the platform is non-empty AND the tool isn't on it → deny (strict allowlist). - Look up the tool in
guard.native_tool_mapping. Mapped → continue to Phase 1 with the mapped action type. Unmapped → allow (auto-approve, log only).
Rules
| Rule | Description |
|---|---|
blocked_tools | Hard-deny list — never runs. |
permitted_tools | Strict allowlist — when non-empty, only listed tools may run; everything else is denied. |
native_tool_mapping | Native tool → action-type classification. Mapped tools enter Phase 1–6 deep analysis with the chosen action type; unmapped tools auto-allow (log only). |
Configuration that affects this phase
guard.blocked_tools— per-platform +mcpdenylist.guard.permitted_tools— per-platform +mcpstrict allowlist (only listed tools pass when non-empty).guard.native_tool_mapping— per-platform native tool → action-type classification table (decides which Phase 1–6 rule set runs).guard.mcp_servers— manual MCP server registry, used by the indirect-invocation detectors below.
MCP Tool Routing — direct and indirect, one allowlist
An agent can drive an MCP server two ways: directly through the platform's MCP tool surface (Claude Code / Codex mcp__hass__HassTurnOff; OpenClaw / Hermes hass__HassTurnOff) or indirectly via a Bash command that talks to the server through some other channel (mcporter, curl, python -c, stdio pipes, package runners, …). Phase 0 routes both paths through a single permitted_tools.mcp allowlist.
Why the indirect path needs handling
From the platform's point of view, when an agent shells out, the tool name is just Bash — the actual MCP invocation lives in the command's content. Without further inspection, indirect calls escape the allowlist that direct calls obey. Nio closes that gap by adding content detection at Phase 0: unwrap nested forms, run detectors against each fragment, map every hit back to {server, tool} via an endpoint registry, and feed the resulting candidates into the same allowlist check. The policy layer is unchanged.
Capture model
┌──────────────────────────────────────────────────────────────────────┐
│ Phase 0: checkToolGate(toolName, toolInput, config, registry) │
└────────────────────────────────────────┬─────────────────────────────┘
│
┌────────────────────────┴──────────────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Direct call │ │ Indirect call │
│ parseMcpToolName│ │ extractCommand │
│ → {server,tool} │ │ → command string │
└────────┬────────┘ └─────────┬────────┘
│ │
│ ┌──────────────────▼─────┐
│ │ Stage 1: unwrapCommand │
│ │ U1-U16 (recursive) │
│ └──────────────────┬─────┘
│ │
│ ┌──────────────────▼─────┐
│ │ Stage 2: runDetectors │
│ │ D1-D16 (per-fragment) │
│ └──────────────────┬─────┘
│ │
│ ┌──────────────────▼─────┐
│ │ Filter audit-only │
│ │ (D12, D15, D16) │
│ └──────────────────┬─────┘
│ │
▼ ▼
┌────────────────────────────────────────────────────────┐
│ Combined candidate list → │
│ permitted_tools.mcp / blocked_tools.mcp │
└────────────────────────────────────────────────────────┘
Stage 1 unwraps shells, heredocs, evals, encodings, package wrappers, and remote-shell prefixes until each fragment is plain enough that a detector can match it. Pass-through flags (remote, background, compiled) propagate from outer wrappers so the audit log can name the channel. Stage 2 runs every detector against every fragment; hits become RoutedMcpCall { server, tool?, via, evidence, flags?, auditOnly? }. Stage 3 flattens hits to {tool, server__tool} candidates and runs the existing allowlist check.
Allowlist entries accept either a bare local name (HassTurnOn) or server-qualified (hass__HassTurnOn). Matching is case-insensitive.
MCP server registry
For Stage 2 to know that http://localhost:5173/mcp is an MCP server, Phase 0 loads a registry on hook entry. Sources are auto-discovered:
| Source | Path |
|---|---|
| Claude Code | ~/.claude.json mcpServers.* |
| Claude Desktop | ~/Library/Application Support/Claude/claude_desktop_config.json (macOS), $XDG_CONFIG_HOME/Claude/, %APPDATA%\Claude\ |
| Hermes | ~/.hermes/config.yaml mcp_servers.* |
| OpenClaw | ~/.openclaw/openclaw.json mcp.servers.* |
| Manual override | ~/.nio/config.yaml guard.mcp_servers.* |
Every server is indexed by every reachable handle:
type MCPServerEntry = {
serverName: string
urls: string[] // http(s)://, ws(s)://
sockets: string[] // unix socket paths
binaries: string[] // mcp-server-X, X-mcp
cliPackages: string[] // @scope/mcp-cli, npx package names
source: 'claude' | 'claude_desktop' | 'hermes' | 'openclaw' | 'manual'
}
Lookups: lookupByUrl(), lookupBySocket(), lookupByBinary(), lookupByCliPackage(). URLs are normalized (host lower-cased, trailing slash stripped); origin matching captures sub-paths of the registered URL. Binary and package lookups are case-insensitive and basename-aware. Caching is per-source by file mtime — only changed sources re-parse.
Stage 1 — Unwrappers (U1–U16)
Each unwrapper either returns inner fragments or emits flags that decorate the current fragment. Recursion goes up to MAX_UNWRAP_DEPTH = 8 levels.
| # | Channel | Example trigger |
|---|---|---|
| U1 | shell -c | bash/sh/zsh/dash/ksh/fish/busybox -c "..." |
| U2 | variable shell | $SHELL/$BASH/${SHELL} -c "..." |
| U3 | eval | eval "...", eval $(...) |
| U4 | heredoc / here-string | <<EOF...EOF, <<<'...' |
| U5 | process substitution | <(...), >(...) — additionally decodes bash|sh|zsh|dash|ksh|source|. <(echo "X") to expose the inner X (the consumer executes echo's output, so X is the real command) |
| U6 | command substitution | $(...), backticks |
| U7 | source / script exec | source <(...), . <(...), bash <(...) |
| U8 | interpreter inline | python/python3/node/bun/deno/ruby/perl/php/lua/Rscript/tclsh/osascript/pwsh -c|-e|-r|--eval (sets inline=true) |
| U9 | encoded-pipe decoder | echo '<b64>' | base64 -d | <interp>, xxd -r -p, openssl base64 -d |
| U10 | string-concat / variable folding | c=cu; c=$c"rl"; $c URL (best-effort static fold) |
| U11 | indirect executor | xargs <cmd>, find -exec <cmd>, parallel/watch/time/env <cmd> — for echo X | xargs CMD / printf X | xargs CMD also synthesizes CMD X so detectors see the appended argument xargs would deliver |
| U12 | remote shell | ssh user@host '...', docker exec C ..., kubectl exec ... -- ..., podman exec ... (sets remote=true) |
| U13 | editor escape | vim/nvim/ed/ex -c '!...', emacs --batch ... |
| U14 | build/orchestration inline shell | make -f /dev/stdin, ansible -m shell -a '...' |
| U15 | background / scheduled | nohup ... &, trailing &, disown, setsid, at <<<'...', crontab -, systemd-run, launchctl bsexec (sets background=true) |
| U16 | compile-and-run | gcc/clang -x c -; ./a.out, go run -, rustc -; ./a (sets compiled=true) |
Composition: layers stack freely. bash -c "echo <b64> | base64 -d | sh" unwraps through U1 → U9 → final fragment, and any detector match is attributed correctly.
Stage 2 — Detectors (D1–D16)
Each detector consumes one fragment and emits zero or more RoutedMcpCall. D2–D11 require the registry; D1 (mcporter) parses the target out of the command directly.
| # | Channel | Match rule | via tag |
|---|---|---|---|
| D1 | mcporter CLI | mcporter [call] <server>.<tool> (incl. npx mcporter, absolute paths, after separators) | mcporter |
| D2 | curl-class | curl/wget/aria2c/fetch/lwp-request hitting registry URL or --unix-socket | http_client |
| D3 | HTTPie-class | http/https/httpie/xh [METHOD]? URL hitting registry URL | httpie |
| D4 | TCP / socket multiplex | nc/netcat/ncat/socat/openssl s_client/websocat/grpcurl host:port or -U /sock | tcp_socket |
| D5 | Bash builtin networking | /dev/tcp/<host>/<port>, /dev/udp/..., exec N<>/dev/tcp/... | dev_tcp |
| D6 | PowerShell HTTP | Invoke-WebRequest, Invoke-RestMethod, System.Net.WebClient, HttpClient URL | pwsh_http |
| D7 | Language-runtime HTTP | URL literals inside U8 inline code (urllib/requests/httpx, fetch/http.request, Net::HTTP/open-uri, LWP, file_get_contents/curl_exec, deno/bun fetch, net/http, reqwest), plus tool name extracted from any JSON-RPC body literal in the inline source — handles Python single-quoted dict literals via fallback parse | language_runtime |
| D8 | Stdio JSON-RPC pipe | <echo|printf|cat|jq|tee|yes> ... | <bin> where bin matches a registry binary | stdio_pipe |
| D9 | Stdin redirect | <bin> < file.json, <bin> <<EOF, <bin> <<<'json' | stdin_redirect |
| D10 | FIFO / named pipe | mkfifo PATH; <bin> < PATH &; ... > PATH | fifo |
| D11 | Package runners | npx/bunx/pnpm dlx|exec/yarn dlx|exec/pipx run/uv run/uvx/deno run/go run <pkg> against registry CLI packages | package_runner |
| D12 | MCP server self-launch — audit-only | <registry-binary> --transport http|sse, --port, --listen, --bind, --host, --address | self_launch |
| D13 | Remote-shell pass-through | Emergent from U12 — flags.remote=true on any inner D2–D11 hit | (carried by flags) |
| D14 | Background pass-through | Emergent from U15 — flags.background=true | (carried by flags) |
| D15 | Compile-and-run — audit-only | Fragment carries flags.compiled=true (from U16); compiled binary's runtime is opaque | compiled |
| D16 | Obfuscation fallback — audit-only | Registry URL or binary literal appears in fragment text without any other detector firing | obfuscation_fallback |
Conservative fallback
When a detector resolves a server but cannot resolve a tool (e.g. raw TCP via nc, or a body shape no extractor can decode), Phase 0 treats the call as a "whole-server invocation" — the candidate is ${server}__*. This biases toward denial in two ways:
- Allowlist mode (
permitted_tools.mcp):${server}__*fails any allowlist that doesn't whitelist the server entire — the call is denied. - Denylist mode (
blocked_tools.mcp): if the denylist contains any entry that could be a tool on the resolved server (a bare tool name likeHassTurnOff, or a server-qualified entry likehass__HassTurnOff), the ambiguous call is denied. Trade-off: legitimate indirect calls to non-blocked tools on the same server are also denied. Users who need fine-grained allow + deny on one server should switch topermitted_tools.mcpallowlist mode.
Audit-only detectors
D12, D15, and D16 emit calls with auditOnly: true. Phase 0 filters these out before the allowlist check — they inform the audit log but never deny. D12 because dev workflows commonly start a local MCP server; D15 because compile-and-run is a normal build pattern (the compiled binary's runtime HTTP behaviour is properly an OS-sandbox concern); D16 because the fallback is heuristic and prone to false positives.
Restricting MCP tools via ~/.nio/config.yaml
The capture model just feeds the existing permitted_tools.mcp allowlist. To restrict an agent to a single MCP tool — direct or indirect — set:
guard:
protection_level: balanced
permitted_tools:
mcp: ['HassTurnOn'] # only this tool is available, on any server
# or:
# mcp: ['hass__HassTurnOn'] # server-qualified — only on the hass server
Once set, Phase 0 denies every indirect channel that resolves to a different tool: a curl POST to the registered MCP URL, a python3 -c "fetch(...)" one-liner, a npx of the server's CLI package, an echo '...' | mcp-server-bin stdio injection, and so on.
Servers Nio doesn't auto-discover (e.g. a SaaS MCP not declared in any of the known config files) can be declared manually:
guard:
mcp_servers:
hass:
urls: ['http://localhost:5173/mcp']
sockets: ['/tmp/mcp-hass.sock']
binaries: ['mcp-server-hass']
cliPackages: ['@hass/mcp-cli']
Sensitive-path write protection
The detector pipeline can be sidestepped if the agent registers a new MCP server (changing the registry under our feet) or installs a persistence hook that runs at next launch. SENSITIVE_FILE_PATHS rejects writes to the relevant paths unconditionally: MCP configuration files (.claude.json, Claude Desktop config, .hermes/config.yaml, .openclaw/) and persistence channels (LaunchAgents/LaunchDaemons, cron files, systemd units, shell rc files, authorized_keys). write_file actions hitting any of these → critical reject, regardless of content.
Examples
guard:
blocked_tools:
claude_code: ['WebSearch', 'WebFetch']
permitted_tools:
claude_code: [] # empty → no whitelist mode
mcp: ['hass__*'] # whitelist of MCP tools (any platform)
native_tool_mapping:
claude_code:
Bash: exec_command
Write: write_file
Edit: write_file