> ## Documentation Index
> Fetch the complete documentation index at: https://ccsafetynet.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# How the analysis engine works

> Inside the analysis engine: safety-level boundaries, wrapper and interpreter recursion, shell function calls, git rules, recursive-delete target classification, device commands, and custom rule matching.

This is the fourth page of the technical sequence and the narrowest: it assumes the guard pipeline from [Architecture](/docs/guides/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](/docs/configuration/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](/docs/guides/architecture#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](/docs/guides/architecture#inside-command-analysis). 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.

| Level      | Fail closed | Paranoid `rm` | Paranoid interpreters |
| ---------- | ----------- | ------------- | --------------------- |
| `standard` | off         | off           | off                   |
| `strict`   | **on**      | off           | off                   |
| `paranoid` | **on**      | **on**        | **on**                |

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](/docs/reference/allowed-commands#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](/docs/configuration/modes#strict-mode-cc_safety_net_strict=1) 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](#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:

| Rule id                                                 | Example                                          | Intent             |
| ------------------------------------------------------- | ------------------------------------------------ | ------------------ |
| `rm.recursive-force-dynamic-target`                     | `rm -rf "$target"`                               | `scope_down`       |
| `powershell.remove-item-recursive-force-dynamic-target` | `Remove-Item $target -Recurse -Force`            | `scope_down`       |
| `powershell.remove-item-pipeline-dynamic-target`        | `Get-ChildItem . -Recurse \| Remove-Item -Force` | `scope_down`       |
| `shell.dynamic-executable`                              | `$(printf r)m -rf /`                             | `manual_only`      |
| `shell.dynamic-structure`                               | `git reset $(printf --hard)`                     | `stop_and_explain` |

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](/docs/configuration/modes#paranoid-mode-cc_safety_net_paranoid=1) is strict plus two capabilities.

* [Paranoid `rm`](/docs/configuration/modes#rm-check-cc_safety_net_paranoid_rm=1) 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](/docs/configuration/modes#interpreter-one-liners-cc_safety_net_paranoid_interpreters=1) 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 wrappers** — `bash`, `sh`, `zsh`, `ksh`, `dash`, `fish`, `csh`, `tcsh`. The argument following `-c` is extracted and recursively analyzed.
* **Interpreters** — `python`, `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](/docs/configuration/modes#interpreter-one-liners-cc_safety_net_paranoid_interpreters=1) 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](/docs/configuration/custom-rules#transparent-wrappers); 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.

<Note>
  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](/docs/guides/known-limitations#interpreter-long-form-flags).
</Note>

## 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](#standard) 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](#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](/docs/configuration/modes#interpreter-one-liners-cc_safety_net_paranoid_interpreters=1) 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](#worktree-relaxation).

| Classification   | Meaning                                                                      | Examples                                                                                                                      |
| ---------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **localDiscard** | Discards only local working-tree state — a candidate for worktree relaxation | `checkout --`, `restore`, `clean -f`, `reset --hard` (no ref), `switch --force`, `rebase --abort`, `merge --abort`            |
| **sharedState**  | Affects shared, remote, or recovery state — never relaxed                    | `push --force`, `branch -D`, `stash drop`/`clear`, `worktree remove --force`, `tag -d`, `reflog delete`, `reset --hard <ref>` |

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](/docs/reference/blocked-commands) for the full list of blocked git patterns.

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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).
  </Accordion>

  <Accordion title="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).
  </Accordion>
</AccordionGroup>

## 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:

| #  | Classification                      | What it matches                                                                           | Outcome                                                                                                                                 |
| -- | ----------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| 1  | **unsafe `$TMPDIR` word-splitting** | An unquoted `$TMPDIR` that could split into multiple words                                | Blocked (treated as outside the cwd)                                                                                                    |
| 2  | **unsupported Windows namespace**   | UNC and device-namespace paths the classifier cannot anchor                               | Blocked (treated as outside the cwd)                                                                                                    |
| 3  | **root/home target**                | `/`, `/*`, `~`, `~/`, `$HOME`, `${HOME}` and their children, literal or canonicalized     | Always blocked — catastrophic                                                                                                           |
| 4  | **protected Git metadata**          | The resolved `.git` entry, its directories, or its hooks directories                      | Always blocked — catastrophic                                                                                                           |
| 5  | **temp target**                     | `/tmp`, `/var/tmp`, the system temp dir, `$TMPDIR` (unless overridden to a non-temp path) | Allowed                                                                                                                                 |
| 6  | **dynamic target**                  | Any target whose expansion cannot be predicted                                            | Allowed in standard; blocked once [strict](/docs/configuration/modes#strict-mode-cc_safety_net_strict=1) turns on the fail-closed capability |
| 7  | **configured allow path**           | A literal, verified target under `destructive_command_protection.allow_paths`             | Allowed (classified as temp)                                                                                                            |
| 8  | **home-cwd target**                 | The cwd *is* the home directory (move into a project directory first)                     | Blocked                                                                                                                                 |
| 9  | **cwd self-target**                 | `.`, `./`, or any target that resolves to the same inode as the cwd                       | Blocked                                                                                                                                 |
| 10 | **within-cwd target**               | A path that resolves inside the current working directory                                 | Allowed; blocked under [paranoid rm](/docs/configuration/modes#rm-check-cc_safety_net_paranoid_rm=1)                                         |
| 11 | **outside-cwd target**              | Anything else (absolute, parent, or non-temp paths outside the cwd)                       | Blocked                                                                                                                                 |

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.

<Note>
  The important distinction for everyday use: `rm -rf ./subdir` (within cwd) is **allowed**, but `rm -rf .` (the cwd itself) is **blocked**. See [Allowed commands](/docs/reference/allowed-commands).
</Note>

### 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

| Command | Trigger                                                                                                             | Rule id           | Intent            |
| ------- | ------------------------------------------------------------------------------------------------------------------- | ----------------- | ----------------- |
| `dd`    | An operand matching `of=/dev/…` — a direct write to a device path. Reading from a device is not itself the trigger. | `dd.device-write` | `manual_only`     |
| `mkfs`  | Head is `mkfs` or any `mkfs.*` variant and some operand starts with `/dev/`                                         | `mkfs.device`     | `manual_only`     |
| `shred` | **Any** target, including `shred --help` and `shred --version`                                                      | `shred.target`    | `use_alternative` |

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

| Command                 | What is blocked                                                                       |
| ----------------------- | ------------------------------------------------------------------------------------- |
| `find ... -delete`      | Permanent removal via the find primary (use `-print` to preview)                      |
| `find -exec rm -rf ...` | The exec command is re-analyzed as a nested segment, so a destructive exec is blocked |
| `xargs rm -rf`          | rm driven by piped, dynamic input — targets are unpredictable                         |
| `xargs <shell> -c`      | Shell execution from dynamic input                                                    |
| `parallel rm -rf`       | rm driven by parallel placeholders or stdin                                           |
| `parallel <shell> -c`   | Shell execution from dynamic input                                                    |

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](/docs/configuration/modes#worktree-mode-cc_safety_net_worktree=1) 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](#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).

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## 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](/docs/configuration/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`:

```bash theme={"dark"}
npx cc-safety-net explain "rm -rf ./build"
npx cc-safety-net explain --json "git checkout -- file.txt"
```

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](/docs/reference/explain-trace) 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](/docs/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](/docs/guides/architecture) — the ordered guard stages this classifier is the last part of.
* Next: [Design principles](/docs/guides/design-principles) — why classification is semantic rather than pattern-based, and why the level boundaries fall where they do.

Related: [Modes](/docs/configuration/modes) for selecting a level, [Blocked commands](/docs/reference/blocked-commands) and [Allowed commands](/docs/reference/allowed-commands) for the outcome reference, and [Known limitations](/docs/guides/known-limitations) for what the classifier does not see.
