AIShell-Gate — Remote Deployment Guide  ·  v25

Running a Remote AI Through
AIShell-Gate Using SSH

How to allow an AI system to submit controlled command plans for execution on a remote machine

Copyright © 2026 AIShell Labs LLC Winston-Salem NC USA. All Rights Reserved. Use of this software requires a valid license. — www.aishellgate.com  ·  www.aishell.org

!! BETA RELEASE — PLEASE READ BEFORE USE !! This is a beta release. Do not use on production systems. Best tested on a clean, isolated OS instance. Before committing a forced-command configuration to a live host, validate it with --dry-run: the full policy evaluation and confirmation flow runs without executing anything.

./aishell-gate --policy-preset ops_safe --dry-run

Report issues to [email protected].

01 Overview

This guide explains how to allow an AI system running on one machine to submit controlled command plans for execution on another machine using AIShell-Gate.

The goal is not to give the AI a shell. Instead, the AI submits a structured JSON plan that passes through a policy gate before any command is executed. This preserves security while still allowing meaningful automation.

Scope of this guide: This document covers remote AI agent deployment over SSH. AIShell-Gate also operates in a local interactive mode where a human operator types commands directly at a terminal — the same policy engine, same confirmation gates, and same audit chain apply to both. Local interactive use is covered in the Getting Started Guide.
Why SSH The choice of SSH as the transport is not incidental. The forced command pattern used here is over twenty-five years old and already trusted in production environments for exactly this kind of capability-scoped access. Git uses it (git-shell). rsync uses it. rdiff-backup and restic use it. The entire model — restricted account, no login shell, one forced command — is the established Unix answer to the question "how do I expose a single capability over the network without exposing a shell." AIShell slots into that pattern without asking anyone to trust something new.

01b Plan Sources: Three Integration Patterns

AIShell-Gate reads a JSON action plan on standard input and evaluates it against policy before anything runs. It does not care how that plan arrived or what produced it. The gate is the boundary; what is on the other side is your choice.

In practice there are three ways to connect an AI model to the gate. Two are variations on the same local deployment (the gate runs on your machine, the AI is somewhere else); one inverts the relationship and lets a remote agent call in.

Pattern A1 — MCP: AI coding environment, local gate

If you are using Claude Code or Cursor, this is the shortest path. The MCP server — aishell-gate-mcp.py, a local Python stdio process — exposes the gate's policy engine and execution gateway as tools the AI agent can call directly. The agent calls evaluate_plan and execute_plan; the MCP server translates those calls into subprocess invocations of the two local binaries. No pipeline script, no stdin pipe, no JSON plan assembled by hand.

Claude Code / Cursor MCP tool call (JSON-RPC 2.0 over stdio) aishell-gate-mcp.py (local Python process — no network access) ├──► aishell-gate-exec evaluate_plan · execute_plan └──► aishell-gate-policy evaluate_command · get_policy_template · verify_policy execve() command runs locally

The MCP server is a thin, stateless translator with no policy logic of its own. The binaries are always the authority. Installation is covered in the Using the MCP reference document.

Pattern A2 — HTTPS out: pipeline script calling any model, local gate

For users calling any AI model — local or cloud — via a script rather than through an MCP-compatible AI coding environment. A pipeline script calls the model over HTTPS, receives a JSON plan in response, and pipes it to aishell-gate-exec on stdin. The gate itself makes no network calls — network access is listed among the properties it intentionally avoids.

Pipeline script ──── HTTPS ────► LLM (cloud or local inference server) JSON plan (stdout → stdin pipe) aishell-gate-exec ──── spawns ────► aishell-gate-policy execve() command runs locally

A reference pipeline script for this pattern (aishell-pipe.sh) is provided in §18 Pattern A2: Pipeline Script Reference below, along with instructions for adapting it to different inference backends.

Pattern B — SSH in: remote AI agent, gate on target host

The gate runs on the machine the AI is operating on. An AI agent running elsewhere is given an SSH key whose authorized_keys entry specifies aishell-gate as the forced command. The agent connects, delivers its JSON plan on stdin, and that is all it can do. The rest of this guide covers this pattern in full.

AI agent (remote — cloud or local) SSH connection (JSON plan on stdin) Restricted account (ai-agent, /bin/false shell) forced command — client cannot override aishell-gate-exec ──── spawns ────► aishell-gate-policy execve() command runs on target host

The agent has no interactive shell. It cannot override the forced command, request a different binary, or see what happens after its plan is delivered. Revoking access is removing one line from authorized_keys. No new daemon is required — the only process listening is sshd, which is already running.

Choosing between them

PropertyA1 — MCPA2 — HTTPS outB — SSH in
Gate locationLocalLocalTarget host
AI delivers plan viaMCP tool call (stdio)stdin pipe from scriptSSH stdin
Network requirementNone — all localOutbound HTTPS to model endpointInbound SSH on target host
New daemon requiredNoNoNo — uses existing sshd
Best forClaude Code / Cursor usersCalling any model via script, without an MCP-compatible environmentRemote AI agent on a target machine
Access revocationRemove from .mcp.jsonRevoke the API keyRemove one line from authorized_keys
Reference documentationUsing the MCP§18 of this guideThis guide
The gate does not care which pattern you use All three patterns result in the same thing: a JSON plan reaching aishell-gate-exec. Whether it arrived via an MCP tool call, a stdin pipe, or an SSH connection is invisible to the policy engine. The transport is your concern; the policy boundary is the gate's.

02 Background: SSH and Forced Commands

This section is for readers who are new to SSH or who have used it primarily for interactive logins. If you are already comfortable with SSH keys and the authorized_keys forced command pattern, skip ahead to § 03.

What SSH Is

SSH (Secure Shell) is the standard protocol for encrypted remote access to Unix systems. When you connect to a remote server over SSH, the entire session — every command, every response — is encrypted in transit. SSH has been in production use since the mid-1990s and is the backbone of most server management, deployment automation, and remote development workflows.

SSH supports two authentication methods. Password authentication works like a web login — you provide a password and the server checks it. Public key authentication is more secure and is what AIShell-Gate relies on. You generate a key pair: a private key that stays on your machine and never leaves it, and a public key that you place on the remote server. When you connect, the server challenges your SSH client to prove it holds the matching private key without ever asking the client to transmit the key itself. The cryptographic proof succeeds or fails; no password is exchanged.

The public key is placed in a file on the remote server called ~/.ssh/authorized_keys. One line per key. Revoking access means removing the line. No service restart, no API call, no database entry.

The Forced Command Pattern

Each line in authorized_keys can optionally specify a command= option before the key material. When a connection arrives using that key, the specified command runs — regardless of what the connecting client requested. The client cannot override it. If the client asks for an interactive shell, the forced command runs. If the client tries to execute something specific, the forced command runs. The forced command is what runs, full stop.

This pattern is older than most of the tooling that uses it. Git uses it: git-shell is a restricted shell that only handles git commands, and it is deployed as a forced command on Git hosting servers so that an SSH key grants git access without granting an interactive shell. rsync uses it in many configurations. Restic, rdiff-backup, and other backup tools use it to give a backup agent access to a specific command without giving it free shell access. It is the established Unix answer to the question "how do I expose one capability over the network without exposing a shell?"

Why this is the right pattern The forced command model has been trusted in production for over two decades. It requires no new infrastructure — just a file and a line of text. Access is controlled by the key, revoked by removing a line, and audited by the existing SSH daemon logs. AIShell-Gate slots into a pattern that experienced operators already understand and already trust, without asking them to accept anything new.

How AIShell-Gate Uses This Pattern

In an AIShell-Gate deployment, the AI agent is given an SSH key whose authorized_keys entry specifies aishell-gate as the forced command. When the AI connects, regardless of anything it requests, aishell-gate starts. The AI delivers its JSON action plan on standard input. aishell-gate evaluates it, collects any required human confirmation, and executes only what the policy engine has approved.

