Core0 / Nio
Core0
GitHub v2.4.4
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), hand the call to Phase 1+ as a mapped native action, or — if the tool is an MCP call — dispatch it as `mcp_tool_call` through Phase 1+ for content scan over the arguments. Truly uncategorised tools fall through to allow with an `UNCATEGORIZED_TOOL` audit entry.

Property | Value
--- | ---
Latency | <1ms
Scope | All tools
Type | Gate (no score emitted)
Weight | —
Short-circuit | blocked → deny · uncategorised (non-MCP, no native mapping) → allow with `UNCATEGORIZED_TOOL` audit

## 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.
4. If unmapped but the tool name matches an MCP convention (see MCP Tool Routing below) → continue to Phase 1 as a synthetic `mcp_tool_call` action; the arguments are JSON-serialised for Phase 3 content scan.
5. Otherwise → allow and emit an `UNCATEGORIZED_TOOL` audit entry (Phase 1–6 are skipped).

## 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 with the chosen action type. Unmapped MCP tools enter Phase 1–6 as `mcp_tool_call`; anything else allows and logs `UNCATEGORIZED_TOOL`.

## Configuration that affects this phase

- `guard.blocked_tools` — per-platform + `mcp` denylist.
- `guard.permitted_tools` — per-platform + `mcp` strict 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 `hass__HassTurnOff`; Hermes either `hass__HassTurnOff` or its flattened single-underscore form like `mcp_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; list tool names verbatim as you see them in the hook payload (no prefix-stripping).

### 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` — matches any server, and matches Hermes's single-underscore form when the name is listed verbatim, e.g. `mcp_config_db_get_current_config`) or server-qualified (`hass__HassTurnOn` — that server only). 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 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