Skip to main content

cmd-risk

A heuristic advisory risk classifier for shell command strings, with a small forkable rule table. This is the Python port of the JavaScript package cmd-risk.

This is not a security boundary. It is for deciding when a coding agent (or an agent sandbox) should pause and prompt a human before running a shell command, not for containing a hostile actor. It matches on naive, regex-based heuristics and can be fooled by obfuscation, quoting tricks, aliases, or variable expansion. The command splitter is naive text splitting, not a real shell parser: it does not understand quoting, escaping, subshells, or heredocs.

The problem

Every coding agent and agent sandbox ends up hand-rolling some version of "is this shell command about to destroy something?" as an ad-hoc regex list buried deep in the codebase. These lists are rarely reused, rarely tested, and rarely agreed on between projects. This package pulls that logic out into one small, readable, forkable rule table plus a classifier, so you can start from something reasonable and edit it for your own risk tolerance instead of writing it from scratch.

Install

pip install cmd-risk

Usage

from cmd_risk import classify, is_safe, split_segments, RULES

classify("rm -rf /")
# Verdict(
#     level='critical',
#     reasons=[
#         'Recursive force-remove targeting root, home, or the current directory.',
#         'Recursive, forced file removal.',
#     ],
#     matched=['rm-rf-root', 'rm-rf'],
#     segments=['rm -rf /'],
# )

classify("cd /tmp && rm -rf /tmp/build")
# level='high', segments=['cd /tmp', 'rm -rf /tmp/build']

is_safe("git status")  # True

split_segments('git add . && git commit -m "x"')
# ['git add .', 'git commit -m "x"']

# Add your own rule without touching the built-in table:
import re
from cmd_risk import Rule

classify(
    "terraform destroy",
    extra_rules=[
        Rule(
            id="terraform-destroy",
            level="high",
            pattern=re.compile(r"\bterraform\s+destroy\b"),
            reason="Tears down provisioned infrastructure.",
        )
    ],
)

The example above has been run against this port; see test_cmd_risk.py for the full executed suite.

CLI

cmd-risk "rm -rf /tmp/x"

Prints the risk level on the first line, then one - reason line per matched rule. Exits 0 for safe/moderate, 1 for high/critical, so it can gate a script:

cmd-risk "$CMD" || echo "needs human review"

cmd-risk --version prints the installed version and exits 0. cmd-risk --help prints usage and exits 0. Both are handled as real flags, not classified as shell commands.

API

classify(command, *, rules=None, extra_rules=None) -> Verdict

  • command: Any - a shell command string. A non-string value behaves as an empty command (matching the JS implementation's dynamic-typing fallback), it does not raise.
  • rules: Sequence[Rule] | None - replaces the built-in RULES table entirely.
  • extra_rules: Sequence[Rule] | None - appended to whichever rule table is in use.
  • Returns a frozen Verdict dataclass:
    @dataclass(frozen=True)
    class Verdict:
        level: str  # 'safe' | 'moderate' | 'high' | 'critical'
        reasons: list  # human-readable, deduplicated, one per matched rule id
        matched: list  # deduplicated matched rule ids, first-seen order
        segments: list  # command split into naive segments
    
  • level is the highest level matched across all segments. A command matching no rule is 'safe' with empty reasons/matched.

split_segments(command) -> list[str]

Splits on ;, &&, ||, |, and newlines. Trims each segment and drops empty ones. This is intentionally naive: it does not track quoting, so a delimiter character inside a quoted string still splits the command.

is_safe(command) -> bool

Shorthand for classify(command).level == 'safe'.

RULES

The exported list of built-in Rule frozen dataclass instances:

@dataclass(frozen=True)
class Rule:
    id: str
    level: str  # 'moderate' | 'high' | 'critical'
    pattern: "re.Pattern[str]"
    reason: str

Copy the list, edit it, drop entries you don't care about, add your own, and pass it back in as rules=. That is the entire point of shipping it as plain data.

Built-in rule ids:

  • critical: rm-rf-root, mkfs, dd-to-device, fork-bomb, chmod-777-root, overwrite-block-device
  • high: rm-rf, git-reset-hard, git-clean-force, git-push-force, git-checkout-dot, shred, truncate, pipe-to-shell, drop-sql, docker-prune-all, kill-all
  • moderate: sudo, npm-publish, git-push, chown, chmod, package-remove, write-outside-cwd

Use as a Claude Code hook

Run the hook via module invocation:

python3 -m cmd_risk --hook

Note: this package does not install a cmd-risk-hook console script, because the npm package cmd-risk already installs a binary of that name, and the two would collide on PATH if both are installed globally. Use the module-invocation form above instead.

Add this to ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "python3 -m cmd_risk --hook", "timeout": 5 }]
      }
    ]
  }
}