The AI has no interactive shell. It cannot override the forced command. It cannot request a different binary. It cannot see or influence what happens after its plan is delivered. And revoking its access — permanently or temporarily — means removing one line from authorized_keys.

Multiple AI agents: Granting a second AI agent access is adding another line to authorized_keys with its own key and its own forced command — which can point at a different preset, a different jail root, or a different audit log. Each agent's access is independently revocable. All of this lives in a flat text file visible to standard Unix audit tools.

03 Background: Command Injection

Command injection happens when a program passes untrusted input to a shell. The shell interprets metacharacters — ;, |, &&, >, $(), and others — as syntax, which means a carefully crafted input can embed additional commands into what was supposed to be a single operation. This is one of the oldest and most consistently exploited vulnerability classes in computing.

AI agents make this more dangerous than it was with human operators. A human typing at a terminal has accumulated pattern recognition — they hesitate before unusual characters in a filename. An AI generates commands fluently and with the same confidence regardless of whether the character combination is routine or catastrophic. Combined with prompt injection (where a document or web page the AI processes can embed instructions into its content), the attack surface is orders of magnitude larger than a human at a keyboard.

AIShell-Gate eliminates this entire vulnerability class by removing the shell from the execution path. Shell metacharacters are rejected before any policy rule is evaluated. Commands are tokenized into an argument array, and that array is passed directly to execve() — the kernel system call for starting a program — with no shell anywhere in between. Metacharacters that try to reach the target program arrive as literal characters in its argv[], with no special meaning. You cannot exploit a shell that is not there.

For readers new to this class of vulnerability: Appendix A covers command injection in depth — how it works, the SQL injection analogy, why AI agents amplify it, and how AIShell-Gate's architecture eliminates it by construction rather than by filtering.

04 Architecture

The Two-Binary Design

AIShell-Gate consists of two cooperating binaries with strictly separated responsibilities:

This separation is the central security property of the system. The executor cannot approve or deny a command; only the policy engine can, and it does so in a separate process.

Deployment Flow

AI runtime JSON action plan (via stdin) SSH transport Restricted account (ai-agent, /bin/false shell) Forced SSH command reads plan from stdin aishell-gate-exec ──── spawns ────► aishell-gate-policy JSON decision │◄─────────────────────────────── policy verdict (after human confirmation if required) execve() command runs
Note: The AI never interacts with a shell. Every command must pass through aishell-gate-policy before aishell-gate-exec will call execve().
The daemon-free property Most tools that broker remote command execution require a listening daemon on the target machine: a new service, a new port, a new attack surface, a new thing that can crash or be misconfigured. This design has none of that. The only process listening is sshd, which is already running, already audited, already monitored. When the AI is not active there is nothing extra running at all. The process model is fork-on-connection — which is exactly what sysadmins expect from SSH and exactly what makes it easy to reason about.

Security Layers

The design creates multiple independent security boundaries. Even if one layer fails, the others continue to protect the system.

LayerPurpose
AI runtimeDecision engine only — never touches the OS directly
SSH transportEncrypted connection; identity bound to a specific key
Restricted userDedicated account with no login shell and minimal permissions
Forced commandPrevents arbitrary shell access regardless of client request
aishell-gate-policyEvaluates each command against policy; returns allow or deny
execve()Direct kernel exec — no shell interpolation or PATH search

05 Installation

For remote deployment the binaries go on the machine that will execute commands. See the INSTALL file distributed with the software — path C (system-wide install) is the right choice for shared or remote hosts. Briefly: all five files (aishell-gate, aishell-gate-exec, aishell-gate-policy, aishell-confirm, aishell-gate-mcp.py) install to /usr/bin/, owned by root and mode 755.

Important: Neither binary may be setuid or setgid. aishell-gate-exec checks this at startup and refuses to run if either condition is true, logging the violation to the audit trail. Keep both binaries owned by root and not world-writable.
Per-host policy Because both binaries live on each target machine, policy lives where execution lives. There is no centralized policy server. This means policy changes require touching the config or the forced command on each host — which for a large fleet means doing it through whatever config management you already use (Ansible, Salt, Puppet, a deploy script). Whether that feels like a limitation or a feature tends to depend on your environment. In practice, the absence of a central policy service is also the absence of a central point of failure and a central target for policy tampering. Each host is self-contained and auditable in isolation.

06 SSH Configuration

Create a Dedicated AI User

Create an account whose sole purpose is to receive AI command plans via SSH:

useradd -r -s /bin/false ai-agent

This account has no interactive login shell and no home directory access. It exists only to receive the forced SSH command.

Access control the Unix way Revoking the AI's access is a single operation: remove the key from authorized_keys, or delete the file entirely. No service restart, no API call, no database entry. Key rotation follows the same workflow as any other SSH key. Granting access to a second AI agent is adding another key with its own forced command, which can point at a different preset or a different jail root. All of this lives in flat files under normal Unix permissions, visible to the usual audit tools, manageable by the usual automation.

Configure the Forced Command

Edit ~ai-agent/.ssh/authorized_keys and add the AI's public key with a forced command. The full minimal example:

command="/usr/bin/aishell-gate \
  --policy-preset ops_safe \
  --audit-log /var/log/aishell/audit.log",\
no-port-forwarding,no-agent-forwarding,no-X11-forwarding \
ssh-rsa AAAA...

Line by line:

With this in place, any SSH connection from the AI — regardless of what the client requests — will run aishell-gate with the specified options. The AI cannot bypass the gate.

During development: aishell-gate-exec can be called directly with an explicit --policy-binary path for testing. In production always use aishell-gate — it is the stable entry point for SSH forced commands and will remain so as the system evolves. Add --dry-run when testing any forced-command configuration; the full evaluation and confirmation flow runs without executing anything.

Flag Reference

The most commonly used flags for forced-command deployments:

FlagPurpose
--policy-preset <name>Named policy preset. Seven built-in presets:
read_only — inspection commands only, no writes
ops_safe — conservative read and repository commands (default)
dev_sandbox — developer workflow: git, make, compilers, package managers
ci_build — unattended build/test pipeline, all commands at confirm:none
ci_deploy — unattended deploy pipeline, adds container/k8s/Terraform
ci_admin — supervised admin with mixed confirmation levels
danger_zone — minimal restrictions, typed confirmation for most commands
--audit-log <file>Write executor audit log to this path
--jail-root <path>Restrict write-class commands to this directory tree
--eval-timeout <n>Policy engine evaluation timeout in seconds (default: 30)
--confirm-tty <path>Single-session interactive use only. Reads confirmation prompts from the given PTY device instead of /dev/tty. Not suitable for multi-session remote deployments — use --confirm-pipe instead. See § 11.
--confirm-pipe <base>Secure pipe-based confirmation relay. aishell-confirm creates two FIFOs (BASE.req, BASE.resp) owned by the operator; aishell-gate-exec exchanges JSON requests and plain-text responses through them. The operator sees full command context; ai-agent never opens any PTY. Use with --confirm-lock for multi-session safety. Default: /run/aishell-gate/confirm. See § 11.
--confirm-lock <path>Serialise concurrent sessions: only one may be in the confirmation phase at a time. Required when using --confirm-pipe with multiple AI agents. Default: /run/aishell-gate/confirm.lock. See § 11.
--dry-runEvaluate all actions and fire confirmation gates without executing anything. Useful for validating forced-command configurations.
--dry-run-jsonLike --dry-run but writes a machine-readable JSON document to stdout describing every action: decision, confirm level, resolved binary path, risk score, and reason. No execution. Useful for AI agents that inspect a plan before committing.
--verboseEmit diagnostic output to stderr

Less common policy engine flags that aishell-gate-exec does not recognise natively can be passed after a -- separator in the forced command:

