run
def run(
cmd, ignore_ex:bool=False, split:bool=False
):Run cmd in shell; return stdout (+ stderr if any); raise IOError on failure
safecmd checks shell commands against an allowlist before running them. Use it to put command and destination rules around an LLM’s shell access. The host chooses those rules.
extract_commands from safecmd.bashxtract parses Bash into an AST. It extracts commands from pipelines, substitutions ($(...)), and subshells, including nested commands. validate checks them against ok_cmds and checks output destinations against ok_dests. safe_run executes the command only after validation passes.
The allowlist permits named commands rather than trying to enumerate every dangerous command. It is not process isolation. An allowed program still runs with the user’s operating-system permissions. Review the default list before adopting it: it includes workspace-changing Git operations and package installs as well as read-only tools.
Command matching uses token prefixes. An ls entry permits ls, ls -la, and ls /home/user. A git status entry permits git status --short, but not git push. You can allow individual read-only subcommands such as git log, git status, and git diff without allowing git reset.
A denied flag rejects an otherwise-matching command. The default find rule blocks -delete.
exec_flags identify arguments that contain commands. For find -exec, the nested command gets its own validation: find . -exec ls passes, while find . -exec rm fails. dest_flags identify output paths. For example, curl -o /tmp/file url passes the default destination rules; curl -o /etc/passwd url fails.
Some commands use positional arguments instead of flags. env takes a command as its first argument. cp and mv take a destination as their last argument. exec_pos and dest_pos identify these positions, starting at zero after the command name. Negative indices count from the end. In configuration strings, env:exec=$0 checks the first argument as a command and cp:dest=$-1 checks the last argument as a destination.
Redirects such as > and >> also require an allowed destination. The defaults permit the current directory (./), /dev/null, and /tmp. normalize_dest expands relative paths to absolute paths before checking them. Change ok_dests to choose different destinations.
Edit config.ini to change your configuration. The module creates it from the defaults on first use, at the location returned by xdg_config_home():
~/.config/safecmd/config.ini~/Library/Application Support/safecmd/config.ini%LOCALAPPDATA%\safecmd\config.ini, typically C:\Users\<username>\AppData\Local\safecmd\config.iniThese locations follow the XDG Base Directory convention through fastcore’s platform-specific helper.
Call safe_run with a shell command string. For example, safe_run('ls -la | grep py') returns the filtered directory listing. The result combines stdout and stderr. A failed command raises IOError; an unapproved command or destination raises DisallowedCmd or DisallowedDest before execution.
The defaults include utilities such as cat, grep, ls, and diff, shell builtins such as cd, export, [, and true, and the Git subcommands listed below.
For one call, dests replaces the allowed destinations and add_dests extends them. For instance, safe_run(cmd, add_dests='~/') grants that call permission to write under the home directory. Permission-expanding options belong to the host, not to an LLM choosing its own permissions.
Run cmd in shell; return stdout (+ stderr if any); raise IOError on failure
run executes a shell command without validation. It returns stdout followed by stderr. With ignore_ex=True, it returns (returncode, output) instead of raising on a failed command. split=True keeps stdout and stderr separate.
def CmdSpec(
name, # the command (str, will be split into tuple)
denied:NoneType=None, # if set, these flags blocked
exec_flags:NoneType=None, # flags whose next arg is a command to validate
dest_flags:NoneType=None, # flags whose next arg is a destination to validate
exec_pos:NoneType=None, # positional arg indices (0-based) that are commands to validate
dest_pos:NoneType=None, # positional arg indices (0-based) that are destinations to validate
):Base class for objects needing a basic __repr__
A CmdSpec holds one command prefix and its argument rules. For example, CmdSpec('git log') stores ('git', 'log') as its name; it matches both git log and git log --oneline.
The other fields describe what to check:
denied: flags that reject the command.exec_flags and dest_flags: flags followed by a command or destination to validate.exec_pos and dest_pos: positions of command or destination arguments.Positions start at zero after the command name. Negative indices count backwards, so -1 means the last argument.
assert find(['find', '.', '-name', '*.py'])
assert not find(['find', '.', '-exec', 'rm'])
assert not find(['ls', '-la'])
# Combined short flags should be caught
tar = CmdSpec('tar', denied=['-I', '--to-command'])
assert tar(['tar', '-xvf', 'file.tar']) # allowed
assert not tar(['tar', '-I', 'zstd']) # exact match blocked
assert not tar(['tar', '-xvfI', 'zstd']) # combined flag blocked
assert not tar(['tar', '--to-command=cat']) # long flag still worksUse CmdSpec.from_str when writing configuration rather than Python objects. Its syntax is:
command:-flag1|-flag2:exec=-exec|$0:dest=-o|$-1
Colons separate sections; | separates values within a section. The first section names the command, including any subcommand such as git log. An unprefixed section lists denied flags, for example -delete|-ok. In an exec= or dest= section, a flag identifies the next argument as a command or destination. A $N value identifies a positional argument instead.
The $ resembles bash’s positional parameters, but these indices start after the command name. $0 means the first argument and $-1 means the last, using Python’s negative indexing.
For example:
| Configuration string | Meaning |
|---|---|
find:-delete:exec=-exec|-execdir |
Allow find, block -delete, and validate the command after -exec or -execdir |
cp:dest=$-1 |
Validate the last argument as a destination |
env:exec=$0 |
Validate the first argument as a command |
cat |
No special argument rules |
test_eq(CmdSpec.from_str('cat'), CmdSpec('cat'))
test_eq(CmdSpec.from_str('find:-delete:exec=-exec|-execdir'), CmdSpec('find', denied=['-delete'], exec_flags=['-exec', '-execdir']))
test_eq(CmdSpec.from_str('curl:dest=-o|--output'), CmdSpec('curl', dest_flags=['-o', '--output']))
test_eq(CmdSpec.from_str('git log'), CmdSpec('git log'))
test_eq(CmdSpec.from_str('env:exec=$0'), CmdSpec('env', exec_pos=[0]))
test_eq(CmdSpec.from_str('cp:dest=$-1'), CmdSpec('cp', dest_pos=[-1]))
test_eq(CmdSpec.from_str('tee:dest=$0'), CmdSpec('tee', dest_pos=[0]))
test_eq(CmdSpec.from_str('curl:dest=-o|$-1'), CmdSpec('curl', dest_flags=['-o'], dest_pos=[-1]))default_cfg supplies the initial command and destination rules. The argument rules near its end permit find -exec with command validation, block find -delete, and check the command arguments of env and xargs. They also identify the destination arguments of cp, mv, tee, and ex.
default_cfg = '''[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, mkdir: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
'''This creates the default file only if there isn’t already a config.ini at the XDG location.
Parse config string, return (ok_dests set, ok_cmds set of CmdSpecs)
parse_cfg reads the configuration into two sets: destination strings in ok_dests and CmdSpec objects in ok_cmds.
{'/dev/null', '/tmp', '/Users/jhoward/git', '/Users/jhoward/aai-ws', './'}
[aws dynamodb list-tables, bun install, unrar, yarn list, yq, folder2ctx, tr]
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 validate_cmd(['find', '.', '-exec', 'rm']) # -exec now handled by exec_flags, not denied
assert not validate_cmd(['find', '.', '-delete']) # -delete is still denied
assert not validate_cmd(['git', 'push'])Check if dest (resolved to absolute) matches an allowed destination pattern
Normalize destination to absolute path, expanding ~ and env vars
normalize_dest expands ~ and environment variables such as $HOME, makes the path absolute, and resolves .. components before validation. For example, ./subdir/../../escape must be checked as a path outside the current directory, not accepted because it starts with ./. validate_dest compares the normalized destination with the normalized entries in ok_dests.
cwd = os.getcwd()
home = os.path.expanduser('~')
parent = os.path.dirname(cwd)
# normalize_dest now returns absolute paths
test_eq(normalize_dest('file.txt'), f'{cwd}/file.txt')
test_eq(normalize_dest('./file.txt'), f'{cwd}/file.txt')
test_eq(normalize_dest('/tmp/file'), '/tmp/file')
test_eq(normalize_dest('../up.txt'), f'{parent}/up.txt')
test_eq(normalize_dest('~/home.txt'), f'{home}/home.txt')
test_eq(normalize_dest('$HOME/file'), f'{home}/file')
# With default ok_dests = {'./', '/tmp'}
assert validate_dest('file.txt') # /cwd/file.txt matches /cwd/
assert validate_dest('./subdir/f.txt') # /cwd/subdir/f.txt matches /cwd/
assert validate_dest('/tmp/test') # matches /tmp
assert not validate_dest('/etc/passwd') # no match
assert not validate_dest('../../../../up.txt') # resolves outside cwd - blocked!
assert not validate_dest('~/file') # ~/ not in defaultsA hook or UI can call validate to check a command before offering to run it. It checks the allowlists without executing the command, and raises DisallowedCmd or DisallowedDest on failure.
Validate cmd against allowlists; raises DisallowedCmd or DisallowedDest on failure
Before parsing the command, _build_flag_dicts gathers the four argument-rule dictionaries from the CmdSpec objects. Each maps command names to flag sets (exec_flags, dest_flags) or position sets (exec_pos, dest_pos). extract_commands uses them to find commands for recursive validation and destinations to check.
# Safe commands pass validation silently
validate('ls -la | grep py')
validate('git status && echo done')
validate('echo hi > file.txt') # allowed - writes to ./file.txt
validate('cat data > /tmp/out') # allowed - /tmp is ok
# Unsafe commands raise exceptions
with expect_fail(DisallowedCmd): validate('rm -rf /')
with expect_fail(DisallowedDest): validate('echo hi > /etc/badplace')
with expect_fail(DisallowedCmd): validate('ls $(rm -rf /)') # nested command caught
with expect_fail(DisallowedDest): validate('echo > ../../../../escape.txt') # parent dir not allowed
# Path traversal attacks - must be blocked
with expect_fail(DisallowedDest): validate('echo hi > ./../../../..') # escapes via ./..
with expect_fail(DisallowedDest): validate('echo hi > ./../../../../escape.txt') # escapes via ./../
with expect_fail(DisallowedDest): validate('echo hi > ./subdir/../../../../escape.txt') # nested escape
with expect_fail(DisallowedDest): validate('echo hi > /tmp/../bad.txt') # escape via /tmp/../
# Resolved paths that stay within allowed dirs should work
validate('echo hi > ./subdir/../file.txt') # resolves to ./file.txt, still in cwd# exec_flags: find -exec with allowed command passes, with disallowed or restricted command fails
validate('find . -exec ls')
with expect_fail(DisallowedCmd): validate('find / -exec rm')
# dest_flags: curl -o with allowed dest passes, with disallowed dest fails
validate('curl -o /tmp/out http://example.com')
with expect_fail(DisallowedDest): validate('curl -o /etc/passwd http://example.com')Positional exec/dest validation: env and xargs validate their first arg as a command, while cp, mv, tee, and ex validate their destination arg against ok_dests:
validate('env ls')
with expect_fail(DisallowedCmd): validate('env rm')
with expect_fail(DisallowedCmd): validate('xargs rm')
validate('cp src.txt ./dest.txt')
validate('mv old.txt /tmp/new.txt')
with expect_fail(DisallowedDest): validate('cp src.txt /etc/passwd')
with expect_fail(DisallowedDest): validate('mv old.txt /etc/shadow')
validate('tee /tmp/out.log')
validate('ex ./myfile.txt')
with expect_fail(DisallowedDest): validate('tee /etc/passwd')
with expect_fail(DisallowedDest): validate('ex /etc/shadow')wget allowed: True
cat allowed: False
dests: {'/dev/null', '~/Downloads', '/Users/jhoward/git', '/Users/jhoward/aai-ws', './'}
def safe_run(
cmd:str, # Bash command string to execute
cmds:str=None, # Allowed commands (comma-separated, config format); defaults to ok_cmds
dests:str=None, # Allowed destinations (comma-separated); defaults to ok_dests
add_cmds:str=None, # Temp add these commands
add_dests:str=None, # Temp add these destinations
rm_cmds:str=None, # Temp remove these commands
rm_dests:str=None, # Temp remove these destinations
ignore_ex:bool=False, # If True, return (returncode, output) instead of raising on error
split:bool=False, # If True, return stdout and stderr separately
)->str: # Combined stdout/stderr outputRun cmd in shell if all commands and destinations are in allowlists, else raise
safe_run applies per-call allowlist overrides, validates the command, then calls run. DisallowedCmd identifies a rejected command; DisallowedDest identifies a rejected output path.
test_eq(safe_run('ls ..'), run('ls ..'))
test_eq(safe_run('echo hello | cat'), 'hello\n')
test_eq(safe_run('[ -f /etc/passwd ] && echo exists'), 'exists\n')
assert '00_bashxtract.ipynb' in safe_run('find . -exec ls \;')
# Redirects to allowed destinations work
safe_run('echo test > /tmp/safecmd_test_xyz')
safe_run('echo test > test_file_xyz.txt')
with expect_fail(DisallowedCmd): safe_run(r'env rm -rf /asdfff')
with expect_fail(DisallowedDest): safe_run('echo hi > /badpath/file')
with expect_fail(DisallowedCmd): safe_run('find . -exec sudo ls \;')Pass ignore_ex=True to receive (returncode, output) when the command fails. Permission failures still raise.
For an LLM tool interface, use these wrappers around safe_run.
def bash(
cmd:str, # Bash command string to execute - all shell features like pipes and subcommands are supported
as_dict:bool=False, # Return a dict response with 'success' or 'error' key
rm_cmds:str=None, # Temp remove these commands from allow list
rm_dests:str=None, # Temp remove these destinations from allow list
):Run a bash shell command line safely and return the concatencated stdout and stderr. Since it is run with bash, special chars like $ and * are handled by the shell, so must be quoted if literal. cmd is parsed and all calls are checked against an allow-list. 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. Output redirects are validated against allowed destinations (default: ./ and /tmp). 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.
warning: sed not recommended here. Has not been run. Review available tools carefully.
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
dests:str=None, # Allowed destinations; defaults to ok_dests; 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_dests:str=None, # Temp add these destinations to allow list; DO NOT USE without upfront user permission
rm_cmds:str=None, # Temp remove these commands from allow list
rm_dests:str=None, # Temp remove these destinations 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. Output redirects are validated against allowed destinations. cmds/dests and add_/rm_ params are comma-separated strs.
unsafe_bash exposes the allowlist options that bash omits. Changing or extending permissions through cmds, dests, add_cmds, or add_dests requires the user’s explicit permission. If a command is denied, stop and let the user decide whether to allow it or run it themselves.
Remove comma-separated dests from the allow list
Remove comma-separated cmds from the allow list
Add comma-separated dests to the allow list; (this can not be used as an LLM tool)
Add comma-separated cmds to the allow list; (this can not be used as an LLM tool)
These functions modify the global ok_cmds and ok_dests sets at runtime. add_allowed_cmds and add_allowed_dests expand the allowlist, while rm_allowed_cmds and rm_allowed_dests restrict it. The add_ functions are intentionally not exposed as LLM tools to prevent an LLM from expanding its own permissions.
def ex(
path:str, # The file to run `ex` on
cmds:str='', # The commands to run (a 'heredoc' is used automatically, so embedded newlines work
sw:int=4, # shiftwidth for in/dedent commands
linenums:bool=False, # Return file listing with line numbers as response? (adds `%#` as final command)
as_dict:bool=False, # Return a dict response with 'success' or 'error' key
):Run ex commands on a file via bash. Always runs in noai and et mode. x is *alwaysadded at the end, so do not addwqorxtocmds. Useex(path, linenums=True)(i.e no cmds) to get an initial file listing with line numbers. Can also be used to create new files (useawith a non-existent path). Always enda/iblocks with.on its own line. ex commands include in/dedent, join,g/pat/cmd, copy/cut/paste, etc. Tip:grep -n/rg -nline numbers match ex addressing — find then fix. Tip: use#,@,+, or;as alternatesdelimiters to avoid escaping/.|` won’t work (command separator).
ex gives an LLM Vim’s editing commands with a check on the target path. It checks that path before creating parent directories, then passes the shell command through safe_run. This is path validation, not a restriction on Vim’s command language. Only expose it when you intend to grant those editing capabilities.
Run ex commands on a str and return the modified str, with line numbers optionally included. Use ex_str(s, linenums=True) (i.e no cmds) to get the str listing with line numbers. Always runs in noai and et mode.
1 apple
2 banana
3 cherry
4 date
5 elderberry
def sed(
path:str, # The file to run `sed` on
cmds:str, # The sed arguments to use (e.g `s/x/y/`, `1,$p`, …)
inplace:bool=False, # Same as `sed -i '' …`
quiet:bool=False, # Same as `sed -n …`
linenums:bool=False, # Show file with line numbers after (inplace) or number output lines
as_dict:bool=False, # Return a dict response
):Run the sed command with the args in argstr (e.g for reading a section of a file)
Run ripgrep with args (which should not* be escaped)*
1:.cell {
5:.cell > .sourceCode {
9:.cell-output > pre {
13:.cell-output > pre, .cell-output > .sourceCode > pre, .cell-output-stdout > pre {
22:.cell-output > .sourceCode {
26:.cell-output > .sourceCode {
From a terminal, put the command after safecmd:
Quote a pipeline so your shell passes the whole expression to safecmd:
The CLI joins its arguments into one string and passes it to safe_run for validation and execution. A disallowed command prints an error and exits with code 1.
Scripts and other tools can use the same CLI to apply their configured allowlists before running a command.