Skip to main content
This is the fourth page of the technical sequence and the narrowest: it assumes the guard pipeline from Architecture and documents only the exact classification behavior and edge cases of the destructive-command classifier — the level of detail you need when reading 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 'unterminated is allowed.
  • Recognizable destructive text is still blocked, even when unparseable. git reset --hard 'unterminated blocks via the raw-text heuristic scan, which recognizes rm -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/, and shred <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 -rf targets 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 'unterminated denies with a reason stating the command could not be safely analyzed.
  • Metadata-only sensitive-path discovery is blocked. test -f ~/.ssh/id_rsa and find ~/.ssh -type f are 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 <<EOF are 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 rm blocks non-temp recursive forced removal even inside the current working directory — rm -rf ./cache and Remove-Item ./cache -Recurse -Force both 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 switch destructive_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 wrappersbash, sh, zsh, ksh, dash, fish, csh, tcsh. The argument following -c is extracted and recursively analyzed.
  • Interpreterspython, python2, python3, node, ruby, perl. The code argument (after -c, or -e for node/ruby/perl) is extracted and scanned for embedded destructive operations.
By default the interpreter’s code is scanned for embedded destructive commands, so 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), the time keyword with its -p option 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 eval and trap, which run in the same shell — cleanup() { rm -rf ../outside; }; eval cleanup blocks — but are not inherited by a child shell, so sh -c cleanup resolves no function.
Positional parameters stay unbound inside the body: 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, or gh issue create — no path prefix, no wrapper such as env. cat and tee are additionally rejected when an output process substitution (>(...)) is present, because that hands the body to another command.
A command that passes the gate treats the body as inert data in every level: 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:
  1. A quoted heredoc on stdin feeding a shell that only syntax-checks it (for example bash -n) is inert and allowed.
  2. 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 as interpreter.one-liner-paranoid, a body containing dangerous code blocks as interpreter.dangerous-command, and a clean body is allowed.
  3. Everything else — unquoted delimiters, unknown consumers, bash heredoc scripts, interpreter invocations with a script operand — falls through to the raw-text heuristic scan of the joined bodies. A match blocks as raw-text.dangerous-command; no match is allowed.
One structural bound sits underneath all three paths: a heredoc body is re-parsed as shell text, and a body can declare further heredocs of its own. Nesting across those re-parses is capped at the parser’s 64-level depth limit; deeper nesting reports the 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 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 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 --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.
Allow paths must be absolute or ~/-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 -wi abbreviation neutralize an otherwise-blocked removal; an explicit -WhatIf:$false blocks again.
  • Dynamic forms are strict-only: Remove-Item $target -Recurse -Force, a Get-ChildItem … | Remove-Item -Force pipeline, a -Path with no value, and splatting (Remove-Item @params -Recurse -Force) are all allowed in standard and blocked in strict. The exception is Remove-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 -fo blocks. Invocation-operator forms (& Remove-Item …, & { … }, . { … }) are analyzed, as are Invoke-Expression with 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 why Remove-Item .git -Recurse -Force and a PowerShell wildcard at a repository root both hit Git-metadata protection, while POSIX ./* does not cover .git.
  • Shell selection matters: the posix dialect deliberately does not apply the PowerShell removal rules, while auto detects an explicit Remove-Item and still keeps cross-shell rules such as git.reset-hard in 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:
  1. The matched rule is classified localDiscard (see the git rule engine table). sharedState rules never relax.
  2. Worktree mode is on — workflow.worktree_mode in policy.json or CC_SAFETY_NET_WORKTREE=1, combined as a logical OR.
  3. No git context environment override is present (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_INDEX_FILE), and no --git-dir/--work-tree on the command line.
A linked worktree is positively verified — not just assumed. The check confirms the .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).
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.
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, run explain:
The human-readable output walks through each segment, showing parse steps and rule evaluations. The JSON output returns the structured trace — see the Explain trace reference for the schema. If a decision looks wrong for your configuration rather than for the command, check whether the runtime is enforcing a fallback policy: 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.
Related: Modes for selecting a level, Blocked commands and Allowed commands for the outcome reference, and Known limitations for what the classifier does not see.
Last modified on August 6, 2026