
Auditing CLI AI Tools for Unsafe Command Execution
CLI AI tools look harmless because they live in a terminal and print a plan before they act. That can be misleading. The risky part is not the chat loop; it is the moment the tool turns model output, user text, or retrieved content into a shell command.
Why CLI AI tools are risky
I usually start with one question: what can this tool do without another human in the loop?
If it can edit files, run git, call package managers, or execute arbitrary commands, then the model is sitting on a real trust boundary. A prompt is not the same thing as a shell argument, but a lot of tools quietly treat them that way. The result is predictable:
- a harmless-looking instruction becomes a command
- user content gets stitched into a shell string
- a “helpful” automation step runs with the current user's privileges
The impact is usually local, but local is enough. A bad command can overwrite files, leak secrets from the environment, install packages, or trigger cleanup steps you never meant to run.
The trust boundary that gets ignored
The mistake is assuming the model only produces suggestions. In CLI tools, the model often influences three different things:
- the command itself
- the working directory or target file list
- whether the command is approved automatically
That is a lot of authority for something that can be nudged by prompt injection, pasted text, issue descriptions, README files, or repository content.
A good audit treats model output as untrusted input until the very last step.
What to inspect before you run it
Command construction and shell escaping
Look for any path where the tool builds a command as a single string and hands it to a shell. That is where quoting bugs and injection bugs usually show up.
import { exec } from "node:child_process";
function runTest(fileName) {
exec(`cat ${fileName}`, (err, stdout) => {
if (err) throw err;
console.log(stdout);
});
}If fileName comes from model output or user input, the shell gets to interpret it. That is the wrong boundary.
Tool permission scopes and approval prompts
Some tools claim they are safe because they ask for approval. I do not trust that until I check what actually gets approved.
Questions that matter:
- Can the model choose the command before approval?
- Can it swap arguments after approval?
- Does one approval cover multiple destructive operations?
- Is there a non-interactive mode that skips review entirely?
If the approval prompt shows a sanitized summary but the runtime uses a different command string, the review step is mostly theater.
Logging, dry runs, and replayability
A serious CLI tool should leave a trace you can inspect later.
Look for:
- raw command logging
- argument logging, not just prettified summaries
- dry-run mode that does not mutate state
- replayable runs with the same inputs
Without that, you cannot tell whether the tool was safe, lucky, or just quiet.
Reproducing unsafe execution in a safe test setup
Building a harmless wrapper to observe commands
I like to wrap the dangerous interface first. You do not need to execute real destructive actions to learn a lot.
import { spawn } from "node:child_process";
export function runObserved(cmd, args = []) {
console.log("COMMAND:", cmd);
console.log("ARGS:", JSON.stringify(args));
return spawn(cmd, args, { stdio: "pipe" });
}Then point the tool at the wrapper or shim the binary path in a test container. If the tool claims to use safe arguments but your wrapper prints one long shell string, you already found a problem.
Checking whether user text reaches the shell
Use harmless marker text, not exploit payloads. I usually test with input that should never affect execution, such as a filename containing spaces and punctuation.
If the tool is safe, the marker stays an argument. If it is unsafe, you will see shell parsing behavior leak through.
| Test input | Safe result | Unsafe result |
|---|---|---|
report final.txt | one filename argument | split into multiple tokens |
a;b.txt | literal filename | shell separator behavior |
$(marker).txt | literal text | command substitution path |
That is enough to prove whether the tool respects argument boundaries.
Concrete defenses that actually help
Argument arrays over shell strings
Use spawn() or equivalent APIs that pass argument arrays directly. Do not build commands by concatenating strings unless you absolutely have to, and if you do, treat that as a security review item.
import { spawn } from "node:child_process";
function runSafe(fileName) {
return spawn("cat", [fileName], { stdio: "inherit" });
}This does not solve every problem, but it does remove the shell from the equation.
Explicit allowlists for destructive actions
If the tool can delete files, push commits, write outside a workspace, or install dependencies, those actions should be explicit and narrow.
I want to see allowlists like:
- only these commands
- only these flags
- only these directories
- only these modes of operation
A broad “approved by the model” permission model is not enough.
Separate planning from execution
The best pattern I have seen is a two-step flow:
- the model drafts a plan
- a separate deterministic executor validates and runs it
That executor should reject anything outside policy before it reaches the shell. If the same model both proposes and executes the command, you are trusting one component with too much.
What a good audit report should say
A useful report does not just say “command injection possible.” It should explain where the trust boundary fails and how to fix it.
A strong write-up usually includes:
- the exact input source that reaches execution
- whether the tool uses a shell or direct process spawn
- whether approval happens before or after command construction
- whether the issue is exploitable with normal user input
- a concrete remediation path
If you can show that a free-form prompt or repository text changes a command without strict validation, that is the core bug.
Conclusion
CLI AI tools are only as safe as their execution layer. Once the model can influence shell commands, the question is no longer whether the interface looks interactive. The real question is whether untrusted text can cross into execution without strict argument handling, policy checks, and review. If you cannot answer that clearly, the tool is not ready for trust.


