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 · info@aishellgate.com
--dry-run:
the full policy evaluation and confirmation flow runs without executing anything../aishell-gate --policy-preset ops_safe --dry-runThis 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.
none needs somewhere to go — and the only supported answer for a headless session is relay confirmation mode (--confirm-pipe, --confirm-lock, and the aishell-gate-confirm companion tool), which is available in both editions. See §09. What does require Enterprise here are the ci_build, ci_deploy, ci_admin, and danger_zone presets used in several examples below, plus base/project policy layers and the HMAC audit chain. A relay-based deployment — including one where confirmations fire — works in Standard; reach for Enterprise when you need those specific features.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.
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.
If you are using Claude Code or Cursor, this is the shortest path. The MCP server — aishell-gate-mcp, 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.
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.
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.
A reference pipeline script for this pattern (aishell-gate-pipe.sh) is provided in §16 Pattern A2: Pipeline Script Reference below, along with instructions for adapting it to different inference backends.
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.
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.
| Property | A1 — MCP | A2 — HTTPS out | B — SSH in |
|---|---|---|---|
| Gate location | Local | Local | Target host |
| AI delivers plan via | MCP tool call (stdio) | stdin pipe from script | SSH stdin |
| Network requirement | None — all local | Outbound HTTPS to model endpoint | Inbound SSH on target host |
| New daemon required | No | No | No — uses existing sshd |
| Best for | Claude Code / Cursor users | Calling any model via script, without an MCP-compatible environment | Remote AI agent on a target machine |
| Access revocation | Remove from .mcp.json | Revoke the API key | Remove one line from authorized_keys |
| Reference documentation | Using the MCP | §16 of this guide | This guide |
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.For remote deployment the binaries go on the machine that will execute commands. System-wide install is the right choice for shared or remote hosts:
sudo install -m 755 aishell-gate-policy /usr/local/bin/
sudo install -m 755 aishell-gate-exec /usr/local/bin/
sudo install -m 755 aishell-gate /usr/local/bin/
sudo install -m 755 aishell-gate-confirm /usr/local/bin/
sudo install -m 755 aishell-gate-mcp /usr/local/bin/
All five files install to /usr/local/bin/, owned by root and mode 755 — this is what aishell-gate-install.sh does, and it is the path the forced-command examples in this guide use. Confirm with command -v aishell-gate and use whatever it reports in authorized_keys.
root, never by the ai-agent account. An account that can write to the gate binaries can replace its own policy engine, which would defeat the entire mechanism. Mode 755 gives ai-agent the execute permission it needs and nothing more.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.The rest of this section explains each step and why it is the way it is. If you would rather work from a single list, this is the whole of it. Replace ai-agent with your account name and operator with your own login throughout.
# ---- 1. On the server, as root: create the account -------------------
sudo useradd --system --shell /bin/sh --create-home ai-agent
sudo passwd -l ai-agent # no password login (see below)
# ---- 2. On the AI's machine: generate a key --------------------------
ssh-keygen -t ed25519 -C "ai-agent" -f ~/.ssh/ai_agent_key
cat ~/.ssh/ai_agent_key.pub # copy this whole line
# ---- 3. On the server, as root: prepare the destination --------------
sudo mkdir -p /home/ai-agent/.ssh
sudo chmod 700 /home/ai-agent/.ssh
sudo touch /home/ai-agent/.ssh/authorized_keys
sudo chmod 600 /home/ai-agent/.ssh/authorized_keys
sudo chown -R ai-agent:ai-agent /home/ai-agent/.ssh
# ---- 4. Create this account's own audit log --------------------------
# One log per gate account. A shared path fails as soon as a second
# account uses it: the file belongs to whoever created it, the next
# account cannot open it for writing, and the gate exits rather than
# run unaudited.
sudo touch /var/log/aishell-ai-agent.log
sudo chown ai-agent:ai-agent /var/log/aishell-ai-agent.log
sudo chmod 600 /var/log/aishell-ai-agent.log
# ---- 5. Write authorized_keys ----------------------------------------
# See the paste-ready file below. ONE PHYSICAL LINE per key.
sudo -e /home/ai-agent/.ssh/authorized_keys
# ---- 6. Verify -------------------------------------------------------
command -v aishell-gate # must match the path in command=
sudo awk '{print NR": "substr($0,1,60)}' /home/ai-agent/.ssh/authorized_keys
echo '{"goal":"smoke test","actions":[{"cmd":"ls"}]}' \
| ssh -i ~/.ssh/ai_agent_key ai-agent@server
authorized_keys — continue to §09 once the smoke test above passes. Get this working first; it is much easier to diagnose one layer at a time.Everything in this section assumes an SSH server is installed, running, and reachable on the target machine. If you already administer this host over SSH, it is. If the machine is fresh, install and enable it first:
# Fedora / RHEL / Rocky / Alma
sudo dnf install -y openssh-server
sudo systemctl enable --now sshd
# Debian / Ubuntu
sudo apt install -y openssh-server
sudo systemctl enable --now ssh
Confirm the service is listening and the firewall permits it:
systemctl is-active sshd # expect: active (use 'ssh' on Debian/Ubuntu)
ss -lntp | grep :22 # expect: a listening socket on port 22
# Open the port if a firewall is running (firewalld shown; ufw: sudo ufw allow ssh)
sudo firewall-cmd --add-service=ssh --permanent && sudo firewall-cmd --reload
ssh youruser@server and confirm you get a normal login. Do this before creating the ai-agent account. If plain SSH does not work, nothing later in this guide will either, and the failure will be much harder to read once a forced command is in the path.Create an account whose sole purpose is to receive AI command plans via SSH:
sudo useradd --system --shell /bin/sh --create-home ai-agent
This account exists only to receive the forced SSH command. It has a home directory because SSH needs somewhere to read ~/.ssh/authorized_keys from — that file is the entire mechanism by which the forced command is applied.
--no-create-home leaves SSH with nowhere to find authorized_keys, so public key authentication fails and the forced command never applies. Later sections of this guide also place binaries, policy files, and a working directory under /home/ai-agent/ (see §12). If the account already exists without a home directory, create it by hand: sudo mkdir -p /home/ai-agent && sudo chown ai-agent:ai-agent /home/ai-agent.<account's shell> -c "<command>", not directly. /bin/false and /usr/sbin/nologin are not shells; both ignore -c and every other argument, print nothing (nologin prints its own fixed message), and exit 1 unconditionally. Configured as the account's shell, either one means the forced command never runs — not for the AI, not for anyone — and every connection fails silently with an exit status that happens to look exactly like an ordinary policy denial. There is no error, no audit entry, nothing on stderr: aishell-gate-exec is never invoked in the first place, so it never gets the chance to log anything. This was confirmed directly: the identical setup with /bin/false as the shell exits 1 with zero output on every connection, and starts working the moment the shell is changed to /bin/sh — every other line of this section's configuration, unchanged.
command= restriction below overrides whatever the SSH client requests regardless of the account's shell, and that restriction was confirmed to hold even when a client explicitly asks for an interactive shell (ssh -t) or tries to run something else entirely — the forced command runs either way, every time. What /bin/false/nologin would actually prevent, if they worked at all here, is a form of access the forced command already blocks by itself.
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.
Generate the key on the machine the AI runs on, so the private key never travels:
# On the AI's machine
ssh-keygen -t ed25519 -C "ai-agent" -f ~/.ssh/ai_agent_key
# Print the public key — this is what goes on the server
cat ~/.ssh/ai_agent_key.pub
Two files result: ai_agent_key (private — never leaves this machine) and ai_agent_key.pub (public — installed on the server in the next step). Copy the full single line of output from cat.
Now prepare the destination on the server:
# On the server
sudo mkdir -p /home/ai-agent/.ssh
sudo chmod 700 /home/ai-agent/.ssh
sudo touch /home/ai-agent/.ssh/authorized_keys
sudo chmod 600 /home/ai-agent/.ssh/authorized_keys
sudo chown -R ai-agent:ai-agent /home/ai-agent/.ssh
ssh-copy-id? The usual tool for installing a public key does not work here, for two reasons. It authenticates with a password to get in, and this account has no password login; and it appends the bare key with no command= prefix, which is the one part that matters. Install the key by hand, as shown below — the forced command and the key material are a single line and must be written together.authorized_keys if the file or its directory is writable by anyone other than the owner, and logs the reason only in the server's own log. If key authentication fails for no visible reason, check ls -ld /home/ai-agent /home/ai-agent/.ssh first, then sudo journalctl -u sshd -n 30.Edit /home/ai-agent/.ssh/authorized_keys and add the AI's public key with a forced command. The full minimal example:
command="/usr/local/bin/aishell-gate --policy-preset ops_safe --audit-log /var/log/aishell-ai-agent.log",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA...
authorized_keys has no line-continuation syntax: a trailing backslash is not a continuation, it is literal text, and sshd parses every physical line as a separate entry.
command= option at all — so the key authenticates normally and the AI receives an ordinary interactive shell, with the gate bypassed entirely and nothing reported. Verify after every edit:
awk '{print NR": "substr($0,1,60)}' /home/ai-agent/.ssh/authorized_keys
One numbered line per authorised key. More than that means the entry is broken.
Reading it left to right:
command="/usr/local/bin/aishell-gate — the forced command. Whatever the SSH client asks for, this runs instead. Use aishell-gate rather than calling aishell-gate-exec directly; it handles pre-flight checks and locates the policy binary automatically. Check the path against your own install with command -v aishell-gate — packaged installs commonly land in /usr/local/bin, and a wrong path here fails at connect time with a bare command not found.--policy-preset ops_safe — which policy applies to this session. See §05 of the Getting Started Guide for the full preset list.--audit-log /var/log/aishell-ai-agent.log" — where to write the executor audit log. Give each gate account its own file; see the note under the sample file above. Note the closing quote: everything inside it is the command, everything after is SSH options.no-pty, — refuses terminal allocation for this key. The gate reads a JSON plan from stdin and never needs a terminal, and the ai-agent account is designed to touch no PTY device at all (see §09). Recommended for every deployment. Note that with no-pty in place, confirmations must be routed through --confirm-pipe; --confirm-tty cannot work.no-port-forwarding,no-agent-forwarding,no-X11-forwarding — SSH option restrictions that block the AI's key from being used for anything other than plan submission. Comma-separated; no space after the commas.ssh-ed25519 AAAA... — the AI's public key material, pasted as a single line from ai_agent_key.pub.command= is an attribute of the key, so it is applied only when that key authenticates. If the ai-agent account also accepts a password, a password login bypasses the forced command and the gate along with it. Lock it:
sudo passwd -l ai-agent
For defence in depth, also add to /etc/ssh/sshd_config:
Match User ai-agent
PasswordAuthentication no
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.
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.This is a whole file, not a fragment — copy it, replace the key material with your own, and adjust the paths. Comment lines beginning with # are ignored by sshd, so they can stay.
# /home/ai-agent/.ssh/authorized_keys
#
# Each entry below is ONE PHYSICAL LINE. Do not wrap them.
# A backslash is not a line continuation here -- see the warning below.
# --- Basic gate, no confirmations ---
command="/usr/local/bin/aishell-gate --policy-preset ops_safe --audit-log /var/log/aishell-ai-agent.log",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPasteYourPublicKeyHere ai-agent
# --- Same, but with operator confirmations (see section 09) ---
# command="/usr/local/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-ai-agent.log",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPasteYourPublicKeyHere ai-agent
# --- A second agent, tighter policy, its own log ---
# command="/usr/local/bin/aishell-gate --policy-preset read_only --audit-log /var/log/aishell-reporter.log",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAADifferentKeyHere reporting-agent
The trailing word on each line (ai-agent, reporting-agent) is the key's comment field. It is a label only and has no effect, but it is worth setting to something meaningful — it is what tells you which key is which when you come to revoke one.
The second and third entries show the two things you are most likely to want next: adding confirmations, and giving a second agent a different preset. Each key gets its own forced command, so different agents can have entirely different permissions on the same account.
--audit-log must name a file the account can write. The file belongs to whichever account creates it, so a path shared between two gate accounts fails for the second one — and the failure is fatal rather than a warning: cannot open audit log ... quitting. The gate refuses to run unaudited by design. Create it explicitly as shown in step 4 above.Before writing a real plan, confirm the whole path works end to end: key accepted, forced command fired, policy evaluated, output returned. One line does it:
echo '{"goal":"smoke test","actions":[{"cmd":"ls"}]}' \
| ssh -i ~/.ssh/ai_agent_key ai-agent@server
A successful run prints an evaluation summary showing ls allowed, followed by the directory listing itself. If you see that, every piece of the configuration above is correct and you can move on to §04.
To exercise the same path without executing anything, add --dry-run to the forced command temporarily. The evaluation and confirmation flow runs in full and the final line reads (execution suppressed).
ssh ai-agent@server on its own and type a command like ls. That will appear to hang, and the hang is not a fault. aishell-gate reads one complete JSON plan from stdin and processes it at end-of-input; typing at an open connection simply adds bytes to that buffer, and your keyboard never sends the end-of-input the gate is waiting for.
[gate-exec] input is not a JSON object and exit code 4 — which is the gate working correctly, telling you that ls is not a plan. Always pipe a JSON plan in, as shown above. (If nothing at all is typed, the gate exits 5 after 30 seconds via --input-timeout; once any byte arrives it waits for end-of-input.)
If the smoke test does not succeed, work down this list in order:
| Symptom | Likely cause |
|---|---|
| Connection refused / timeout | sshd not running, or firewall closed. Recheck the prerequisites above. |
| Permission denied (publickey) | Key not installed, or authorized_keys permissions too open. Check sudo journalctl -u sshd -n 30. |
| Exit 1, no output whatsoever | The ai-agent shell is /bin/false or nologin. The forced command never ran. See the aside above. |
| Interactive shell prompt appears | The command= option is malformed — most often a line-continuation backslash or a missing closing quote. The whole entry must be one logical line. |
| Appears to hang | Nothing was piped in. See the warning above. |
command not found | The path in command= is wrong. Verify with command -v aishell-gate and use exactly that path. |
The most commonly used flags for forced-command deployments:
| Flag | Purpose |
|---|---|
| --policy-preset <name> | Named policy preset. Seven built-in presets — three ship in Standard, four require Enterprise:read_only — inspection commands only, no writesops_safe — conservative read and repository commands (default)dev_sandbox — developer workflow: git, make, compilers, package managersci_build [ENTERPRISE] — unattended build/test pipeline, all commands at confirm:noneci_deploy [ENTERPRISE] — unattended deploy pipeline, adds container/k8s/Terraformci_admin [ENTERPRISE] — supervised admin with mixed confirmation levelsdanger_zone [ENTERPRISE] — 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 §09. |
| --confirm-pipe <base> | Secure pipe-based confirmation relay. aishell-gate-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 §09. |
| --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: /tmp/aishell-gate-<uid>.lock. See §09. |
| --dry-run | Evaluate all actions and fire confirmation gates without executing anything. Useful for validating forced-command configurations. |
| --dry-run-json | Like --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. |
| --verbose | Emit 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/local/bin/aishell-gate --policy-preset ops_safe -- --mode batch"
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"}
]
}
| Field | Description |
|---|---|
| protocol | Optional. 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. |
| goal | Human-readable description of intent. Included in the audit log. Optional but recommended. |
| source | Ignored by the executor. Caller identity is set by the deployer with the --source flag in the forced command (ai|human|broker|scheduled), not in the plan; any source field here is silently ignored. |
| strategy | Execution strategy: fail_fast (default) or best_effort. A policy denial on any action refuses the whole plan regardless of strategy; the strategy only governs runtime behaviour once execution begins — fail_fast stops at the first non-zero exit, best_effort runs the rest and reports the first failure. |
| actions | Array of command objects. Each has a cmd field containing the complete command string. Maximum 64 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.
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.
The AI sends its plan to aishell-gate 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. Whatever the SSH client requested is ignored.
'EOF') to prevent the local shell from expanding variables or backslash sequences inside the JSON before it is sent.For each action in the plan, aishell-gate-exec submits the command to aishell-gate-policy and reads back a structured JSON decision. The full field reference — decision, confirm, layer, reason, risk.score, risk.blast_radius, argv, suggestions — is in the Policy Decision Fields section of the Getting Started Guide.
In the remote deployment context the key fields are decision (allow or deny) and confirm (the confirmation level that determines whether a human must respond before the command runs). See §08 — Confirmation Levels for how confirmation interacts with the SSH session.
You do not need your own AI infrastructure to test the full remote deployment path. A cloud GPU instance running Ollama costs a few dollars for an afternoon — enough to walk through every step end to end and tear it down when you are done. This section is a complete walkthrough aimed at developers who have not run a local LLM before.
Nine things happen in sequence. Read this before starting so you can see where each setup step is leading.
The steps in plain language:
ollama run mistral — opens an interactive session. The LLM now has a terminal interface with conversation context.Go to runpod.io, create an account, and add a payment method. RunPod is pay-as-you-go — you are charged only for the time a pod is running. An afternoon of testing typically costs two to five dollars.
From the dashboard, create a new GPU pod. For Mistral 7B you need at least 8 GB of VRAM — an RTX 3080 or equivalent is sufficient and one of the cheaper options available. Select a Ubuntu template. Once the pod status shows as running, RunPod displays an SSH connection command in the pod details — it looks like:
ssh root@XX.XX.XX.XX -p NNNNN -i ~/.ssh/your_local_key
Run that command from your local machine. You are now on the RunPod instance and ready to continue.
While you are still on your local machine (before SSHing to RunPod), find your server's public IP — this is the address the LLM on RunPod will connect to:
curl ifconfig.me
Ollama is the glue between you and the LLM. It runs the model, manages memory, and gives you both an interactive terminal interface and an API. The install is a single command:
# On the RunPod instance
curl -fsSL https://ollama.com/install.sh | sh
# Pull Mistral 7B — a capable, fast model that fits comfortably on modest GPU
# Ollama ships with no models — this downloads the model weights (~4 GB)
ollama pull mistral
The pull downloads the model weights — a few gigabytes. This takes a minute or two depending on RunPod's connection speed.
The AI agent on RunPod needs an SSH key to authenticate to your server. Generate it on RunPod so the private key never has to travel:
# On the RunPod instance — run after SSH-ing in
ssh-keygen -t ed25519 -C "ai-agent-runpod" -f ~/.ssh/ai_agent_key
# Show the public key — you will add this to your server
cat ~/.ssh/ai_agent_key.pub
Two files are created: ai_agent_key (private — never share this) and ai_agent_key.pub (public — goes on your server). Copy the full output of cat ai_agent_key.pub to your clipboard.
The forced command in authorized_keys uses the path ./aishell-gate — relative to the ai-agent user's home directory. The binaries must be there before the first SSH connection arrives:
# On your server — as root or with sudo
cp aishell-gate aishell-gate-exec aishell-gate-policy /home/ai-agent/
chown ai-agent:ai-agent /home/ai-agent/aishell-gate*
chmod 755 /home/ai-agent/aishell-gate*
On your server, add the public key to the ai-agent account's authorized_keys with the aishell-gate forced command:
# On your server — as root or with sudo
mkdir -p /home/ai-agent/.ssh
chmod 700 /home/ai-agent/.ssh
# Add the forced command and public key (replace the key material)
cat >> /home/ai-agent/.ssh/authorized_keys << 'EOF'
command="/usr/local/bin/aishell-gate --policy-preset ops_safe --audit-log /var/log/aishell-ai-agent.log",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA...paste-your-public-key-here...
EOF
chmod 600 /home/ai-agent/.ssh/authorized_keys
chown -R ai-agent:ai-agent /home/ai-agent/.ssh
Before running, it helps to understand what Ollama is. The LLM process itself is just a server — it sits listening on a port with no terminal interface of its own. Ollama wraps it and gives you two things: a REST API, and an interactive CLI that maintains conversation context automatically. The CLI is what you will use:
ollama run mistral
This opens an interactive session in your terminal. You type, the model responds, and the conversation history is maintained — each message knows what came before. This is different from a raw curl request, which is a single one-way message with no context.
Inside the Ollama session, ask the model to send a test plan to your server. The model can execute shell commands — tell it to use SSH with the key you generated:
>>> I need you to send a JSON plan to an AIShell-Gate server via SSH.
Use this command to send it:
ssh -i ~/.ssh/ai_agent_key -o StrictHostKeyChecking=no \
ai-agent@YOUR.SERVER.IP \
<< 'EOF'
{
"goal": "check disk usage",
"source": "ai",
"actions": [
{"cmd": "df -h"}
]
}
EOF
Show me the full output you receive.
The model will execute the SSH command, send the plan, and display the response from aishell-gate on your server. A successful response shows the policy decision (ALLOW/DENY), the command that ran, and its output.
df -h under ops_safe. If you see DENY, check your preset.df -h in the response.cat /var/log/aishell-ai-agent.log shows the full record of what was submitted and decided.curl ifconfig.me and confirm sshd is running.When you are done, stop the RunPod pod from the RunPod dashboard. Billing stops when the pod is stopped. Remove the key from /home/ai-agent/.ssh/authorized_keys on your server if you no longer want that key to have access — one line deletion, no service restart.
The full confirmation level reference — none, plan, action, and typed — is in the Confirmation Levels section of the Getting Started Guide. In the remote deployment context the key point is how confirmation interacts with a non-interactive SSH session:
/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. To handle confirmations in a remote session, you must be logged into the same local server as aishell-gate in a separate terminal running aishell-gate-confirm — that session provides the human's TTY while the LLM running on a remote server is logged into the local server as user ai-agent. This relay path is available in both editions. §09 walks through the full setup: the FIFO pair that connects the headless ai-agent session to your interactive operator terminal, and the aishell-gate-confirm command that reads the request, displays it to you, and sends your response back.Relay confirmation mode is available in both editions. 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-gate-confirm companion tool, provides the secure answer.
The operator opens a second SSH session to the remote host and runs aishell-gate-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-gate-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.
aishell-gate-confirm opens both FIFOs O_RDWR rather than O_RDONLY or O_WRONLY. A FIFO opened O_RDONLY blocks until a writer appears; O_RDWR returns immediately, keeping the relay responsive from the moment it starts — this is documented POSIX behaviour for FIFOs on Linux, not a workaround.
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.An earlier design attempted to have aishell-gate-exec open the operator's PTY device directly from the ai-agent account. Three problems ruled this out:
tty group does not grant read access, so opening the device for reading fails with EACCES in any correctly configured deployment.ai-agent membership in the tty group to work around the permission problem gives it write access to every PTY on the machine — enabling keystroke injection into any user's terminal session, not just the operator's.The FIFO relay avoids all three: the operator's own process owns the channel, the ai-agent account needs no PTY-related privilege at all, and every piece of context the operator needs travels through the request FIFO to the operator's own terminal — never through the AI's stderr.
Step 1 — One-time: create a shared group and the runtime directory.
ai-agent is the locked-down account the AI connects to over SSH — the one configured in §03. The operator is simply whichever ordinary human account approves the confirmations; if you normally log in as sean, then sean is the operator. Substitute your own username everywhere this guide writes operator. There is no need to create a user by that name.
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
# Replace 'operator' with your own login name
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-gate-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.
usermod -aG, your existing session still carries the old list, so aishell-gate-confirm will fail with permission errors on the FIFOs that look like a software fault rather than a stale session. Verify with two commands:
getent group aishell-gate # the database: should list your name and ai-agent
id # your session: should show aishell-gate in 'groups='
If getent lists you but bare id does not, the change worked and the session is stale.
ControlMaster socket under ~/.ssh/, which can silently reuse the old session). To confirm the fix without logging out, run newgrp aishell-gate — it starts a subshell with the group applied, which is enough to proceed in that one terminal.
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-gate-confirm.
# Operator's own SSH session — keep this window open
ssh operator@remotehost
$ aishell-gate-confirm
[aishell-gate-confirm] Terminal: /dev/pts/3
[aishell-gate-confirm] Req FIFO: /run/aishell-gate/confirm.req
[aishell-gate-confirm] Resp FIFO: /run/aishell-gate/confirm.resp
[aishell-gate-confirm] Status: armed — waiting for confirmation requests
[aishell-gate-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/local/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-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA...
One physical line, as always — see §03.
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.
If aishell-gate-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-gate-confirm is not running in an operator session.
[gate-exec] Start it: ssh operator@host then run: aishell-gate-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-gate-confirm before the AI tries again.
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.
--confirm-pipe and --confirm-lock are required together. Neither is needed in a single-session interactive deployment using /dev/tty.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.
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.
Policy preset read_only. No confirmation required for listing and reading operations.
# On the server — in ~ai-agent/.ssh/authorized_keys:
# command="/usr/local/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
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/local/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
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
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.
ci_deploy preset, which requires Enterprise edition (see §01 and §03).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.
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:
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.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
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.
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 aishell-gate-exec on the production host over SSH. 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.
{
"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.
{
"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).
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.{
"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.
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 over SSH.
# 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.
Looking back at the three stages with the plan model now in hand, the role each one plays becomes explicit:
| Stage | Contributes | Cannot replace |
|---|---|---|
| 1 — AI writes | Proposed change, expressed in version control where it can be reviewed | Automated correctness checks, human approval, production execution |
| 2 — CI validates | Automated schema, test, and dry-run verification before anything reaches prod | Policy-gated execution on the production host — CI is not running there |
| 3 — Gate deploys | Policy-gated, audit-logged, confirmation-checked execution on production | AI 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.
The authorized_keys entry forces aishell-gate for every connection, regardless of what the SSH client requests — an explicit request for an interactive shell (ssh -t) or a different command is ignored the same as any other. There is no path through which the AI can obtain an interactive shell. (The account's own shell must be a real shell — see the note in §03 — but that is what makes the forced command run at all; the command= restriction, not the shell choice, is what prevents interactive access.)
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.
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.
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.
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.
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 — Enterprise edition) disable this for build pipeline access to registries. Configure it per-project with "net_default_deny": false in a policy override file.
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."
Allowing multiple AI agents to connect simultaneously requires two mitigations: the --confirm-pipe / --confirm-lock combination described in §09, and a persistent HMAC key for audit chain integrity (Enterprise edition). 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.
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.
aishell-gate-exec returns a precise exit code so the AI can distinguish between types of failure:
| Code | Meaning |
|---|---|
| 0 | All actions allowed, confirmed, and executed successfully |
| 1 | One or more actions denied by policy |
| 2 | Human confirmation refused, or no terminal available to present the confirmation prompt (see §09) |
| 3 | Policy engine process error (subprocess could not be started, or returned no output) |
| 4 | JSON parse error in the input plan or in the policy engine response |
| 5 | Usage or argument error, or startup security check failed |
| 6 | execve() failure after a confirmed ALLOW decision |
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.
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.
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.
/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."
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
Strip the ai-agent account's 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. (The account's shell should remain /bin/sh, per §03 — not /bin/false — or the forced command itself will not run; PATH hardening is independent of that setting.)
# 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.
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. (The example below also includes --confirm-pipe — see §09; omit it if the policy never produces a confirmation above none.)
# In authorized_keys forced command -- ONE PHYSICAL LINE:
command="/usr/local/bin/aishell-gate --policy-preset ops_safe --safe-path /home/ai-agent/allowed-bin --audit-log /var/log/aishell-ai-agent.log --confirm-pipe /run/aishell-gate/confirm",no-pty,no-user-rc,no-agent-forwarding,no-port-forwarding,no-X11-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.
| Layer | What it enforces | Who enforces it |
|---|---|---|
| Unix account | No PATH, no access to standard binary directories, no writable system paths | Operating system |
--safe-path | Only binaries in allowed-bin are reachable by name; child processes inherit the same restricted PATH | aishell-gate-exec |
| Policy | What within the reachable set is actually permitted for this session, at what confirmation level | aishell-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.
This section covers the reference pipeline script (aishell-gate-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 and supports two backends — Anthropic's API and a local Ollama instance — selected with --backend or the AISHELL_BACKEND environment variable.
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.
Backend-specific logic is isolated to two functions, call_anthropic() and call_ollama(). Everything else — the system prompt, fence-stripping, JSON validation, gate argument construction, and exit-code handling — is shared between both backends:
#!/usr/bin/env bash
# aishell-gate-pipe.sh — Pipe AI-generated JSON plans through AIShell-Gate
#
# Usage:
# ./aishell-gate-pipe.sh "check disk usage and list recent logs"
# ./aishell-gate-pipe.sh --backend ollama "run the test suite"
# AISHELL_BACKEND=ollama ./aishell-gate-pipe.sh "run the test suite"
# ./aishell-gate-pipe.sh --dry-run "clean build artifacts"
#
# Environment:
# AISHELL_BACKEND AI backend: anthropic | ollama (default: anthropic)
# AISHELL_MODEL Model name override.
# anthropic default: claude-sonnet-4-6
# ollama default: mistral
# ANTHROPIC_API_KEY Required for the anthropic backend (sk-ant-...).
# If unset, the script will prompt interactively.
# AISHELL_PRESET Policy preset (default: ops_safe)
# 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
SCRIPT_NAME="aishell-gate-pipe"
BACKEND="${AISHELL_BACKEND:-anthropic}"
PRESET="${AISHELL_PRESET:-ops_safe}"
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:-}"
ANTHROPIC_MODEL_DEFAULT="claude-sonnet-4-6"
OLLAMA_MODEL_DEFAULT="mistral"
DRY_RUN=""
GOAL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--backend) BACKEND="$2"; shift 2 ;;
--backend=*) BACKEND="${1#--backend=}"; shift ;;
--dry-run) DRY_RUN="--dry-run"; shift ;;
*) GOAL="$1"; shift ;;
esac
done
if [[ -z "$GOAL" ]]; then
echo "Usage: $0 [--backend anthropic|ollama] [--dry-run] \"goal description\"" >&2
exit 1
fi
if [[ "$BACKEND" != "anthropic" && "$BACKEND" != "ollama" ]]; then
echo "Error: unknown backend '$BACKEND'. Use 'anthropic' or 'ollama'." >&2
exit 1
fi
MODEL="${AISHELL_MODEL:-}"
if [[ -z "$MODEL" ]]; then
if [[ "$BACKEND" == "anthropic" ]]; then MODEL="$ANTHROPIC_MODEL_DEFAULT"
else MODEL="$OLLAMA_MODEL_DEFAULT"; fi
fi
if [[ ! -x "$GATE_EXEC" ]]; then
echo "Error: aishell-gate-exec not found at '$GATE_EXEC'" >&2
exit 1
fi
if [[ ! -x "$GATE_POLICY" ]]; then
echo "Error: aishell-gate-policy not found at '$GATE_POLICY'" >&2
exit 1
fi
if [[ "$BACKEND" == "anthropic" ]]; then
command -v curl &>/dev/null || { echo "Error: curl not found in PATH." >&2; exit 1; }
command -v python3 &>/dev/null || { echo "Error: python3 not found in PATH." >&2; exit 1; }
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
read -rsp "[$SCRIPT_NAME] Enter API key (input hidden): " ANTHROPIC_API_KEY </dev/tty
echo "" >&2
[[ -z "$ANTHROPIC_API_KEY" ]] && { echo "Error: no API key provided." >&2; exit 1; }
fi
else
command -v ollama &>/dev/null || { echo "Error: ollama not found in PATH." >&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.'
# ── Backend: model invocation — the only backend-specific logic ───────────
# Add a new backend by writing a call_<name>() function and adding a case
# below. Everything downstream is shared and does not need to change.
call_anthropic() {
local prompt="$1"
local payload
payload=$(python3 -c "
import json, sys
print(json.dumps({'model': sys.argv[1], 'max_tokens': 1024,
'messages': [{'role': 'user', 'content': sys.argv[2]}]}))
" "$MODEL" "$prompt")
local response
response=$(curl -s \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d "$payload" "https://api.anthropic.com/v1/messages")
python3 - "$response" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1])
if "error" in data:
print(f"Error: {data['error'].get('message','unknown')}", file=sys.stderr); sys.exit(1)
blocks = [b["text"] for b in data.get("content", []) if b.get("type") == "text"]
print(blocks[0].strip() if blocks else "", end="")
PYEOF
}
call_ollama() {
ollama run "$MODEL" "$1"
}
echo "[$SCRIPT_NAME] Goal: $GOAL Backend: $BACKEND Model: $MODEL Preset: $PRESET" >&2
[[ -n "$DRY_RUN" ]] && echo "[$SCRIPT_NAME] Mode: dry-run (no execution)" >&2
FULL_PROMPT="$SYSTEM_PROMPT
Goal: $GOAL"
case "$BACKEND" in
anthropic) RAW_PLAN=$(call_anthropic "$FULL_PROMPT") ;;
ollama) RAW_PLAN=$(call_ollama "$FULL_PROMPT") ;;
esac
if [[ -z "$RAW_PLAN" ]]; then
echo "Error: model returned empty response." >&2
exit 1
fi
# Strip markdown code fences and validate the response is a JSON object
PLAN=$(python3 - "$RAW_PLAN" <<'PYEOF'
import json, re, sys
text = re.sub(r'^```(?:json)?\s*|\s*```$', '', sys.argv[1].strip()).strip()
try:
assert isinstance(json.loads(text), dict)
except Exception as e:
print(f"Error: invalid JSON plan: {e}", file=sys.stderr); sys.exit(1)
print(text, end="")
PYEOF
)
echo "[$SCRIPT_NAME] 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=$?
case $EXIT in
0) echo "[$SCRIPT_NAME] All actions completed." >&2 ;;
1) echo "[$SCRIPT_NAME] One or more actions denied by policy." >&2 ;;
2) echo "[$SCRIPT_NAME] Confirmation refused or unavailable." >&2 ;;
3) echo "[$SCRIPT_NAME] Policy engine error." >&2 ;;
4) echo "[$SCRIPT_NAME] JSON parse error in plan or policy response." >&2 ;;
5) echo "[$SCRIPT_NAME] Argument or startup error." >&2 ;;
6) echo "[$SCRIPT_NAME] execve() failure after ALLOW decision." >&2 ;;
*) echo "[$SCRIPT_NAME] Unexpected exit code: $EXIT" >&2 ;;
esac
exit $EXIT
The full version in the distribution includes additional argument parsing for --version / --help and policy override file flags (AISHELL_POLICY_BASE, AISHELL_POLICY_PROJECT, AISHELL_POLICY_USER); the listing above is trimmed to the parts relevant to backend selection and gate submission.
Add a new backend by writing a call_<name>() function that takes a prompt and writes the model's raw text response to stdout, then add one line to the case "$BACKEND" dispatch. Nothing downstream needs to change — the system prompt, fence-stripping, JSON validation, gate argument construction, and exit-code handling are shared across every backend. This pattern applies equally to an OpenAI-compatible endpoint via curl, a llama.cpp server, or any command-line tool that writes valid JSON to stdout.
The gate validates the JSON itself — a malformed plan produces a clear parse error rather than unexpected behaviour.
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.
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.
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?"
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.
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.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.
Appendix A2 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.
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:
| Character | What 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.
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 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.
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:
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.
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.