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
--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.
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.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.
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-pipe.sh) is provided in §18 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 | §18 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.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.
AIShell-Gate consists of two cooperating binaries with strictly separated responsibilities:
aishell-gate-policy — the policy evaluation engine. Accepts a command description, evaluates it against configured policy, and returns a structured JSON decision. It never executes anything.aishell-gate-exec — the execution harness. Reads a JSON plan from the AI, submits each action to aishell-gate-policy as a child process, collects any required human confirmation, and calls execve() with the validated argument vector. It contains no policy logic of its own.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.
aishell-gate-policy before aishell-gate-exec will call execve().The design creates multiple independent security boundaries. Even if one layer fails, the others continue to protect the system.
| Layer | Purpose |
|---|---|
| AI runtime | Decision engine only — never touches the OS directly |
| SSH transport | Encrypted connection; identity bound to a specific key |
| Restricted user | Dedicated account with no login shell and minimal permissions |
| Forced command | Prevents arbitrary shell access regardless of client request |
| aishell-gate-policy | Evaluates each command against policy; returns allow or deny |
| execve() | Direct kernel exec — no shell interpolation or PATH search |
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.
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.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.
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.
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:
command="/usr/bin/aishell-gate \ — the forced command. Whatever the SSH client asks for, this runs instead. Use the launcher (aishell-gate) rather than calling aishell-gate-exec directly; the launcher handles pre-flight checks and locates the policy binary automatically.--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/audit.log", — where to write the executor audit log. This matches the executor's compiled-in default, but being explicit in the forced command makes the path visible during review.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-rsa AAAA... — the AI's public key material.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.The most commonly used flags for forced-command deployments:
| Flag | Purpose |
|---|---|
| --policy-preset <name> | Named policy preset. Seven built-in presets:read_only — inspection commands only, no writesops_safe — conservative read and repository commands (default)dev_sandbox — developer workflow: git, make, compilers, package managersci_build — unattended build/test pipeline, all commands at confirm:noneci_deploy — unattended deploy pipeline, adds container/k8s/Terraformci_admin — supervised admin with mixed confirmation levelsdanger_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-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/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 | Provenance label for the request. Defaults to "ai" if omitted. |
| strategy | Execution strategy: fail_fast (default) or best_effort. Controls whether a denied action stops the plan. |
| actions | Array 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.
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 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.
'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 as a child process and reads back a structured JSON decision. Key fields in that decision:
| Field | Meaning |
|---|---|
| decision | allow or deny |
| confirm | Required confirmation level: none, plan, action, or typed |
| layer | Policy layer that produced the decision |
| reason | Human-readable explanation |
| risk.score | Integer 0–100 |
| risk.blast_radius | single, tree, system, or unknown |
| argv | Validated argument vector — passed to execve() on ALLOW decisions |
| suggestions | Allowed 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.
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."
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.
| Level | Behaviour |
|---|---|
| none | Proceed without any confirmation prompt. |
| plan | Prompt once before execution begins. Operator types y to proceed. |
| action | Prompt for each individual command. Operator types yes. |
| typed | Operator 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.
/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.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.
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.
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-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 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.
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 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/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/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.
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 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.
{
"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 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.
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 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.
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) 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 § 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.
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 § 11) |
| 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
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.
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.
| 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-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.
#!/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
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.
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.
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.