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-inRULEStable entirely.extra_rules: Sequence[Rule] | None- appended to whichever rule table is in use.- Returns a frozen
Verdictdataclass:@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
levelis the highest level matched across all segments. A command matching no rule is'safe'with emptyreasons/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/--forcestyle 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 <<EOFis not recognized asbash, 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
remodule 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 identicallevelandmatchedresults 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8839732e5a59d0743d3de036b10a2704b428a2db39c56670fb51629f26e3351c
|
|
| MD5 |
a0622c99a43faa853ad55ee360f6da7f
|
|
| BLAKE2b-256 |
4a814368f6925782e9ccc0045e379f5fd20596fc6707aab2a620a30fb6bfc97e
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7fd98c5a80e254164eff9473f5a0dca909973a4262284161ba47a7f0c81f24dc
|
|
| MD5 |
fadc3a55a9b14938d7a969c0dcde7391
|
|
| BLAKE2b-256 |
ade01559ef1a5bf5dd35dda528cf541b79119f5b395c05fe181ec4f0418d4b01
|