command="/usr/bin/aishell-gate \
  --policy-preset ops_safe -- --mode batch"

07 JSON Plan Format

aishell-gate-exec reads a JSON plan from standard input. The plan describes the AI's goal and the list of actions to carry out. The only required field is actions.

{
  "protocol": {"name": "aishell-gate-exec-input", "version": "1.0"},
  "goal":    "check disk usage and list recent logs",
  "source":  "ai",
  "actions": [
    {"cmd": "df -h"},
    {"cmd": "ls -lt /var/log"}
  ]
}
FieldDescription
protocolOptional. Wire protocol version block: {"name": "aishell-gate-exec-input", "version": "1.0"}. The executor validates the name and rejects unknown major versions. Absent = treated as v1.0 (backward compatible). Recommended for new integrations.
goalHuman-readable description of intent. Included in the audit log. Optional but recommended.
sourceProvenance label for the request. Defaults to "ai" if omitted.
strategyExecution strategy: fail_fast (default) or best_effort. Controls whether a denied action stops the plan.
actionsArray of command objects. Each has a cmd field containing the complete command string. Maximum 32 actions per plan.

Commands are given as a single cmd string — arguments are not supplied separately. aishell-gate-policy tokenizes the command string and produces a validated argument vector. aishell-gate-exec passes that vector to execve() directly, without invoking a shell.

Why no shell injection is possible here The policy engine tokenizes the command string itself and hands the executor a validated argv array. That array goes straight to execve(). There is no shell anywhere in the execution path — not as an interpreter, not as a subprocess, not as an intermediate step. Shell metacharacters in a command string are inert: they reach the policy engine tokenizer as literal characters, and whatever command results from that tokenization is evaluated against policy on its own terms. A sysadmin reviewing this design does not have to reason about quoting edge cases, argument splitting, or whether some combination of inputs might slip a character past a shell. That entire attack surface is absent by construction.

08 Sending Plans

The AI sends its plan over SSH via standard input:

ssh ai-agent@server <<'EOF'
{
  "goal":    "check disk usage and list recent logs",
  "source":  "ai",
  "actions": [
    {"cmd": "df -h"},
    {"cmd": "ls -lt /var/log"}
  ]
}
EOF

The forced command defined in authorized_keys intercepts the connection. The AI's plan arrives on the stdin of aishell-gate-exec. Whatever the SSH client requested is ignored.

Note: Use a single-quoted heredoc delimiter ('EOF') to prevent the local shell from expanding variables or backslash sequences inside the JSON before it is sent.
Stateless by design Each plan submission is a complete, self-contained transaction: connect, deliver stdin, run, disconnect. There is no persistent session, no accumulated context, no long-lived process the AI can park itself in between invocations. If something goes wrong mid-plan the connection dies and nothing lingers. There is no state across connections for the AI to exploit or corrupt — each submission starts from zero. Sysadmins who have dealt with misbehaving long-running daemons will recognize this as a deliberate structural virtue.

09 Policy Decisions

For each action in the plan, aishell-gate-exec submits the command to aishell-gate-policy as a child process and reads back a structured JSON decision. Key fields in that decision:

FieldMeaning
decisionallow or deny
confirmRequired confirmation level: none, plan, action, or typed
layerPolicy layer that produced the decision
reasonHuman-readable explanation
risk.scoreInteger 0–100
risk.blast_radiussingle, tree, system, or unknown
argvValidated argument vector — passed to execve() on ALLOW decisions
suggestionsAllowed alternatives — present on DENY decisions only

If the decision is DENY, the action is refused and the reason is reported. If the decision is ALLOW, the executor proceeds according to the confirmation level.

Everything already in the toolbox Because execution happens as a normal process under the ai-agent account, the full range of standard Unix observability tools applies without adaptation. ps, top, lsof, and strace see honest process trees. Audit logging goes to a file in /var/log, and SSH logs to syslog — both feed existing log aggregation pipelines without requiring adapters or agents. There is nothing hidden behind a proprietary protocol, no opaque daemon to peer into, no special tooling needed to answer the question "what is the AI actually doing right now."

10 Confirmation Levels

Some commands require a human operator to confirm before execution. This is a policy decision — neither the AI nor the executor controls it. Confirmation for all actions is collected in a single pass before any command runs, so a mid-plan refusal cannot leave the system in a partially-executed state.

LevelBehaviour
noneProceed without any confirmation prompt.
planPrompt once before execution begins. Operator types y to proceed.
actionPrompt for each individual command. Operator types yes.
typedOperator must type a short challenge code derived from the exact command text. Used for high-risk operations where muscle memory alone should not suffice.

By default, aishell-gate-exec opens /dev/tty for confirmation prompts — the controlling terminal of the process, separate from stdin. This works correctly in any interactive session.

Non-interactive SSH sessions: In a forced-command SSH session there is no controlling terminal, so /dev/tty cannot be opened. If a confirmation fires and no terminal or pipe relay is available, the executor exits immediately with code 2 and a clear diagnostic. See § 11 — Remote Human Confirmation for how to keep the human in the loop.
The confirmation system is working when it fires A confirmation requirement surfacing during a remote session is the policy engine doing exactly what it was designed to do — flagging a command that a human should review before it runs. The right response is not to suppress it. Either choose a preset that produces confirm: none for the commands the AI will run, or wire up --confirm-pipe and aishell-confirm so a human can respond. Test the exact plan shapes the AI will submit against the exact preset you have configured before going to production. Any action showing a confirmation level other than none belongs either in a workflow where a human is present, or in a policy rule that explicitly reduces its requirement.

11 Remote Human Confirmation

When the AI operates over SSH and the policy engine requires a human confirmation, the question is: where does the prompt appear, and how does the operator answer it? The --confirm-pipe flag, combined with the aishell-confirm companion tool, provides the secure answer.

How it works

The operator opens a second SSH session to the remote host and runs aishell-confirm. That tool creates two named FIFOs — confirm.req and confirm.resp — owned by the operator with a shared group. When a confirmation fires, aishell-gate-exec writes a JSON request to confirm.req containing the full command context: command text, goal, source, risk score, blast radius, policy reason, and challenge code for typed confirmations. aishell-confirm reads it, displays everything on the operator's terminal, reads the operator's response, and writes it back through confirm.resp. The ai-agent account never opens any PTY device.

