explain output or writing precise custom rules.
The classifier is the last stage of the guard. Everything before it — bounded tool-input extraction, parser budgets, policy-file and Git-metadata protection, the policy snapshot load, and sensitive-path protection — has already run and is specified in the ordered guard stages. Those earlier stages fail closed in every safety level, so nothing on this page relaxes them.
Its dispatch flow — split into segments, strip env assignments and wrappers, identify the head command, hand it to the matching analyzer — is diagrammed once in Architecture. This page picks up from there: what each analyzer does with the segment it receives, and where the safety levels move the boundary.
Safety-level boundaries
The three safety levels are presets over three capabilities. Getting these boundaries right is the difference between predicting a block and being surprised by one.
Any capability mix that is not exactly one of the three presets reports as the effective level
custom.
Standard
Standard blocks recognizable destructive commands. It is deliberately not adversarial-grade, and it is best-effort against dynamic or hostile input.- Safe-looking unparseable text is permitted.
echo 'unterminatedis allowed. - Recognizable destructive text is still blocked, even when unparseable.
git reset --hard 'unterminatedblocks via the raw-text heuristic scan, which recognizesrm -rf,git reset --hard/--merge,git clean -f,git checkout --force,git push --force/--delete,git branch -D,git tag -d,git stash drop/clear,git checkout --,git restore,find -delete,dd of=/dev/,mkfs /dev/, andshred <arg>. - Dangerous text in a quoted-literal assignment defers to use time.
W='rm -rf ~'; echo "$W"is allowed: the assignment executes nothing, and a quoted expansion in argument position stays one argv word, so it cannot split into a command plus flags. Any riskier reference — unquoted, in command position, inside a substitution, or in an unquoted heredoc body — keeps the assignment-time block, and handing the value to a shell (eval "$W",bash -c "$W",echo "$W" | sh) still denies because the shell execution source cannot be verified. Strict never defers; see Standard-only allowances. - Dynamic
rm -rftargets are not categorically blocked.rm -rf "$target"is allowed in standard; it blocks only once the fail-closed capability is on. - Standard also intentionally allows dynamic executables, guarded command structure assembled through substitution, other unverifiable recursive-delete targets, and standalone metadata-only checks of built-in sensitive paths.
- Standard never relaxes sensitive content access or configured deny paths, and never relaxes the catastrophic protections.
Strict
Strict mode turns on the fail-closed capability. It does considerably more than tighten the unparseable case.- Unparseable commands are blocked.
echo 'unterminateddenies with a reason stating the command could not be safely analyzed. - Metadata-only sensitive-path discovery is blocked.
test -f ~/.ssh/id_rsaandfind ~/.ssh -type fare allowed in standard and blocked in strict. - The standard-only relaxation that treats sensitive path literals inside Node and Bun inline evaluation as inert diagnostic data is disabled.
- Heredocs fail closed. Before the other analyzers run on a segment, a command containing a heredoc is denied unless it passes the narrow supported gate described under Heredoc analysis: exactly one heredoc, on stdin, with a quoted delimiter, no other input redirection, and one of six literal data consumers. An unquoted delimiter denies with “Unquoted heredoc input is not supported safely. Quote the delimiter or ask the user to verify.”; every other failure denies with “This heredoc form or stdin consumer is not supported safely. Use a quoted heredoc with a supported consumer (cat, tee, git apply, git commit, gh pr create, gh issue create), or ask the user to verify.” In practice,
python3 - <<'PY'and any unquoted<<EOFare denied in strict and paranoid. - Unverifiable destructive targets are blocked. Five rules are gated on the fail-closed capability, so they are allowed in standard and blocked in strict:
Individual strict-tier rules can be turned off, but strict is still strict for fail-closed outcomes that have no registered destructive-command rule id — parser fail-closed and sensitive-path outcomes among them.
Paranoid
Paranoid mode is strict plus two capabilities.- Paranoid
rmblocks non-temp recursive forced removal even inside the current working directory —rm -rf ./cacheandRemove-Item ./cache -Recurse -Forceboth block. Temp targets and configured allow paths remain allowed. - Paranoid interpreters blocks every interpreter one-liner regardless of content, including
python -c "print(1)".
Per-rule overrides versus levels
For non-catastrophic rules, precedence is: the master switchdestructive_command_protection first, then the per-rule "on" / "off" override, then the resolved preset capability. Any strict- or paranoid-tier rule can be force-enabled under standard with an "on" override.
Catastrophic rules ignore both the master switch and any "off" override. They are rm.recursive-force-root-or-home, rm.git-metadata, powershell.remove-item-root-or-home, powershell.remove-item-recursive-force-root-or-home, powershell.remove-item-git-metadata, and find.delete-git-metadata, alongside the always-on policy-file guard.
Shell wrappers and interpreter one-liners
Recursion into wrappers and interpreters is capped at 10 levels deep, and if any segment blocks, the whole command is denied. After environment assignments and wrappers are stripped from a segment, the command name is checked against two sets:- Shell wrappers —
bash,sh,zsh,ksh,dash,fish,csh,tcsh. The argument following-cis extracted and recursively analyzed. - Interpreters —
python,python2,python3,node,ruby,perl. The code argument (after-c, or-efor node/ruby/perl) is extracted and scanned for embedded destructive operations.
python -c 'import os; os.system("rm -rf /")' is blocked because of the embedded rm -rf / — the one-liner form alone is allowed. Paranoid interpreters mode blocks every one-liner outright regardless of content.
busybox dispatch is handled as a special case: the subcommand is shifted into the command position and re-analyzed. awk/gawk/mawk programs are scanned for system() calls and backtick command substitutions.
Transparent wrappers
Standard wrappers (sudo, env, command, builtin) are always stripped. Proxy commands you have declared as transparent wrappers are stripped too — declaring and constraining them belongs to Custom rules; what matters for classification is how the engine unwraps them.
Unwrapping finds the first protectable child command after wrapper flags and environment assignments, or the token right after an explicit --. Once unwrapped, both built-in analysis and custom rules apply to the child: rtk git reset --hard blocks on the built-in rule, and rtk docker system prune hits a matching custom rule. Child commands that nothing protects are not unwrapped.
A proxy that is not declared, or one that rewrites or hides its child command rather than exec’ing a visible child, is still not unwrapped — only the top-level dangerous-text fallback scan may catch it.
Cwd is tracked across segments of the same command. A cd or pushd with a literal target updates the effective cwd for subsequent rm and find analysis; a cd to a dynamic target (containing $ or a backtick) sets the cwd to unknown, which rm analysis treats as having no cwd anchor.
A known limitation: interpreter long-form flags (
--eval, --execute, --require, and the attached =value form) are not recognized in every code path, so the code argument may not always be extracted. See Known limitations.POSIX shell functions
A POSIX function definition —name() { ... } — is parsed as a definition, not as executed code. The body executes only where the function is called, so that is where it is analyzed: at each call site, with the caller’s effective cwd and shell state. cleanup() { rm -rf ../outside; } on its own is allowed; add the call — cleanup() { rm -rf ../outside; }; cleanup — and the command blocks on the body. State changes made inside a called body carry forward: cleanup() { cd ..; }; cleanup && rm -rf build blocks because the rm is anchored one directory up.
Call resolution follows the shell’s own rules:
- A call resolves past leading environment assignments (
X=1 cleanup), thetimekeyword with its-poption and--terminator, and the!negation — including combined,time -p -- ! cleanup. Quoting or escaping the name ('cleanup',"cleanup",\cleanup) suppresses alias expansion but never a function lookup, so those call the function too. - Shapes a real shell would not run as the keyword form do not resolve:
X=1 time cleanup,time "--" cleanup, and!cleanup(no space) are not treated as calls. - The latest definition before the call wins, matching the shell’s redefinition semantics.
- A definition made inside a subshell (
( ... )) does not escape it, while a brace group ({ ...; }) runs in the same shell, so its definitions do. - Definitions stay visible to
evalandtrap, which run in the same shell —cleanup() { rm -rf ../outside; }; eval cleanupblocks — but are not inherited by a child shell, sosh -c cleanupresolves no function.
f() { rm -rf "$1"; }; f ~ is a dynamic target, allowed in standard and blocked once the fail-closed capability is on — exactly like rm -rf "$X". The quoted-assignment deferral applies inside called bodies too: in W='rm -rf ~'; f() { $W; }; f the unquoted command-position use keeps the assignment-time block, while f() { echo "$W"; }; f stays allowed as quoted argument data.
The pre-analysis guard stages see through calls as well: policy-file protection and sensitive-path extraction evaluate executed brace groups and called function bodies at each call site.
Two structural bounds fail closed in every level, standard included: self-recursion (loop() { loop; }; loop) denies on the recursion depth limit, and branching call chains deny on the derived-command work budget or on the projection’s cap of 256 inlined call sites. A heredoc attached inside a function body is not supported safely — it makes the command unparseable, so it falls to the heuristic scan in standard and is denied outright in strict.
Heredoc analysis
A heredoc body is text on stdin, and the consumer decides whether that text is data or a program. Before the other analyzers run on a segment, the engine checks the command against a narrow supported gate. The gate passes only when all of the following hold:- The command has exactly one heredoc (
<<or<<-), attached to stdin (fd 0). - The delimiter is quoted (
<<'EOF'), so the body cannot expand substitutions. - No other input redirection competes for stdin (
<,<<,<<-,<<<,<&,<>). - The consumer is a literal
cat,tee,git apply,git commit,gh pr create, orgh issue create— no path prefix, no wrapper such asenv.catandteeare additionally rejected when an output process substitution (>(...)) is present, because that hands the body to another command.
cat > note.md <<'EOF' and git commit -F - <<'EOF' are allowed even when the body describes destructive commands in prose, and this holds in strict and paranoid too. For cat, tee, git commit, gh pr create, and gh issue create the body is also masked before sensitive-path extraction, so a commit message containing the word “credentials” does not read as a filename. git apply is deliberately not masked there — a patch body names the files it writes, so it stays visible to path extraction. Commands outside the heredoc are still analyzed: cat <<'EOF' && rm -rf ~ blocks on the rm.
A command that fails the gate is denied outright in strict and paranoid. In standard, the body is still analyzed, down one of three paths:
- A quoted heredoc on stdin feeding a shell that only syntax-checks it (for example
bash -n) is inert and allowed. - A quoted heredoc on stdin feeding an interpreter (
python/python2/python3,node,ruby,perl), where every other word on the command line is a literal starting with-, is that interpreter’s program:python3 - <<'PY'qualifies,python3 tool.py <<'PY'does not (there stdin is data for the script). The body is analyzed under the interpreter rules — paranoid interpreters blocks it outright asinterpreter.one-liner-paranoid, a body containing dangerous code blocks asinterpreter.dangerous-command, and a clean body is allowed. - Everything else — unquoted delimiters, unknown consumers,
bashheredoc scripts, interpreter invocations with a script operand — falls through to the raw-text heuristic scan of the joined bodies. A match blocks asraw-text.dangerous-command; no match is allowed.
structural-limit parse status and denies the command in every safety level, standard included.
Passing the gate as inert data is not the end of the story when the body lands in a file. When a gate-passing heredoc is written verbatim to a literal path — cat > setup.sh <<'EOF', or tee setup.sh <<'EOF' without append — the engine remembers the body under that path, and a later bash setup.sh, sh setup.sh, source setup.sh, or shell startup reference to it (BASH_ENV, ENV, --rcfile, --init-file) in the same command is analyzed against the remembered script text, shown in the trace as a recurse step with reason heredoc-file. So cat > x.sh <<'EOF' … EOF && bash x.sh blocks when the body is destructive. Tracking is deliberately narrow: at most 64 files are remembered per analysis (MAX_TRACKED_HEREDOC_FILES; exceeding that fails closed on the derived-command work limit), paths under /dev, /proc, and /sys are never tracked, and a later write or redirection to a tracked path invalidates the stored body.
Git rule engine
The git analyzer extracts the subcommand and its options, matches dangerous option patterns, and returns a reason plus a classification:localDiscard or sharedState. This classification drives worktree relaxation.
Option matching handles the real-world grammar of git: long options use prefix matching (so
--forc, --force, and --force-with-lease resolve correctly), short options are unbundled (so -Df is read as -D plus -f), and global options that take values (-c, -C, --git-dir, --work-tree) are skipped when locating the subcommand. See Blocked commands for the full list of blocked git patterns.
Git SSH environment overrides
Git SSH environment overrides
Git accepts
GIT_SSH_COMMAND, GIT_SSH, and GIT_SSH_VARIANT to run an arbitrary program during network operations. CC Safety Net blocks any of these overrides when combined with a network subcommand (clone, fetch, pull, push, ls-remote, submodule), because they can execute arbitrary commands during a network operation.checkout specifics
checkout specifics
checkout analysis checks, in order: force (--force/-f); new-branch escape (-b/-B/--orphan returns no block); --pathspec-from-file; double-dash pathspec (git checkout -- discards uncommitted changes; git checkout <ref> -- <path> overwrites the working tree with the ref version); and ambiguous multi-positional forms (two or more positionals suggests using switch/restore instead).reset classification
reset classification
reset --hard/--merge is classified sharedState when a ref precedes -- (it moves a branch pointer), and localDiscard otherwise (it only discards working-tree changes).Recursive-delete target classification
rm analysis detects recursive plus force flags, extracts targets, and classifies each target against the current working directory. Targets are checked in this order — the first match wins, and the order itself is load-bearing:
Two consequences of the ordering are worth stating explicitly:
- Step 4 precedes step 7, so an allow path that contains a repository does not relax Git-metadata protection.
- Step 6 precedes step 7, so allow paths never apply to dynamic or otherwise unverifiable targets.
~/-prefixed directories. They apply in every safety level to rm, Remove-Item, and find -delete. They never relax secret protection, deny paths, root, home, or protected Git metadata. Entries equal to or containing $HOME are rejected at validation and again after canonicalization; symlink escapes are not covered.
Both recursive (-r/-R/--recursive) and force (-f/--force) flags must be present for this classification to run. Path comparison uses canonical (realpath) resolution, so a symlink to / is correctly classified as dangerous, and /tmp-malicious does not match the /tmp temp rule.
The important distinction for everyday use:
rm -rf ./subdir (within cwd) is allowed, but rm -rf . (the cwd itself) is blocked. See Allowed commands.PowerShell Remove-Item
Remove-Item and its aliases use the same target taxonomy as rm, through a conservative PowerShell subset that preserves native quoting, path separators, connectors, pipelines, and dynamic-word provenance.
-WhatIf,-WhatIf:$true, and the-wiabbreviation neutralize an otherwise-blocked removal; an explicit-WhatIf:$falseblocks again.- Dynamic forms are strict-only:
Remove-Item $target -Recurse -Force, aGet-ChildItem … | Remove-Item -Forcepipeline, a-Pathwith no value, and splatting (Remove-Item @params -Recurse -Force) are all allowed in standard and blocked in strict. The exception isRemove-Item $HOME -Recurse -Force, which blocks in standard because it classifies as a root/home target rather than a dynamic one. - Aliases and abbreviated parameters resolve, so
ri . -r -foblocks. Invocation-operator forms (& Remove-Item …,& { … },. { … }) are analyzed, as areInvoke-Expressionwith a literal string and$(…)subexpressions. #line comments and<# … #>block comments (including nested ones) are ignored, but real commands after them still block. Malformed or depth-limited block comments and subexpressions fail closed.- PowerShell wildcards do match dot-entries, unlike a POSIX
*glob. That is whyRemove-Item .git -Recurse -Forceand a PowerShell wildcard at a repository root both hit Git-metadata protection, while POSIX./*does not cover.git. - Shell selection matters: the
posixdialect deliberately does not apply the PowerShell removal rules, whileautodetects an explicitRemove-Itemand still keeps cross-shell rules such asgit.reset-hardin force.
Device and disk destruction
All three also appear in the unparseable-text heuristic scan, so
dd of=/dev/…, mkfs /dev/…, and shred <arg> inside otherwise unparseable text block as raw-text.dangerous-command — except when the text starts with echo or rg . None of the three is catastrophic, so they follow the master-switch and per-rule-override precedence.
find, xargs, and parallel
For
xargs and parallel, the concern is that the targets come from dynamic input (piped stdin or placeholder expansion), so they cannot be verified against the cwd. SSH-remote mode in parallel (-S/--sshlogin) also disables worktree relaxation.
Worktree relaxation
When worktree mode is active, local-discard git commands are allowed inside a confirmed linked worktree. Relaxation requires all of the following:- The matched rule is classified
localDiscard(see the git rule engine table).sharedStaterules never relax. - Worktree mode is on —
workflow.worktree_modeinpolicy.jsonorCC_SAFETY_NET_WORKTREE=1, combined as a logical OR. - No git context environment override is present (
GIT_DIR,GIT_WORK_TREE,GIT_COMMON_DIR,GIT_INDEX_FILE), and no--git-dir/--work-treeon the command line.
.git entry is a file (not a directory or symlink) whose gitdir: pointer resolves to a directory containing a commondir file, that the backlink points back to this worktree, and that config.worktree matches. Main worktrees, bare repos, and submodules are not relaxed. If verification fails for any reason, the command stays blocked (fail-closed).
Non-relaxable local discards
Non-relaxable local discards
Even inside a confirmed linked worktree, these are never relaxed: dynamic arguments containing
$, *, ?, or [; forced branch resets (git checkout -B/-Bf or git switch -C/-Cf with -f or --discard-changes); git clean with more than one -f flag (needed to remove nested git repos, which crosses the disposable-worktree boundary); and any --recurse-submodules option or recursive-submodule config.git -C path resolution
git -C path resolution
The effective git working directory is resolved by walking leading global options.
-C <path> and inline -C<path> apply a directory change. --git-dir/--work-tree (separate or = forms) mark an explicit git context, which disables relaxation entirely.Custom rules
When no built-in analyzer matches, custom rules run as a fallback. They are strictly additive — they can only add blocks, never override a built-in block or relax protection. Rules are namespaced as<rulebook-name>/<rule-name> and matched on the command basename, an optional subcommand, and literal block_args (with short-option unbundling, so -Ap matches -A).
See Custom rules for the full authoring guide and matching semantics.
Tracing a decision
To see exactly how the engine evaluated a specific command, runexplain:
npx cc-safety-net status prints ready or degraded, and Configuration recovery explains how to repair the named source.
Where to go next
The technical guides run from the user-facing lifecycle down to the reasoning behind the design. This page is step 4.- Back: Architecture — the ordered guard stages this classifier is the last part of.
- Next: Design principles — why classification is semantic rather than pattern-based, and why the level boundaries fall where they do.