β€’πŸ‘€ By Richard Scorerβ€’
AIRustSecuritySSHMCP

Stop Giving AI Agents Unchecked SSH Access

Why autonomous AI coding assistants with raw terminal access are a security liability, and how we engineered zero-trust connection pooling in Rust with considerable token savings.

If you’ve spent any time pair-programming with AI coding agents you’ve likely handed them remote server permissions.

It feels productive. You ask the agent to inspect a failing deployment, check container health across your cluster, or tail application logs, and it obliges in seconds.

I learned this the hard way sitting at my terminal, watching my AI agent do remote server triage across my own machines.

I asked it to investigate an anomaly in my fleet of Pi5s. I watched as it opened the same connection time after time to talk to the same remote server, checking one small thing at a time. Then it asked the exact same question of each server individually, spinning up fresh SSH handshakes for every single command.

Worse, every single docker logs or system check dumped hundredsβ€”sometimes thousandsβ€”of tokens of raw terminal noise and ANSI escape codes straight into the context window. Whenever an SSH connection dropped mid-query, the agent would lose its place and spin up retry loops.

And then I realized what I had actually given it.

In the background, the AI assistant wasn’t just checking my staging box. It had complete, uninhibited read and execute access to my entire ~/.ssh/config. That included jump hosts, production database bastions, client servers, and private key identities.

One hallucinated shell expansion, one prompt injection hidden inside an untrusted Git repo, or one poorly scoped bash command, and your agent is executing arbitrary commands directly on production infrastructure.

Talking of hallucinations, we should point out that the one thing we didn’t add was individual command checking as this was deemed slightly out of bounds. We allow you to apply security by disallowing host access in general. You could also be secure by allowing a more restricted user logging into your host.

You can integrate something like destructive_command_guard which as it suggests guards against destructive commands in your shell(s). Because agentic_ssh maintains an open connection, this allows the history to be followed correctly too, which is one of the key elements of dcg.


πŸ’₯ The Two Hidden Problems of Raw Agent SSH

When AI agents interact with remote servers via raw bash, two massive failure modes emerge:

1. The β€œBlast Radius” Problem (Zero Guardrails)

Standard AI terminal tools treat all SSH hosts equally. If it’s in your ~/.ssh/config, the model can reach it. There is no concept of β€œthis server is for development, but that server holds the customer database.”

Asking an agent to β€œclean up lingering Docker containers across our machines” can turn into an unrecoverable disaster in milliseconds.

2. The β€œToken Avalanche” & Reconnect Lag

Whenever an agent executes a command like ssh server 'journalctl -n 500' or docker ps -a, two things happen:

  • Connection Latency: Every single command undergoes a full SSH TCP handshake, key exchange, and authentication negotiation (typically 1.2s – 2.0s per invocation).
  • Context Poisoning: The command dumps 15,000 tokens of raw ASCII, ANSI color escape codes, and repeated column headers straight into the LLM context window.

Within 3 queries, your model hits its context limit, reasoning degrades, and your API bill explodes.


πŸ“Š Benchmark: Raw Bash vs. Agentic SSH

Let’s use an example we actually check for, and show comparisons between using agentic_ssh and not.

We need to see what containers are running in docker on 3 hosts. Sounds simple, and with agentic_ssh it can be :)

1. The Tool Call Issued by the Agent

When an agent decides to check Docker status across 3 hosts (e.g. cartman, stan, and kyle), it issues a standard tool call to the MCP server.

Agent Tool Invocation

{
  "name": "check_docker_status",
  "arguments": {
	"hosts": ["cartman", "stan", "kyle"]
  }
}

Underlying MCP JSON-RPC Wire Request (tools/call)

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
	"name": "check_docker_status",
	"arguments": {
	  "hosts": ["cartman", "stan", "kyle"]
	}
  }
}

For local testing or scripts, the equivalent CLI call is

  $ agentic_ssh json check_docker_status '{"hosts": ["cartman", "stan", "kyle"]}'

2. The Result Returned to the Agent

agentic_ssh executes the command across all 3 hosts concurrently using connection pooling.

The custom tool definition formats Docker output as {{.Names}}|{{.Status}}|{{.Ports}}:

{  
  "jsonrpc": "2.0",  
  "id": 42,  
  "result": {  
    "content": [  
      {  
        "type": "text",  
        "text": {  
          "cartman": {  
            "status": "success",  
            "data": "traefik|Up 3 days (healthy)|0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\npostgres|Up 3 days|5432/tcp"  
          },  
          "stan": {  
            "status": "success",  
            "data": "nextcloud|Up 12 days|80/tcp\nredis|Up 12 days|6379/tcp"  
          },  
          "kyle": {  
            "status": "error",  
            "error": "Connection failed: SSH connection timed out after 5 seconds"  
          }  
        }  
      }  
    ],  
    "isError": false  
  }  
}

