from fastcore.test import test_fail,test_eqCore API
Introduction
safecmd.core provides a safe execution layer for shell commands. It’s designed for situations where you need to run bash commands from untrusted sources—such as LLM-generated commands—while ensuring they can’t modify your system in dangerous ways.
The module builds on top of safecmd.bashxtract (which parses bash into an AST and extracts commands) to validate commands against an allowlist before execution. The key insight is that rather than trying to blacklist dangerous commands (which is error-prone), we whitelist a generous set of read-only and easily-reverted commands that are safe to run.
The core workflow is:
- Parse the bash command string using
extract_commands()from bashxtract - Check each extracted command against
ok_cmds(the allowlist). Commands inside substitutions ($(...)), subshells, pipelines, etc are extracted recursively, so nested commands are also validated. - Check that only safe operators are used (pipes, semicolons, etc.—but not redirects like
>) - If everything passes, execute the command and return the result
This approach handles complex bash syntax correctly—pipelines, command substitutions, subshells, and more—because it uses a proper bash parser rather than regex or string splitting.
The allowlist (ok_cmds) uses prefix matching to determine if a command is permitted. A simple entry like 'ls' matches any command starting with ls—so ls, ls -la, and ls /home/user are all allowed. A multi-word entry like 'git status' only matches commands that start with both those words—so git status and git status --short are allowed, but git push is not.
This prefix approach lets you be precise about which subcommands are safe. For instance, you might allow git log, git status, and git diff (all read-only) while blocking git push and git reset (which modify state).
Some commands are mostly safe but have a few dangerous flags. For example, find is useful for searching files, but its -exec flag can run arbitrary commands—which defeats our safety guarantees. For these cases, you can specify a denied list of flags that will cause the command to be rejected. So we allow find . -name '*.py' but block find . -exec rm {} \; because -exec is in the denied list.
The operators in a command are also checked. By default, pipes (|), logical operators (&&, ||), semicolons (;), and input redirection (<) are allowed. But output redirection (>, >>) is blocked by default since it writes to files.
The first time this module is used a config file (config.ini) is created with the default configuration. The file location follows the XDG Base Directory spec via xdg_config_home():
- Linux:
~/.config/safecmd/config.ini - macOS:
~/Library/Application Support/safecmd/config.ini - Windows:
%LOCALAPPDATA%\safecmd\config.ini(typicallyC:\Users\<username>\AppData\Local\safecmd\config.ini)
This file can be edited to change configuration.
How to use
The simplest way to use safecmd is to call safe_run() with a bash command string. This function validates the command against the built-in allowlist and executes it if safe, returning the combined stdout/stderr output as a string. If the command fails, it raises an IOError. If the command or operators aren’t allowed, it raises either DisallowedCmd or DisallowedOps.
For example: safe_run('ls -la | grep py') will execute and return the filtered directory listing, while safe_run('rm -rf /') will raise a DisallowedCmd exception before anything dangerous happens.
The module comes with a predefined set of safe commands. This includes common read-only utilities like cat, grep, ls, diff, builtins like cd, export, [, and true, as well as safe git subcommands like git log, git status, and git diff. The find command is included with a denied list that blocks dangerous flags like -exec and -delete.
You can also customize the allowed operators by passing an ops parameter to safe_run(). The default set allows pipes, input redirection, logical operators, and command sequences, but blocks output redirection. If you want to allow writing to files, you could call safe_run(cmd, ops=ok_ops | {'>', '>>'}).
API
Helpers
run
def run(
cmd, ignore_ex:bool=False
):
Run cmd in shell; return stdout (+ stderr if any); raise IOError on failure
Executes a shell command and returns its combined stdout/stderr output. If ignore_ex=True, returns a tuple of (returncode, output) instead of raising on failure. This is the low-level execution function—it doesn’t do any safety checking.
test_eq(run('echo hello'), 'hello')
test_eq(run('echo out; echo err >&2'), 'out\nerr')
test_eq(run('exit 1', ignore_ex=True), (1, ''))
test_eq(run('echo fail >&2; exit 1', ignore_ex=True), (1,'fail'))
test_fail(lambda: run('exit 1'))Command Specifications
CmdSpec
def CmdSpec(
name, # the command (str, will be split into tuple)
denied:NoneType=None, # if set, these flags blocked
):
Base class for objects needing a basic __repr__
CmdSpec represents an allowed command with optional denied flags. The name is stored as a tuple for prefix matching—so CmdSpec('git log') matches git log, git log --oneline, etc. The denied set contains flags that will cause the command to be rejected even if the prefix matches.
find = CmdSpec('find', denied=['-exec', '-delete'])
findfind !{'-delete', '-exec'}
assert find(['find', '.', '-name', '*.py'])
assert not find(['find', '.', '-exec', 'rm'])
assert not find(['ls', '-la'])The from_str classmethod provides a compact string syntax for creating CmdSpec objects. The format is command:-flag1|-flag2 where the colon separates the command name from comma-separated denied flags.
For example, CmdSpec.from_str('find:-exec|-delete') creates a spec that allows find but blocks -exec and -delete flags. If no denied flags are needed, just pass the command name: CmdSpec.from_str('cat').
test_eq(CmdSpec.from_str('cat'), CmdSpec('cat'))
test_eq(CmdSpec.from_str('find:-exec|-delete'), CmdSpec('find', denied=['-exec', '-delete']))
test_eq(CmdSpec.from_str('git log'), CmdSpec('git log'))Default Allowlists
In the default configuration, ok_ops permits pipes, input fd and file redirection, out fd redirection, and logical/sequential operators — but blocks output file redirection. ok_cmds contains a generous set of read-only commands plus some safe git operations. Note that find blocks dangerous flags like -exec:
Exported source
default_cfg = '''[DEFAULT]
ok_ops = |, <, &&, ||, ;, |&, <&file, <&, >&
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 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
# Builtins
cd, pwd, export, test, [, true, false
# Deny-lists
find:-exec|-execdir|-delete|-ok|-okdir
rg:--pre
tar:--to-command|--use-compress-program|-I|--transform|--checkpoint-action|--info-script|--new-volume-script
curl:-o|--output|-O|--remote-name
'''# cfg_path.unlink()If config.ini doesn’t exist, it’s created with the default configuration, in the file location following the XDG Base Directory spec.
parse_cfg
def parse_cfg(
cfg_str
):
Parse config string, return (ok_ops set, ok_cmds set of CmdSpecs)
The config is parsed into ok_ops and ok_cmds.
print(ok_ops)
list(ok_cmds)[:7]{'<&file', ';', '&&', '|', '>&', '<', '||', '|&', '<&'}
[bunzip2,
stat,
tr,
unrar,
git cat-file,
date,
curl !{'--output', '-o', '--remote-name', '-O'}]
Safe Execution
validate_cmd
def validate_cmd(
toks, cmds:NoneType=None
):
Check if toks matches an allowed command; returns False if denied flags present
validate_cmd checks whether a tokenized command matches any entry in the allowlist by calling each CmdSpec until one returns True.
assert validate_cmd(['ls', '-la'])
assert validate_cmd(['git', 'status'])
assert validate_cmd(['find', '.', '-name', '*.py'])
assert not validate_cmd(['find', '.', '-exec', 'rm'])
assert not validate_cmd(['rm', '-rf', '/'])
assert not validate_cmd(['git', 'push'])DisallowedCmd
def DisallowedCmd(
cmd
):
Not enough permissions.
DisallowedOps
def DisallowedOps(
ops
):
Not enough permissions.
DisallowedError
def DisallowedError(
args:VAR_POSITIONAL, kwargs:VAR_KEYWORD
):
Not enough permissions.
validate checks a bash command string against the allowlists without executing it. This is useful for pre-validation (e.g., in hooks or UI) where you want to know if a command would be allowed before actually running it. It raises DisallowedOps or DisallowedCmd if validation fails.
validate
def validate(
cmd:str, # Bash command string to validate
cmds:NoneType=None, # Allowed commands set; defaults to ok_cmds
ops:NoneType=None, # Allowed operators set; defaults to ok_ops
):
Validate cmd against allowlists; raises DisallowedOps or DisallowedCmd on failure
# Safe commands pass validation silently
validate('ls -la | grep py')
validate('git status && echo done')
# Unsafe commands raise exceptions
test_fail(lambda: validate('rm -rf /'), exc=DisallowedCmd)
test_fail(lambda: validate('echo hi > file'), exc=DisallowedOps)
test_fail(lambda: validate('ls $(rm -rf /)'), exc=DisallowedCmd) # nested command caughtsafe_run
def safe_run(
cmd:str, # Bash command string to execute
cmds:str=None, # Allowed commands (comma-separated, config format); defaults to ok_cmds
ops:str=None, # Allowed operators (comma-separated); defaults to ok_ops
add_cmds:str=None, # Temp add these commands
add_ops:str=None, # Temp add these operators
rm_cmds:str=None, # Temp remove these commands
rm_ops:str=None, # Temp remove these operators
ignore_ex:bool=False, # If True, return (returncode, output) instead of raising on error
)->str: # Combined stdout/stderr output
Run cmd in shell if all commands and operators are in allowlists, else raise
safe_run is the main entry point. It parses the bash command, validates all extracted commands and operators against the allowlists, and only executes if everything passes. DisallowedOps and DisallowedCmd are raised for violations, giving clear error messages about what was blocked.
test_eq(safe_run('ls'), run('ls'))
test_eq(safe_run('echo hello | cat'), 'hello')
test_eq(safe_run('[ -f /etc/passwd ] && echo exists'), 'exists')
test_fail(lambda: safe_run('env rm -rf asdfff'), exc=DisallowedCmd)
test_fail(lambda: safe_run('echo hi > file'), exc=DisallowedOps)
test_fail(lambda: safe_run('exec=-exec; find asdf $exec somecmd'), exc=DisallowedOps)
test_fail(lambda: safe_run('find . -exec rm'), exc=DisallowedCmd)Pass ignore_ex=True to return a tuple of return_code,result instead of raising on error.
safe_run('cat /nonexistent_xyz123 2>&1', ignore_ex=True)(1, 'cat: /nonexistent_xyz123: No such file or directory')
Bash tool
In Solveit, any function with types and a docstring can be used as a tool. Instead of raising an exception, it’s best to return a success/error dict. The functions in this section wrap safe_run in this way, and provide documentation suitable for an LLM.
bash
def bash(
cmd:str, # Bash command string to execute - all shell features like pipes and subcommands are supported
rm_cmds:str=None, # Temp remove these commands from allow list
rm_ops:str=None, # Temp remove these operators from allow list
):
Run a bash shell command line safely and return the concatencated stdout and stderr. cmd is parsed and all calls are checked against an allow-list. If the command is not allowed, STOP and inform the user of the command run and error details; so they can decide whether to whitelist it or run it themselves. The default allow-list includes most standard unix commands and git subcommands that do not change state or are easily reverted. All operators are supported (including ;, &&, ||, >&, <&, pipes, and subshells), except output file redirection. rm_ params are comma-separated strs.
bash does not surface any parameters that could allow the LLM to add or change the allowed tool list.
bash('ls | head -2'){'success': '_quarto.yml\n00_bashxtract.ipynb'}
bash('ls | head -2', rm_cmds='head'){'error': DisallowedCmd('head -2')}
bash('sudo ls'){'error': DisallowedCmd('sudo ls')}
unsafe_bash
def unsafe_bash(
cmd:str, # Bash command string to execute - all shell features like pipes and subcommands are supported
cmds:str=None, # Allowed commands; defaults to ok_cmds; DO NOT USE without upfront user permission
ops:str=None, # Allowed operators; defaults to ok_ops; DO NOT USE without upfront user permission
add_cmds:str=None, # Temp add these commands to allow list; DO NOT USE without upfront user permission
add_ops:str=None, # Temp add these operators to allow list; DO NOT USE without upfront user permission
rm_cmds:str=None, # Temp remove these commands from allow list
rm_ops:str=None, # Temp remove these operators from allow list
):
Run a bash shell command line safely and return the output. cmd is parsed and all calls are checked against an allow-list. If the command is not allowed, STOP and inform the user of the command run and error details; so they can decide whether to whitelist it or run it themselves. The default allow-list includes most standard unix commands and git subcommands that do not change state or are easily reverted. All operators are supported (including ;, &&, ||, pipes, and subshells), except output redirection. cmds/ops and add_/rm_ params are comma-separated strs.
rm_allowed_ops
def rm_allowed_ops(
ops
):
Remove comma-separated ops from the allow list
rm_allowed_cmds
def rm_allowed_cmds(
cmds:str
):
Remove comma-separated cmds from the allow list
add_allowed_ops
def add_allowed_ops(
ops
):
Add comma-separated ops to the allow list; (this can not be used as an LLM tool)
add_allowed_cmds
def add_allowed_cmds(
cmds
):
Add comma-separated cmds to the allow list; (this can not be used as an LLM tool)
rm_allowed_cmds('ls')bash('ls -l'){'error': DisallowedCmd('ls -l')}
CLI
main
def main(
):
The CLI provides a simple command-line interface to safe_run.
Usage:
safecmd ls -laIf you have pipes etc, you’ll need to quote the whole command:
safecmd 'ls -la | grep py'The command and all its arguments are joined back into a single string and passed to safe_run, which validates against the allowlist before execution. If the command isn’t allowed, it shows an error and returns exit code of 1.
This lets you use safecmd as a drop-in replacement for running untrusted commands from scripts or other tools: anything not in the allowlist is blocked before execution.