By default, classify()'s level maps to a permission decision like this:

level decision
critical deny
high ask
moderate no action
safe no action

Two environment variables override the thresholds: CMD_RISK_DENY_AT (default critical) and CMD_RISK_ASK_AT (default high), each one of safe, moderate, high, critical. The command is denied when its level is at or above CMD_RISK_DENY_AT, otherwise it prompts when at or above CMD_RISK_ASK_AT, otherwise the hook stays silent and the normal permission flow applies. An unrecognized value for either variable falls back to its default.

The deny check is evaluated before the ask check, so if CMD_RISK_DENY_AT is set at or below CMD_RISK_ASK_AT, the ask threshold becomes unreachable for anything that already clears the deny threshold. safe is a legal value for both variables, but setting CMD_RISK_DENY_AT=safe denies every Bash command, including harmless ones like ls or pwd, which effectively disables Bash for the session. This is almost never what you want.

Set CMD_RISK_HOOK=off to disable the hook entirely: it then exits immediately with no output on every call, regardless of the command or the threshold variables above. If you run Claude Code unattended on a schedule, for example a nightly build or a scanning job on a systemd timer with nobody watching, set this in those jobs so an automated run is never blocked waiting on a decision nobody is there to make.

The hook fails open: if anything goes wrong while it runs, it exits silently (exit 0, no stdout) and the command proceeds as if the hook were not installed. It never exits non-zero. As with the rest of this package, it is a heuristic advisory classifier, not a security boundary.

How it works

classify runs every rule's regex against every segment produced by split_segments, then takes the deduplicated union of matched rule ids and the highest matched level. Two rules (git-push-force and git-push) have an explicit interaction: if a segment matches git-push-force, that same segment will not also report the plain git-push rule (a different segment with an unrelated plain git push can still report it).

Two rule ids, pipe-to-shell and fork-bomb, are shapes that only exist as a literal | or ; sequence (curl x | sh, :(){ :|:& };:). Because split_segments also splits on | and ;, per-segment matching alone would never see either shape intact. For exactly these two rule ids, classify additionally tests the untouched original command string, on top of the normal per-segment checks every other rule gets. This is a deliberate, narrow exception to keep those two rules functional given a naive splitter; every other rule is matched strictly per segment.

Before any rule pattern is tested, the contents of every single- and double-quoted string in the text being checked are masked out (replaced with neutral filler, quote characters left in place), so a risk-looking word inside a commit message, echo string, or -m argument - e.g. git commit -m "sudo is mentioned here" - is not mistaken for the real command. A backslash inside a quote escapes the next character, including a same-type quote, so it doesn't end the string early. Masking is only used for matching: the segments list in the returned Verdict is always the original, unmasked text. If a quote is left unterminated, masking falls back to matching the raw, unmasked text for that piece of the command instead of masking everything from the stray quote to the end of the string - an advisory classifier should fail open (still flag real risk) rather than let an unbalanced quote hide something dangerous.

