Core API

Validate shell commands and write destinations against allowlists, with Bash, ex, sed, and ripgrep tools

Introduction

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():

  • Linux: ~/.config/safecmd/config.ini
  • macOS: ~/Library/Application Support/safecmd/config.ini
  • Windows: %LOCALAPPDATA%\safecmd\config.ini, typically C:\Users\<username>\AppData\Local\safecmd\config.ini

These locations follow the XDG Base Directory convention through fastcore’s platform-specific helper.

How to use

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.

API

Helpers


source

run

def run(
    cmd, ignore_ex:bool=False, split:bool=False
):

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.

from fastcore.test import expect_fail,test_eq
test_eq(run('echo hello'), 'hello\n')
test_eq(run('echo out; echo err >&2'), 'out\nerr\n')
test_eq(run('exit 1', ignore_ex=True), (1, ''))
test_eq(run('echo fail >&2; exit 1', ignore_ex=True), (1,'fail\n'))
with expect_fail(): run('exit 1')

Command Specifications


source

CmdSpec

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.

find = CmdSpec('find', denied=['-exec', '-delete'])
find
find !{'-delete', '-exec'}
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 works

Use 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 Allowlists

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.

Exported source
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
'''
# cfg_path.unlink()

This creates the default file only if there isn’t already a config.ini at the XDG location.


source

parse_cfg

def parse_cfg(
    cfg_str
):

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.

print(ok_dests)
list(ok_cmds)[:7]
{'/dev/null', '/tmp', '/Users/jhoward/git', '/Users/jhoward/aai-ws', './'}
[aws dynamodb list-tables, bun install, unrar, yarn list, yq, folder2ctx, tr]
first(o for o in ok_cmds if str(o).startswith('find'))
find !{'-delete', '-ok', '-okdir'} exec={'-execdir', '-exec'}

Safe Execution


source

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 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'])

source

DisallowedDest

def DisallowedDest(
    dest
):

Not enough permissions.


source

DisallowedCmd

def DisallowedCmd(
    cmd, name:NoneType=None
):

Not enough permissions.


source

DisallowedError

def DisallowedError(
    *args, **kwargs
):

Not enough permissions.


source

validate_dest

def validate_dest(
    dest, dests:NoneType=None
):

Check if dest (resolved to absolute) matches an allowed destination pattern


source

normalize_dest

def normalize_dest(
    dest
):

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 defaults

A 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.


source

validate

def validate(
    cmd:str, # Bash command string to validate
    cmds:NoneType=None, # Allowed commands set; defaults to ok_cmds
    dests:NoneType=None, # Allowed destinations set; defaults to ok_dests
):

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')
cmds, dests = _eff_sets(add_cmds='wget', add_dests='~/Downloads', rm_cmds='cat', rm_dests='/tmp')
print('wget allowed:', any(s.name==('wget',) for s in cmds))
print('cat allowed:', any(s.name==('cat',) for s in cmds))
print('dests:', dests)
wget allowed: True
cat allowed: False
dests: {'/dev/null', '~/Downloads', '/Users/jhoward/git', '/Users/jhoward/aai-ws', './'}

source

safe_run

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 output

Run 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 \;')
!rm -f test_file_xyz.txt

Pass ignore_ex=True to receive (returncode, output) when the command fails. Permission failures still raise.

safe_run('cat /nonexistent_xyz123 2>&1', ignore_ex=True)
(1, 'cat: /nonexistent_xyz123: No such file or directory\n')

Bash tools

For an LLM tool interface, use these wrappers around safe_run.


source

bash

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.

print(bash('ls | head -2'))
_quarto.yml
00_bashxtract.ipynb
print(bash('sed a'))
warning: sed not recommended here. Has not been run. Review available tools carefully.
try: bash('ls | head -2', rm_cmds='head')
except DisallowedCmd as e: assert any('allowed_cmds' in n for n in e.__notes__)
else: assert False, "expected DisallowedCmd"
with expect_fail(DisallowedCmd): bash('sudo ls')

source

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
    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.


source

rm_allowed_dests

def rm_allowed_dests(
    dests
):

Remove comma-separated dests from the allow list


source

rm_allowed_cmds

def rm_allowed_cmds(
    cmds:str
):

Remove comma-separated cmds from the allow list


source

add_allowed_dests

def add_allowed_dests(
    dests
):

Add comma-separated dests to the allow list; (this can not be used as an LLM tool)


source

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)

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.

rm_allowed_cmds('ls')
with expect_fail(DisallowedCmd): bash('ls -l')

source

ex

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.

print(ex('styles.css', cmds='2p'))
  margin-bottom: 1rem;
print(ex('styles.css', cmds='1,3#'))
  1 .cell {
  2   margin-bottom: 1rem;
  3 }
%%writefile /tmp/foo
foobar
goobar
Overwriting /tmp/foo
print(ex('/tmp/foo', linenums=True))
  1 foobar
  2 goobar
print(ex('/tmp/foo', cmds='%s/foo/bar', linenums=True))
  1 barbar
  2 goobar

source

ex_str

def ex_str(
    s:str, # The str 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, # Include line numbers in response?
    as_dict:bool=False, # Return a dict response
):

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.

s = "apple\nbanana\ncherry\ndate\nelderberry"
print(ex_str(s, linenums=True))
  1 apple
  2 banana
  3 cherry
  4 date
  5 elderberry
print(ex_str(s, '3,$d'))
apple
banana

source

sed

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)

print(sed('styles.css', '1,3p', quiet=True))
.cell {
  margin-bottom: 1rem;
}
print(sed('styles.css', '/margin-top/p', quiet=True))
  margin-top: 0;
  margin-top: 0;
print(sed('/etc/bad', "s/x/y/", inplace=True))
err: /etc/bad

source

rg

def rg(
    args:str, rm_cmds:str=None, rm_dests:str=None
):

Run ripgrep with args (which should not* be escaped)*

print(rg("-n cell styles.css"))
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 {
with expect_fail(DisallowedCmd): rg("--pre cat foo .")
with expect_fail(DisallowedCmd): rg("--hostname-bin evil foo .")

os.environ['RIPGREP_CONFIG_PATH'] = '/tmp/evil.conf'
rg("-n cell styles.css")
test_eq(os.environ.get('RIPGREP_CONFIG_PATH'), '/tmp/evil.conf')

CLI


source

main

def main():

From a terminal, put the command after safecmd:

safecmd ls -la

Quote a pipeline so your shell passes the whole expression to safecmd:

safecmd 'ls -la | grep py'

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.