Shell interpolation: stop agent input becoming commands
Shell interpolation lets agent-controlled paths and resource names become executable syntax. Build safer local and SSH actions with arrays and validation.

An agent does not need a shell tool to trigger shell execution. Give it a path, branch, image tag, hostname, or resource name, then splice that value into a command string somewhere downstream, and you have given untrusted text a chance to change the program that runs.
The fix is not a more clever quote function. The fix is to stop treating command construction as string formatting. An action should name a fixed operation, accept typed fields with narrow rules, invoke a program with a real argument vector, and keep authorization separate from parsing. I have reviewed enough incident writeups to be blunt about this: a tool that accepts a free-form command because it is "only for our agent" will eventually accept somebody else's instructions too.
Agents make this failure easier to reach. They consume issue text, repository contents, tool output, web pages, and chat messages. Any of those inputs can influence the next argument. Whether the agent intended harm is beside the point. The boundary that reaches an operating system or remote host must assume that an attacker can shape its input.
Shell interpolation changes data into syntax
Shell interpolation occurs when code builds text for a command interpreter instead of supplying separate arguments to a process API. Once a shell receives that text, it applies its own language rules. Separators can start another command, command substitutions can run a nested command, redirections can alter files, and expansions can turn one apparent value into many words.
Consider an agent asked to inspect a branch before a release. This handler looks innocent:
branch = request["branch"]
command = f"git show {branch}"
subprocess.run(command, shell=True, check=True)
If branch is release; id, the shell sees two commands. The first part asks Git to show a revision. The second part runs id. The same mistake appears in JavaScript through exec, in Ruby through a string passed to system, in Go through sh -c, and in CI scripts that put agent output directly in a shell variable without controlling its later use.
The dangerous character does not need to arrive in the agent's prompt. It can sit in a pull request title, an error message, a package manifest, or a filename returned by a repository scan. The agent may quote that material in a later tool call because its task told it to inspect or repair it. A review that asks only whether the model obeys an instruction misses the place where the text becomes executable syntax.
POSIX Shell Command Language describes a sequence that includes expansions, field splitting in applicable cases, pathname expansion, and quote removal. That order matters. A developer often says, "I wrapped it in quotes," as if quotes create a generic safe string. They do not. They constrain particular parsing rules in a particular shell at a particular point in its expansion sequence.
A shell also has more than one way to receive input. A value can affect the command text itself, an environment assignment, a redirection target, a command substitution, a shell startup file, or code evaluated later by another interpreter. Escaping for the first shell does not automatically protect the next parser.
Keep this distinction sharp:
- Command injection happens when input changes syntax interpreted by a command language.
- Argument injection happens when input remains one argument but changes how the called program interprets that argument, often as an option or expression.
- Authorization failure happens when a valid argument requests an action outside the caller's permitted scope.
Using an argument vector stops the first class. It does not solve the second or third. A path that escapes a workspace, a Git revision that selects an unintended object, and a URL that reaches an internal service can all be one perfectly separate argument.
A quoted command string still has too many parsers
Quoting input before concatenating it into a shell command is popular because it appears to be a small repair. It is also fragile because the command often crosses more than one grammar.
Suppose a local program constructs an SSH call, and the remote system runs a shell. A developer may quote the branch for the local shell, quote it again for SSH, and quote it a third time for the remote shell. Each layer has different assumptions about whitespace, quotes, backslashes, command substitution, and encoding. A later maintenance change can remove one layer of quoting or place the already quoted string inside a new context. The code still looks careful while the protection has expired.
This pattern is especially bad:
const command = `git checkout '${branch}'`;
execFile("ssh", [host, command], callback);
execFile protects the local invocation of ssh, which is good. It does not make command safe on the remote side. SSH normally provides the remote side a command string. The remote account commonly hands that string to a shell. The branch therefore enters shell syntax one hop later.
Single quotes are not a general answer. An input that contains a single quote can terminate the intended quoted region. A quote routine written for POSIX shell is wrong for PowerShell. A routine correct for a shell word is wrong when the value lands in a redirection, an arithmetic expression, or a language interpreter. A routine correct today is still a maintenance burden when somebody changes git show into git log --format=... and rearranges the template.
Do not confuse serializing an argument list for logging with reconstructing one for execution. A log line such as git show release-42 is useful for a human, but it cannot prove where the original argument boundaries were. It is not a safe executable representation. Store arguments as a structured array, and only render them for display with clear escaping rules.
Environment variables create another trap. This is safer than interpolation:
BRANCH="$branch" git show "$BRANCH"
but it only stays safe if the shell script owns both quoted expansions and never evaluates the value later. If a downstream script uses eval, constructs another command string, or passes the value to a template engine with execution features, the original work did little. A better boundary avoids handing the agent's value to a shell at all.
Fixed executables and argument arrays remove the shell grammar
For local actions, call a fixed executable with an argument array and leave shell execution off. The operating system then gives the child process the individual strings you supplied. It does not reinterpret semicolons, dollar signs, spaces, parentheses, or glob characters as shell language.
The Python subprocess documentation recommends a sequence of arguments and states that shell=False is the default. Use that default deliberately rather than relying on it by accident:
import subprocess
result = subprocess.run(
["git", "status", "--porcelain=v1"],
cwd=repo_dir,
text=True,
capture_output=True,
check=True,
)
print(result.stdout)
This action has no agent-controlled executable and no agent-controlled command template. It performs one known operation in one approved directory. The output shape is plain text, with each status record on its own line. You can improve the interface again by parsing that output and returning structured records instead of feeding raw text into another agent decision.
When an operation needs input, keep the executable and operation fixed and append validated values as separate entries:
subprocess.run(
["tool", "fetch-resource", resource_id],
cwd=workspace,
check=True,
shell=False,
)
In Node.js, prefer spawn or execFile with an array of arguments. Do not switch on the shell option to make a compound command convenient. In Go, use exec.Command with each argument supplied separately. In Rust, use Command and repeated arg calls. The APIs differ, but the invariant does not: no input crosses into a command language.
This is a good place to argue against a common recommendation: "Use a shell script as the safe wrapper." A fixed shell script can be an acceptable compatibility boundary, but it is not safer merely because it is a script. It becomes safe only when it receives values through fixed positional parameters or standard input, quotes every expansion in the script, avoids eval, and does not build a second command string. A small program written with a process API is usually easier to audit.
There are legitimate shell features: pipelines, conditional flow, redirection, and shell builtins. Put those features in a reviewed static script when you cannot remove them. Do not let the agent assemble the pipeline. Give the agent an action named collect_build_logs, then let the wrapper run its fixed pipeline with fixed file locations and bounded parameters.
The executable itself also belongs in the policy. Never choose it from agent input, from a writable configuration file, or by searching an attacker controlled PATH. Use an absolute, administrator controlled path where your environment needs that assurance. Set a known working directory and a reduced environment rather than inheriting arbitrary variables from the agent process.
Validation defines what the action may mean
Argument arrays protect the parser boundary. Validation protects the action boundary. If an action says read_file, the service must decide which files count as readable for that action. A process API cannot make that decision for you.
Start with a narrow schema. A deployment name may be lowercase letters, digits, and single internal separators, with a length limit. A branch selector may be a branch name in a chosen subset of Git's rules. A resource identifier may be a UUID or an integer from an inventory. A server target may be an ID that your service maps to a stored host, rather than an arbitrary hostname.
Avoid generic blacklists such as "reject semicolon and ampersand." They are built around one parser and leave semantic abuse untouched. They also grow until ordinary users cannot predict what works. An allowlist grammar gives the caller a contract and gives the reviewer a bounded set of inputs to reason about.
For a branch field in a release workflow, a deliberately limited grammar can be better than accepting every ref Git permits:
import re
BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/]{0,127}")
def release_ref(value: str) -> str:
if not isinstance(value, str):
raise ValueError("branch must be text")
if not BRANCH.fullmatch(value):
raise ValueError("branch has unsupported characters")
if "/./" in value or "//" in value or value.endswith("/"):
raise ValueError("branch has an unsupported path form")
return "refs/heads/" + value
This code does not claim to implement Git's complete ref grammar. It intentionally defines a smaller grammar for a release action. That is often the right engineering choice. If users need spaces or another unusual form, add it because the workflow needs it, test it, and document it. Do not inherit every edge case of a general version control system when your action only deploys release branches.
The returned value has a fixed namespace prefix. That matters. An arbitrary revision expression can refer to tags, commit ancestry, reflogs, or syntax that a Git command interprets specially. A release branch action should not quietly become "show any Git object the agent can describe." Use a library or Git's own branch validation mode when you need full Git compatibility, then still map the accepted result into the allowed namespace.
Resource names need the same discipline. If an agent requests a cloud object, store the allowed account, region, bucket, or project context outside the free-form field. Let it supply an object name that follows the one grammar you support. Do not accept a complete URL and call it a resource name. A full URL chooses protocol, host, port, path, and sometimes credentials. That is a network authorization decision disguised as a string.
Length limits are part of validation. They cap log growth, prevent accidental command line limits, and make denial of service less convenient. Character encoding rules are part of it too. Reject control characters in text fields unless the action genuinely needs them. Reject NUL unconditionally for process arguments because operating system process interfaces cannot carry it as part of an argument.
Paths require containment checks, not character filters
A path can be safe for the shell and dangerous for the filesystem. ../../secrets.env contains none of the shell metacharacters that quote functions worry about. It can still move a file operation beyond the workspace.
A safe path policy begins by declaring a root directory for the action. Resolve the requested relative path against that root, then require the resolved target to remain inside the resolved root. Do not accept an absolute path when the action only needs workspace files. Do not normalize a string and assume that the resulting spelling tells you where the filesystem will go.
A conceptual check looks like this:
from pathlib import Path
workspace = Path("/srv/agent-workspaces/repo-a").resolve()
def permitted_file(relative_name: str) -> Path:
if not isinstance(relative_name, str) or relative_name.startswith("/"):
raise ValueError("file must be a relative path")
candidate = (workspace / relative_name).resolve(strict=True)
if candidate == workspace or workspace not in candidate.parents:
raise ValueError("file is outside the workspace")
if not candidate.is_file():
raise ValueError("requested target is not a regular file")
return candidate
The resolve call catches ordinary traversal and follows existing symlinks, which is better than checking whether the raw string contains ... It is still not the complete answer for a sensitive write operation. An attacker who can modify the directory tree may replace a path component with a symlink after your check and before the later open. That check and use gap is a time of check to time of use race.
For read operations in a controlled workspace, a resolved containment check may match your risk. For writes, deletion, permission changes, or archives that attackers can influence, use filesystem operations that hold directory descriptors and refuse symlink traversal where the operating system provides those controls. Keep writable agent workspaces separate from the code that decides allowed roots. If the threat model includes a local attacker who can race the filesystem, a high-level path library alone is not enough.
Also decide whether dotfiles, repository metadata, generated dependency directories, and symbolic links belong in scope. A vague rule such as "anything under the repo" often exposes credential files, build caches, or configuration that the workflow never needed. Read permission should follow the action's purpose, not the convenience of a recursive glob.
Archive extraction deserves a separate warning. Validate each archive member after joining it to the destination root, and treat links inside an archive as potentially hostile. Path traversal is not limited to requests made directly by the agent. A tool that extracts an agent selected artifact can perform the same escape on its behalf.
Options and expressions stay dangerous without a shell
An argument vector keeps a value separate from shell syntax, but the called program still parses that value. Programs commonly treat arguments beginning with a hyphen as options. Some accept expressions, configuration references, file inclusion syntax, plugins, or command hooks. Passing untrusted input as one argument does not make those meanings harmless.
Take a wrapper that calls a search program with a user supplied pattern. The wrapper may correctly provide ["search", pattern, directory]. If the program treats the pattern as a regular expression, a pathological expression can consume enormous CPU. If it supports an option that reads a configuration file and the pattern lands in the wrong position, the caller may change program behavior. If the program's language supports code evaluation, you have handed the agent a different interpreter.
Put fixed options before data, and use the program's conventional end-of-options sentinel when it supports one. In prose, that sentinel is two hyphen characters. It tells many command line parsers that later values are operands rather than flags. Do not rely on it as validation. Some tools do not honor it, and an operand can still be a dangerous semantic value.
Git has a large command surface, which is why agents need narrow Git actions rather than a generic "run Git" escape hatch. A show_release_branch action can accept a restricted branch name, convert it to refs/heads/ plus that name, and invoke a fixed Git subcommand. A checkout_anything action that takes arbitrary revision syntax has a broader meaning and needs a broader authorization decision. Those are different products, even if both happen to start a Git process.
The same rule applies to package managers, database clients, media tools, archivers, and infrastructure commands. Ask what grammar the target program will parse after the operating system delivers the argument. A command line is often merely the first parser in a stack.
Regular expressions deserve special care. A pattern is not shell code, but it is executable work inside a regex engine. If the agent must search text, select a regex engine with predictable performance where possible, enforce pattern and input limits, and expose literal search as the default action. Do not turn every search request into an unrestricted expression language because a developer wants one flexible endpoint.
SSH turns a local command into a remote parsing problem
SSH creates a second execution boundary. You can invoke the local SSH client with a perfect argument array and still send an unsafe command string to the remote host. Many implementations of SSH execute the requested remote command through the remote account's shell or an equivalent command parser.
Do not build remote commands from agent fields. This is wrong even if the local call uses an array:
remote = "deploy " + environment + " " + branch
subprocess.run(["ssh", host, remote], check=True)
A safe arrangement sends a constant remote command and moves data into a structured input channel. For example, the remote account can expose one reviewed program at a fixed path. The local side starts that fixed program, sends a JSON request on standard input, and the remote program validates environment and branch before it runs any local operation.
import json
import subprocess
request = {"environment": "staging", "branch": "release/42"}
subprocess.run(
["ssh", "deploy.internal", "/usr/local/libexec/receive-deploy-request"],
input=json.dumps(request) + "\n",
text=True,
check=True,
)
The remote command in this example is constant. The JSON is data on standard input, not text embedded into the remote command. The remote receiver still has to reject unknown fields, enforce length limits, validate each value, and use an argument vector for any child process. Structured transport removes a quoting maze. It does not grant trust.
The host name should usually be selected from stored configuration by an opaque environment ID. Letting an agent provide host means it can choose where credentials go and which network boundary the action crosses. If a workflow genuinely needs several hosts, map approved names such as staging and production to known connection settings. Keep host verification on. A tool that disables host checking to avoid setup friction has removed a meaningful part of the boundary.
Do not send credentials in the remote command, command line, or JSON request. Use the remote account's existing authentication arrangement and scope that account to the action it must perform. A deployment receiver that can only deploy one application is easier to reason about than an agent that receives a broadly capable shell session.
Approval cannot repair an unsafe action design
Human approval and an audit trail are useful controls, but they answer different questions from validation. Approval answers whether this caller may attempt an action now. Validation answers whether the request has a defined, permitted shape. Logging answers what happened and whether the record changed later. Treating any one of those as a substitute for the others produces weak tools with attractive screens.
A one time session approval can be appropriate for a fixed action gateway. It is not a careful review of every interpolated string that a process will feed into a shell during the rest of the session. Per-call approval gives a human more chances to notice an odd target, but nobody should be expected to audit nested quote rules or spot a malicious Unicode lookalike in a dense dialog.
Make the approval card show the semantic request: deploy branch release/42 to staging, not a shell command reconstructed from a template. The receiver should be able to compare the displayed environment and branch against the request schema. If a command needs a raw script field to explain itself, the action is too broad for reliable approval.
Log structured facts at the action boundary. Retain the caller or agent process identity, the approved action name, the separate arguments or request fields, the selected target, timestamps, exit status, and output references subject to your data retention rules. Record validation failures too. Repeated rejected traversal strings or option-like values often reveal a bad prompt, an integration mistake, or active probing.
For teams using Sallyport, per-session authorization, per-call key approval, and its separate Sessions and Activity journals can preserve control and evidence around agent actions, but the command template still needs the design rules above.
A tamper-evident log helps after an event only if it records the boundaries that mattered. A string such as ssh deploy internal deploy staging release 42 is ambiguous after the fact. An event with separate host identity, fixed receiver path, JSON fields, authorization result, and receiver exit status can support an investigation.
Test the rejection path as hard as the happy path
Most command injection tests prove only that a normal branch like release/42 works. That is the least interesting input. The test suite should prove that the handler rejects dangerous values before it starts a child process or opens a path.
Build a small table for each field type. The exact inputs depend on the grammar, but the categories should include shell separators, command substitutions, quote characters, whitespace and newlines, option-like prefixes, traversal components, empty values, control characters, oversized values, and valid syntax that requests something outside the action's scope. For a branch action, also test a tag-like name and a revision expression that your interface does not support.
Use a fake process runner in unit tests. Assert the executable and each argument as separate values. Assert that invalid input never invokes the runner. A test that only compares a rendered command string can miss the very boundary you are trying to protect.
class RecordingRunner:
def __init__(self):
self.calls = []
def run(self, argv, **kwargs):
self.calls.append((argv, kwargs))
def test_rejects_branch_separator():
runner = RecordingRunner()
try:
release_ref("release/42; id")
except ValueError:
pass
else:
raise AssertionError("expected rejection")
assert runner.calls == []
That test checks the policy function, not the shell. Add an integration test for the process boundary too. Run a harmless fixture program that prints its received arguments with explicit indexes and lengths. Feed it inputs containing spaces, quotes, wildcard characters, parentheses, and line breaks. Confirm that each value arrives as one argument and that no second program starts.
Fuzzing is useful once the action has a schema. Generate random Unicode within and outside the accepted set, then assert one of two outcomes: the validator rejects it, or the fixed operation receives it as one bounded field. Do not fuzz against a production deployment target. The point is to find parser assumptions in the gateway and receiver before the agent reaches a real credentialed environment.
Finally, review every place that turns structured input back into text. That includes log viewers with clickable commands, generated shell snippets, CI configuration, error reports, chat notifications, and remote wrappers. Injection bugs often return when a safe structured request becomes a convenient string in a later component.
The first action worth fixing is usually the most flexible one. Replace run_command(command) with one operation that has a name, a small request schema, a fixed executable, and a test suite full of inputs nobody would type during a normal demo. That change removes an entire class of model behavior from your threat model and leaves you with rules you can actually inspect.
FAQ
Is shell quoting enough to prevent command injection?
Quoting only changes how one shell parses a value. It does not decide whether that value names an allowed file, branch, host, or API resource. Treat quoting as parser hygiene and validation as permission design; you need both.
Should an AI agent ever be allowed to use sh -c?
Usually, no. Use a process API that accepts an executable plus an argument array, with shell execution disabled. This removes the shell grammar, but you must still validate arguments against the semantics of the program you call.
Can a safe-looking path still cause damage?
A path such as ../../private/config contains no shell metacharacters and can still escape the intended directory. Resolve the path against an approved root, check containment after resolution, and account for symlinks before opening or acting on it.
Are argument arrays safe when the command runs over SSH?
No. A separate argument remains data to the local process, but SSH commonly sends a command string for a remote shell to parse. Send a constant remote command and pass structured input through standard input, then validate it again remotely.
How should I validate a Git branch supplied by an agent?
Reject names outside the grammar your workflow actually supports, then convert the accepted name into a fixed ref namespace such as refs/heads/ plus the name. Do not accept arbitrary revisions, ref expressions, or command options merely because Git can interpret them.
Why does indirect prompt injection matter for shell commands?
Prompt injection does not need to look malicious. A repository file, issue description, build log, or API response can tell an agent to pass a dangerous value into a tool. The tool boundary must enforce its own rules even when the agent follows its instructions faithfully.
Do approval dialogs prevent unsafe agent commands?
An approval tells you that a process may make a call. It does not turn an unsafe command template into a safe one, and it does not prove that the argument is within scope. Design the action safely before asking a human to approve it.
What should an audit log record for an agent command?
A useful audit record includes the executable, each argument as a separate field, the caller identity, the target context, the result, and a digest or securely retained copy of structured input. A single reconstructed command line loses the boundary information you need during an investigation.
Should I block special characters or use an allowlist?
Rejecting every unusual character is safe only if your product can live with the restricted grammar. Prefer an allowlist that matches the actual domain, such as simple deployment names or repository branches, and document the supported forms. Do not pretend that a generic blacklist understands every parser involved.
How can I test an agent action gateway for command injection?
Start with a tool that accepts one bounded operation, one structured request, and no shell command field. Test it with separators, substitutions, leading option characters, traversal strings, newlines, and values that are valid syntax but out of scope. If any case reaches an unintended action, the interface is too broad.