One targeted exception to masking: if a quoted argument's entire content is exactly a bare root path (/, ~, $HOME, /*, or similar), it is left unmasked, so rm -rf "/" and rm -rf '~' are still recognized as targeting root, the same as their unquoted forms. This only applies when the quoted content is exactly one of those literal path shapes - a quoted word that happens to be short, like "sudo", is unaffected and still gets masked.

Heredoc bodies are masked the same way, and for the same reason: writing documentation, a commit message, a config file, or a code example through a heredoc is an extremely common shape, and the body is just prose sitting unquoted in the command, so cat >> notes.md <<'EOF' followed by lines that happen to mention rm -rf / or git clean -fdx should not get flagged. classify detects <<WORD, <<'WORD', <<"WORD", and the indent-stripping <<-WORD forms, masks everything from the line after the introducer up to the first line that is exactly WORD (leading whitespace allowed for <<-), or to the end of input if that line never appears, and does this before split_segments runs, since a heredoc body's newlines would otherwise be shredded into unrelated segments first. The one exception: a heredoc is left unmasked when it is fed to a known interpreter (bash, sh, zsh, dash, ksh, python, python3, node, perl, ruby, eval), because that body is genuinely executed, so bash <<EOF with rm -rf / in it still classifies critical. If no command word can be identified in front of the << at all, the body is also left unmasked, the same fail-open direction as the unterminated-quote ruling above.

Known limitations, honestly:

  • The splitter has no notion of quoting: echo "a; rm -rf /" is split as if the ; were a real command separator, even though it is inside a string literal.
  • Regex-based flag detection is approximate. It looks for -r/-f/--recursive/--force style tokens; unusual flag bundling or long-option abbreviations it doesn't recognize can be missed.
  • The interpreter list for heredoc masking matches exact command words only, not paths: /bin/bash <<EOF is not recognized as bash, so that heredoc's body would be masked even though it is really executed. Invoke interpreters by their bare name for the exception to apply.
  • Nothing here executes, sandboxes, or blocks anything. It only classifies a string.
  • It is trivially bypassed by anyone motivated to bypass it (encoding, variable indirection, wrapper scripts). That is expected and fine for its intended use: a heuristic nudge for when to ask a human, not a control that has to hold up against an adversary.
  • The Python re module and JavaScript's regex engine differ in some corners (lookbehind width restrictions, Unicode category handling). None of the 24 built-in rules rely on a lookbehind or on a corner where the two engines disagree; a cross-implementation parity check covering 25 commands (including every rule-triggering shape and several benign/masking shapes) produced identical level and matched results on both sides.

See the JavaScript version at the repository root for the original implementation.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cmd_risk-0.4.0.tar.gz (16.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cmd_risk-0.4.0-py3-none-any.whl (14.3 kB view details)

Uploaded Python 3

File details

Details for the file cmd_risk-0.4.0.tar.gz.

File metadata

  • Download URL: cmd_risk-0.4.0.tar.gz
  • Upload date:
  • Size: 16.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.10.12

File hashes

Hashes for cmd_risk-0.4.0.tar.gz
Algorithm Hash digest
SHA256 8839732e5a59d0743d3de036b10a2704b428a2db39c56670fb51629f26e3351c
MD5 a0622c99a43faa853ad55ee360f6da7f
BLAKE2b-256 4a814368f6925782e9ccc0045e379f5fd20596fc6707aab2a620a30fb6bfc97e

See more details on using hashes here.

File details

Details for the file cmd_risk-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: cmd_risk-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 14.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.10.12

File hashes

Hashes for cmd_risk-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7fd98c5a80e254164eff9473f5a0dca909973a4262284161ba47a7f0c81f24dc
MD5 fadc3a55a9b14938d7a969c0dcde7391
BLAKE2b-256 ade01559ef1a5bf5dd35dda528cf541b79119f5b395c05fe181ec4f0418d4b01

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page