security-tiers · git:20260828.af24f60 · 2026-08-28 · sha256 2fed40d51803e196
security-tiers git:20260828.af24f60B
Immutable. This exact content is served forever at /api/v1/blob/2fed40d51803e196.
---
name: security-tiers
description: Use when classifying any operation before executing it, or deciding whether user approval is required
---
# Security Tiers
## COMMAND_SET boundary
Classification applies to each exact atomic command. After read-only
investigation, one or more predictable T3 commands may be requested plan-first
with `gaia approvals request-set` -- a single predictable T3 command uses the
same verb proactively, not only the reactive path that begins after
PreToolUse returns `[T3_BLOCKED]`. Never discover a set by attempting a
compound shell command. Group only one bounded goal with exact known order
and coherent risk, rollback, and verification. Those last two are supplied on
the request itself -- `--rollback` and `--verification`, alongside `--rationale`
and one `--command` per item -- and are sealed at mint, then rendered verbatim
as the `ROLLBACK` and `VERIFICATION` fields of the surface the user consents
against; requesting without them asks for consent while the surface states they
are absent. Do not group speculative or output-dependent steps. Consent grouping
is not execution atomicity: execution remains one command per call, ordered and
fail-fast.
security-tiers classifies every operation into four tiers so an agent knows whether it can run freely or must request the user's consent.
## The four tiers
| Tier | What it is | Approval? | Example verbs |
|------|------------|:---:|---------------|
| **T0** | Read-only; observes state, changes nothing | No | get, list, describe, show, logs, status |
| **T1** | Local validation; no remote calls, no state | No | validate, lint, fmt, check |
| **T2** | Simulation / dry-run; may read remote, never writes | No | plan, diff, dry-run, template |
| **T3** | State-mutating; creates, updates, or destroys | **Yes** | apply, create, delete, push, deploy |
**A fifth verdict exists OUTSIDE this ladder: the categorical denial** -- blocked commands, the `.claude`/hooks tree targeted through *Bash* (`hooks/modules/security/protected_path_guard.py::check`), the DB-write guards, and a file write into a git working tree whose author is the SHELL (`hooks/modules/security/shell_write_guard.py::check`). It is not a tier, it is not approvable, and no grant lifts it.
That last one is categorical for a reason worth stating, because it looks at first glance like it should be approvable: **it withholds no capability.** A grant is scoped to a TOOL AND A PATH, never to an effect, so consenting to a shell writer would be consenting to a channel rather than to a change -- and the change itself is already permitted, byte for byte, through `Write`/`Edit`. What the guard refuses is the channel that breaks ATTRIBUTION: a redirect, `tee`, `sed -i` or `dd of=` presents the gate a command string instead of the destination, so the boundary is evaluated against the wrong object and the write lands with no tool call naming the file. Signing a permission for a channel that exists precisely to leave no trace buys nothing. The rule needs BOTH halves to fire -- shell-authored AND resolving under a git tree -- which is why `/dev/null`, `/tmp` and `~/.gaia/scratch` still pass: they fall out of the rule, not off an allow-list. A consequence to know before it surprises you: in-tree `sed -i` is therefore NO LONGER the T3 ask-dialog that `_classify_sed` would route it to; the guard returns first, with no `approval_id`. Outside a working tree that same `sed -i` still reaches the ordinary approvable path.
The counter-argument was weighed and rejected, and is recorded because it is real and will resurface: `Write`/`Edit` force a file's contents through the agent's context and from there into the transcript, while `sed -i` transforms without anyone reading. For a file holding sensitive material the shell is better for confidentiality and worse for traceability. It was rejected because in these repositories secrets do not live in versioned files -- they live in Secret Manager, and the GitOps repo carries a check that refuses plaintext secrets at commit. A file with sensitive material sitting inside a git tree is the underlying problem, not the way it gets edited. If a concrete case ever appears, it reopens with that case in hand. **A protected path targeted through *Write*/*Edit* is a different verdict: T3-approvable**, not categorical -- a `FILE_WRITE` grant lifts it (`hooks/modules/security/approval_grants.py::check_approval_grant_for_file`); see "The `.claude` rule" below for the exact split. **The operative read for any denial: one carrying an `approval_id` has a consent path; one with none is categorical -- never report a categorical denial as T3**, since T3 names a consent request and the request they answer cannot change the outcome.
`git commit` and `git add` are **not** T3 -- they are local-only operations (they touch the working tree and local refs, never remote state), so they classify as safe by elimination. Only `git push` mutates remote state and is T3. This matches `GIT_LOCAL_SAFE_SUBCOMMANDS` in `mutative_verbs.py`, where `commit` and `add` are listed as local-safe.
**T3 gates a direction, not a category of verb.** An operation needs consent because it moves the system toward *more* capability (it grants) or *less* recoverability (it destroys). An operation that only moves the other way -- that *reduces* capability already granted -- does not need consent, because the worst it can do is take back power that was given. So within Gaia's own consent layer, `gaia approvals revoke|reject|reject-all|clean` are **not** T3: they only revoke or discard grants Gaia itself issued, never reaching outside the local approval store. The asymmetry is deliberate -- `gaia approvals approve` *grants* capability without passing through the user's consent surface at all, so it stays T3. This is anchored to the `gaia approvals` group in `CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS` (`mutative_verbs.py`), not generalized to every CLI's "revoke" -- a cloud IAM revoke is a real remote mutation and remains T3.
**T3 gates EXECUTION of a mutation, not the authoring of a file. `Write` and `Edit` are deliberately NOT T3.** Writing or modifying a file is not itself a change to live state -- it produces bytes on disk, an inert artifact that does nothing until something *runs* it. The state-mutation that requires consent happens only when an executed command (a `Bash` invocation of a mutative verb, an interpreter pointed at a script, an exec sink inside that script) reaches out and changes something live: a cluster, a remote, a database, an account. That is why the tier ladder classifies *commands*, and why the script-file lane above reads a file's contents only when an *interpreter is invoked on it* (`node deploy.js`, `python3 migrate.py`) -- not when the file is written. So the apparent "gap" where an agent can `Write` a script full of `kubectl delete` without triggering T3 is not a defect: authoring the script is free, and the T3 gate fires the moment the script is *executed*. This is intentional and load-bearing -- it lets agents draft, edit, and iterate on code (including infra and deploy scripts) without a consent prompt on every keystroke, while still requiring consent at the single point that matters: the run. (Orthogonal to tiers: a protected path is blocked by default on both write surfaces, but the two are not the same verdict -- Bash is categorical and unconditional, Write/Edit is T3-approvable via a `FILE_WRITE` grant -- see "The `.claude` rule" below.)
## The no-elusion rule
**A gate that has already fired for an effect forbids that effect by every other route -- and it makes no difference that the other route is not blocked.** Once a T3 block has named an effect, what is withheld is the *effect*, not the spelling of the command that asked for it. Reaching the same outcome through a different verb, a different interpreter, a different library, a script file instead of an inline payload, or a language whose lane does not scan for it, is the withheld effect obtained without the consent that was demanded. This is not a guideline to weigh against the pressure to finish; it is the precondition that makes the consent layer mean anything at all.
**A hook that does not block is not a hook that permits.** The classifier's silence carries no authority -- it is the absence of a match, not the presence of a grant. Only two things convert a withheld effect into a permitted one: the approval flow, or the user's own instruction. An agent that reads "it went through" as "it was allowed" has substituted a measurement of the classifier for a decision that was never the classifier's to make.
Once a gate has fired, there are exactly three continuations:
1. Request consent for the effect -- emit `APPROVAL_REQUEST` carrying the `approval_id` verbatim, and wait (see "T3 approval handoff" below), **or**
2. Abandon the effect and report it as an open gap, **or**
3. Ask the user.
There is no fourth. Retrying the *identical* command once a grant exists is not elusion -- it is the designed path, and `command-execution` requires that retry be byte-identical. What is forbidden is reaching the effect *without* the grant: rewording the command until some spelling passes, moving the operation into a script or an inline payload, or relocating it into a language whose lane does not look for it. The reliable self-test: if a route occurred to you **because** the direct one was blocked, that is the forbidden kind.
**The norm has to be the first layer, because static classification cannot be a containment boundary -- and was never built to be one.** Every lane described below reads the *text* of a command and matches it against names it already knows: verbs, flags, API symbols, cmdlet taxonomies. That is a sound way to catch the ordinary case and a hopeless way to stop a determined one, for a structural reason no amount of list-tending repairs: a deny-list of API names is **enumerable by whoever is going around it and inexhaustible for whoever is defending it**. `getattr(shutil, "copy" + "file")` never spells the symbol. `importlib` resolves it at runtime. A one-line helper in another module moves the call out of the scanned text entirely. A name assembled from fragments, read from an environment variable, or decoded from base64 is not a name until the interpreter already holds it. And past all of those sits the next library nobody has catalogued yet. The measured incident is that whole class in miniature: an agent blocked three times on `cp` obtained the same copy from a Python script calling `shutil.copyfile` -- a symbol the AST deny-list (`_DANGEROUS_CALLS` in `inline_ast_analyzer.py`) carried `shutil.copy`, `copy2`, `copytree` and `move` but did not name. That list has since been extended -- `copyfile`, `copyfileobj`, and some thirty further audited `shutil`/`os`/`pathlib`/`io`/`codecs` APIs -- which closes those spellings and does not close the class. One part of the extension does better than enumerating, and is worth copying rather than the list itself: the `CLOUD_SDK` category matches by module PREFIX plus invoked VERB (`_CLOUD_SDK_PREFIXES` -- `boto3`, `google.cloud`, `googleapiclient`, `kubernetes.client`, `python_terraform` -- crossed with `_CLOUD_SDK_MUTATIVE_VERBS`) precisely because the surface is thousands of generated methods per SDK that are syntactically indistinguishable from reads except by their verb. Even that narrows a known verb set; it is not a closure over what an SDK can mutate.
**And there is a sharper reason not to read a deny-list as a boundary: a list can be present and still not apply.** `_AliasResolver._dotted_parts` returned `None` for any dotted chain passing through a `Call`, so the seven `pathlib.Path.*` entries that had been sitting in the table for a long time were **dead code for every idiomatic form** -- `Path(x).unlink()`, `.write_text()`, `.chmod()` all resolved to nothing and matched nothing. The list *looked* like coverage and was not; it took resolving call chains (`Path("/x").unlink()` -> `pathlib.Path.unlink`) to make those entries reachable at all. So the honest inference runs stronger than "the list is incomplete": if an entry written explicitly into the table does not necessarily fire, then an agent has no ground whatsoever for treating the ABSENCE of a block as the presence of permission.
So the norm is primary and the mechanism is the net beneath it. Where the policy holds, the hook catches the ordinary miss -- the typo, the honest oversight, the lane nobody thought about. Where the policy is absent, the hook is the only thing standing, and then every gap in it is silently promoted into a permission the user never gave. That inversion is the real failure mode: not that a hole exists, but that the agent read the hole as consent. It is also why the residual limitations and known gaps documented in this skill are disclosures for the defender, never a map for the agent -- reading one of them as an available route is precisely the move this rule forbids.
## Classification heuristic
Ask, in order -- the first "yes" wins:
1. **Does it mutate live state?** (create, update, delete, apply, push, deploy) -- **T3**
2. **Does it only simulate?** (plan, diff, dry-run, template) -- **T2**
3. **Does it validate locally?** (validate, lint, fmt, check) -- **T1**
4. **Is it read-only?** (get, list, describe, show, logs) -- **T0**
This mirrors `_classify_command_tier_cached` in `hooks/modules/security/tiers.py`: blocked patterns and mutative verbs resolve to T3 first, then simulation to T2, then validation to T1, and everything left over defaults to T0 -- safe by elimination, never by an allow-list.
Conditional commands depend on flags: `git branch` is T0 for listing but T3 only with `-D` or `-M` (the force-delete and force-rename flags checked by `_scan_dangerous_flags`) or the long-form `--delete`. The lowercase short forms `-d` (delete) and `-m` (rename) are deliberately LEFT UNGATED -- this is an intentional design decision, not a classification gap. Git itself refuses `-d` on a branch with unmerged commits (it exits non-zero and demands the explicit `-D` override to force it through), so the safety check already lives inside git before Gaia's tier classification ever runs; gating a command git already declines to run unsafely would add friction without closing a real risk. The same split applies to `-m` vs `-M`: `-m` renames without clobbering an existing branch of the same name, while `-M` forces the rename and can silently overwrite it. So the free/gated line mirrors git's own safe/force distinction -- the safe verbs (`-d`, `-m`) stay free, the force verbs (`-D`, `-M`) are gated. Separately, the short force flag `-f` now escalates git to T3 across subcommands (`git` is in `F_FLAG_MEANS_FORCE` in `mutative_verbs.py`, mirroring the long-form `--force`): `git mv -f` (force-overwrite the destination), `git checkout -f` (discard uncommitted changes), `git branch -f`, and `git add -f` are all T3. Without this, those slipped through as T0 because their subcommands live in `GIT_LOCAL_SAFE_SUBCOMMANDS` and `-f` was not collected by `_scan_dangerous_flags` for git. `git tag` is the read-vs-write-by-flags case: it is dual-mode, so a dedicated discriminator (`_classify_git_tag` in `mutative_verbs.py`) decides its tier rather than the generic verb entry. A LISTING form is T0 -- bare `git tag`, `git tag -l`/`--list` (with or without a filter pattern), `git tag --points-at <x>`, `--contains <x>`, `--merged`, `--sort=...`, `-n`; the trailing token in list mode is a filter pattern, never a tag name. A MUTATING form is T3 -- a positional tag NAME to create (`git tag v1.2.3`), or any of the create/delete/force/sign/annotate flags `-a`/`-s`/`-d`/`-f`/`-m` (`git tag -a v1 -m x`, `git tag -d v1`, `git tag -f v1`). Before this refinement the generic `tag` verb (in `MUTATIVE_VERBS`) forced every `git tag` to T3, so read-only listing needlessly demanded approval.
`git worktree` is a dedicated family classifier (`_check_git_worktree` in `mutative_verbs.py`), not a generic-verb match: `add` and `move` are T3 (creating or relocating a worktree contracts a cleanup obligation), and `remove` is T3 EXCEPT when every target resolves, via `realpath`, strictly inside Gaia's own managed worktrees root (`~/.gaia/worktrees`) with no glob character, no `..` segment, and no relative path -- that narrow exemption exists because recycling a worktree Gaia itself created and owns inside its own root is not the same act as deleting an arbitrary worktree. `--force` (long form, or any short cluster containing `f`, e.g. `-f`/`-ff`) is NEVER exempted, on ANY target, including inside the managed root: what needs consent is destroying uncaptured work, and git's own `worktree remove` already refuses a dirty worktree on its own -- `--force` is exactly the override of that refusal, so keeping it at T3 unconditionally means the exempted path can never bypass a protection git itself would have provided. Every other worktree subcommand (`list`, `prune`, `lock`, `unlock`, `repair`, or one a future git adds) is left unmodeled here and falls through to whatever classification already applied.
Packed short-flag heuristic (`_scan_dangerous_flags` -> `_is_posix_short_flag_cluster`): a single-dash multi-char token like `-rf`/`-rfi` is a bundle of single-character POSIX flags (`-r -f -i`), and the scanner escalates it when it packs a dangerous combination. That heuristic USED to test only "does the token contain both `r` and `f`?" on ANY `>2`-char single-dash token -- which mis-read the .NET/PowerShell/Java-style long word-flag `-NoProfile` (P**rof**ile carries both `r` and `f`) as if it were `-rf`, forcing EVERY PowerShell invocation (Claude Code prepends `-NoProfile`) to a spurious T3. The gate now fires only on a genuine packed bundle: short (`<= _MAX_POSIX_SHORT_FLAG_CLUSTER`, 4 chars), all letters, and with no CamelCase word boundary (`_CAMEL_WORD_RE`, an uppercase letter followed by a lowercase one). So `-rf`/`-rfi`/`-fv`/`-rv` still escalate, while `-NoProfile`/`-Force`/`-Recurse`/`-ExecutionPolicy` no longer do -- for ANY CLI with single-dash word flags, not just PowerShell. Accepted trade-off: an uppercase-led bundle such as `-Rf` trips the CamelCase gate and is not matched here, deliberately -- the `-R`/`-r`/`-f` single flags are still caught by their exact-match handling, and killing the ubiquitous `-NoProfile` false positive is worth far more than the rare uppercase-packed form.
KNOWN ASYMMETRY (documented, not changed): `--delete` is git's documented long-form synonym for `-d` -- both invoke the same safe, refuses-if-unmerged deletion -- yet `--delete` IS currently gated (`DELETE_FLAG_IS_DESTRUCTIVE` in `mutative_verbs.py` lists `git`) while `-d` is not (`DANGEROUS_FLAGS` has no entry for `-d`). Two spellings of the identical safe operation currently classify differently. This is a known, accepted inconsistency between the two flag forms -- flagged here for visibility, left as-is by design. For cloud-specific verb patterns -- e.g. kubectl, terraform, gcloud, helm, flux, among the CLIs in `CLI_FAMILY_LOOKUP`'s k8s/iac/cloud families -- see `reference.md`; that name list is illustrative, not the full membership, which lives in `CLI_FAMILY_LOOKUP` (`mutative_verbs.py`).
## Enforcement anchors
The runtime, not this skill, enforces tiers. Three modules layer the decision:
- `tiers.py` -- the `SecurityTier` enum (`T0_READ_ONLY`, `T1_VALIDATION`, `T2_DRY_RUN`, `T3_BLOCKED`) and `_classify_command_tier_cached` assign every command a tier.
- `blocked_commands.py` -- pattern-matches irreversible commands and permanently denies them (exit 2, never approvable). Before matching, `is_blocked_command` peels a leading env prefix -- one or more `NAME=value` assignments and/or an `env [opts]` wrapper -- via `_peel_leading_env_prefix` (reused from `mutative_verbs.py`, the SAME helper the T3 verb classifier applies before verb classification, alongside `_peel_leading_cd` for a leading `cd` chain) and re-classifies the peeled real command. Without this, `env FOO=bar gh repo delete` evaded the floor entirely because `env` is a `READ_ONLY_BASE_CMDS` carrier (the whole command was skipped as a false positive), and `FOO=bar dd if=... of=/dev/sda` defeated the `^`-anchored disk-operation regexes (`^dd`/`^fdisk`/`^mkfs`, which have no semantic-rule backup). The peel is strictly additive -- the command as written is classified first, so re-classifying the peeled form can only ADD a block, never remove one.
- `mutative_verbs.py` -- CLI-agnostic detection of mutative verbs; drives the nonce / approval flow for T3. Includes script-file detection (Step 1d, `_check_script_file`): when a command is `<interpreter> <script-file>` (`python3 deploy.py`, `bash setup.sh`, `node migrate.js`) or `./script.ext`, the file is read and classified by its real invocations -- AST analysis for Python, the blocked/mutative regex layer for shells and other interpreters. A script whose interpreter IS recognized but whose file is missing or unreadable defaults to T3 (conservative, `verb="script-file-unreadable"`). **That conservative default does NOT extend to an unrecognized interpreter, and there is no "unknown runner is safe by default" guarantee -- MEASURED, and stated in `_check_prefix_runner`'s own docstring: "the reachable behavior and the documented one disagree".** The T3 fallback is reachable only after `_resolve_script_argument` has already matched the invocation as a script-file shape, which requires the base token to be in `_SCRIPT_FILE_INTERPRETERS` (`python*`/`bash`/`sh`/`zsh`/`dash`/`ksh`/`node`/`ruby`/`perl`/`php`) or a `./path.ext` whose extension is in `_SHEBANG_EXT_LANES`. A base token outside that set never enters the lane -- `_resolve_script_argument` returns `None`, classification falls through to the ordinary verb scan, and a wrapper verb like `run` (deliberately absent from `MUTATIVE_VERBS`) lands the command at **T0 by elimination, not T3**. The conservative default is real only INSIDE `_check_script_file`, once a token is already known to be an interpreter. This prevents the evasion path where `<interp> <file>` bypasses the verb scanner because the filename token has no recognizable subcommand -- but only for interpreters the set names. A prefix-runner lane (Step 1c-run, `_check_prefix_runner`, wired ahead of `_check_script_file`) now narrows that hole for the named wrappers: `uv run`/`uvx`/`poetry run`/`pipx run`/`npx` have their wrapped payload resolved (`_resolve_prefix_runner_payload`, with the runner's own value-flags consumed) and re-dispatched through `detect_mutative_command`, prepending the payload's canonical interpreter from `_SCRIPT_EXT_INTERPRETERS` when it is a script path -- so `uv run x.py` classifies exactly as `python3 x.py`, reaching the Python AST lane instead of passing as T0. **The residual is deliberate, not an oversight, and it survives that fix: the runner table is inherently OPEN.** A fifth runner nobody listed, a project-local wrapper script, or an interpreter reached by a path whose basename is not a known interpreter name still classifies T0. Inverting the DEFAULT instead (unknown base command, or any command carrying a script-shaped positional, -> T3) was considered and rejected: it would put the entire long tail of ordinary tooling (`make`, `cargo`, `go`, `just`, `task`, every project-local wrapper) behind a consent prompt on every read-only invocation, trading a narrow false negative for a broad false positive that trains blind approval and contradicts the module's own model -- safe by elimination, never by an allow-list. Read that residual as the no-elusion rule instructs: it is a disclosure for the defender, never a route. Moving a blocked mutation behind an unlisted runner to exploit it is forbidden whether or not it passes. A RELATIVE script token is resolved against the `cd` TARGET of its command chain, NOT the hook's own cwd: `detect_mutative_command` peels a leading `cd <dir>` chain (`_peel_leading_cd`, on `&&` / `;`, `||` excluded) and threads the resulting `cwd` into `_read_script_content`; the compound validator (`bash_validator._validate_compound_command`) additionally folds the cwd across the SEPARATE components a chain splits into (via `cwd_after_component`), so `cd /repo && node engine/build.mjs` reads `/repo/engine/build.mjs` and classifies at its true tier instead of a false `script-file-unreadable` T3. Gaia governs arbitrary workspaces, so this must not assume the install dir; when no `cd` is present the process cwd is still used, and a path that is unreadable AFTER honoring the `cd` keeps the conservative T3 fallback. Before reading the body, `_check_script_file` first checks `_INTERP_SYNTAX_CHECK_FLAGS`: a leading syntax-check-only flag (`bash -n`, `sh -n`, `node --check` / `node -c`) that precedes the script positional never executes the script, so the invocation downgrades to T0 without reading the file's contents at all -- a flag appearing after the script positional is an argument to the script and does not qualify. One narrowly-scoped script is re-dispatched rather than AST-scanned: the Gaia CLI dispatcher `bin/gaia` (recognized by basename `gaia` + parent dir `bin` + a body signature, via `_check_gaia_cli_dispatcher`) has its own `subprocess.run(...)` for the lazy DB bootstrap, which AST analysis would flag as mutative -- turning EVERY `python3 <path>/bin/gaia <subcmd>` into a false T3, including read-only subcommands (`doctor`, dry-runs). Instead the tokens after the script positional are reconstructed as `gaia <subcmd> ...` and re-classified through the normal engine, so the form classifies IDENTICALLY to the installed launcher form `gaia <subcmd>` (`dev`, `scan`, `context prune-workspaces` and `release check` stay T3 via `COMMAND_PATH_MUTATIVE_UPGRADES` -- those four are the whole `gaia` anchor set, in declaration order -- `install` T3 via `MUTATIVE_VERBS`, read-only subcommands T0). `release check` is the one worth knowing before you run it: it is NOT a free local gate, because it spawns npm's prepack lifecycle (`npm pack` via `pack_tarball`, `npm install` via `bin/validate-sandbox.sh`) and prepack rewrites `hooks/hooks.json`, so it asks for consent on every run. It is also the live instance of a metadata gap: `tiers.py` matches `T1_PATTERNS` on the word `check` and returns before consulting `detect_mutative_command`, so the command REPORTS `tier=T1` while being ENFORCED as T3 -- read `detect_mutative_command(...).is_mutative`, not the tier, when you need to know whether something is gated. This mirrors the `python3 -m pip install` -> `pip install` re-dispatch and is NOT a general subprocess.run bypass -- an unrelated `bin/gaia` without the signature is still AST-scanned. That `-m` re-dispatch (`_check_python_module_runner`) has since widened beyond package managers, because ANY module invoked through `-m` is that module's CLI (`python3 -m twine upload`, `python3 -m alembic upgrade head`) and the `-m` spelling must not classify lower than the direct one. The widening is **ESCALATE-ONLY**, applying the same false-positive gate `_scan_exec_sink_string_args` uses on an extracted inner command: the rewrite is adopted only when it comes back mutative, otherwise the helper returns `None` and ordinary detection classifies the command untouched -- so `python3 -m pytest` and `python3 -m http.server` keep their ordinary classification instead of being relabelled by a re-dispatch for no security gain. Separately, the stdin sentinel is now conservatively mutative (`verb="script-stdin-payload"`): `<interpreter> -` reads its PROGRAM from standard input, so unlike a script file there is no path to open and the payload cannot be inspected at classification time. This closed a measured hole where `python3 - < payload.py` classified T0 while `cat payload.py | python3` was already T3 through the composition layer -- the SAME payload by two routes, one gated and one not, which is precisely the shape the no-elusion rule addresses. A heredoc payload is excluded, since its body IS in the command string and is already analyzed. Step 1e (`_check_npm_script_runner`) applies the same real-effect standard to npm: `npm run <script>` is resolved to its `package.json` `scripts.<script>` body (the `package.json` is read under the same `cd`-honored cwd, so `cd /repo && npm run build` reads `/repo/package.json`) and that body is classified by the same regex engine used for script files (an unresolvable body -- missing/unparseable `package.json` or absent entry -- falls back to conservative T3), while `npm ci` is unconditionally mutative (T3) because it rewrites `node_modules` regardless of the verb taxonomy. Non-shell source files (`.js`/`.mjs`/`.cjs`/`.rb`/`.pl`/`.php`) route through the **"code" lane**, which splits by language. Each of the four registered families -- the JS family (`.js`/`.mjs`/`.cjs`, or a `node` interpreter token), plus php (`.php`/`php`), ruby (`.rb`/`ruby`), and perl (`.pl`/`.pm`/`perl`) -- resolves a `LanguageSpec` via `source_lexer.spec_for_script` and is classified by `_classify_source_with_lexer`; a language with no registered spec (`spec_for_script` returns `None`) falls through to the older regex lane, `_classify_script_content_by_regex` with `from_source_code=True`. For any lexed family, `source_lexer.strip_source` runs a single left-to-right state machine over the file and produces two line-aligned projections: `verb_view` (comments blanked, string/template-literal CONTENTS blanked) and `exec_view` (comments blanked, string CONTENTS KEPT). `_classify_source_with_lexer` then runs, per line: (1) `is_blocked_command` on `verb_view` as a defense-in-depth safety net for a permanently-blocked pattern; (2) `_scan_exec_sink_string_args` on `exec_view` with `shell_backticks=spec.backticks_are_exec` -- `JS_SPEC.backticks_are_exec` is `False`, because a JS backtick delimits a template literal, not shell execution, so backtick/`%x{}` bodies are NOT treated as exec sinks for JS. Deliberately **not** run for JS: the whole-token mutative-verb scan (`detect_mutative_command`) that the regex lane uses -- in JS a bare word at subcommand position is a language identifier, not a CLI subcommand (`const label = ...`, `let close = ...`), and the scan caught no real JS mutation, only these identifier collisions, so it is removed entirely for this lane rather than merely down-weighted. A JS mutation that goes through the SHELL does still reach an exec sink whose argument is a string literal (`execSync("kubectl delete ...")`), which `exec_view` preserves (including `${…}` interpolation) and re-classifies -- and removing the whole-token scan did not itself open a hole, since a whole token `rm` never matched `fs.rmSync(` either. **But the premise that a real JS mutation must pass through the shell is FALSE: Node mutates the filesystem through its own API with no shell involved.** That was a measured false negative -- `fs.rmSync` in a `.js` file classified T0 while the same call via `node -e` classified T3 -- and it is now closed by a THIRD detector. The lane runs three per line: (1) `is_blocked_command` on `verb_view`; (2) `_scan_direct_mutation(code_line, spec.name)` on `verb_view`, matching a direct filesystem mutation; (3) `_scan_exec_sink_string_args` on `exec_view`. **The layer wired in is deliberately NOT `_UNIVERSAL_DANGEROUS_PATTERNS`** -- that table is still not run here, and the reason is the lesson: it carries `\.write\s*\(` (matching `process.stdout.write(`) and `url-literal` `\bhttps?://\S+` (matching ANY URL in live code), so wiring it would manufacture exactly the false-positive class the exec-sink-only design existed to avoid. Instead a separate, narrower table is keyed by language, `_DIRECT_MUTATION_PATTERNS_BY_LANGUAGE`, indexed by `LanguageSpec.name` with **only `"javascript"` registered** (ruby/perl/php carry the same divergence via `FileUtils.rm_rf`/`File.delete`/`unlink` and are left deliberately open rather than closed untested, since each grammar widens the false-positive surface on a shared path). What makes the narrow table safe to run is a **receiver requirement**: a match needs an fs-ish receiver (`fs.`, `fsp.`, `fsPromises.`, an inline `require(...)`, or a `.promises.` chain) OR a bare call whose name carries the exact `Sync` suffix of the Node API. `mkdirSync` is excluded on purpose, for consistency with the `mkdir`-into-the-working-tree T0 override (`_mkdir_targets_sensitive_path`). Stated residual, not a closed case: a destructured non-`Sync` import (`import { rm } from 'node:fs/promises'; await rm(p)`) is NOT matched -- a bare `rm(` is indistinguishable from any local helper of that name in this projection. Ruby/perl/php are now comment/string-aware exactly like JS: each resolves its own `LanguageSpec` (`RUBY_SPEC`/`PERL_SPEC`/`PHP_SPEC`) and routes through `_classify_source_with_lexer`, closing the false-T3 class where a mutative verb mentioned only inside a comment (`php` with `// update the user cache`, ruby `=begin ... delete ... =end`, perl POD) was read as an invocation. Their comment grammars exceed JS's `//` + `/* */`, so the spec carries three extensions (all defaulting off, leaving `JS_SPEC` unchanged): `extra_line_comments` for PHP's second line marker `#` alongside `//`; `line_block_comments` for Ruby's column-0 `=begin`/`=end`; and `pod_style` for Perl POD (a line starting with `=`+letter, closed by `=cut`). Heredocs (`<<<`/`<<~`) and `q{}`/`qq{}` string forms are an accepted limitation that can only cost a residual false positive, never a false negative. Crucially, unlike `JS_SPEC`, these three specs set `backticks_are_exec=True` and do NOT list the backtick in `string_quotes`: a ruby/perl/php backtick body -- and Ruby's `%x{}` -- is left verbatim in the exec view and re-classified as a shell command (`_EXEC_SINK_BACKTICK_RE`/`_EXEC_SINK_PERCENT_X_RE`, `shell_backticks=True`), since a backtick in these languages IS shell execution. This preserves the exec detection the old regex lane had (`system()`/`shell_exec()`/backticks/`%x{}` still classify T3) while dropping the whole-token verb scan, which -- as in JS -- produced only language-identifier collisions and caught no real mutation (those go through exec sinks). Because the quote making a command one token would otherwise hide a mutation passed to a subprocess as a string literal, `_scan_exec_sink_string_args` is one detector shared across three callers -- the inline `-c`/`-e` path, the shell/other-language regex code lane, and the (JS/ruby/perl/php) lexer lane: the command handed to an exec sink (`execSync`/`execFile`/`spawn`/`system`/`shell_exec`/`passthru`/backticks/`%x{}`, backticks gated by `shell_backticks`) is extracted and re-classified, escalating to T3 **only when the inner command is itself mutative or blocked** (so `execSync("kubectl delete ...")` is T3 while a benign `execSync("ls")` stays T0 -- the false-positive gate). With that third detector in place, `node deploy.js` and `node -e "..."` now classify the same `fs.rmSync(...)` identically -- **both T3** -- so the measured divergence is closed on the native-API slice as well as the exec-sink one. Note WHY the two reach that verdict by different routes: the inline form goes through `_check_inline_code` and `_UNIVERSAL_DANGEROUS_PATTERNS`, the file form through the narrow language-keyed table. Agreement on the cases tested is not proof of equivalence in general -- two enumerations that currently coincide can diverge again the moment either is extended. Residual accepted-limitation: the general case -- a mutation assembled by string concatenation, variable interpolation, or base64, or passed to a sink not in the exec-sink set -- is not detected by static classification; the exec-sink slice is the bounded, low-false-positive portion that is closed. This is the concrete instance of the ceiling the no-elusion rule names: every one of these tables is an enumeration, so these limits are disclosures for the defender, not an available route.
- `mutative_verbs.py` (PowerShell lane, Step 1c-ps, `_check_powershell_command`) -- the POSIX verb scanner is blind to the Windows/.NET shell: `powershell.exe -Command "<script>"` collapses its payload into one opaque token, so a destructive `Remove-Item -Recurse` inside it slipped through as safe-by-elimination (a false NEGATIVE). This lane mirrors `_INLINE_CODE_MAP` / `_check_script_file` for `powershell`/`powershell.exe`/`pwsh`/`pwsh.exe`: it introspects the payload of `-Command`/`-c` (splitting the command text into composition stages and classifying each stage by its FIRST Verb-Noun cmdlet -- the first-cmdlet-per-stage match transparently skips the interpreter wrapper) and of `-File <script.ps1>` (read via `_read_script_content` under the `cd`-honored cwd; unreadable -> conservative T3, mirroring the script-file lane). Each cmdlet is classified by its Verb-Noun VERB (the part before the hyphen) against PowerShell's approved-verb taxonomy: `_PS_READ_VERBS` (get/measure/select/where/sort/compare/test/resolve/find/search/show/convertfrom/convertto/... -> read) vs `_PS_CHANGE_VERBS` (set/new/remove/clear/move/copy/rename/start/stop/invoke/... -> T3); the ambiguous `Out-*` and `Format-*` verbs split by noun (`Out-String`/`Out-Host`/`Out-Null` read via `_PS_OUT_READ_NOUNS`, `Out-File`/`Out-Printer` change; `Format-Table`/`Format-List`/`Format-Wide` read via `_PS_FORMAT_READ_NOUNS`, `Format-Volume` and other storage `Format-*` change). `format` is therefore NOT a blanket read verb -- verb alone is insufficient for these two. Three security rules keep it conservative, NOT permissive: (1) composition -- the payload is split into stages (`;`/`|`/`&&`/`||`) and the tier is the MAX across stages; a change/unknown verb at ANY stage's command position escalates the WHOLE payload to T3 (`Get-ChildItem; Remove-Item x` is T3 because the second stage's command is `Remove-Item`). Classification is per-stage FIRST-cmdlet, so a hyphenated PATH/FLAG argument that follows the command (`Get-ChildItem C:\x\my-folder`) is an argument, not a cmdlet, and no longer false-positives to T3; (2) obfuscation -- `iex`/`iwr`/`icm`, `Invoke-Expression`, `&`/`.` call operators (`_PS_OBFUSCATION_RES`), and `-EncodedCommand <base64>` (`_is_ps_encoded_flag`) are T3 regardless of surrounding cmdlets, checked FIRST and across the WHOLE payload (NOT per-stage-first, since a marker may hide mid-stage) so a read cmdlet piped into `iex` cannot launder the payload; (3) positive allowlist -- to drop BELOW T3 EVERY command-position cmdlet must be a read verb AND at least one recognizable cmdlet must be present at a command position; a payload with no recognizable command-position Verb-Noun cmdlet (or any unknown verb) stays T3 (default-deny fallback, mirroring an unreadable script file). Because the verb taxonomy classifies by verb rather than a per-cmdlet list, a never-before-seen cmdlet is classified correctly (`Get-FooBar` -> read, `Set-FooBar` -> T3) with no allow-list to maintain. Accepted limitation: a mutation via a bare native command inside `-Command` (no Verb-Noun) is caught only by the conservative no-cmdlet fallback (T3).
- `mutative_verbs.py` (bare-Windows lane, Step 1b-win, `_check_windows_native_command`) -- closes the sibling hole where a Windows command with NO `powershell.exe`/`pwsh` wrapper reached the POSIX verb scanner, found no subcommand to match, and fell to safe-by-elimination (T0). A PEELED `Remove-Item -Recurse -Force`, a cmd.exe `del`/`rd`, or a PowerShell alias thereby mutated WITHOUT a gate (the confirmed rc.3 live incident). This lane inverts the default to conservative DEFAULT-DENY **scoped to recognized Windows tokens** (mirroring the `_check_script_file` unreadable->T3 and `_check_npm_script_runner` unresolvable->T3 fallbacks). It fires ONLY when `base_cmd` is: a cmd.exe builtin (`_CMD_MUTATIVE_BUILTINS` del/erase/rd/ren/rename/move/format/attrib/taskkill/shutdown/diskpart/cipher/takeown/icacls -> T3; `_CMD_READ_BUILTINS` dir/findstr/where/ver/ipconfig/systeminfo/tasklist/netstat/... -> T0; two-token `_CMD_SUBCOMMAND_BUILTINS` `reg`/`sc`/`vssadmin` where `delete`/`add`/`create`/`config`/... -> T3 and `query`/`list`/`export` -> T0, unknown subcommand -> T3); a PowerShell alias (`_PS_ALIASES` -- Microsoft's verbatim table: rm/del/erase/rd -> Remove-Item T3, ls/dir/gci -> Get-ChildItem T0, cat/type/gc -> Get-Content T0, cp/copy/cpi -> Copy-Item, mv/move/mi -> Move-Item, ni -> New-Item, ren/rni -> Rename-Item; navigation/output aliases sl/cd/chdir -> Set-Location, cls/clear -> Clear-Host, write/echo -> Write-Output classify **read** because they neither destroy nor grant); a bare execution alias (`_PS_BARE_OBFUSCATION_ALIASES` iex/iwr/icm -> T3); or a bare Verb-Noun cmdlet. A lowercase hyphenated token counts as a cmdlet ONLY when its verb is a real PS verb (`_PS_ALL_VERBS`), and a PascalCase token (`_PS_PASCAL_CMDLET_RE`) counts by its casing alone -- this is the exact split that keeps `docker-compose`/`pre-commit` (lowercase, non-PS verb) on the untouched POSIX path while catching `Frobnicate-Thing` (PascalCase, unknown verb -> T3). Any token NOT recognized returns `None` so bash/POSIX classification (including the `rm` scratch and `mkdir` sensitive-path overrides, which run FIRST via `COMMAND_ALIASES`/`READ_ONLY_BASE_CMDS`) is left completely intact. A recognized bare cmdlet is classified by delegating the WHOLE command string to `_classify_powershell_payload`, which splits it into composition stages and classifies each by its FIRST cmdlet, so composition (`Get-ChildItem | Remove-Item -Recurse -Force` -> T3, MAX across stages), the ambiguous `Out-*`/`Format-*` noun-split, and obfuscation markers all apply identically to the wrapped lane. Because only the first cmdlet of each stage is a command position, a hyphenated PATH argument in a read command (`Get-ChildItem C:\my-folder`, `Get-Content C:\a-b\file.txt`) is an argument, not a cmdlet, and classifies T0 -- while a destructive cmdlet still classifies T3 (it must occupy a command position to run).
- `composition_rules.py` -- `check_composition` / `classify_stage` classify pipe compositions (FILE_READ→EXEC_SINK, network→exec, decode→exec); triggers T3 on dangerous pipelines such as `file_to_exec`.
- `flag_classifiers.py` -- `_classify_curl` / `classify_by_flags` detect flag-dependent mutations; triggers T3 on commands whose flags make them mutative (e.g., `curl -X POST`).
Safe by elimination, with no allow-list: anything not blocked and not mutative is T0. Runtime is the single source of truth for nonce handling, grant scope, and approval enforcement -- this skill teaches how to think about the tier; it does not enforce it.
**The symmetric limitation, in the other direction: a command that passes command TEXT AS DATA is classified as if it were executing that text.** The classifier reads strings, and it cannot tell a payload meant to RUN from a payload meant to be READ. So a meta-tool -- anything that feeds command text to a linter, a test, a parser, or the classifier itself -- is scored on the text it merely carries: an inline `python3 -c` probe whose body only *contains* `fs.rmSync` matches `_UNIVERSAL_DANGEROUS_PATTERNS` as `fs-delete` and is gated as a deletion, and a long enough inline payload trips `heuristic-long-code` on length alone. This is the mirror image of the false negatives above and is equally structural: the two are the same inability to distinguish quotation from use. **The practical consequence, learned the hard way: tier behaviour cannot be verified with ad-hoc inline probes -- use the pytest suite.** An inline probe that tries to measure the classifier becomes an input to it. And when such a probe is blocked, the no-elusion rule applies unchanged: a gate that fired on a probe is not an invitation to respell the probe until it passes, however benign the intent -- rebuilding the token to dodge the match is the very move the rule names, so the route is the test suite, not a cleverer string.
## The `.claude` rule
**Do not touch anything under `.claude/` -- ever, by any mechanism.** By Gaia core policy it is a hard security boundary. This is not a guideline to weigh against convenience; it is a precondition that must be satisfied before any operation begins.
**The rule applies to every execution path, not only deliberate edits.** A `sed -i`, `find -exec`, `xargs`, glob expansion, or any script that sweeps a directory tree is bound by exactly the same policy as a targeted `Edit` call. The mechanism does not change the obligation. If a bulk operation's scope *could* include a path whose components contain `.claude/` -- even deeply nested, such as `tests/fixtures/repo/.claude/settings.json` -- the correct sequence is:
1. Exclude those paths explicitly before running (e.g., `find . -path '*/.claude/*' -prune -o ...`), **or**
2. Do not run the operation at all, **or**
3. Ask the user first.
There is no fourth option. The policy is not "run it and let the hook decide" -- it is "do not attempt it." An agent that launches a bulk operation hoping the hook will catch `.claude/` paths has already violated the policy, regardless of whether the hook fires.
A second, deterministic layer backs the policy on BOTH write surfaces -- Write/Edit AND Bash -- so an attempted write is blocked by default on either one. The two surfaces do not carry the same verdict, though: **Bash is categorical** (see below); **Write/Edit is T3-approvable**, not categorical -- the gate hard-protects the `file_path` param, fires regardless of `permissionMode`, and denies BY DEFAULT, but a `FILE_WRITE` grant for the exact path lifts it (`hooks/modules/security/approval_grants.py::check_approval_grant_for_file`, consulted from `hooks/adapters/claude_code.py::_adapt_write_edit`) -- reachable only on that function's SUBAGENT branch; for the primary/orchestrator role (`is_subagent=False`) the same default-deny instead resolves inline via the host's native consent dialog (`request_consent` with `approval_id=None`), no Gaia grant and no TTL involved. That grant is minted only after the user approves the pending `[T3_BLOCKED]` request the block itself carries an `approval_id` for -- exact-path scoped (no glob, no symlink resolution: `hooks/modules/security/approval_scopes.py::matches_file_path_approval`), reusable for its whole 30-minute window (`FILE_PATH_GRANT_TTL_MINUTES`) rather than single-use, since one protected-path fix is usually several Edits to the same file (`gaia/store/writer.py::insert_file_path_grant`). The protected set is not decided at the adapter: it comes from `hooks/modules/security/protected_paths.py::is_protected_hook_path`, the SAME predicate the Bash command-string guard consumes, so widening one surface cannot leave the other open against the same tree. Its scope is EVERY Gaia hook tree -- the source checkout as well as the installed copy -- because it derives its roots from the workspace registry, a structural path shape and a root marker, never from where the evaluating module happened to be loaded from; plus `settings.json` / `settings.local.json` anywhere under a `.claude/` path. That the source checkout is in scope is the load-bearing part: an earlier version computed the root from the running module's load path, so a dev install that materialised the hooks into a package store hard-protected the copy the next install overwrites and left the only durable location ungated. But the predicate only inspects Write/Edit `file_path`; it never sees a Bash `command` string. The Bash surface is covered by a separate guard, `check` in `hooks/modules/security/protected_path_guard.py`, wired early into `bash_validator.validate()` (alongside the gaia.db and subagent-memory write guards): it scans the command string per operator-split component and CATEGORICALLY denies (exit 2, not approvable) any write-capable command whose target resolves into the protected `.claude/` tree -- git working-tree writers (`git mv`/`checkout`/`restore`/`stash`/...), filesystem writers (`mv`/`cp`/`tee`/`sed -i`/...), and redirects. **Write capability here is judged by the component's BASE TOKEN, never by its flags** -- `_component_writes_protected_path` takes `os.path.basename(tokens[0])` and asks whether that name is in `_FILESYSTEM_WRITE_COMMANDS` (or, for `git`, whether the first non-flag token is in `_GIT_WRITE_SUBCOMMANDS`). So do not expect reads to pass untouched: a command whose base is not a listed writer does pass (`git diff .claude/...`, `cat`, `grep`), while a READ-ONLY SPELLING OF A LISTED WRITER is denied all the same -- `sed -n '1,40p' .claude/settings.json` prints and mutates nothing and is still categorically denied, because the base token is `sed` (measured three times in a single day). When that happens the read itself is not withheld: reach it with `Read`, `cat`, `grep`, or `awk`. Re-spelling the *denied* command until one form passes is the move the no-elusion rule forbids. **Do not "fix" this by making the guard flag-aware** -- three of four audit lenses proposed exactly that, on the premise that a listed writer with no in-place flag cannot write, and that premise is false: `sed` writes its target from a `w` script command (`sed -n 'w /path/out'`) with no in-place flag anywhere in the invocation, so a flag-based guard would pass a real write through a boundary that is categorical and therefore *not approvable*. Nor is it one token -- `rsync -n` and a `dd` whose `of=` points elsewhere are read-capable too, making it a per-command flag matrix rather than one predicate, each cell a fresh hole. The in-place case is meanwhile already covered a second time on the tier path (`_classify_sed` in `flag_classifiers.py` returns mutative for `-i`/`-I`/`--in-place` and the bundled and `-i.bak` forms), so flag-awareness would buy convenience at the cost of the one failure mode this guard exists to make impossible. The honest fix is knowing the rule: base-token-keyed, flags unread. Both layers fire regardless of `permissionMode`; an agent running with `acceptEdits` is still blocked by default on either surface. Only the Bash layer's enforcement is unconditional (exit 2, no `approval_id`, nothing to approve); the Write/Edit layer's default-deny stands down for exactly this session-agnostic condition -- an active `FILE_WRITE` grant for the exact path -- and for nothing else, so neither the agent's intent nor the operation's surface area moves it.
Why state it here, at the top of tier classification: an agent that ignores the policy and tries anyway collides with the deterministic block. That surfaces as drift and confusing failures with no clear cause -- wasted cycles that do not produce a recoverable state. The policy is the prevention; the hook is the backstop. Knowing the rule before forming the intention spares that path entirely.
## T3 approval handoff
When a T3 command is blocked with an `approval_id`, emit `agent_state: APPROVAL_REQUEST` with the `approval_id` in `approval_request`, per the response envelope in `agent-protocol/SKILL.md`. See `subagent-request-approval/SKILL.md` for the full request schema.
Do not retry the command, and do not reach the same effect another way while you wait -- the block withheld the *effect*, so every other route to it is withheld too, including one the classifier would let through. See "The no-elusion rule" above for the three legitimate continuations.