3. Why This Structure is Easy for LLMs to Parse

  1. Per-Host Key Partitioning: Rather than interleaving stdout streams or requiring terminal prompt regexes, each host is isolated in a key (cartman, stan, kyle).
  2. Explicit Status Tagging (status): Each target carries β€œstatus”: β€œsuccess”, β€œerror”, or β€œtimeout”. The agent can branch deterministically without inspecting raw error strings.
  3. Partial Failure Isolation: If one host is down or unreachable (like kyle above), the overall RPC succeeds and returns the working hosts intact while cleanly reporting the failure for the unreachable node.
  4. Structured Line Delimiters: Because the underlying tool command formats container output with pipe delimiters ({{.Names}}|{{.Status}}|{{.Ports}}), the model can reliably parse container name, uptime, and exposed ports without column misalignment or header confusion.

Without agentic_ssh

Without agentic_ssh, an agent typically relies on a generic local bash execution tool (e.g. execute_command or bash). To query 3 remote hosts, the workflow degrades into either multiple sequential round-trips or brittle shell orchestration.

Here is what that looks like in practice.

Option A: The Sequential Multi-Turn Route (3 Tool Calls)

Most agents default to executing commands one host at a time, creating a multi-turn conversation loop.

Turn 1: Query cartman

The agent issues a bash command:

{
  "name": "run_command",
  "arguments": {
	"command": "ssh -o BatchMode=yes -o ConnectTimeout=5 cartman \"docker ps -a\""
  }
}

Raw Output Returned to Agent:

Linux cartman 6.8.0-45-generic #45-Ubuntu SMP PREEMPT_DYNAMIC Wed Sep 11 18:25:23 UTC 2024 x86_64
Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-45-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

System information as of Thu Sep 17 17:22:30 UTC 2026

  System load:  0.15              Processes:               182
  Usage of /:   34.2% of 48.29GB   Users logged in:         1
  Memory usage: 42%               IPv4 address for eth0:   192.168.1.10

CONTAINER ID   IMAGE          COMMAND                  CREATED        STATUS                    PORTS                                            NAMES
c8e1a90f23b1   traefik:v3.1   "/entrypoint.sh trae…"   3 days ago     Up 3 days (healthy)       0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp         traefik
4b0098f411cd   postgres:16    "docker-entrypoint.s…"   3 days ago     Up 3 days                 5432/tcp                                         postgres

Turn 2: Query stan

The agent waits for Turn 1 to complete, parses the MOTD and table text, then issues:

{
  "name": "run_command",
  "arguments": {
	"command": "ssh -o BatchMode=yes -o ConnectTimeout=5 stan \"docker ps -a\""
  }
}

(Waits another round trip for SSH handshake and response).

Turn 3: Query kyle (Failure / Timeout)

{
  "name": "run_command",
  "arguments": {
	"command": "ssh -o BatchMode=yes -o ConnectTimeout=5 kyle \"docker ps -a\""
  }
}

Output Returned to Agent:

ssh: connect to host kyle port 22: Operation timed out

Option B: The Brittle Bash Orchestration Route (1 Tool Call)

To avoid 3 round-trips, a sophisticated agent might try to script concurrency directly in bash:

Tool Call

{
  "name": "run_command",
  "arguments": {
	"command": "for host in cartman stan kyle; do (echo \"=== $host ===\"; ssh -o BatchMode=yes -o ConnectTimeout=5 -T \"$host\" \"docker ps -a --format '{{.Names}}|{{.Status}}|{{.Ports}}'\" 2>&1) & done; wait"
  }
}

Raw Output Returned to Agent:

=== cartman ===
=== kyle ===
=== stan ===
ssh: connect to host kyle port 22: Operation timed out
nextcloud|Up 12 days|80/tcp
redis|Up 12 days|6379/tcp
traefik|Up 3 days (healthy)|0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
postgres|Up 3 days|5432/tcp

Key Comparison: Why the Raw Approach Fails or Bloats

Feature Raw SSH via Generic Bash agentic_ssh MCP
Turns & Latency 3 sequential round-trips (or complex backgrounded subshell code). 1 tool call, executed concurrently across all nodes.
Connection Overhead Performs TCP handshake, key exchange (KEX), host verification, and user authentication from scratch for every single command. Persistent connection pool (auto-reused, auto-closed after 5 min idle).
Output Cleanliness Polluted with MOTD banners, system stats, last login info, and shell warnings. Zero shell noise; only returns stdout or clean errors.
Parsing Reliability Non-deterministic whitespace tables that wrap on narrow viewports, or race conditions where backgrounded outputs interleave. Deterministic JSON dictionary mapped per host ({ β€œcartman”: { β€œstatus”: β€œsuccess”, β€œdata”:… } }).
Prompt Traps Can hang the session indefinitely on host key verification prompts (Host key verification failed) or password prompts unless exact -o flags are remembered. Non-interactive by default, controlled timeouts, and strict error isolation.
Token Consumption High token waste from repeated prompts, tool definitions, MOTDs, and back-and-forth turns. Minimal token payload directly consumable by downstream reasoning.

