Pipeline · Phase 0

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).

PropertyValue
Latency<1ms
ScopeAll tools
TypeGate (no score emitted)
Weight
Short-circuitblocked → deny · permitted (no native mapping) → allow

How the gate decides

  1. Look up the tool name in guard.blocked_tools for the active platform (and mcp if it's an MCP tool). Match → deny.
  2. If guard.permitted_tools for the platform is non-empty AND the tool isn't on it → deny (strict allowlist).
  3. 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

RuleDescription
blocked_toolsHard-deny list — never runs.
permitted_toolsStrict allowlist — when non-empty, only listed tools may run; everything else is denied.
native_tool_mappingNative 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

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:

SourcePath
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.

#ChannelExample trigger
U1shell -cbash/sh/zsh/dash/ksh/fish/busybox -c "..."
U2variable shell$SHELL/$BASH/${SHELL} -c "..."
U3evaleval "...", eval $(...)
U4heredoc / here-string<<EOF...EOF, <<<'...'
U5process 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)
U6command substitution$(...), backticks
U7source / script execsource <(...), . <(...), bash <(...)
U8interpreter inlinepython/python3/node/bun/deno/ruby/perl/php/lua/Rscript/tclsh/osascript/pwsh -c|-e|-r|--eval (sets inline=true)
U9encoded-pipe decoderecho '<b64>' | base64 -d | <interp>, xxd -r -p, openssl base64 -d
U10string-concat / variable foldingc=cu; c=$c"rl"; $c URL (best-effort static fold)
U11indirect executorxargs <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
U12remote shellssh user@host '...', docker exec C ..., kubectl exec ... -- ..., podman exec ... (sets remote=true)
U13editor escapevim/nvim/ed/ex -c '!...', emacs --batch ...
U14build/orchestration inline shellmake -f /dev/stdin, ansible -m shell -a '...'
U15background / schedulednohup ... &, trailing &, disown, setsid, at <<<'...', crontab -, systemd-run, launchctl bsexec (sets background=true)
U16compile-and-rungcc/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.

#ChannelMatch rulevia tag
D1mcporter CLImcporter [call] <server>.<tool> (incl. npx mcporter, absolute paths, after separators)mcporter
D2curl-classcurl/wget/aria2c/fetch/lwp-request hitting registry URL or --unix-sockethttp_client
D3HTTPie-classhttp/https/httpie/xh [METHOD]? URL hitting registry URLhttpie
D4TCP / socket multiplexnc/netcat/ncat/socat/openssl s_client/websocat/grpcurl host:port or -U /socktcp_socket
D5Bash builtin networking/dev/tcp/<host>/<port>, /dev/udp/..., exec N<>/dev/tcp/...dev_tcp
D6PowerShell HTTPInvoke-WebRequest, Invoke-RestMethod, System.Net.WebClient, HttpClient URLpwsh_http
D7Language-runtime HTTPURL 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 parselanguage_runtime
D8Stdio JSON-RPC pipe<echo|printf|cat|jq|tee|yes> ... | <bin> where bin matches a registry binarystdio_pipe
D9Stdin redirect<bin> < file.json, <bin> <<EOF, <bin> <<<'json'stdin_redirect
D10FIFO / named pipemkfifo PATH; <bin> < PATH &; ... > PATHfifo
D11Package runnersnpx/bunx/pnpm dlx|exec/yarn dlx|exec/pipx run/uv run/uvx/deno run/go run <pkg> against registry CLI packagespackage_runner
D12MCP server self-launch — audit-only<registry-binary> --transport http|sse, --port, --listen, --bind, --host, --addressself_launch
D13Remote-shell pass-throughEmergent from U12 — flags.remote=true on any inner D2–D11 hit(carried by flags)
D14Background pass-throughEmergent from U15 — flags.background=true(carried by flags)
D15Compile-and-run — audit-onlyFragment carries flags.compiled=true (from U16); compiled binary's runtime is opaquecompiled
D16Obfuscation fallback — audit-onlyRegistry URL or binary literal appears in fragment text without any other detector firingobfuscation_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 like HassTurnOff, or a server-qualified entry like hass__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 to permitted_tools.mcp allowlist 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