How to allow an AI system to submit controlled command plans for execution on a remote machine
Copyright © 2026 AIShell Labs LLC. All Rights Reserved. — www.aishell.org/aishellgate
This guide explains how to allow an AI system running on one machine to submit controlled command plans for execution on another machine using AIShell-Gate.
The goal is not to give the AI a shell. Instead, the AI submits a structured JSON plan that passes through a policy gate before any command is executed. This preserves security while still allowing meaningful automation.
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 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 |
Both binaries must be present on the machine that will execute commands. The executor calls the policy engine as a subprocess, so both must be reachable.
Recommended locations:
/usr/local/bin/aishell-gate-policy
/usr/local/bin/aishell-gate-exec
Ensure both are executable:
chmod 755 /usr/local/bin/aishell-gate-policy
chmod 755 /usr/local/bin/aishell-gate-exec
aishell-gate-exec checks this at startup and will refuse 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:
command="/usr/local/bin/aishell-gate-exec \
--policy /usr/local/bin/aishell-gate-policy \
--preset ops_safe \
--audit-log /var/log/aishell_ai.log",\
no-port-forwarding,no-agent-forwarding,no-X11-forwarding \
ssh-rsa AAAA...
With this in place, any SSH connection from the AI — regardless of what command the client requests — will run aishell-gate-exec with the specified options. The AI cannot bypass the gate.
--policy flag must be an explicit path containing a / character. Bare names are rejected at startup to prevent PATH-based substitution. The path is resolved with realpath(3) before any subprocess is forked.| Flag | Purpose |
|---|---|
| --policy <path> | Absolute path to aishell-gate-policy binary (required) |
| --preset <name> | Named policy preset: read_only, ops_safe, dev_sandbox |
| --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-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: /run/aishell-gate/confirm.lock. See § 09. |
| --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-exec \
--policy /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.
{
"goal": "check disk usage and list recent logs",
"source": "ai",
"actions": [
{"cmd": "df -h"},
{"cmd": "ls -lt /var/log"}
]
}
| Field | Description |
|---|---|
| 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. In v0.21 and earlier, the executor fell back to reading from stdin. Because the AI's JSON plan arrives on stdin and the pipe is still open when the confirmation fires, the process blocked indefinitely — the SSH connection hung with no error. This is fixed in v0.22.0. The executor now exits immediately with code 2 and a clear diagnostic. See § 09 — 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 1b — Make the directory survive reboots.
/run is a tmpfs memory filesystem on virtually every modern Linux system — 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. On systemd systems, a tmpfiles.d drop-in recreates the directory automatically before sshd starts:
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
mkdir, chown, and chmod commands from Step 1 to /etc/rc.local (or the equivalent early-boot script for your init system) so the directory is recreated before sshd starts on each boot.Step 2 — 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 3 — Add --confirm-pipe and --confirm-lock to the forced command in authorized_keys.
command="/usr/local/bin/aishell-gate-exec \
--policy /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-port-forwarding,no-agent-forwarding,no-X11-forwarding \
ssh-rsa AAAA...
Step 4 — 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.
# Forced command in authorized_keys:
# command="/usr/local/bin/aishell-gate-exec \
# --policy /usr/local/bin/aishell-gate-policy \
# --preset read_only \
# --audit-log /var/log/aishell_ai.log"
# AI sends this plan:
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.
# Forced command in authorized_keys:
# command="/usr/local/bin/aishell-gate-exec \
# --policy /usr/local/bin/aishell-gate-policy \
# --preset ops_safe \
# --jail-root /srv/deployments \
# --audit-log /var/log/aishell_ai.log"
# AI sends this plan:
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.
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
The ai-agent account has /bin/false as its shell and the authorized_keys entry forces aishell-gate-exec 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.
Specify --audit-log in the forced command so every plan submission is recorded. The executor log captures the plan source, each action, the policy engine decision, confirmation outcome, and execution result. The policy engine has its own separate audit log (--audit flag) that supports HMAC-SHA256 tamper-evident chaining for cryptographic log integrity when that level of assurance is required.
Each aishell-gate-exec invocation is an independent process with its own HMAC chain state. When multiple AI sessions write to the same audit log file, the file contains one internally consistent HMAC chain per session_id, not a single global sequence. When verifying chains, always operate per session_id; treating the entire log as a single linear sequence will produce false verification failures when sessions are concurrent.
For the HMAC chain to remain verifiable across restarts and sessions, a persistent key must be configured. Without one, each invocation generates a random per-session key that is discarded on exit, making post-hoc chain verification impossible. Configure a persistent key by one of these methods, in priority order:
AISHELL_AUDIT_KEY environment variable to a 64-character hex string (32 raw bytes) in the forced command environment./etc/aishell/audit.key containing a 64-character hex string on the first line, readable by the ai-agent account. Mode 0640 with group ai-agent is appropriate.If neither is configured, aishell-gate-exec emits a warning to stderr at startup. In a multi-session deployment this warning appears in the stderr of each AI's SSH session; if those streams are not monitored, the degraded mode may go unnoticed. The warning text includes the word ephemeral to make it grep-friendly in log aggregation.
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 as described above. 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
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.