AI runtime (remote machine — no terminal) JSON plan via SSH stdin aishell-gate-exec ──── policy check ────► aishell-gate-policy │ confirm: action │◄────────────────────────────────────────────── writes JSON request → confirm.req FIFO aishell-confirm (operator's SSH session — owns the FIFOs) displays: cmd, goal, risk, reason, challenge operator terminal operator types response → confirm.resp FIFO aishell-gate-exec execve() command runs
Security property: ai-agent never opens any PTY device. The operator's Unix credentials — owning the FIFO — are what authorise the confirmation response. Nothing useful reaches the AI's stderr channel: the evaluation summary, challenge code, and operator response all flow through the FIFOs, not through the AI's SSH connection.

Step-by-step setup

Step 1 — One-time: create a shared group and the runtime directory.

The confirmation FIFOs must be accessible by both the operator and the ai-agent account. The cleanest model is a dedicated shared group:

# Create shared group and add both accounts
sudo groupadd aishell-gate
sudo usermod -aG aishell-gate operator
sudo usermod -aG aishell-gate ai-agent

# Create the runtime directory with the shared group and setgid bit
sudo mkdir -p /run/aishell-gate
sudo chown root:aishell-gate /run/aishell-gate
sudo chmod 2770 /run/aishell-gate   # setgid: new files inherit aishell-gate group

With this in place, aishell-confirm (running as the operator) creates FIFOs with mode 0660 and group aishell-gate. aishell-gate-exec (running as ai-agent, a member of aishell-gate) can open them for reading and writing. Neither account needs PTY group membership.

Step 2 — Make the directory survive reboots.

/run is a tmpfs memory filesystem on most Linux systems — it is created fresh on every boot. Without action, /run/aishell-gate/ disappears at shutdown. On the first AI connection after a reboot, aishell-gate-exec will fail to open the lock file, exit with code 5, and refuse all plans. Recreate the directory at boot using whichever mechanism your init system provides.

With tmpfiles.d (systemd):

sudo tee /etc/tmpfiles.d/aishell-gate.conf <<'EOF'
d /run/aishell-gate 2770 root aishell-gate -
EOF
sudo systemd-tmpfiles --create /etc/tmpfiles.d/aishell-gate.conf
ls -ld /run/aishell-gate   # verify: should show drwxrws--- root aishell-gate

With rc.local (sysvinit, OpenRC, runit, or any init that supports a boot-time script):

# Add these three lines to /etc/rc.local (or the equivalent), before sshd starts:
mkdir -p /run/aishell-gate
chown root:aishell-gate /run/aishell-gate
chmod 2770 /run/aishell-gate

Either approach recreates the directory at boot with the correct ownership and mode. Pick the one that matches your system.

Step 3 — Each session: operator arms the relay with aishell-confirm.

# Operator's own SSH session — keep this window open
ssh operator@remotehost
$ aishell-confirm
[aishell-confirm] Terminal:  /dev/pts/3
[aishell-confirm] Req  FIFO: /run/aishell-gate/confirm.req
[aishell-confirm] Resp FIFO: /run/aishell-gate/confirm.resp
[aishell-confirm] Status:    armed — waiting for confirmation requests
[aishell-confirm] Press Ctrl-C to disarm.

# Full confirmation requests will appear here as the AI submits plans

Step 4 — Add --confirm-pipe and --confirm-lock to the forced command in authorized_keys.

command="/usr/bin/aishell-gate \
  --policy-preset ops_safe \
  --confirm-pipe /run/aishell-gate/confirm \
  --confirm-lock /run/aishell-gate/confirm.lock \
  --audit-log /var/log/aishell/audit.log",\
no-port-forwarding,no-agent-forwarding,no-X11-forwarding \
ssh-rsa AAAA...

Step 5 — AI submits a plan that triggers a confirmation. The operator sees the full context on their terminal:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  AISHELL-GATE CONFIRMATION REQUEST
  Session:  a3f8c21d9e4b7012...
  Action:   0   Level: action
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Command:  git push origin main
  Goal:     deploy release v2.4.1
  Source:   ai
  Reason:   modifies remote branch; requires explicit approval
  Risk:     72/100   blast=system
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Approve? [yes/NO]

The operator types yes (or anything else to refuse). The AI's SSH session receives the result immediately.

For confirm: typed (high-risk commands), the challenge code is displayed here — on the operator's terminal only — and the operator must type it back exactly:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  AISHELL-GATE CONFIRMATION REQUEST
  Session:  a3f8c21d9e4b7012...
  Action:   1   Level: typed
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Command:  rm -rf /var/tmp/old-builds
  Goal:     clean build artifacts
  Source:   ai
  Reason:   recursive delete; high blast radius
  Risk:     91/100   blast=system

  ⚠  HIGH-RISK — typed confirmation required.
  Type the challenge code exactly to confirm:
  Challenge: 3k7mw2nx
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Type code: 

The challenge code is derived from the exact command text. It is sent only through the pipe to the operator's terminal — it never appears in the AI's SSH channel. aishell-gate-exec verifies the operator's typed response independently.

What happens when the relay is not armed

If aishell-confirm is not running when a confirmation fires, aishell-gate-exec will find no reader on the request FIFO and fail immediately with exit code 5:

[gate-exec] ERROR: confirmation request pipe '/run/aishell-gate/confirm.req' has no reader.
[gate-exec]   aishell-confirm is not running in an operator session.
[gate-exec]   Start it: ssh operator@host  then run: aishell-confirm
[gate-exec]   No commands have been executed.

The AI receives exit code 5, the audit log records the failure, and no commands run. The operator needs to start aishell-confirm before the AI tries again.

Multiple concurrent AI sessions

Multiple concurrent AI sessions share the same FIFO pair. The --confirm-lock flag serialises the confirmation phase: only one aishell-gate-exec session can write to confirm.req and read from confirm.resp at a time. Other sessions block on the lock until the current session finishes. The lock is released before any execve() call, so command execution across sessions still proceeds in parallel. Without the lock, concurrent sessions would interleave their JSON frames on the shared FIFOs, producing unparseable requests.

Important: When multiple AI agents may connect simultaneously, both --confirm-pipe and --confirm-lock are required together. Neither is needed in a single-session interactive deployment using /dev/tty.

Single-session interactive deployments

For a single operator running a single AI session on a machine they are logged into interactively, no special setup is required. aishell-gate-exec opens /dev/tty by default and prompts directly on the controlling terminal. --confirm-tty is available for the rare case where the operator wants to redirect prompts to a specific device. Neither --confirm-pipe nor --confirm-lock is needed in this scenario.

Fully unattended deployments If the AI genuinely needs to operate with no human in the loop, the right answer is a policy preset — such as read_only or a custom policy file — that produces confirm: none for every command the AI will run. The confirmation system should then never fire. A confirmation requirement appearing in a fully automated session is the policy engine correctly identifying a command that warrants human review. That is its job; the right response is to fix the policy or add a human, not to work around the system.

12 Complete Examples

Read-Only Monitoring

Policy preset read_only. No confirmation required for listing and reading operations.

# On the server — in ~ai-agent/.ssh/authorized_keys:
# command="/usr/bin/aishell-gate \
#   --policy-preset read_only \
#   --audit-log /var/log/aishell/audit.log"

# On the AI's machine — the plan is submitted over SSH:
ssh ai-agent@server <<'EOF'
{
  "goal":    "check system health",
  "source":  "monitoring-agent",
  "actions": [
    {"cmd": "uptime"},
    {"cmd": "df -h /"},
    {"cmd": "free -m"}
  ]
}
EOF

Operations Work with a Jail Root

Policy preset ops_safe. The --jail-root flag restricts write-class commands to the specified directory tree.

# On the server — in ~ai-agent/.ssh/authorized_keys:
# command="/usr/bin/aishell-gate \
#   --policy-preset ops_safe \
#   --jail-root /srv/deployments \
#   --audit-log /var/log/aishell/audit.log"

# On the AI's machine:
ssh ai-agent@server <<'EOF'
{
  "goal":     "deploy updated configuration",
  "source":   "deploy-agent",
  "strategy": "fail_fast",
  "actions":  [
    {"cmd": "cp /srv/deployments/staging/app.conf /srv/deployments/prod/app.conf"},
    {"cmd": "systemctl reload myapp"}
  ]
}
EOF

Development Sandbox

Policy preset dev_sandbox allows a broader set of operations within a workspace. Using best_effort strategy so that a test failure does not prevent subsequent steps from running.

# On the AI's machine (server-side forced command uses dev_sandbox preset):
ssh ai-agent@devserver <<'EOF'
{
  "goal":     "update dependencies and run tests",
  "source":   "ci-agent",
  "strategy": "best_effort",
  "actions":  [
    {"cmd": "git pull"},
    {"cmd": "npm install"},
    {"cmd": "npm test"}
  ]
}
EOF

13 CI/CD Worked Example — AI Writes, CI Validates, Gate Deploys

This section walks a complete multi-stage pipeline from end to end. The scenario is deliberately chosen to exercise every part of the plan model that gives people trouble: the AI must produce a configuration file, the CI system must validate it, and a deploy plan must run through the gate. Nothing here is artificial — this is the shape of a realistic production flow.

The purpose of working the example is not to teach CI/CD. It is to show where each piece of the work actually lives in the plan model, so that when you build your own pipeline you know which code belongs where.

The Scenario

A team maintains a small internal service. They want an AI agent to be able to propose configuration changes, have those changes validated by their existing CI, and have the deploy step run through AIShell-Gate on the production host. The three stages:

  1. AI writes — the AI generates an updated service.yaml and commits it to a feature branch in the project's git repo. This happens entirely outside the gate, on the AI's own workstation or scratch environment.
  2. CI validates — the team's existing CI (GitHub Actions, GitLab CI, Jenkins, anything) runs schema validation, unit tests, and a dry-run of the deploy against a staging cluster. Standard CI work, no gate involvement.
  3. Gate deploys — once CI passes and a human merges the PR, an orchestration script submits a plan through the MCP to the production host. The plan pulls the merged branch, runs the deploy, and checks health. The gate evaluates every action against the production policy.
Why three stages: Each stage uses a different trust model. Stage 1 is AI proposing; stage 2 is automated validation; stage 3 is gated execution. Collapsing any two of them removes a useful checkpoint. Keeping them separate is the pattern — the gate is not trying to replace CI, and CI is not trying to replace human review.

Stage 1 — AI Writes the Configuration

This stage does not touch AIShell-Gate at all. The AI is working in its own environment: a sandbox, a local checkout, a scratch container — wherever the AI normally does its work. It reads the current service.yaml, proposes the change, writes the new file, and commits.

# The AI runs in its own environment — no gate, no MCP.
# It uses whatever file-writing primitives it has available there:
# filesystem MCP, direct Python, sandbox tools, etc.

git checkout -b ai/bump-replicas-20260416
# ... AI edits service.yaml directly ...
git add service.yaml
git commit -m "bump replicas 3 -> 5 for peak traffic"
git push origin ai/bump-replicas-20260416
Why this stage lives outside the gate The gate's job is to govern execution on the target system. The AI's scratch environment is not the target system. Routing AI file-writes through the gate when those files are being written to the AI's own disk would be theatrical — it would not prevent anything, because the AI can already do whatever it wants in its own sandbox. The gate activates at the boundary where AI-proposed actions reach infrastructure that matters.

Stage 2 — CI Validates

The push triggers CI. This is standard pipeline work: the kind of thing you already have. No AIShell-Gate involvement, because CI runs on runners that are themselves sandboxed and the CI engine has its own execution model.

# .github/workflows/validate.yml (example — adapt to your CI)
name: validate service config
on: pull_request:
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: schema check
        run: yq eval service.yaml | kubectl apply --dry-run=client -f -
      - name: unit tests
        run: make test
      - name: staging dry-run
        run: ./scripts/stage-dryrun.sh service.yaml

If CI fails, the PR is blocked. The AI sees the failure, can propose a fix, and stage 1 repeats. If CI passes, a human reviews the PR and merges. The deploy does not start automatically — the merge is the trigger for stage 3, which is initiated by an operator or a merge-triggered webhook calling the orchestration script.

Stage 3 — Gate Deploys

This is where the plan model comes in. The orchestration script runs on a machine with network access to the production host. It submits plans to the production host's aishell-gate-mcp server. Every command that touches production flows through the gate.

There are three plans in this stage, submitted in sequence. Plan 1 pulls the merged code. Plan 2 runs the deploy. Plan 3 checks health. Each plan is evaluated in full, executed or rejected atomically, and separated by a result check in the orchestration code.

Plan 1 — Pull the Merged Code

{
  "protocol": {"name": "aishell-gate-exec-input", "version": "1.0"},
  "goal":     "pull merged config changes for service deploy",
  "strategy": "fail_fast",
  "actions": [
    {"cmd": "git -C /opt/service fetch origin main"},
    {"cmd": "git -C /opt/service checkout main"},
    {"cmd": "git -C /opt/service pull --ff-only"}
  ]
}

Policy evaluation for this plan: under a reasonable production policy, all three actions are ALLOW at confirm: none or confirm: plan. The executor runs them in sequence via execve(). Audit records are written for each. If any action exits non-zero, fail_fast stops the plan and the orchestration code reads the failure and does not proceed to Plan 2.

Plan 2 — Apply the Deploy

{
  "protocol": {"name": "aishell-gate-exec-input", "version": "1.0"},
  "goal":     "apply updated service config to production",
  "strategy": "fail_fast",
  "actions": [
    {"cmd": "kubectl apply -f /opt/service/service.yaml"},
    {"cmd": "kubectl rollout status deployment/service --timeout=120s"}
  ]
}

This is the interesting plan. kubectl apply is a production-affecting action and will typically sit at confirm: plan under a ci_deploy preset — not because the command is denied, but because the policy requires the operator to see what the plan contains before it runs. The orchestration script submitting the plan must be in an environment where that confirmation can be collected (interactive operator, or a lowered confirm level in the production policy file for known-safe plan shapes).

The confirmation model, concretely: In a real production setup, teams frequently run stage 3 in one of two modes. In attended deploys, the orchestration script runs at an operator's terminal — the human sees the plan, confirms, and stays through completion. In automated deploys, the production policy file grants confirm: none to specific command patterns that have already passed CI validation (the kubectl apply against a known manifest path, for example), and the plan runs headless. Which you choose is a policy decision, not a technical one.

Plan 3 — Post-Deploy Health Check

{
  "protocol": {"name": "aishell-gate-exec-input", "version": "1.0"},
  "goal":     "verify service is healthy after deploy",
  "strategy": "best_effort",
  "actions": [
    {"cmd": "kubectl get pods -l app=service"},
    {"cmd": "kubectl get deployment/service -o yaml"},
    {"cmd": "curl -sS -o /dev/null -w %{http_code} http://service.internal/healthz"}
  ]
}

Plan 3 is read-only — all three actions are ALLOW at confirm: none under any reasonable policy. strategy: best_effort means all three run even if one fails, so the operator gets the full picture.

Why three plans, not one A first instinct is to put all seven actions in a single plan. The plan model does not forbid this, and it would work. But splitting into three plans makes the orchestration code explicit about intent at each boundary: "I have pulled the code, and I have verified the pull succeeded, and now I am making the production change." That explicitness is the point. A single flat plan with seven actions hides those boundaries. Three separate plans make them structural — and make each boundary a place where the orchestration code can decide to stop, retry, alert, or escalate.

The Orchestration Script

This is the glue that ties the three plans together. It lives outside the gate — on the CI runner, the operator's workstation, or a dedicated deploy server — and it is where all the branching logic lives that the plan model deliberately excludes from plans.

#!/usr/bin/env python3
# deploy-orchestrator.py — submits three plans through AIShell-Gate MCP.
# This script is the composition layer. The plans themselves are flat.

import json, subprocess, sys

def submit_plan(goal, actions, strategy="fail_fast"):
    plan = {
        "protocol": {"name": "aishell-gate-exec-input", "version": "1.0"},
        "goal": goal,
        "strategy": strategy,
        "actions": [{"cmd": c} for c in actions],
    }
    result = subprocess.run(
        ["ssh", "deploy@prod.example.com", "aishell-gate-exec",
         "--policy-preset", "ci_deploy"],
        input=json.dumps(plan), capture_output=True, text=True,
    )
    return result.returncode, result.stdout, result.stderr

# Plan 1 — pull the merged code. Stop if this fails.
rc, out, err = submit_plan(
    "pull merged config changes for service deploy",
    ["git -C /opt/service fetch origin main",
     "git -C /opt/service checkout main",
     "git -C /opt/service pull --ff-only"],
)
if rc != 0:
    print(f"pull failed: {err}", file=sys.stderr); sys.exit(1)

# Plan 2 — apply. Stop if this fails.
rc, out, err = submit_plan(
    "apply updated service config to production",
    ["kubectl apply -f /opt/service/service.yaml",
     "kubectl rollout status deployment/service --timeout=120s"],
)
if rc != 0:
    print(f"apply failed: {err}", file=sys.stderr); sys.exit(2)

# Plan 3 — health check. best_effort so all three actions run.
rc, out, err = submit_plan(
    "verify service is healthy after deploy",
    ["kubectl get pods -l app=service",
     "kubectl get deployment/service -o yaml",
     "curl -sS -o /dev/null -w %{http_code} http://service.internal/healthz"],
    strategy="best_effort",
)
print(out)

The Python script contains all the branching. The plans contain none. That is the division of labour the model asks for.

What Each Stage Contributes

Looking back at the three stages with the plan model now in hand, the role each one plays becomes explicit:

StageContributesCannot replace
1 — AI writesProposed change, expressed in version control where it can be reviewedAutomated correctness checks, human approval, production execution
2 — CI validatesAutomated schema, test, and dry-run verification before anything reaches prodPolicy-gated execution on the production host — CI is not running there
3 — Gate deploysPolicy-gated, audit-logged, confirmation-checked execution on productionAI creativity, automated validation — the gate is an execution layer, not a design layer

No stage is redundant with another. No stage is trying to be something it is not. The AI stage is a proposer. The CI stage is a validator. The gate stage is an executor. The plan model works because it refuses to blur those boundaries — it declines to be a validator (that is CI's job), it declines to be a creative surface (that is the AI's job), and it does its one thing well: gate the moment when AI-proposed actions reach the real system.

Three takeaways to carry forward First, the gate is not the entire pipeline — it is one specific stage in a larger flow, and trying to do everything inside the gate breaks both the gate and the flow. Second, branching logic lives in orchestration code, not in plans. Third, the AI does its file-writing work in its own environment, where the gate has nothing to prove; the gate engages at the production boundary. Get those three things right and the plan model stops feeling restrictive and starts feeling structural.

14 Security Considerations

Never Expose a Shell

The ai-agent account has /bin/false as its shell and the authorized_keys entry forces aishell-gate for every connection. There is no path through which the AI can obtain an interactive shell.

Environment Variables

SSH may pass environment variables into the session. aishell-gate-exec sanitizes its execution environment and does not pass the ambient environment to child processes. Use no-user-rc and no-agent-forwarding in authorized_keys as additional precautions.

Binary Integrity

aishell-gate-exec checks at startup that neither it nor the aishell-gate-policy binary is setuid or setgid. If either check fails, execution halts immediately and the violation is written to the audit trail before exit. Keep both binaries owned by root and not world-writable.

No Shell Execution

aishell-gate-policy tokenizes the command string itself and returns a validated argv array. aishell-gate-exec passes that array to execve() directly. No shell is invoked at any point. Shell metacharacters in a command string are inert — any command that tries to exploit them will be evaluated against policy on its literal terms.

Least Privilege

The ai-agent account should have the minimum permissions required for the AI's work: access to specific paths, no sudo, no writable home directory, no membership in privileged groups.

Network Default-Deny

The ops_safe, read_only, and dev_sandbox presets enforce a network default-deny model: any command with a detected network target — a URL, host:port, or parseable address in its arguments — is denied unless an explicit net_rules allow entry matches. This mirrors the command policy model. CI presets (ci_build, ci_deploy, ci_admin) disable this for build pipeline access to registries. Configure it per-project with "net_default_deny": false in a policy override file.

Audit Logging

Specify --audit-log in the forced command so every plan submission is recorded. For multi-session deployments, configure a persistent HMAC key — either via the AISHELL_AUDIT_KEY environment variable or by placing a key file at /etc/aishell/audit.key — so audit chains can be verified across sessions and restarts. Without a persistent key, each session generates an ephemeral key that is discarded on exit, and post-hoc verification is not possible; the executor emits a stderr warning containing the word ephemeral in that case.

The exec and policy engines write separate audit logs in different internal formats. Never point both programs at the same log file. For the full treatment of log formats, key file conventions, and verification commands, see the Getting Started Guide §13 "Enabling Audit Logging."

Concurrent AI Sessions

Allowing multiple AI agents to connect simultaneously requires two mitigations: the --confirm-pipe / --confirm-lock combination described in § 11, and a persistent HMAC key for audit chain integrity. Neither is required in a single-session deployment using /dev/tty, but both are required the moment a second AI agent can reach the host. The pipe design additionally eliminates the PTY access problem: ai-agent never needs tty group membership, and the operator sees full command context before responding to every confirmation request.

Why this earns trust The design earns sysadmin trust not by asking them to trust a new framework, but by not asking them to trust anything new at all. The security primitives — SSH keys, restricted accounts, forced commands, execve(), file permissions — are primitives they have already accepted and already operate. AIShell adds policy enforcement and a structured JSON channel in the middle of a pattern they already know. No new daemons, no new ports, no new firewall rules, no new monitoring agents, no new log formats to parse. The existing SSH logs and the aishell log file feed directly into whatever log aggregation is already running. Operators who need to understand what happened reach for the same tools they always reach for.

15 Exit Codes

aishell-gate-exec returns a precise exit code so the AI can distinguish between types of failure:

CodeMeaning
0All actions allowed, confirmed, and executed successfully
1One or more actions denied by policy
2Human confirmation refused, or no terminal available to present the confirmation prompt (see § 11)
3Policy engine process error (subprocess could not be started, or returned no output)
4JSON parse error in the input plan or in the policy engine response
5Usage or argument error, or startup security check failed
6execve() failure after a confirmed ALLOW decision

16 Conceptual Model

Traditional shells are designed to give a human direct, unmediated access to the operating system:

human  →  shell  →  operating system

AIShell-Gate inserts a deterministic policy gate between AI intent and OS execution:

AI  →  aishell-gate-exec  →  aishell-gate-policy  →  execve()  →  operating system

For AI coding environments (Claude Code, Cursor), aishell-gate-mcp adds a fourth path via the MCP protocol:

AI (Claude Code)  →  aishell-gate-mcp  →  aishell-gate-exec  →  aishell-gate-policy  →  execve()

The MCP server wraps the same executor and policy engine — the same policy rules, the same confirmation model, and the same audit chain apply. The difference is the delivery mechanism: tool calls rather than SSH stdin. See the Using MCP guide for configuration.

Instead of granting raw shell access, the system enforces policy, validates intent, and executes only what the policy engine has explicitly approved. The AI never touches a shell. The executor never makes a policy decision. The gate never bypasses itself.

Because each SSH connection is independent — connect, submit plan, disconnect — the system is stateless and predictable. There is no persistent channel for accumulated state or privilege to leak through.

The Unix answer If you sat down and thought carefully about how to give an AI controlled access to a Unix system in a way that would make a experienced sysadmin comfortable, you would probably arrive at something very close to this. You would reach for SSH because it is already there and already trusted. You would use a forced command because that is what forced commands are for. You would create a dedicated account because that is how you scope identity. You would call execve() directly because you know what shells do to arguments. The result is not a clever new thing — it is the application of well-understood primitives to a new problem. That is exactly the right kind of design.

17 Constrained Account Hardening

The deployment described in this guide — a dedicated ai-agent account, forced command, restricted permissions — is already a strong baseline. This section describes an additional hardening step that converts AIShell-Gate from a policy gate into a hard wall: configuring the ai-agent account so that aishell-gate-exec is the only binary the account can reach.

The difference is structural. In the baseline deployment, AIShell-Gate governs what the AI may execute. In the constrained account model, the AI has no path to any binary other than aishell-gate-exec. Policy governs what is permitted within that universe. The OS enforces what is reachable. They are independent layers and both must be defeated for anything to go wrong.

Why this matters With a standard account, the AI agent cannot bypass AIShell-Gate — but the account can still see /bin, /usr/bin, and everything else in the system binary directories. The policy engine stops commands that reach it. It cannot stop commands that never do. A constrained account removes that distinction entirely: there is nothing else to reach. When someone looks at this configuration they will not say "the policy prevents misuse." They will say "there is nothing to misuse."

Directory Layout

Create a controlled binary directory owned by root and not writable by the ai-agent account. Place only the binaries the policy allows into it:

# Create the gate binaries directory — owned by root, not writable by ai-agent
mkdir -p /home/ai-agent/bin
chown root:root /home/ai-agent/bin
chmod 755 /home/ai-agent/bin

# Place AIShell-Gate binaries — owned by root, executable by ai-agent
cp /opt/aishell/aishell-gate-exec    /home/ai-agent/bin/
cp /opt/aishell/aishell-gate-policy  /home/ai-agent/bin/
cp /opt/aishell/aishell-gate          /home/ai-agent/bin/
chown root:root /home/ai-agent/bin/*
chmod 755 /home/ai-agent/bin/*

# Create the allowed-bin directory for commands the AI may execute
mkdir -p /home/ai-agent/allowed-bin
chown root:root /home/ai-agent/allowed-bin
chmod 755 /home/ai-agent/allowed-bin

# Symlink exactly the binaries the policy permits — nothing more
ln -s /usr/bin/git     /home/ai-agent/allowed-bin/git
ln -s /usr/bin/npm     /home/ai-agent/allowed-bin/npm
ln -s /usr/bin/python3 /home/ai-agent/allowed-bin/python3
# Add only what your policy explicitly permits

# Policy and working directories
mkdir -p /home/ai-agent/policy
mkdir -p /home/ai-agent/work
chown root:ai-agent   /home/ai-agent/policy
chown ai-agent:ai-agent /home/ai-agent/work
chmod 750 /home/ai-agent/policy
chmod 750 /home/ai-agent/work

Removing PATH Access

Set the ai-agent account's shell to /bin/false (already done for the SSH forced command model) and strip its effective PATH entirely by setting it to empty in the SSH environment file or in the forced command wrapper. The account should have no path to /bin, /usr/bin, or any standard binary directory.

# In /home/ai-agent/.ssh/environment (requires PermitUserEnvironment yes in sshd_config)
PATH=

With PATH empty, the only binaries the account can reach are those referenced by absolute path. The forced command uses an absolute path to aishell-gate, which uses an absolute path to aishell-gate-exec. The chain is explicit from the first byte.

The --safe-path Flag

Pass --safe-path to aishell-gate-exec pointing at the allowed-bin directory. This tells the executor to search only that directory when resolving command names to absolute paths, and to set PATH= in each child process's environment to the same list. The compile-time default path (/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin) is not used as a fallback — --safe-path replaces it entirely.

# In authorized_keys forced command:
command="/home/ai-agent/bin/aishell-gate \
  --policy-preset ops_safe \
  --safe-path /home/ai-agent/allowed-bin \
  --audit-log /var/log/aishell/audit.log \
  --confirm-pipe /run/aishell-gate/confirm",no-pty,no-user-rc,no-agent-forwarding \
  ssh-ed25519 AAAA... ai-agent-key

Commands in JSON plans may still be specified as absolute paths (e.g. /usr/bin/git). The executor accepts absolute paths directly — they bypass the --safe-path search and are verified executable before use. The policy engine correctly identifies absolute-path commands by their basename for risk scoring and flag catalog assessment.

What the Three Layers Enforce

LayerWhat it enforcesWho enforces it
Unix accountNo PATH, no access to standard binary directories, no writable system pathsOperating system
--safe-pathOnly binaries in allowed-bin are reachable by name; child processes inherit the same restricted PATHaishell-gate-exec
PolicyWhat within the reachable set is actually permitted for this session, at what confirmation levelaishell-gate-policy

Each layer is independent. A policy misconfiguration cannot widen what the OS permits. A path misconfiguration cannot widen what policy permits. Both must be defeated simultaneously for an action outside the intended scope to execute.

18 Pattern A2: Pipeline Script Reference

This section covers the reference pipeline script (aishell-pipe.sh) for users calling any AI model via a script rather than through an MCP-compatible AI coding environment. The script is included in the beta tarball.

The gate reads a JSON plan on standard input. The pipeline script handles the other side: it calls the model, receives a plan, and delivers it. The gate does not know or care what produced the plan — it evaluates what arrives on stdin and either executes or denies.

Not your path? If you are using Claude Code or Cursor, use the MCP server instead — see the Using the MCP reference. If you are deploying a remote AI agent over SSH, the plan delivery pattern is covered in §08 Sending Plans above. This section is specifically for the scripted pipeline pattern.

The script

#!/usr/bin/env bash
# aishell-pipe.sh — Pipe AI-generated JSON plans through AIShell-Gate
#
# Usage:
#   ./aishell-pipe.sh "check disk usage and list recent logs"
#   AISHELL_PRESET=dev_sandbox ./aishell-pipe.sh "run the test suite"
#   ./aishell-pipe.sh --dry-run "clean build artifacts"
#
# Environment:
#   AISHELL_PRESET      Policy preset (default: ops_safe)
#   AISHELL_MODEL       Ollama model name (default: mistral)
#   AISHELL_GATE_EXEC   Path to aishell-gate-exec (default: ./aishell-gate-exec)
#   AISHELL_GATE_POLICY Path to aishell-gate-policy (default: ./aishell-gate-policy)
#   AISHELL_AUDIT_LOG   Audit log path (default: aishell-audit.jsonl)
#   AISHELL_SAFE_PATH   Colon-separated safe path override (default: unset)
#
# Copyright 2026 AIShell Labs LLC. All Rights Reserved.

set -euo pipefail

PRESET="${AISHELL_PRESET:-ops_safe}"
MODEL="${AISHELL_MODEL:-mistral}"
GATE_EXEC="${AISHELL_GATE_EXEC:-./aishell-gate-exec}"
GATE_POLICY="${AISHELL_GATE_POLICY:-./aishell-gate-policy}"
AUDIT_LOG="${AISHELL_AUDIT_LOG:-aishell-audit.jsonl}"
SAFE_PATH="${AISHELL_SAFE_PATH:-}"

DRY_RUN=""
GOAL=""

for arg in "$@"; do
    case "$arg" in
        --dry-run) DRY_RUN="--dry-run" ;;
        *)         GOAL="$arg" ;;
    esac
done

if [[ -z "$GOAL" ]]; then
    echo "Usage: $0 [--dry-run] \"goal description\"" >&2
    exit 1
fi

if [[ ! -x "$GATE_EXEC" ]]; then
    echo "Error: aishell-gate-exec not found at '$GATE_EXEC'" >&2
    echo "Set AISHELL_GATE_EXEC or run from the AIShell-Gate directory." >&2
    exit 1
fi

if [[ ! -x "$GATE_POLICY" ]]; then
    echo "Error: aishell-gate-policy not found at '$GATE_POLICY'" >&2
    echo "Set AISHELL_GATE_POLICY or run from the AIShell-Gate directory." >&2
    exit 1
fi

if ! command -v ollama &>/dev/null; then
    echo "Error: ollama not found in PATH." >&2
    echo "Install from https://ollama.ai or set AISHELL_MODEL to an available model." >&2
    exit 1
fi

SYSTEM_PROMPT='You are a Unix operations assistant. When given a goal, respond
with ONLY a valid JSON object — no explanation, no markdown, no code fences.

The JSON must follow this exact format:
{
  "goal": "the goal as stated",
  "source": "ai",
  "actions": [
    {"type": "command", "cmd": "command here"},
    {"type": "command", "cmd": "another command"}
  ]
}

Rules:
- Use only simple Unix commands. No shell pipelines, no semicolons, no redirects.
- One command per action entry.
- Commands must be safe, targeted, and reversible where possible.
- Do not include any text outside the JSON object.'

echo "[aishell-pipe] Generating plan for: $GOAL" >&2
echo "[aishell-pipe] Model: $MODEL  Preset: $PRESET" >&2
[[ -n "$DRY_RUN" ]] && echo "[aishell-pipe] Mode: dry-run (no execution)" >&2

PLAN=$(ollama run "$MODEL" "$SYSTEM_PROMPT

Goal: $GOAL")

if [[ -z "$PLAN" ]]; then
    echo "Error: model returned empty response." >&2
    exit 1
fi

echo "[aishell-pipe] Plan received — submitting to AIShell-Gate" >&2

GATE_ARGS=(
    --policy-binary "$GATE_POLICY"
    --policy-preset "$PRESET"
    --audit-log     "$AUDIT_LOG"
)
[[ -n "$DRY_RUN"   ]] && GATE_ARGS+=(--dry-run)
[[ -n "$SAFE_PATH" ]] && GATE_ARGS+=(--safe-path "$SAFE_PATH")

echo "$PLAN" | "$GATE_EXEC" "${GATE_ARGS[@]}"
EXIT=$?

echo "" >&2
case $EXIT in
    0) echo "[aishell-pipe] All actions completed." >&2 ;;
    1) echo "[aishell-pipe] One or more actions denied by policy." >&2 ;;
    2) echo "[aishell-pipe] Confirmation refused or unavailable." >&2 ;;
    3) echo "[aishell-pipe] Policy engine error." >&2 ;;
    4) echo "[aishell-pipe] JSON parse error in plan or policy response." >&2 ;;
    5) echo "[aishell-pipe] Argument or startup error." >&2 ;;
    6) echo "[aishell-pipe] execve() failure after ALLOW decision." >&2 ;;
    *) echo "[aishell-pipe] Unexpected exit code: $EXIT" >&2 ;;
esac

exit $EXIT

Adapting to other inference backends

Replace the ollama run call with any command that reads a prompt and writes a JSON response to stdout. The system prompt instructs the model to return only a JSON object with no surrounding text. If your model wraps output in markdown code fences, strip them before piping to the gate:

sed 's/^```json//;s/^```//'

The gate validates the JSON itself — a malformed plan produces a clear parse error rather than unexpected behaviour. The pattern works with ollama (as shown), any OpenAI-compatible endpoint via curl and jq, llama.cpp server, or any command-line tool that writes valid JSON to stdout.

Why the gate accepts any backend The executor does not know or care what produced the JSON plan on its stdin. Anything that can produce a valid plan on stdout is a valid plan source — a hand-written file, a shell variable, an AI model, a CI system, a test harness. The gate is the boundary; what is on the other side is your choice.

19 Appendix A — Command Injection in Depth

Section 03 gives the short version: command injection happens when untrusted input reaches a shell, AI amplifies the risk, and AIShell-Gate eliminates the vulnerability class by removing the shell from the execution path. This appendix is the longer treatment for readers who want the mechanics, the historical analogue (SQL injection), and the specific properties of AI that make the problem worse.

What a Shell Does

A Unix shell is a command interpreter. When you type a command, the shell does not simply run it. It first processes the string you typed, expanding variables, resolving globs, splitting on whitespace, and interpreting special characters called metacharacters. Only after this processing does it invoke the underlying program.

The metacharacters that matter for injection are:

CharacterWhat the shell does with it
;Command separator — run this, then run the next command
|Pipe — feed the output of this command as input to the next
&&Conditional — run the next command only if this one succeeded
||Conditional — run the next command only if this one failed
> >>Output redirection — write output to a file instead of the terminal
<Input redirection — read input from a file instead of the terminal
` ` and $()Command substitution — run this and use its output as an argument
${}Variable expansion — substitute the value of a variable
" ' Quoting — alter how the shell interprets what is inside

These characters are what make the shell powerful. They are also what make it dangerous when untrusted input reaches it.

How Injection Works

Command injection happens when a program accepts input from an untrusted source and passes that input to a shell for evaluation. The shell cannot tell the difference between the program's intended command and instructions embedded in the input by an attacker. It evaluates everything.

A simple example: imagine a program that accepts a filename and runs ls on it. The developer writes something like:

// Pseudocode — vulnerable pattern
filename = get_user_input()
system("ls -la " + filename)

The developer intends the user to provide a path like /tmp/myfile. But if the user provides /tmp/myfile; rm -rf ~, the shell receives:

ls -la /tmp/myfile; rm -rf ~

The semicolon is a command separator. The shell runs ls on the file, then runs rm -rf ~. The program executed one command. The shell executed two.

The injection does not require an attacker with shell access. It requires only that untrusted input reach a shell. Web applications that call shell commands with user-provided parameters, log parsers that evaluate filenames, deployment scripts that accept branch names — any of these can be exploited if the input is not sanitised before reaching the shell.

SQL Injection — the Same Problem, Different Domain

SQL injection is the same class of vulnerability applied to databases and is more widely known because of its prevalence in web applications. A login form that constructs a SQL query by string concatenation — "SELECT * FROM users WHERE username='" + input + "'" — can be exploited by entering ' OR 1=1 -- as the username. The quote closes the string literal; the rest is interpreted as SQL. The query returns all users.

The fix for SQL injection is parameterised queries: the query structure is fixed at compile time, and user input is passed as a separate data argument that the database driver handles safely. The input is never interpreted as SQL syntax.

The fix for shell injection is the same idea: never pass user input to a shell. Instead, tokenise the input into a structured argument array and pass that array directly to the operating system's execve() call. The argument array bypasses the shell entirely. Metacharacters in the input are inert — they reach the program as literal characters, not as shell instructions.

Why AI Agents Make This Significantly More Dangerous

Human operators who type shell commands have a natural metacharacter filter built in: they know what a pipe looks like and they know not to type one in a filename. Years of accumulated pattern recognition produces an instinctive hesitation before unusual characters. This is not perfect — tired operators make mistakes — but it is a real mitigation.

AI agents have no such filter. Several properties of AI systems combine to make injection more dangerous in AI-assisted contexts:

The structural gap The gap is not that AI agents are malicious. It is that they are probabilistic operating in a deterministic environment. A command that is usually correct can be wrong in a specific context, and "usually correct" is not good enough when execution is irreversible. The policy layer that catches the exceptions is not a criticism of AI capability — it is the same quality control that engineers apply to every other source of instructions that reaches production infrastructure.

How AIShell-Gate Addresses This

AIShell-Gate eliminates shell injection at the architectural level — not by filtering, but by removing the shell from the execution path entirely.

Metacharacter rejection. Before any evaluation begins, every proposed command string passes through a check for shell metacharacters. Any command containing a pipe, semicolon, ampersand, redirection, backtick, quote, or shell expansion syntax is denied outright — before tokenisation, before policy evaluation, before anything else. The rejection is total and unconditional.

Tokenisation into argv[]. The clean command string is tokenised into a structured argument array: ["git", "pull", "--rebase"], not "git pull --rebase". The structure is fixed at this point. No subsequent processing can reinterpret any part of it.

Policy evaluation against argv[]. The argument array — not the original string — is what the policy engine evaluates. Rules match against the command name and individual arguments. A rule that allows git can distinguish between git status and git push --force at the argument level.

Direct execve(). The validated argv[] array passes directly to the operating system's execve() call. No shell is invoked. The kernel loads the specified binary with the specified arguments. Metacharacters that made it through the first step (none) would arrive at the target program as literal characters in its argv[], with no special meaning.

The result is that the entire class of shell injection vulnerabilities — every metacharacter combination, every quoting edge case, every variable expansion trick — is eliminated by construction, not by enumeration. You cannot exploit a shell that is not there.

What this means for policy authors: When you write a policy rule matching git, you are matching against the first element of the tokenised argv[] array. You are not writing a string pattern that could be confused by shell quoting. The argument that reaches policy evaluation is the argument that would reach the binary. There is no gap between what the policy sees and what execve() receives.