πŸ›‘οΈ The Architecture of agentic_ssh

To solve the problems described above, we built Agentic SSHβ€”a dedicated, high-performance Model Context Protocol (MCP) server written in Rust.

Instead of letting agents execute unconstrained shell commands, agentic_ssh acts as a zero-trust cryptographic gateway between the AI and your servers.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       MCP Tool Call       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       Pre-Warmed SSH       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ AI Assistant    β”‚ ────────────────────────> β”‚ agentic_ssh (Rust MCP) β”‚ ─────────────────────────> β”‚ Remote Server   β”‚
β”‚ (Claude/Cursor) β”‚ <──────────────────────── β”‚                        β”‚ <───────────────────────── β”‚                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     Structured JSON       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        0ms Latency         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         (185 Tokens)            β€’ Strict Host Allowlist
                                                 β€’ Stream Parser & Truncation
                                                 β€’ Keepalive Connection Pool

⚑️ Key Architectural Pillars

1. Cryptographic allow_hosts Guardrails

By default, agentic_ssh operates on a strict Zero-Trust model. Even if your local SSH config lists 50 servers, the AI can only interact with hosts explicitly enumerated in .agentic_ssh.toml:

# Only staging and local nodes are accessible to the AI
allow_hosts = [
    "staging-app-01",
    "staging-db-01",
    "aruba-node"
]

# Production servers are invisible and strictly unreachable
ignore_hosts = ["prod-*", "bastion-primary"]

If the agent attempts to target an unauthorized host, the tool call is rejected locally before a single network packet leaves your machine.

If you don’t specify allow_hosts then agentic_ssh will complain and fail to execute commands, we need to know which hosts we can run on!


2. Pre-Warmed Rust Connection Pooling (0ms Handshakes)

Rather than spinning up and tearing down SSH processes on every command, agentic_ssh maintains an asynchronous in-memory connection pool built on russh and tokio.

  • Active Reuse: Consecutive tool calls to the same server execute in < 1ms with zero reconnect overhead.
  • Auto-Teardown: Idle sessions automatically close after 5 minutes of inactivity.

3. Squeezing 15,000 Tokens Down to size

AI models don’t need 500 lines of kernel timestampsβ€”they need actionable status. agentic_ssh parses raw remote system outputs into compact, structured JSON telemetry:

{
  "staging-app-01": {
    "status": "success",
    "data": {
      "load_averages": [
        0.31,
        0.47,
        0.4
      ],
      "memory": {
        "total_kb": 8122336,
        "free_kb": 4254188,
        "available_kb": 6668804,
        "used_kb": 1453532
      },
      "disks": [
        {
          "filesystem": "/dev/nvme0n1p2",
          "size_kb": 491678424,
          "used_kb": 118604208,
          "available_kb": 348079296,
          "use_percent": 26,
          "mount_point": "/"
        }
      ]
    }
  }
}

By compressing terminal noise into structured payloads, we slashed prompt token consumption, often dramatically reducing model reasoning latency and operational costs.


4. Interactive Live-Stream TUI (agentic_ssh watch)

When you want to oversee what your AI assistant is doing on your servers in real-time, agentic_ssh includes a high-performance terminal UI built with ratatui:

agentic_ssh watch stan,cartman,kyle "docker ps"

You get a split-pane live stream of active SSH sessions, followed by a summary of each host’s logs across multiple hosts concurrently. (eg this example would invoke the command docker ps on your hosts stan, cartman & kyle)

agentic_ssh watch live stream


πŸ“¦ Getting Started in 30 Seconds

agentic_ssh is officially verified on the Anthropic Model Context Protocol Registry and available across macOS and Linux:

1. Install via Homebrew or Cargo

# macOS & Linux (Homebrew)
brew install sandbanks/tap/agentic_ssh # no need for rust to be installed

cargo binstall agentic_ssh # no need for rust to be installed

# Rust toolchain
cargo install agentic_ssh # requires rust toolchain

2. Updates

# macOS & Linux (Homebrew)
brew upgrade sandbanks/tap/agentic_ssh # no need for rust to be installed

# If you use cargo-update:
cargo install-update agentic_ssh

# Or re-install directly:
cargo binstall agentic_ssh
# or: cargo install --force agentic_ssh

3. Auto-Register with Your AI Tools

Run the self-install command to automatically configure Claude Desktop, Cursor, and other MCP clients:

agentic_ssh install

4. Configure Your Allowed Hosts

Create a simple ~/.config/agentic_ssh/config.toml or .agentic_ssh.toml in your project root:

allow_hosts = ["my-dev-box", "staging-cluster"]

🎯 Final Thoughts

Autonomous AI agents are only as safe as the boundaries we give them. Stop letting agents wander through your ~/.ssh/config unrestricted.

Give them dedicated, high-speed, zero-trust tools that keep your production infrastructure safe and your context windows clean.

RS

Richard Scorer

Independent software engineer building high-performance, sovereign tools and agentic primitives at sandbanks.tech.