cmd = '''
echo | head 2 <<EOF
asdf
jkljl
EOF
'''Bashxtract API
Introduction
safecmd.bashxtract provides tools for parsing and extracting commands from bash command strings. It’s designed for security-conscious applications where you need to understand exactly what commands a shell script will execute before running it.
The core use case is validating shell commands from untrusted sources (like LLM-generated commands) against an allowlist. Rather than trying to regex-match bash syntax—which is notoriously tricky—this module uses shfmt, a proper bash parser, to build an AST and then extracts all executable commands from it.
It’s likely that the only function you’ll actually need from here is extract_commands. But we provide a full API of all the pieces we use to build that function, which we’ll take you through here.
The Problem
Suppose we want to find the commands in this pipeline. Let’s try shlex.split:
shlex.split(cmd)['echo', '|', 'head', '2', '<<EOF', 'asdf', 'jkljl', 'EOF']
That gives us tokens, but it loses the distinction we need. echo and head are separate commands; | connects them. The text after <<EOF supplies input to head, not more arguments. shlex treats them all as arguments.
We’ll use shfmt to parse the bash syntax into a JSON AST (abstract syntax tree). The shfmt-py dependency supplies its binary when you install safecmd:
!shfmt --help 2>&1 | head -6usage: shfmt [flags] [path ...]
shfmt formats shell programs. If the only argument is a dash ('-') or no
arguments are given, standard input will be used. If a given path is a
directory, all shell scripts found under that directory will be used.
The flag we need is --to-json:
!shfmt --help 2>&1 | grep to-json --to-json print syntax tree to stdout as a typed JSON
shfmt returns nested dictionaries. A node’s Type tells us what it contains: CallExpr is a command invocation, BinaryCmd joins commands with a pipeline or logical operator, and Word holds an argument.
For our heredoc example, we want to extract:
- The commands:
['echo']and['head', '2'] - The operators used:
{'|'}(a pipe) - The heredoc content attached to the command that receives it
Our goal is to walk the AST and produce commands with their arguments and a set of operators that we can validate against an allowlist.
We’ll build up to extract_commands in these stages:
- Parsing (
parse_bash): Convert bash syntax to a JSON AST usingshfmt. - Text extraction (
part_text,word_text): Recover argument text, including quotes, escapes, and expansions. - AST walking (
collect_cmds): Find every command in the AST, including commands in loops, conditions, function bodies and substitutions. - Operator detection (
collect_ops): Identify pipes, redirects, and logical operators. - Validation (
check_types): Reject bash constructs we don’t understand. - Main API (
extract_commands): Combine these pieces into one interface.
Parsing
parse_bash runs shfmt --to-json and loads its output into a Python dict. A missing binary raises FileNotFoundError; a syntax error raises ValueError with shfmt’s diagnostic.
parse_bash('echo hello'){'Type': 'File',
'Pos': {'Offset': 0, 'Line': 1, 'Col': 1},
'End': {'Offset': 10, 'Line': 1, 'Col': 11},
'Stmts': [{'Pos': {'Offset': 0, 'Line': 1, 'Col': 1},
'End': {'Offset': 10, 'Line': 1, 'Col': 11},
'Cmd': {'Type': 'CallExpr',
'Pos': {'Offset': 0, 'Line': 1, 'Col': 1},
'End': {'Offset': 10, 'Line': 1, 'Col': 11},
'Args': [{'Pos': {'Offset': 0, 'Line': 1, 'Col': 1},
'End': {'Offset': 4, 'Line': 1, 'Col': 5},
'Parts': [{'Type': 'Lit',
'Pos': {'Offset': 0, 'Line': 1, 'Col': 1},
'End': {'Offset': 4, 'Line': 1, 'Col': 5},
'ValuePos': {'Offset': 0, 'Line': 1, 'Col': 1},
'ValueEnd': {'Offset': 4, 'Line': 1, 'Col': 5},
'Value': 'echo'}]},
{'Pos': {'Offset': 5, 'Line': 1, 'Col': 6},
'End': {'Offset': 10, 'Line': 1, 'Col': 11},
'Parts': [{'Type': 'Lit',
'Pos': {'Offset': 5, 'Line': 1, 'Col': 6},
'End': {'Offset': 10, 'Line': 1, 'Col': 11},
'ValuePos': {'Offset': 5, 'Line': 1, 'Col': 6},
'ValueEnd': {'Offset': 10, 'Line': 1, 'Col': 11},
'Value': 'hello'}]}]},
'Position': {'Offset': 0, 'Line': 1, 'Col': 1}}]}
Text Extraction
part_text
def part_text(
p, cmd
):Extracts the text value from a single word part node in the shfmt AST.
An argument can contain several parts. part_text removes backslash-space escapes from literals and recovers single- or double-quoted text. It keeps parameter expressions such as $var, ${var}, and ${arr[0]}. For command and process substitutions, it uses the AST’s source offsets to copy the original expression.
part_text({'Type': 'SglQuoted', 'Value': 'foo bar'}, "echo 'foo bar'")'foo bar'
word_text
def word_text(
w, cmd
):Converts a Word node (with Parts) into its full text repr by concatenating part_text for each part.
word_text({'Parts': [{'Type': 'Lit', 'Value': 'hello'}]}, 'echo hello')'hello'
AST Walking
The AST stores operators as numeric codes. Rather than hard-code them, _ensure_ops parses a small sample of each operator and caches the codes on first use:
_ensure_ops()
_op_map, _redir_ops, _write_ops({11: '&&', 12: '||', 13: '|', 14: '|&'},
{63: '>', 64: '>>', 65: '<', 67: '<&', 68: '>&', 74: '&>', 76: '&>>'},
{63: '>', 64: '>>', 74: '&>', 76: '&>>'})
collect_cmds
def collect_cmds(
node, cmd
):Walk AST node and collect every command it runs as a token list
collect_cmds walks the whole AST and returns each command it finds as a token list, [cmd, arg1, arg2, ...]. It finds commands in pipelines and lists, in loop and if conditions and bodies, in function bodies, and in $(...) substitutions inside arguments, assignments, redirect targets and heredocs. A declaration such as export FOO=1 gives its keyword and arguments: ['export', 'FOO=1']. A statement’s heredoc text, or a here-string’s <<< and content, goes at the end of that statement’s command tokens. Each command comes before the commands nested inside it. Here, date follows the echo that contains it:
loop = 'while true; do sleep 300; echo $(date); done'
collect_cmds(parse_bash(loop), loop)[['echo', 'foo'], ['cat', 'file']]
collect_ops
def collect_ops(
node, ops:NoneType=None
):Walk AST node and collect all operators into a set
collect_ops records background execution (&), semicolons (;), and assignments (=) from node attributes. It looks up binary operators such as &&, ||, |, and |& in _op_map, and redirects such as >, >>, and < in _redir_ops.
collect_ops(parse_bash('echo a && echo b | cat > out.txt')){'&&', '>', '|'}
collect_redirects
def collect_redirects(
node, cmd, redirects:NoneType=None
):Walk AST node and collect all write redirect destinations as (op, dest) tuples
collect_redirects extracts all write redirect destinations from the AST. For each output redirect (>, >>, &>, &>>, >&), it returns a tuple of (operator, destination). This is used to validate that commands only write to allowed destinations.
>&file writes stdout and stderr to a file. >&2 duplicates a file descriptor instead, so it contributes no write destination.
cmd = 'echo a && echo b | cat > out.txt'
collect_redirects(parse_bash(cmd), cmd)[('>', 'out.txt')]
from fastcore.test import test_eq, expect_faildef _redirects(cmd): return collect_redirects(parse_bash(cmd), cmd)
test_eq(_redirects('echo hi > out.txt'), [('>', 'out.txt')])
test_eq(_redirects('echo hi >> log.txt'), [('>>', 'log.txt')])
test_eq(_redirects('echo hi &> both.txt'), [('&>', 'both.txt')])
test_eq(_redirects('echo hi >& both.txt'), [('>&', 'both.txt')])
test_eq(_redirects('cat > a > b'), [('>', 'a'), ('>', 'b')])
test_eq(_redirects('echo hi | cat'), []) # no redirects
test_eq(_redirects('echo hi < in.txt'), []) # input redirect, not output
test_eq(_redirects('cmd > "$HOME/file"'), [('>', '$HOME/file')]) # variable in destscan_flag_args
def scan_flag_args(
commands, exec_flags:NoneType=None, dest_flags:NoneType=None, dest_pos:NoneType=None, exec_pos:NoneType=None
):Scan commands for exec/dest flags and positional exec/dest args
Some arguments name another command or a write destination. scan_flag_args finds these using four dictionaries, each keyed by command name:
| Dictionary | What to collect | Example |
|---|---|---|
exec_flags |
The argument after each listed flag, as a command | {'find': {'-exec', '-execdir'}} |
dest_flags |
The argument after each listed flag, as a destination | {'curl': {'-o', '--output'}} |
dest_pos |
Arguments at the listed destination positions | {'ex': {0}, 'cp': {-1}} |
exec_pos |
Arguments at the listed command positions | {'env': {0}, 'xargs': {0}} |
Positions start at zero after the command name. Negative indices count from the end, as in Python.
The result is (extra_cmds, extra_dests). The caller recursively parses each string in extra_cmds. Each destination is a (flag_or_idx, dest) pair for the redirect validator.
exec_flags = {'find': {'-exec', '-execdir'}}
dest_flags = {'curl': {'-o', '--output'}}
# Find with -exec extracts the command arg
scan_flag_args([['find', '.', '-exec', 'ls', '{}', ';']], exec_flags=exec_flags)(['ls'], [])
# curl with -o extracts the destination
scan_flag_args([['curl', '-o', '/tmp/file', 'http://example.com']], dest_flags=dest_flags)([], [('-o', '/tmp/file')])
# cat -o is NOT treated as a dest flag (not in dest_flags for cat)
scan_flag_args([['cat', '-o', '/etc/passwd']], dest_flags=dest_flags)([], [])
dest_pos = {'ex': {0}, 'cp': {-1}}
scan_flag_args([['ex', 'somefile']], dest_pos=dest_pos)([], [(0, 'somefile')])
scan_flag_args([['cp', 'src.txt', 'dest.txt']], dest_pos=dest_pos)([], [(-1, 'dest.txt')])
In ex somefile, destination position 0 picks somefile. In cp src dest, position -1 picks dest. An entry for ex doesn’t affect commands absent from dest_pos.
The same indexing works for exec_pos. Here, position 0 picks ls from env ls and grep from xargs grep, ready for recursive command validation:
exec_pos = {'env': {0}, 'xargs': {0}}
scan_flag_args([['env', 'ls', '-la']], exec_pos=exec_pos)(['ls'], [])
scan_flag_args([['xargs', 'grep', 'pattern']], exec_pos=exec_pos)(['grep'], [])
check_types raises ValueError if any node has a Type that is not in HANDLED_TYPES. extract_commands calls it before collecting commands. collect_cmds reports only simple commands and declarations. HANDLED_TYPES lists the constructs that run commands only through those statements, plus the nodes inside them. Any other construct fails here instead of passing with its commands unchecked. [[ ]] tests and $(( )) arithmetic pass, but let fails:
check_types(parse_bash('[[ -f foo ]] && echo $((1+2))'))with expect_fail(ValueError, contains='LetClause'): check_types(parse_bash('let x=1'))Caught unhandled construct
Main API
extract_commands
def extract_commands(
cmd, shfmt:str='shfmt', exec_flags:NoneType=None, dest_flags:NoneType=None, dest_pos:NoneType=None,
exec_pos:NoneType=None
):Split bash command into (commands, operators, redirects)
Call extract_commands with a bash string to get (commands, operators, redirects). commands holds token lists, including nested commands; operators is a set; redirects holds (op, dest) pairs for write destinations. The tokens look like shlex.split output, but come from shfmt’s AST so we can distinguish bash constructs.
The four optional dictionaries described above tell it which arguments need further inspection. exec_flags and exec_pos contribute strings to parse recursively. dest_flags and dest_pos contribute destinations. For example:
| Source and configuration | Additional result |
|---|---|
find . -exec ls, exec_flags={'find': {'-exec'}} |
Command ['ls'] |
curl -o /tmp/f url, dest_flags={'curl': {'-o'}} |
Redirect ('-o', '/tmp/f') |
ex somefile, dest_pos={'ex': {0}} |
Redirect (0, 'somefile') |
cp src dest, dest_pos={'cp': {-1}} |
Redirect (-1, 'dest') |
env ls, exec_pos={'env': {0}} |
Command ['ls'] |
Both position dictionaries use zero-based argument indices after the command name, with Python-style negative indices. xargs can use the same exec_pos entry as env.
A simple call such as echo foo gives [['echo', 'foo']]. A pipeline such as cat file | grep x gives two commands, ['cat', 'file'] and ['grep', 'x']. The sequences ;, &, &&, and || likewise separate commands.
Substitutions keep their source text in the outer command and add the inner commands to the result. Thus echo $(whoami) gives [['echo', '$(whoami)'], ['whoami']]; backticks work the same way. For diff <(ls a) <(ls b), we get diff with its two substitution arguments, followed by ['ls', 'a'] and ['ls', 'b']. This continues recursively through nested substitutions. Subshells work too: (cd /tmp && rm *) gives ['cd', '/tmp'] and ['rm', '*']. Loops, conditionals, function bodies, [[ ]] tests, assignments and heredocs contribute their commands in the same way: x=$(whoami) gives [['whoami']].
Quoted strings and escaped spaces stay within one token. Heredoc (<<EOF) and here-string (<<<) content each occupies one token; the here-string also keeps the <<< token. Output redirects go into the third result: echo hi > file.txt gives [('>', 'file.txt')].
Here are tests for these cases and their combinations:
def test_split(a, *b, ops=set(), redirs=[]): test_eq(extract_commands(a), (list(b), ops, redirs))test_split('echo <<EOF\nasdf\njkljl\nEOF\n', ['echo', 'asdf\njkljl'])
test_split('echo $(foo)', ['echo', '$(foo)'], ['foo'])
test_split('echo $(foo) | cat -a', ['echo', '$(foo)'], ['foo'], ['cat', '-a'], ops={'|'})
test_split('echo $(cat $(ls))', ['echo', '$(cat $(ls))'], ['cat', '$(ls)'], ['ls'])
test_split('echo "hello world" foo', ['echo', 'hello world', 'foo'])
test_split('echo hello\\ world', ['echo', 'hello world'])
test_split('echo foo; echo bar', ['echo', 'foo'], ['echo', 'bar'], ops={';'})
test_split('time pytest -q', ['pytest', '-q'])
test_split('echo $HOME "${USER}"', ['echo', '$HOME', '${USER}'])
test_split('sleep 10 &', ['sleep', '10'], ops={';', '&'})
test_split('cat <<< "some text"', ['cat', '<<<', 'some text'])
test_split("echo \"it's a 'test'\"", ['echo', "it's a 'test'"])
test_split('echo "hello $(whoami) there"', ['echo', 'hello $(whoami) there'], ['whoami'])
test_split('echo "path is ${HOME}/bin"', ['echo', 'path is ${HOME}/bin'])
test_split('echo ${arr[0]}', ['echo', '${arr[0]}'])
test_split('echo "$(echo "inner")"', ['echo', '$(echo "inner")'], ['echo', 'inner'])
test_split('echo "$HOME/$(whoami)/file"', ['echo', '$HOME/$(whoami)/file'], ['whoami'])
test_split('echo `whoami`', ['echo', '`whoami`'], ['whoami'])
test_split('(cd /tmp && rm -rf *)', ['cd', '/tmp'], ['rm', '-rf', '*'], ops={'&&'})
test_split('eval "rm -rf /"', ['eval', 'rm -rf /'])
test_split('echo a && echo b || echo c', ['echo', 'a'], ['echo', 'b'], ['echo', 'c'], ops={'&&', '||'})
test_split('cat file > out', ['cat', 'file'], ops={'>'}, redirs=[('>', 'out')])
test_split('cat file >> out', ['cat', 'file'], ops={'>>'}, redirs=[('>>', 'out')])
test_split('cat < in', ['cat'], ops={'<'})
test_split('diff <(ls dir1) <(ls dir2)', ['diff', '<(ls dir1)', '<(ls dir2)'], ['ls', 'dir1'], ['ls', 'dir2'])
test_split('FOO=bar', ops={'='})
test_split('FOO=bar echo hello', ['echo', 'hello'], ops={'='})
test_split('for i in a b c; do echo $i; done', ['echo', '$i'], ops={';'})
test_split('while true; do sleep 300; echo ping; done', ['true'], ['sleep', '300'], ['echo', 'ping'], ops={';'})
test_split('if [ -f x ]; then echo a; elif false; then echo b; else echo c; fi', ['[', '-f', 'x', ']'], ['echo', 'a'], ['false'], ['echo', 'b'], ['echo', 'c'], ops={';'})
test_split('f() { echo hi; }; f', ['echo', 'hi'], ['f'], ops={';'})
test_split('x=$(whoami)', ['whoami'], ops={'='})
test_split('export FOO=$(whoami)', ['export', 'FOO=$(whoami)'], ['whoami'])
test_split('cat <<EOF\n$(whoami)\nEOF', ['cat', '$(whoami)'], ['whoami'])
test_split('[[ -f $(whoami) ]] && echo y', ['whoami'], ['echo', 'y'], ops={'&&'})test_split('echo &>file', ['echo'], ops={'&>'}, redirs=[('&>', 'file')])
test_split('echo &>>file', ['echo'], ops={'&>>'}, redirs=[('&>>', 'file')])
test_split('echo |& cat', ['echo'], ['cat'], ops={'|&'})
# fd duplication - not file redirects, so no redirs
test_split('echo >&2', ['echo'], ops={'>&'})
test_split('cat <&3', ['cat'], ops={'<&'})
test_split("sed -i '' 's/foo/bar/' file.txt", ['sed', '-i', '', 's/foo/bar/', 'file.txt'])exec_flags = {'find': {'-exec', '-execdir'}, 'tar': {'--to-command', '-I'}}
dest_flags = {'curl': {'-o', '--output'}}
dest_pos = dict(ex={0}, tee={0}, cp={-1}, mv={-1})
exec_pos = {'env': {0}, 'xargs': {0}}
def test_split_flags(a, *b, ops=set(), redirs=[], exec_f=exec_flags, dest_f=dest_flags, dest_p=dest_pos, exec_p=exec_pos):
test_eq(extract_commands(a, exec_flags=exec_f, dest_flags=dest_f, dest_pos=dest_p, exec_pos=exec_p), (list(b), ops, redirs))test_split_flags('find . -exec ls', ['find', '.', '-exec', 'ls'], ['ls'])
test_split_flags(r'find . -exec rm -rf {} \;', ['find', '.', '-exec', 'rm', '-rf', '{}', r'\;'], ['rm'])
test_split_flags(r'find . -execdir cat {} \;', ['find', '.', '-execdir', 'cat', '{}', r'\;'], ['cat'])
test_split_flags('curl -o /tmp/out http://x', ['curl', '-o', '/tmp/out', 'http://x'], redirs=[('-o', '/tmp/out')])
test_split_flags('curl --output file.txt http://x', ['curl', '--output', 'file.txt', 'http://x'], redirs=[('--output', 'file.txt')])
test_split_flags('cat -o /etc/passwd', ['cat', '-o', '/etc/passwd'])
test_split_flags('find . -exec "ls | head"', ['find', '.', '-exec', 'ls | head'], ['ls'], ['head'], ops={'|'})
test_split_flags('ex somefile', ['ex', 'somefile'], redirs=[(0, 'somefile')])
test_split_flags('tee output.log', ['tee', 'output.log'], redirs=[(0, 'output.log')])
test_split_flags('cp src.txt dest.txt', ['cp', 'src.txt', 'dest.txt'], redirs=[(-1, 'dest.txt')])
test_split_flags('mv old.txt new.txt', ['mv', 'old.txt', 'new.txt'], redirs=[(-1, 'new.txt')])