agentleFS
metratonmetraton/gaia/skills/security-tiers/SKILL.md

security-tiers

Skill4 starsChanged 4 months agoMITView on GitHub

Use when classifying any operation before executing it, or deciding whether user approval is required

  • Reads credentials
  • Deletes or force-pushes
  • Installs packages
  • Sends data out
  • Commits and pushes

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 2 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Do not request these forms in advance.

    line 101
  2. Very specific:

    Read-only inline code, read-only subprocess argv, and mv/cp/tar inside your own scratch or worktree run free (the relieved local lanes …

    line 223
  3. Distinctive:

    Ask, in order -- the first "yes" wins:

    line 229

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 after the pre-execution policy gate returns [T3_BLOCKED]. Never discover a set by attempting a compound shell command. subagent-request-approval owns request construction and grouping; this skill owns the classification and no-elusion decision that routes work there.

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 validator model: a simple first line, walls behind it

The Bash classifier is the first line, and it is kept simple on purpose: the target walls below stand behind it, so a command it misreads still meets a wall at the thing that matters. It applies five rules; the sections after this one detail each lane.

  1. A closed change-verb lexicon. A word changes state only when it is in hooks/modules/security/verb_lexicon.py::CHANGE_VERB_CLASSES -- five classes, create, update, delete, publish and exec -- whose union is hooks/modules/security/mutative_verbs.py::MUTATIVE_VERBS. A word outside it is not a change verb, so add, commit and stash are free by elimination. The ambiguous words set, copy, start, tag, label and annotate (verb_lexicon.py::AMBIGUOUS_VERBS) are relieved only on a family proven local, LOCAL_FAMILIES (docker), through verb_lexicon.py::ambiguous_verb_relieved; an unmodelled CLI (rclone copy) is not proven local and signs. Inside the execution scan (rule 3) a lexicon verb counts only on a remote family (hooks/modules/security/execution_scan.py::REMOTE_FAMILIES: k8s, iac, git, cloud, workspace, package, system).
  2. A closed exception table. The verb yields to the effect only in the shapes verb_lexicon.py::EFFECT_OVER_VERB_EXCEPTIONS lists, each naming the symbol that decides it: a push of one's own non-default branch and a --ff-only merge in a managed worktree (mutative_verbs.py::_check_git_publish_relief), a local git merge (mutative_verbs.py::_git_merge_is_local), a --ff-only merge to the branch's own remote ref (mutative_verbs.py::_git_local_sync), opening or editing a change request (verb_lexicon.py::CHANGE_REQUEST_OWN_WORK), and gaia approvals revoke|reject|reject-all|clean (mutative_verbs.py::CONSENT_REDUCING_SUBCOMMAND_EXCEPTIONS). Opening a change request is free on any repository; a local merge is free with only the plain flags mutative_verbs.py::_GIT_LOCAL_MERGE_FLAGS lists (plus -m <msg>, or a bare --abort, --continue, --quit), while a strategy option (-s, -X) or an operand the shell computes (git merge $BRANCH) signs; pr merge signs for every binary, and so does a change-request edit on another repository or with a retarget flag. "What reaches the remote" below gives each shape in full.
  3. The word that decides what runs is literal. When the program word, or the verb word of a CLI that picks its action by word, is a shell expansion, the command asks as unknown.dynamic-span (verb dynamic-program or dynamic-verb; Step 0-dynamic in mutative_verbs.py::_detect_mutative_command, reading _literal_words and _dynamic_verb_span_word).
  4. One short execution scan, depth 1. execution_scan.py::scan_execution reads an execution's entry -- a script file, inline -c/-e code, a heredoc program, a package script with its pre/post scripts, a make target or just recipe with its direct prerequisites, go run, cargo run -- and the literal local files it names, once each and never their own callees (at most MAX_CALLEES, 32, each up to MAX_BYTES, 512 KiB). execution_scan.py::_signal looks for six signals: a change verb on a remote family (remote-change-verb, sign.lands); a write -- never a read -- onto a system, account or harness path (system-path, sign.system-path or sign.lands-on-protected); a download piped into an interpreter (download-and-run, sign.foreign-code); code built at runtime, such as eval of a variable or an import whose specifier is computed (runtime-code, sign.foreign-code); a recursive delete or truncate outside the project or Gaia scratch (destroys-outside, sign.destroys, measured against execution_scan.py::_kept_roots; a permanently blocked disk or delete command reports as floor under the same rule); data sent over the network (network-send, sign.speaks). The scan only adds asks (mutative_verbs.py::_check_execution_scan, run right after _detect_mutative_command): a verdict the older lanes already made mutative is kept unless its verb is one of mutative_verbs.py::_NO_MODEL_VERBS -- an unresolved npm/bun/pnpm/yarn run, an unresolved frozen install, an unreadable script file -- and only such an ask is freed when the scan reads the entry with no signal, never one whose entry is inline code. A file read only in part (over the size cap or not UTF-8) asks as partial-read (unknown.dynamic-span), and an entry that runs a local path the scan cannot read keeps the older verdict.
  5. Reads are free, except secrets and the store. What changes nothing is free by elimination (hooks/modules/security/tiers.py::_classify_command_tier_cached). Reading where secrets live is refused (hooks/modules/security/sensitive_read_guard.py::check), and so is any sqlite3 read of Gaia's store or Python that reaches it in-process (hooks/modules/security/gaia_db_write_guard.py::check, which calls hooks/modules/security/store_code_guard.py::check); gaia context query opens the store read-only.

The target walls behind those rules:

  • Running as another user signs. sudo, su and doas, past assignments and transparent wrappers, ask under sign.privilege, while the payload still meets every floor (hooks/modules/security/privilege_guard.py::privilege_verdict).
  • A shell write onto a system path signs. A writer whose target resolves under /etc, /usr or /boot asks under sign.system-path (hooks/modules/security/shell_write_guard.py::system_path_target, called from hooks/modules/tools/bash_validator.py).
  • The real store is written only by Gaia's entry points. gaia/store/writer.py::real_store_refusal refuses a write to the account's real gaia.db, creating a missing one included, from any process that gaia/store/writer.py::_runs_as_entry_point does not recognize: bin/gaia, whose main sets GAIA_STORE_ENTRY_POINT so a subcommand such as gaia scan and every child it starts inherit it; a hooks/*.py entry; opencode/bridge.py. A pytest run never writes it. This is an accidental-write wall against stray scripts and tests, not a privilege boundary: code running as the user can still pass as an entry point.
  • Session start checks the install. hooks/modules/session/install_integrity.py::integrity_notice compares the install links and Gaia's settings keys with the hashes the install manifest recorded and reports drift under ## Install integrity (hooks/modules/session/session_lifecycle.py::_install_integrity_notice). It is a notice, never a block.

Concealment is not chased in the parser. The scan and the verb rules read only what the text shows literally; a signal behind a variable, a helper of a helper or a computed name is not followed (the header of execution_scan.py::REMOTE_FAMILIES states it). That case belongs to the walls above and to the no-elusion rule below, not to a longer parser. Every validator change lands behind a replay with friction caps set beforehand. tests/replay/test_validator_replay_diff.py::test_the_working_tree_validator_stays_within_the_friction_caps, selected with -m replay, runs tools/validator_replay/replay.py::run to classify the truth table, real command history, approvals and curated cases with the merge-base validator and the working tree; tools/validator_replay/replay.py::check_caps fails the run when the difference exceeds tools/validator_replay/caps.json. A change to a pinned truth row or curated case (replay.py::PINNED) passes only when its task declares it in tools/validator_replay/declared/<task>.json and the run is pointed at that file with GAIA_REPLAY_DECLARED (read by the test and passed to replay.py::run; unset, nothing is declared), and a verdict that loosens needs a named review there.

The four tiers

TierWhat it isApproval?Example verbs
T0Read-only; observes state, changes nothingNoget, list, describe, show, logs, status
T1Local validation; no remote calls, no stateNovalidate, lint, fmt, check
T2Simulation / dry-run; may read remote, never writesNoplan, diff, --dry-run, template
T3State-mutating; creates, updates, or destroysYesapply, 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 (the sqlite3 CLI aimed at gaia.db, whatever its SQL, and any shell writer -- copy, move, redirect, tee, dd of=, truncate, a mutative inline interpreter -- whose destination is the gaia.db file, hooks/modules/security/gaia_db_write_guard.py::check), a file write into a git working tree whose author is the SHELL (hooks/modules/security/shell_write_guard.py::check), and reading or listing a path where secrets live -- credential directories and files, .env files, the key patterns -- through Bash (hooks/modules/security/sensitive_read_guard.py::check) or Read, Glob, Grep and OpenCode's list (check_file_tool in the same module), and a git push of a branch whose net comment and docstring lines exceed the limit (hooks/modules/security/comment_push_gate.py::check, measured by gaia lint comments; it fails open with a warning when the counter errors; a --help, -h or --dry-run push publishes nothing and is not measured). It is not a tier, it is not approvable, and no grant lifts it. The secret-read refusal is categorical because a signature would only put the credential's contents into a transcript. A body file handed to a publishing command (--body-file, -F, --description-file, --notes-file, git commit -F, for any forge binary) that resolves to a secret is a secret read and is refused the same way.

The gaia.db guard (gaia_db_write_guard.py::check) runs sqlite3 only inside an allowlisted subset whose reach it can prove from the line, and refuses every other call categorically. The subset proves where a call can land, not that it only reads: it admits INSERT, UPDATE, DELETE and CREATE/DROP of a table, index or view on a database other than gaia.db, and every call inside it signs. The proof comes from the content the line shows: what that content proves sqlite3 can reach decides the verdict, and the program's name decides nothing. On a line that runs sqlite3 (directly, behind a wrapper, through xargs or a shell's -c):

  • The store is refused whatever the SQL, reads included (STORE_REJECTION_MESSAGE), and the denial names gaia context query, which opens the store read-only. The store is any word of the line -- its operands, a pipe's producers, a heredoc or here-string body -- or of a file sqlite3 reads as SQL, whose name is gaia.db or one of its side files, or whose realpath or inode is the live database's.
  • A database target the guard cannot resolve to a literal path is refused as "could not prove" (gaia_db_write_guard.py::_database_verdict): a database argument, .open operand, or ATTACH/VACUUM INTO argument that is a glob, a variable, a substitution, a brace, ~user or a computed SQL string.
  • A relative file operand resolves only when sqlite3 is the line's first command and its only sqlite3 run (gaia_db_write_guard.py::_starts_in_shell_folder), against the working folder the call was given. On any other line, or with no working folder, a relative operand is refused as "could not prove" (UNPROVEN_START_MESSAGE); an absolute path is judged as written. A literal .cd target -- in an operand, a -cmd, piped or heredoc content, or a file read as SQL -- is one more folder every later relative operand (.open, .read, .once, .output, .import, .log, .save, .backup, .restore, ATTACH, a .shell command) is judged in, so a relative path that lands on the store or on a floor there is refused; after an unresolvable .cd, any relative file operand is refused as "could not prove" (gaia_db_write_guard.py::_folders).
  • SQL the guard cannot read in full is refused (UNPROVEN_INPUT_MESSAGE). The files read as SQL (gaia_db_write_guard.py::_fed_texts) are the < and -init files, every .read target (in an operand, a -cmd, or a file already read), the sqliterc under a HOME or XDG_CONFIG_HOME the line assigns, and the files a cat of literal paths prints anywhere on the line (a pipe, <(...), $(...), .read '|cat f'), each followed recursively. A program's output, a descriptor, a process substitution, computed text, and a file that is missing, unreadable or not named literally leave the SQL unproven.
  • What remains runs only inside the allowlisted subset (gaia_db_write_guard.py::_outside_subset, refused with OUTSIDE_SUBSET_MESSAGE): an absolute database other than gaia.db (a relative or file: URI database is outside it), no environment assignment before sqlite3, the options _SUBSET_OPTIONS and _SUBSET_VALUE_OPTIONS name, the dot-commands sqlite_subset.py::DOT_COMMANDS names, a readable sqliterc in the home folder, and SQL whose every statement, function and table source is on the allowlist sqlite_subset.py::sql_outside_allowlist reads -- ATTACH, VACUUM, a virtual source such as fsdir, a pragma that sets a value and an unlisted function are outside it.

On any other database, a shell command or file that a readable payload names meets the floors a plain Bash line meets (_payload_refusals): the command of .shell/.system, of any dot-command operand starting with | (with the operands after it), of edit()'s second argument and of a VISUAL or EDITOR the line assigns, bare or through env, whether or not the SQL calls edit(), goes through the blocked list, the store, protected-path, shell-write and secret-read guards, and the file of .once/.output or a literal writefile() path is judged as a redirect onto it; a refusal there is categorical. A call inside the subset asks for a signature (verb="sqlite-reach", gaia_db_write_guard.py::sqlite_unproven_effect), whatever its flags: measured on sqlite3 3.45.1, -readonly -safe refuses writefile, edit, load_extension, readfile and ATTACH, but fsdir() still lists and reads files, so no form is provably read-only. ~/.sqliterc and ~/.config/sqlite3/ are account paths (HOME_ENTRIES), so writing them signs.

Every one of those path guards judges a file, not its spelling. They all go through a single canonical-path step, hooks/modules/security/canonical_path.py::canonical_path. It covers both the Bash guards and the file-tool checks (is_protected_hook_path, is_account_path, check_file_tool).

  • Translation comes first. The step opens with git_bash_to_windows, which turns a Git Bash /c/Users into C:/Users. Python's ntpath does not do this: it reads /c/Users as a folder named c on the current drive.
  • The rest of the step. It then normalizes backslashes, removes long-path and local admin-share prefixes (\\?\C:\, \\localhost\C$\), strips alternate data streams (.env::$DATA) and trailing dots and spaces, and expands 8.3 aliases (SSH~1). Last, it folds case, so .ENV gets the same verdict as .env, on Linux too.
  • 8.3 names are matched by shape, never looked up on disk. An alias whose stem Windows replaced with a hash is not recognized.
  • Resolution uses a narrower form. Guards that resolve a path on disk use native_path instead. It drops only what Windows drops, and only on Windows.

The sensitive-path table HOME_ENTRIES has a Windows counterpart, PROFILE_ENTRIES in sensitive_paths.py, matched anywhere in an absolute path:

  • Read-refused: the APPDATA GitHub CLI, gcloud and gnupg folders.
  • Readable but write-signed: the PowerShell profile folders and the Startup folder.

Unexpanded %APPDATA%, $env:APPDATA and %USERPROFILE% expand like ~.

The guards also recognize Windows commands. The secret-read guard knows the Windows readers (type, gc, Get-Content, findstr, certutil, Select-String, Get-ChildItem) and checks what runs inside cmd /c and powershell -Command. The protected-path and gaia.db guards know the cmd and PowerShell writers (copy, Copy-Item, Set-Content, Out-File and their aliases). canonical_path.py::command_name matches a command word with no directory, in any case, and without .exe.

The indirect-execution verdict (hooks/modules/tools/bash_validator.py::_detect_indirect_execution): a subagent's shell wrapper (the -c of any sh-family shell in hooks/modules/security/shell_unwrapper.py::_SHELLS -- bash, sh, dash, zsh, ksh, fish and the rest -- or eval) is refused with the correct form -- run the command directly, or commit a script file -- and carries no approval_id; the orchestrator gets a host ask dialog only when the wrapped command does not mutate (a T2 verdict), while a mutative one reaches the T3 gate like any other command. Behind a chain (cd X && bash -c ...) a mutative wrapper takes the compound-T3 deny, whose message names the same form.

Writing an account path is the opposite case: T3-approvable, not categorical. A path that decides who may log in or what runs on the next shell or git invocation (hooks/modules/security/sensitive_paths.py::is_account_path -- shell start-up files and git config included, though they stay readable) asks the same signature through Bash, Write, Edit and apply_patch. Which paths are read-refused and which are write-signed is one table, HOME_ENTRIES in sensitive_paths.py, each entry marked by use.

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 heredoc body is data only when its reader provably cannot run or write it (pure data readers, jq, grep without -f, and the commit/PR message flags); for such a body the floor scan reads none of it, so a commit message that names kubectl delete is not refused. Any other reader fails closed, and xargs re-checks each body line as <target> <line> in the caller's cwd. awk, by name or by path (/usr/bin/gawk) and in every dialect AWK_PROGRAMS names (goawk, original-awk, onetrue-awk included), runs free only when its whole program is proven read-only by construction (flag_classifiers.py::_classify_awk): every program text -- inline, -e, a readable -f file named with a slash (gawk looks a slash-less name up through AWKPATH, so under awk, gawk or nawk it signs, and an AWKPATH set earlier on the line signs every later awk), or stdin from the one source the line proves (a quoted heredoc, a literal here-string, a readable < file, an echo with no -e and no backslash, a printf whose format has no escape and no directive but %s/%%, a cat of readable files) -- is a literal the line shows verbatim and contains none of >, |, system, @ or /inet anywhere; -i, -l and -E always sign. A shell comment is not part of the line, and a { ... } or ( ... ) group around awk does not hide it. Anything else signs, a > comparison included. The table and the exact rules live in hooks/modules/README.md, "Heredoc readers". A heredoc is stdin data for the trusted Gaia CLI only under all of these conditions (data_heredoc.py::data_heredoc_header):

  • the delimiter is quoted;
  • the receiver is the unquoted bare word gaia, or an absolute path whose realpath is the bin/gaia of the installed Gaia this hook runs from (is_gaia_cli), never another program merely named gaia;
  • the receiver reads the body through an unquoted --<name>-file -;
  • the declaring line is one plain command, and the terminator ends it.

The only other receiver is a bare cat with no argument, the "$(cat <<'EOF' ... EOF)" idiom. The gaia CLI's option values are still judged as file reads by the secret-read guard, its standalone bin/cli scripts included. Abbreviated options are refused (gaia/cli_parsing.py::refuse_abbreviations) by the bin/gaia dispatcher for every group and by the only standalone scripts that parse options, bin/cli/approvals.py and bin/cli/contract.py. Under those conditions this guard -- like the protected-path, gaia.db and account-path guards -- reads only its command line: prose in the body naming > path is not a write, while a redirect on the command line disqualifies the exemption and the command is read whole. A consequence to know before it surprises you: in-tree sed -i is therefore NO LONGER the T3 signature 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 (gaia/approvals/core.py::protected_write_verdict); 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. This matches GIT_LOCAL_SAFE_SUBCOMMANDS in mutative_verbs.py, where commit and add are listed as local-safe. merge and rebase are not on that list: a merge is free only in the shapes listed below, and a rebase only when it rewrites commits no remote ref holds, inside a Gaia-managed worktree (_git_rebase_rewrites_only_unpushed, through _git_local_sync).

What reaches the remote is gated by its effect, not by its verb: the act with effect is the merge into the default branch. The forms below publish work for review or stay local, and run free, each as an exact shape decided by _check_git_publish_relief (mutative_verbs.py) or by the change-request shape (forge_shapes.py::change_request_action); they are the entries of the closed exception table, verb_lexicon.py::EFFECT_OVER_VERB_EXCEPTIONS, and anything outside a shape falls to the verb scan and stays T3:

  • Opening a change request on any repository, and editing one's own, for ANY binary: pr|mr|pulls|pull-request|merge-request create|edit|update (and az repos pr ...) runs free through gh, a renamed copy, glab, tea, az or a binary the classifier does not model; a program it does model (rm, npm, kubectl) keeps its own gates. create is free whatever -R/--repo names, while an edit or update whose -R/--repo is not this checkout's origin signs as speaking for the user (_change_request_elsewhere, sign.speaks). A retarget, ready, draft or status flag on an edit (--base, -B, --target-branch, --ready, --draft, --status, --state; _CHANGE_REQUEST_EDIT_FLAGS) and a landing flag on any of them (--auto-merge, --auto-complete, --bypass-policy; _CHANGE_REQUEST_LANDING_FLAGS) take it back to T3, because they are the merge's decision. comment, note, ready, a review verdict, close and merge are T3 for every binary.
  • git [-C <path>] push [-u|--set-upstream] <remote> <branch>, when <remote> is a remote name, <branch> is a local branch that no tag shares, and it is not main, master, HEAD or the branch refs/remotes/<remote>/HEAD points at (read with git symbolic-ref; when it does not resolve, only main and master count as default). The repository must be known -- an absolute -C, or the caller's cwd; the hook process's own cwd is never assumed. Its configuration must push the branch to itself: a set remote.<remote>.push or remote.<remote>.mirror, or push.default upstream/tracking whose branch.<b>.merge is missing or a default branch, keeps it T3, and so does any git config --get that fails. A leading assignment or wrapper (GIT_DIR=, GIT_WORK_TREE=, HOME=, env, sudo, ...) keeps it T3 (_git_relief_under_prefix), as does any global option other than one -C (--git-dir, --work-tree, -c <key>=<val>). Any other flag (--force, -f, --force-with-lease, --delete, -d, --mirror, --all, --tags, ...), any other global option than one -C, a + or : refspec, a missing or extra operand, and bare git push stay T3; a force push is signed as sign.destroys, never refused. The exception is git push --dry-run, which publishes nothing and runs free toward any branch, the simulation lowering (SIMULATION_FLAGS) deciding before any push flag is read. -n is not read as a dry run, and a dry run naming a declared protected branch still asks.
  • git [-C <path>] merge [flags] [-m <msg>] <ref>... in ANY repository, when every flag is one of _GIT_LOCAL_MERGE_FLAGS (--no-ff, --ff, --ff-only, --squash, --no-edit, --no-commit, -q, --quiet, --stat, --no-stat) and every operand is a literal ref name, and the bare git merge --abort|--continue|--quit (_git_merge_is_local): a merge changes only the checked-out branch, and publishing it is a push. A strategy option (-s, -X) or any other flag, an operand the shell computes ($BRANCH), and an assignment or wrapper before git (_git_relief_under_prefix) keep it T3.
  • git -C <path> merge --ff-only <rev>, when <path> is absolute and resolves inside a Gaia-managed worktrees root (_is_inside_managed_worktrees_root, the same check git worktree remove uses); the previous bullet already frees this shape anywhere, and this entry keeps its own row in the table.
  • git [-C <path>] merge --ff-only <remote>/<branch> in ANY repository, when <branch> is the checked-out branch and <remote> is one of git remote (_git_local_sync): a pure fast-forward of the branch to its own remote ref. Unlike the shapes above it reads the repository on every call and runs outside the classifier cache; a shape it does not free (a detached HEAD, another branch's ref, an unreadable repository) falls to the local-merge bullet above, and an assignment or wrapper before git stays T3. git fetch, git pull and git checkout|switch <branch> are free by elimination; git -C <path> switch <branch> no longer reads the global -C as switch -C.

Do not request these forms in advance; run them, and request only what is still T3.

Gaia's own subcommands are classified by Gaia's group map, not by English verbs. COMMAND_SUBCOMMAND_TIER_EXCEPTIONS in mutative_verbs.py holds one entry per gaia group whose verbs are local, reversible bookkeeping (brief, ac, plan, task, notifications, contract, memory, worktree, and login, which only starts logins that keep the active account), with the destructive verbs re-gated per group (COMMAND_SUBCOMMAND_EXTRA_DENY_VERBS). gaia worktree prune-branches is one of them: it force-deletes local branches with git branch -D, so it signs, while its --dry-run runs free through SIMULATION_FLAGS. A group left out of the map falls to the generic verb scan, which is how gaia session resume-point set once asked for a signature: resume-point hyphen-split onto resume. That scan stops on the first mutative word, so a group whose name is itself a mutative verb gates every action under it; gaia migrate plan asked for a signature that way, on migrate, although it opens the database read-only. Such a group is classified per action from SUBCOMMAND_VERB_TIER_EXCEPTIONS in mutative_verbs.py, which names only the actions proven not to write: gaia migrate plan is T2 and gh label list is T0, while gaia migrate apply, gh label create|edit|delete|clone and any action the table does not name, including one added later, still stop on the group word and are T3. The table also names gaia approvals request-set, request-file-write, question and show, which are free in every spelling of the CLI. A request only stores its command for the user's decision. The stored command is judged on its own when it runs, so a stored push still signs. approve and replay still sign. gaia session is classified per verb instead, from SESSION_VERBS in subagent_resume_point_guard.py: a literal preview, snapshot or resume-point set is T0, a verb word only the shell resolves ($S, a backtick, a glob) is T3, and an undeclared verb takes the ordinary scan; a test fails on any verb the CLI registers that the map does not name. Who may write is a separate question, answered by role, not tier -- the guard categorically refuses a subagent's gaia session call (no approval_id) unless its words are a declared read, as subagent_memory_write_guard.py does for memory writes, while a subagent's snapshot and any --help stay free. It decides on the shell's words, not raw text, so 'resume'-point, resume""-point and resume-poin\t are the same write. Both guards recognize the Gaia CLI through one function, gaia_cli_invocation.py::entry_group: the gaia name, a user tool row whose replaces is gaia, a symlink by its realpath, a renamed copy of the dispatcher by its content, bin/cli/<group>.py (and python3 -m cli.<group> in the memory guard) as gaia <group>, and a shell-resolved word ($G) -- because the CLI cannot tell a subagent once GAIA_DISPATCH_AGENT is unset, the hook is the boundary. An in-process call (python3 -c importing Gaia's store) is not a CLI entry point and is not recognized. Two rules keep the verb from leaving the command text, both categorical for a subagent (subagent_memory_write_guard.check): a call whose entry point is literally a Gaia CLI entry point and whose later words are computed (an unquoted or double-quoted expansion, a substitution, a glob, a brace, or arguments fed by xargs/parallel) is refused under floor.computed-gaia-words (gaia_cli_invocation.py::computes_gaia_words) -- a whole double-quoted "$(cat <<'EOF' ... EOF)" word is the literal text it prints, so it is read as that value (resolve_cat_heredocs) rather than as a computed word -- and under floor.dispatch-identity so is any command that unsets, empties or overrides GAIA_DISPATCH_AGENT (env -u, unset, an assignment, export), or that clears the environment (env -i) around a Gaia CLI call; env -i ls keeps its ordinary verdict. A computed PROGRAM word ($X ..., $(...) ...) is not refused by that rule: it keeps the validator's own verdict, UNKNOWN, which asks for a signature, so it never runs free as a hidden gaia. And gaia ... --help is a read at any depth (gaia is in HELP_ANY_POSITION_BASE_CMDS): argparse prints the deepest subparser's help and exits while parsing, before any handler runs. Other whitelisted CLIs keep the two-positional boundary of the --help exemption, so kubectl delete --help is free while kubectl delete pod x --help still asks.

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.

Inline code and script files: what the parse cannot prove asks. The rules every code lane (-c/-e/-p/-r, a heredoc program, a script file) applies, in inline_ast_analyzer.py::uncatalogued_effect and mutative_verbs.py::_check_inline_code/_check_script_file:

  • Python that does not parse is UNKNOWN and asks (unparsed-code, unknown.dynamic-span); it no longer falls to a regex lane that could free it.
  • A file reached through a path the parse cannot prove asks. open, io.open, os.open, pathlib.Path, os.listdir, glob, shutil.* and the archive openers are relieved only when each path argument is a literal, an f-string with no names, a name bound once to such a value, __file__, or those joined as path segments (os.path.join, /, joinpath) under HOME, Path.home() or the cwd (_provable_path). Concatenation, slicing, decoding, chr, formatting with names and runtime input are not proven, because the secret-read floor reads only literals. A literal secret path is still refused by the floor.
  • Archive writers are writes: zipfile, tarfile, gzip, bz2, lzma, dbm and shelve opened in any mode but a literal read mode, and writestr/addfile.
  • A changed environment a process inherits (os.environ[...] =, update, setdefault, os.putenv) together with a process launch asks.
  • Interpreter internals (__subclasses__, __globals__, __builtins__, any dunder outside _INERT_DUNDERS) ask.
  • A Python script file that hands any string to eval/exec/compile asks; inline code still judges a literal code string as its payload.
  • JavaScript: node -p/--print is the same lane as -e. In every lane and at every length, code built at runtime (eval, Function, import(), a non-literal require), a network module, a computed member of a required effect module (require('fs')[...], or a name bound to fs/net/child_process indexed by [), and a fetch with a body or method ask (_js_hidden_effect). In a file, call shapes are read with comments and string contents blanked.
  • Ruby, perl and php have no parser here: their inline code and script files are UNKNOWN and ask, unless every statement is a print verb (print, puts, p, pp, echo, say, printf) followed only by literals -- quoted strings with no $, @, #, { or backtick, numbers and arithmetic (_prints_literals_only). A heredoc program in those languages asks.
  • Covered interpreters: the JavaScript screen judges node (-e, -p, heredoc, .js/.mjs file), deno (eval, run, a bare file) and bun (-e, -p, run <file>, a bare file) alike, and asks for any Deno./Bun. member that is not a read, an import of bun or a remote module, and a spawn/execFile argv whose command or computed shell payload is not free; lua, luajit, Rscript and bare R (-e, R CMD BATCH) follow the print-literals-only rule above.
  • Configuration an interpreter loads on its own is judged before its code (mutative_verbs.py::_AUTO_LOAD_CHECKS, _js_package_mapped_specifier): a bunfig.toml preload or plugin, a node --env-file whose NODE_OPTIONS loads code, a package.json # import or self-referenced exports, a ./.Rprofile or a ./.Renviron naming a startup file, a ruby -r feature from a path or -I directory, and Python code that adds a site directory or changes sys.path and imports; what cannot be read asks, while python -I/-P takes the local directory off the module search.
  • Python runs free only when its whole import closure is proven (store_code_guard.py::unproven_reason, verb unproven-python, unknown.dynamic-span): a local module the closure cannot prove, a dynamic import, exec of a string, a changed import path or a program read from stdin asks. mutative_verbs.py::detect_mutative_command applies it after the lanes and the execution scan, to a verdict they left free; Python that reaches Gaia's store in-process is refused outright (store_code_guard.py::check).
  • A local module is read and judged by its content, committed or not; a compiled file for it (.so, .pyd, a .pyc beside it or in __pycache__) asks, since Python may run it instead (_local_python_module).
  • bin/gaia and bin/cli/<group>.py run by path are judged as gaia …/gaia <group> … only inside the installed Gaia this hook runs from (_gaia_shipped_script, _hook_root_is_install). The hook's root counts as installed only on positive evidence (_root_is_install): it resolves to an install root (a directory whose package.json names Gaia's package and holds no .git, from any channel; protected_paths._install_root), and git, run with every GIT_* variable removed, answers "not a git repository" or a top level above the root. Any other git outcome -- git missing, a timeout, dubious ownership, an error, unreadable output -- counts as a checkout and is not cached. Accepted residual: a git on PATH that someone else controls can answer either proof falsely and make a checkout at an install location count as installed, as it defeats any git-based check. The same holds when the program word is the path itself (<checkout>/bin/gaia memory list, ./bin/gaia ...): only the installed bin/gaia the hook runs from is classified as gaia …; any other path named gaia asks (gaia-by-path). From any git checkout, Gaia's own source worktrees included, they are scripts judged by content and ask: a writable checkout can put edited code on their import path (an ignored or forged .pyc, an excluded file, a symlinked __pycache__), and proving it clean is not cheap. Use the gaia CLI for a free read from a checkout. A remote URL, a package name or the project registry never vouches for a file, since any repository can claim them.

The verdict model: effect and target

A command is judged by what it does and where it lands, never by which binary runs it.

Four verdicts (hooks/modules/security/verdict.py::Verdict). Every refusal and signature request carries one line: Rule: <id>. Remediation: <sentence> (REMEDIATION, render).

  • FREE -- your own work that reaches no one.
  • SIGNED -- asks for a signature, on one of four legs (effect_rule): sign.destroys, sign.speaks (it says something to others in your name, including data leaving the machine), sign.lands (shared, protected or production state), sign.foreign-code. sign.lands-on-protected, sign.lifecycle-body-verb and sign.system-path are narrower forms of the same legs, and sign.privilege asks for running as another user (privilege_guard.py::PRIVILEGE_RULE).
  • BLOCKED -- a floor.* rule; nothing can sign it.
  • UNKNOWN -- asks like SIGNED, but names what could not be determined (unknown.dynamic-span, unknown.target; undetermined_span). It never lowers a BLOCKED verdict and never relieves a SIGNED one.

Effect classes over the head window. The verb scan reads the command words near the head, whole and by hyphen fragment. The strongest class wins: change, then simulation, then read (MUTATIVE_VERBS, SIMULATION_VERBS, READ_ONLY_VERBS).

  • run-, add-, use- and the other COMPOUND_HEAD_MUTATIVE_VERBS change only as the head of a compound (aws ec2 run-instances).
  • A later fragment that changes outranks a head that does not (_hyphen_candidate, _later_head_mutates, within GROUP_PATH_HYPHEN_DEPTH).
  • auth and config defer to a changing word after them, so gh auth login and kubectl config use-context are signed.
  • test is a live run on IaC and Kubernetes CLIs (LIVE_SIMULATION_VERBS), so terraform test and helm test are signed.

Generic modifiers move a verdict for any CLI.

Up:

  • --force, --push, --auto-approve/-auto-approve and --admin (DANGEROUS_FLAGS).
  • down -v|--volumes|--rmi and prune -a|--all|--volumes destroy (VERB_DESTROYING_FLAGS).
  • An --output that pushes to a registry (_output_pushes).
  • ansible-playbook applies unless it only checks or lists (APPLYING_PROGRAMS).

Down:

  • --dry-run and its spellings (SIMULATION_FLAGS, the last spelling wins in _simulation_flag_holds).
  • The local cache refreshes helm repo update, apt update, apt-get update and act push (LOCAL_CACHE_PATHS).
  • curl -G/--get with literal data, which sends it as a GET query (_classify_curl).

Full parse relieves, literal escalates. A lowering applies only when the whole command is understood: no wrapper or assignment in front, every option known with its arity, no output-type option, no variable, glob, brace or bracket. Otherwise the base verdict stands.

  • _local_cache_refresh accepts only LOCAL_CACHE_SAFE_FLAGS.
  • curl -G -d q=x -o out.json is signed.
  • An unrecognised word can only raise.
  • A downloader (curl, wget, an HTTP client) is judged by its whole line: every word, option value and saved file name it writes goes to the protected-path and gaia.db guards.

Cloud CLIs. aws, az and gcloud find their operation word by position, after the known global options and their values are read away (_CLOUD_GLOBAL_OPTIONS, _cloud_command_readings). An option the table does not know, before the operation word, is read both ways and can only raise.

Nested executors. kubectl/oc/docker/podman exec, gcloud compute ssh --command, op run --, aws-vault exec, and an unmodelled <cli> run|exec -- take the verdict of the command they run (nested_executors.py::nested_run).

  • A fully parsed inner read runs free; a changing inner asks under its own rule.
  • An interactive shell or no command asks as sign.speaks.
  • An option outside the executor's grammar asks as unknown.dynamic-span.

Where it lands (hooks/modules/security/targets.py::command_targets). Targets are the cluster context, project, account, workspace, stack, and the pushed branch. They are read outside the classifier cache, from three sources:

  • Target flags, per program (_PROGRAM_TARGET_FLAGS). A program no classifier table models still honours the generic ones (_GENERIC_TARGET_FLAGS: --context, --project, --account, --profile, --workspace, --stack, ...).
  • Variables under each program's prefixes (_PROGRAM_ENV_PREFIXES: KUBE*, HELM_*, TF_*, PULUMI_*, CLOUDSDK_*, AZURE_*, AWS_*, DOCKER_*, CONTAINER_*/CONTAINERS_*). env -u, -i, -C and -S are applied first (_edited_environment); unsetting a target variable counts as switching the target.
  • The tool's own configuration (kubeconfig, gcloud configurations, docker contexts, the terraform workspace file, pulumi stacks, the az profile). It is read by a safe reader (_read_file): regular files only, opened without blocking, capped at 1 MiB (_READ_LIMIT). YAML is read only in a closed block subset (_yaml_value). JSON is read with Go's case-folding of keys (_json_value, _go_fold).

A config that is absent means "no target". A config that is present and mentions the key without a value the reader can certify is undeterminable (_certified). Any command whose operation word is not a certain read or simulation asks when one of its targets is undeterminable (_landing). A switch or credential fetch (use-context, workspace select, get-credentials, update-kubeconfig, kubectx) also asks when no literal names its destination; a switch that fetches no credential is judged only on the target it switches.

Production. A verb that would not sign by itself asks (sign.lands) when it lands on a production target or a declared protected branch (production_target_verdict, _landing). Reads and simulations keep their verdict when the operation word is certain. is_production decides, in this order:

  1. A name listed under development is not production.
  2. A name listed under production is.
  3. Otherwise, the name is production when one of its words is prod, production or prd. nonprod is one word, so it is not.

The lists live in the targets section of the project's context (DECLARATION_SECTION, read by declaration_for for the project that holds the command's cwd):

{"production": ["gke_acme_us-east1_main"], "development": ["prod-sandbox"], "protected_branches": ["release"]}

Grant scope. A grant binds to the resolved target as well as the command (grant_target, matched by approval_scopes.py::matches_approval_signature).

  • A determinate target binds by name, plus a hash of every variable under the program's prefixes.
  • An undeterminable target binds a fingerprint of every config input that could decide it (_consultable). The fingerprint covers the command, the cwd, the selector variables and each file's content hash. Only an identical fingerprint matches.
  • A target the shell computes, or one whose inputs cannot be enumerated or hashed, matches no grant.

Forges, pushes, egress, secrets.

  • Change requests are judged by shape for any binary (forge_shapes.py::change_request_action). No exception is keyed on gh or on a (gh, pr) pair.
  • A force push is signed (sign.destroys), never refused.
  • Piping data into a network sender asks as sign.speaks (composition pattern egress); a secret piped into one stays refused.
  • A shell read of a coding-CLI credential store (sensitive_paths.py::CODING_CLI_CREDENTIAL_STORES) is refused like any other secret read.
  • Read-only inline code, read-only subprocess argv, and mv/cp/tar inside your own scratch or worktree run free (the relieved local lanes above).

A wrapper is a kind=tool row with replaces=. A program built for the user that stands in for a CLI is declared in memory, gaia memory add --type=user --kind=tool --binary=<name> --replaces=<cli>, and the hooks read every live tool row's (binary, replaces) once per process (hooks/modules/security/cli_aliases.py::declared_aliases), so declared_wrapper judges <name> as <cli> as well, the stricter verdict standing; a failed read means no alias.

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. -M on git show/log/diff is rename detection and runs free; only git branch -M is the forced rename. rm and rmdir run free when every target resolves inside a scratch entry the calling agent owns and reaches no durable store (_rm_scratch_confined_targets); rmdir -p counts every parent it names, so an absolute operand asks. mv, cp and tar -xf -C run free when every path they create or remove resolves inside such an entry or a checked-out worktree under a managed root (_local_work_root), never through .git, the live database and its journals, backups, contract drafts, protected or account paths. An entry is owned when its name is the caller's contract agent_id or starts with <agent_id>. (_owned_by), so a scratch path named by your contract_id is yours. The validator reads that id from the dispatch row SubagentStart stamped for the harness id the hook payload carries (bash_validator.py::_scratch_owner, gaia/store/writer.py::find_dispatch_row_by_harness_agent_id); a harness id no row claims owns only entries named after itself. Only a Claude Code subagent is a caller: the main session and every OpenCode turn (whose id is its dispatch handle, not the contract's) get no relief. A write or removal onto a file with another hard link asks, since it changes that file wherever it is linked (_shares_inode). A path that resolves through /proc or /dev never gets the relief, since their links name the resolving process's cwd, root and file descriptors (_enters_kernel_view). Ownership is checked only on the paths a command writes or removes: a cp source or a tar archive in another entry is only read and needs none, while an mv source is removed and does. Relative paths resolve against the shell's cwd, never the hook's, and only while the line proves it: any cwd change other than a leading cd <absolute or ~/ path> && (a relative target, which CDPATH can redirect, a ;, ||, | or subshell cd, cd $VAR, a substituted target, pushd/popd) leaves the cwd unknown (local_work_cwd_known). A wrapper in front (sudo, env -C, an assignment) withdraws the relief, because the detector re-classifies the wrapped command with no caller (LocalWorkCaller). tar lists the archive's members without trusting tar's own extraction guards: any symlink, hard link, .. or absolute member asks, even into the caller's entry, and an archive Python cannot list extracts free only into the entry that holds it (_tar_extracts_only_into_local_work). A git command that writes a file by option (-o, --output, format-patch's output directory) asks unless that file lands in the caller's own entry (_git_output_targets). A recursive cp, and any mv, whose source tree holds a .git asks, as a .git tar member does (_tree_carries_git). A recursive cp runs free only onto paths that do not exist yet. -t, -T, -l, -s, -b and any unlisted option keep the alias T3. On a line where another stage could plant a symlink (anything outside _PLANTS_NO_LINK), these commands ask (chain_withdraws_local_work_relief).

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 one of Gaia's managed worktrees roots 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. The roots are gaia.worktree.managed_root_containing's: the <workspace>/.project-worktrees that gaia worktree create computes for the target's own repository, and the legacy central ~/.gaia/worktrees; a directory merely named .project-worktrees anywhere else is not one. --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. unlock is T3 when a target is a worktree Gaia retains -- one resolving inside a managed root, whose lock is what keeps prune and the reclaimer off uncaptured work -- or a relative path that cannot be ruled out; unlocking an absolute path outside the managed roots stays free. Every other worktree subcommand (list, prune, lock, repair, or one a future git adds) is left unmodeled here and falls through to whatever classification already applied.

git checkout -B and git switch -C/--force-create are T3 although both subcommands are local-safe (_GIT_BRANCH_RESET_FLAGS, checked ahead of GIT_LOCAL_SAFE_SUBCOMMANDS): over an existing branch they repoint it and strand the commits only it reached. The lowercase -b/-c refuse an existing name and stay free. A wrapper the user declares as a tool row (--binary=mywrapper --replaces=gh, resolved by hooks/modules/security/cli_aliases.py::declared_wrapper) is classified as the CLI it wraps: mywrapper workflow run costs the same consent as its gh spelling, mywrapper repo delete hits the same permanent block (is_blocked_command keeps the stricter of both readings), and mywrapper pr create inherits gh's exception and runs free. The inheritance applies only to a wrapper name no rule of the classifier names -- no CLI, alias, interpreter, shell, executor (ssh, xargs, watch, flock), transparent wrapper (sudo, env), flag classifier or verb table -- and that is not in _NEVER_INHERITING_PROGRAMS (shred, truncate, dd, wipefs, srm, mkfs*, ...; _is_unmodelled_cli). Any other declared name (a kubectl or shred row replacing gh) keeps its own gates, the stricter verdict standing; Gaia ships no wrapper names, and with no tool row there is no alias -- which no longer matters for these shapes: <noun> run for workflow|workflows|pipeline|pipelines|ci, <cli> run rerun|cancel (rerun, dispatch and cancel are mutative verbs), <cli> config <write verb> and <cli> auth switch|logout classify T3 for ANY binary by their leading path (_check_generic_cli_shape, mutative_verbs.py), so an undeclared wrapper pays the same consent; run list, run view, pr view, npm run test and kubectl get stay free. Editing or updating a change request on another repository (-R/--repo is not this checkout's origin, or there is no checkout to compare) signs as speaking for the user, for any binary (_change_request_elsewhere, sign.speaks); opening one there is free. A Gmail modify that adds labels stays free, but one whose inline payload removes INBOX (archives), or whose payload comes from a file or stdin and so cannot be read, asks for a signature (_may_archive_gmail). gcloud secrets versions add publishes a secret value to every consumer of it and is T3 (an anchor in COMMAND_PATH_MUTATIVE_UPGRADES; add stays out of the verb table so git add stays free), while versions list and describe stay free. gcloud auth login is T3 because it activates the new account for every session, unless --no-activate is the last of --activate/--no-activate (the anchor's exempt_flags, undone by a later flag in exempt_cancelled_by, since gcloud lets the last one win); --update-adc keeps it T3 either way, because it rewrites the default credentials. --force is an ALWAYS-dangerous flag, but on gcloud auth login it only re-fetches a credential gcloud already stores, so COMMAND_PATH_ALWAYS_FLAG_EXEMPTIONS exempts it there and on gaia login start, which hands it to the --no-activate login it runs; activation and --update-adc are decided before that table and still ask.

A variable that switches an executor's target or identity costs what its flag costs (nested_executors.py::TARGET_VARIABLES, applied by _check_target_environment): every NAME=value assigned anywhere before the innermost program -- bare, through env, or ahead of a local executor such as op run or aws-vault exec -- is spelled as that program's flag, so KUBECONFIG=/x op run -- kubectl exec pod -- ls, CONTAINER_HOST=... podman exec and CLOUDSDK_AUTH_IMPERSONATE_SERVICE_ACCOUNT=... gcloud compute ssh ask exactly as --kubeconfig, --url and --impersonate-service-account do. Context, project and account variables (KUBECONFIG, CLOUDSDK_CORE_PROJECT, CLOUDSDK_CORE_ACCOUNT, AWS_PROFILE, DOCKER_CONTEXT, TF_WORKSPACE, ...) are read by target extraction instead, described under "Where it lands" below.

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 (Profile 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). Its disk_operations group carries the Windows wipers (D140): format <drive>: with its switches before or after the drive, quoted, or glued to it (format C:/q), diskpart, vssadmin delete shadows, vssadmin resize shadowstorage (shrinking the storage deletes the copies that no longer fit), Format-Volume and Clear-Disk -- blocked bare, under cmd /c, powershell -Command, pwsh -c and through the PowerShell tool, by name or by full path (C:\Windows\System32\vssadmin.exe: every PowerShell command position is respelled by _name_program_head before the floor matches it), while format as a plain word (git log --format=%H, Get-Date -Format) and vssadmin list shadowstorage stay unblocked. Before matching, is_blocked_command peels leading NAME=value assignments and every transparent wrapper in _COMMAND_WRAPPERS (the one list of them -- sudo, command, builtin, exec, busybox, the Windows cmd and start and the rest are read from that table, each with its own flags and operands; env -S is peeled as the words its string splits into, and command -v runs nothing; a bundle of short flags whose last letter takes a value, such as env -iu HOME, consumes that value -- _short_bundle_ends_in_value_flag; cmd and start take /x switches in any case, /c, /k and /r end cmd's arguments, and start skips a quoted window title) via _peel_wrapper_readings (in 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 every reading of the peeled real command. A wrapper is recognized by its executable identity, so C:\Windows\System32\cmd.exe is cmd. The peel also rewrites the remainder's first word as the program Windows starts for it (_name_program_head, through canonical_path.program_identity): a backslash directory, a PATHEXT suffix (.exe, .cmd, .bat, .com) and the py launcher with its version selector (py -3, -3.12-64, -V:3.12) collapse to the bare program, py to python3, so RM.EXE -rf /, C:\tools\git.exe push --force and py deploy.py reach the blocked, mutative and script-file lanes as rm, git and python3. A first word Windows resolves the same way POSIX does is left as written, so ./script paths and PascalCase cmdlets keep the spelling their lanes key on. A quoted cmd /c "a && b" payload is one shell word but a chain to cmd: it is unquoted and every stage shell_parser.parse_command splits it into is a reading. An unquoted first word after start is the program to Windows; the words after it are a reading too, so start Build del x classifies as del x whichever way it is read. Without this, env FOO=bar gh repo delete evaded the floor because env is a READ_ONLY_BASE_CMDS carrier, and timeout 10 dd if=... of=/dev/sda or nice -n 5 mkfs ... defeated the ^-anchored disk-operation regexes (^dd/^fdisk/^mkfs, which have no semantic-rule backup) and fell to an approvable T3 or ran free. The same rule holds for git configuration (_check_git_code_config, _git_config_programs): git pointed at code the command line does not show -- core.hooksPath, core.fsmonitor or an include path on -c, a code key through --config-env, --exec-path, or a GIT_CONFIG_* variable set on the command or earlier in its chain -- signs as foreign code whatever the subcommand, and a program-valued key (core.pager, core.sshCommand, a ! alias) is classified as the command it names. A script's subprocess argv that runs git with a program-valued key or --exec-path is foreign code and asks (_argv_command_is_mutative). One table for both layers is the point: a wrapper known to one and not the other reopens the floor. 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. The floor also judges the command an executor in _COMMAND_EXECUTORS (mutative_verbs.py) is handed -- the -c/--command payload of runuser, su, sg, flock and script, or the trailing words of ssh, xargs and watch (_executor_floor_payload) -- as the command written directly, so runuser -u bob -c 'rm -rf /' and ssh host 'rm -rf /' hit floor.rm_critical exactly as a bare rm -rf / does and no signature can lift it. Every -exec, -execdir, -ok and -okdir payload of a find is judged the same way, as the command it runs, and the find takes the strictest of them: find . -exec echo x \; -exec rm -rf / \; is blocked, find . -exec rm {} \; signs, and find . -exec cat {} \; runs free. The root floor reads rm as rm does (_rm_deletes_root): a recursive flag in any spelling (split, merged, long, after the operands; with or without -f), --, and any operand that normalizes to /, /* or a home directory -- ~, ~user, $HOME, ${HOME}, with or without a trailing / or /* (//, /., /tmp/.. count as /) -- so rm / -rf, rm -R / and rm -r ~bob/ are blocked. A path below a home directory (rm -rf ~/build) signs, like its absolute spelling.
  • Every check judges words after shell quote removal. shell_parser.shell_split is the single word producer: it removes backslash, single, double, $'…' and $"…" quoting and splits on an unquoted $IFS/${IFS}. Signing and verb detection tokenize with it, and the floor also judges each component re-spelled from those words (unquoted_readings). So r\m -rf /, 'rm' -rf /, $'rm' -rf / and rm${IFS}-rf${IFS}/ are blocked exactly like rm -rf /, while echo 'rm -rf /' stays a mention. A target that arrives on stdin (echo / | xargs rm -rf) cannot be read, so it signs instead of reaching the floor.
  • 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. In a Python script FILE, a process call (subprocess.run/call/check_call/check_output/Popen) whose single argv is a list or tuple of string literals is judged as the command it runs: analyze_python_inline receives argv_is_mutative from _check_script_file, which re-classifies shlex.join(argv) through is_blocked_command and detect_mutative_command (_argv_command_is_mutative), escalate-only as the exec-sink lane does for JS/ruby/perl/php -- so subprocess.run(["gcloud", "auth", "print-access-token"]) is free and subprocess.run(["kubectl", "apply", ...]) is T3. Any keyword outside _ARGV_PASSIVE_KEYWORDS (shell, env, cwd, executable, input, stdin, **kwargs), an argv built from a variable, os.system and subprocess.getoutput keep the call T3. So does an argv that starts an interpreter or shell without naming its program (["sh"], ["bash", "-s"], ["python3"], ["node"]; _argv_runs_unnamed_program): the child inherits the script's stdin and runs whatever arrives there, so the argv names no effect to judge -- only a script positional or the interpreter's own inline-code flag (_INLINE_CODE_MAP, -c for a shell, -c/-m for Python) names one. The inline python3 -c lane passes no callback, so there every process call still counts. Separately, in every Python lane, urllib.request.urlopen is a read only when its target is PROVABLY a body-less read (_urlopen_is_read, _RequestProvenance): it takes only that target positionally and no body, and the target is a string literal, an f-string, a direct Request(...) call with no body and a GET/HEAD method, or a name every binding of which in the module is one of those. Anything the analyzer cannot prove is not a read: a request from a helper or a parameter, Request referenced other than as a direct call (functools.partial(Request, data=...), an alias, a subclass), any setattr/vars call or __dict__ access, or a .data/.method assignment. The risk that rule accepts, stated so it is not mistaken for a closed channel: a GET can still carry data out in its URL or query string, exactly as a curl GET already can -- _classify_curl frees a GET to any host, and no host allowlist gates it -- so the rule aligns Python with the existing curl posture rather than opening a new channel. requests.get/httpx.get are still catalogued NETWORK. A script whose interpreter IS recognized but whose file is missing or unreadable defaults to T3 (conservative, verb="script-file-unreadable"), unless the execution scan reads the file and finds no signal (_NO_MODEL_VERBS). 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) is judged by the commands it is handed instead (mutative_verbs.py::_check_launched_payload): a program no classifier rule keys on, and every executor in _COMMAND_EXECUTORS (for the shapes its own grammar misses, such as runuser -u U -- cmd), takes the verdict of every argv that starts at a word naming a program a rule knows, and of every multi-word argument whose head names one, adopting the first that mutates. A command substitution ($(...) or backticks) handed to such a program asks as unknown.dynamic-span, and a plain $VAR stays free. A trap action and a mapfile/readarray -C callback are code bash runs, so any expansion in them asks, while an expression-first tool (_EXPRESSION_FIRST_TOOLS: jq, yq, test, [, ...) and a Windows-native token (_is_windows_native_token: a cmdlet, a PowerShell alias or script-block invoker, a cmd.exe builtin) are outside this lane. An argument that names no known program (a bare deploy.py) is no payload, and the ordinary verb scan decides. 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/poetry run/uvx/pipx run/npx/bunx/bun x/bun exec/npm x/npm exec/pnpm exec/pnpm dlx/yarn dlx 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. A payload that is not itself mutative is still T3 (verb="registry-fetch") unless the whole command fits the one shape that provably runs the project's code, by _RUNNER_CODE_ORIGIN. The rule is a known-safe SHAPE, not a search for danger: anything outside the shape is T3, so an option nobody catalogued signs instead of slipping through. The "installed" runners (npx, bunx, bun x, npm x, npm exec, pnpm exec, bun exec; _installed_runner_fetch_reason) are free only when every runner option is in _INSTALLED_RUNNER_SAFE_FLAGS (no value, cannot change which copy runs -- so --package/-p, --userconfig, --node-options, --ignore-existing and any abbreviation such as --pref are T3) or is the runner's directory option (--prefix, -C, --dir, --cwd), whose LAST value wins as npm reads it, and the payload is an explicit local path (./, ../, /, ~/, file:) or a bare name present under that directory's node_modules (.bin/<name> or node_modules/<name>) -- a declared but uninstalled dependency, a pinned version (eslint@9), a GitHub shorthand (user/repo) or an unknown name is T3. npm exec/npm x read their own options up to -- even after the package (npm-exec(1)), so npm exec eslint --prefix /x . is checked whole and npm exec eslint --fix . is T3 while npm exec -- eslint --fix . is free; npx, bunx, bun x and pnpm exec hand everything after the package to the command, so npx eslint --fix . stays free. uv run (mutative_verbs.py::_uv_run_shape) is free when every option before the command is in _UV_RUN_SAFE_FLAGS or _UV_RUN_SAFE_VALUE_FLAGS (--group, --extra, --package...), a --directory or --project with a literal value (_UV_RUN_CWD_FLAGS, on either side of run), or a --with/-w whose every comma-separated item is a local path. The payload is judged in the last --directory, the folder uv runs in; --project only locates the project and leaves the payload judged in the caller's folder, so uv --directory /abs/repo run pytest runs free. A cwd value carrying an expansion, --with-requirements, --python, --script, --env-file, an attached -wpkg, or any other option is T3. A pytest run (pytest, py.test, python3 -m pytest; mutative_verbs.py::_check_pytest_run) is free when its options are known and everything it loads stays inside the repository it starts in: -c/--config-file, --rootdir, --confcutdir, -o pythonpath, a -c file's pythonpath/testpaths/addopts, PYTEST_ADDOPTS, test paths and a PYTHONPATH set for the run all inside the repository, and -p a plugin module name; one that reaches outside signs as sign.foreign-code. Its output options write inside the repository or the system temp dir, and --basetemp, which pytest deletes and recreates, only strictly inside the system temp dir; any other destination signs as sign.destroys. A uv run <script.py> whose readable file declares PEP 723 inline dependencies (# /// script, bare or quoted key) is T3, since uv fetches them, and so is one whose read stops at the size cap before a complete block; an unreadable script keeps the script lane's verdict. uv run - reads its program from stdin and is T3 exactly as python3 - is. The "registry" runners (pnpm dlx, pnpx, yarn dlx, pipx run, uvx, and uv tool run, which is re-classified as the uvx it aliases) always fetch into a throwaway environment, so they are T3 for any package that is not such a path, declared or not; npm exe is npm exec. An initializer always comes from the registry, so npm init <x>/npm innit <x>/npm create, yarn create, pnpm create and bun create are T3 (_INITIALIZER_SUBCOMMANDS); bare npm init is not an initializer. A package tool (_PACKAGE_TOOL_COMMANDS) run under a leading assignment of npm_config_* in any case (as npm reads it), or of the case-sensitive UV_*, PIP_*, PNPM_*, YARN_* or BUN_*, is T3 whatever the rest says (_check_package_config_environment), because such a variable can name the package, its folder or its registry out of sight of the command line. ~ is a local path only as ~/..., so npx ~name is a fetch. Limits of the shape, disclosures and not routes: "present in node_modules" is a file test, so an entry an agent wrote there (or a .bin symlink pointing elsewhere) counts as installed; and configuration the command line does not show -- an .npmrc, uv.toml, or a variable exported earlier rather than assigned on the command -- is not read. npm cit/install-ci-test is a frozen install that also classifies the test script. 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 is judged by the payload it is handed, as above, and runs free when that payload names no program a rule knows and carries no command substitution. 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. node --test whose EVERY positional is a directory or a glob and whose every flag is on an allowlist of test-runner options that load no code (node --test tests/, node --test --test-reporter=spec 'tests/**/*.test.js'; _is_node_test_suite_run, _NODE_TEST_SAFE_SWITCHES, _NODE_TEST_SAFE_VALUE_FLAGS, _NODE_TEST_CHECKED_VALUES -- --test-reporter only as a built-in reporter, --test-reporter-destination only as stdout/stderr) is the project's test suite, not one script, and runs free (verb="node-test-runner") -- the same posture as pytest tests/ or an npm test body, so the test code itself is not read. A named file anywhere among the positionals (node --test tests/ ./deploy.js), and any flag off the allowlist -- a loader, --test-global-setup, a custom reporter module, a flag a future node adds -- keeps the script-file lane. Code an interpreter loads before the code it is given is foreign code, judged first by _check_interpreter_loads for every invocation, script, inline or test run: a preload of node (-r, --require, --import, --loader, --experimental-loader), bun (-r, --preload, --require) or deno (--preload), spaced, with = or attached (-r./x.js; _flag_values), is classified as node <module>, so an unreadable or mutating preload is T3; a deno --import-map, a deno config -- named by --config, or else the deno.json/deno.jsonc deno finds in the working directory or a parent (_discovered_deno_config; --no-config skips it) -- that is unreadable or maps specifiers (imports, scopes, importMap), lua/luajit -l and R -f/--file ask (sign.foreign-code). A Python module the code imports (import, from, a literal importlib.import_module/__import__/runpy.run_module) that resolves in the code's own directory -- the working directory for -c, -m and stdin, the script's directory for a file -- is read and judged as python3 <module>.py; a local package, a compiled module, a relative import and any runpy.run_path ask (_python_foreign_module, _python_local_module_run); -X importtime/-X dev relax nothing. Inline JavaScript (node -e/-p, bun -e, deno eval) follows its relative require/import closure from the working directory exactly as a script file does (_classify_js_import_closure): each local module is judged by content, and one that cannot be resolved or read asks. A variable that injects code (_CODE_INJECTING_ENVIRONMENT: NODE_OPTIONS carrying a preload, PYTHONSTARTUP, a writable PYTHONPATH, LUA_INIT*, R_PROFILE*, R_ENVIRON*, and git's GIT_PAGER/GIT_EDITOR/GIT_SSH_COMMAND/GIT_SSH/GIT_EXTERNAL_DIFF/GIT_ASKPASS judged as the command they name) asks when the same line runs that program, set inline, under env or by an export earlier in the chain (code_injecting_assignments, runs_under_injected_environment). The node --test exemption does not apply when the command sets NODE_OPTIONS -- inline (NODE_OPTIONS=... node --test), through env, or by an export earlier in the same chain -- since that variable can preload code the command line does not show. 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, and only inside the installed Gaia the hook runs from, 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, context prune-workspaces, context relocate (its --dry-run is free), release check, workspace retire, workspace curate, memory add --replace and memory link --delete stay T3 via COMMAND_PATH_MUTATIVE_UPGRADES -- the whole gaia anchor set, in declaration order -- install T3 via MUTATIVE_VERBS, read-only subcommands T0, and scan free in both spellings, since it rewrites only the workspace and project rows a rescan restores). 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 used to be the live instance of a metadata gap where tiers.py matched T1_PATTERNS before consulting detect_mutative_command; that gap is closed -- detect_mutative_command now runs FIRST, so the command reports and is enforced as tier=T3, and the two agree. 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 analyzed as the program it is: hooks/modules/security/program_heredoc.py::heredoc_program recognizes an interpreter reading its program from a heredoc (python3 -, bare python3, node -, bash -s, sh -s, quoted or unquoted delimiter), the Bash validator then classifies the whole command as ONE unit instead of splitting the body into shell components, a code interpreter's body goes through the inline-code layers (_check_inline_code) and a shell's body through the script regex lane (_classify_script_content_by_regex), exactly as bash script.sh would -- so python3 - <<'PY' calling shutil.rmtree is T3 and a body that only prints or parses runs free. It is the mirror of the data heredoc: the same body fed to gaia ... --<name>-file - is stored, never run. Step 1e (_check_package_manager) applies the same real-effect standard to npm, bun, pnpm and yarn: <manager> run <script>, and pnpm's and yarn's <script> shorthand outside their builtins (_SCRIPT_SHORTHAND_BUILTINS -- pnpm deploy is pnpm's command, not the script), is resolved to its package.json scripts.<script> body (read under the same cd-honored cwd, or the manager's own --prefix/--dir/--cwd, last occurrence winning and none read after --, 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, which the execution scan frees only when it reads the body itself and finds no signal). npm's own script commands (npm test/t/tst, start, stop, restart; _NPM_SCRIPT_COMMANDS) and bun's <script> shorthand are resolved the same way, and the pre<script>/post<script> bodies run around a script are classified with it, so a harmless build with a mutating postbuild is T3. The script NAME decides nothing: bun run deploy whose body is vite build is free. Every other spelling of install (npm i, npm add, bun add, pnpm add, yarn add, bare yarn, npm it/install-test; _INSTALL_ALIASES) is re-classified as <manager> install ..., so it is T3 exactly as npm install is. A frozen install from the lockfile (_FROZEN_INSTALL_FORMS: npm ci, bun/pnpm install --frozen-lockfile, yarn install --immutable; a --frozen-lockfile=false or --immutable=false spelling is not frozen) is classified by the project's install lifecycle scripts (preinstall through postprepare): free when none mutates, T3 when one does or no package.json is readable. 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. Lua and R (lua -e, Rscript -e, or a script file) have no parser here: their code is UNKNOWN and asks unless it only prints literals (_UNPARSED_CODE_INTERPRETERS). 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 script -Command/-c runs (_powershell_payload, every word after the flag) 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. Four security rules keep it conservative, NOT permissive: (1) composition -- _ps_command_positions splits the payload at every place PowerShell starts a command (;, |, &&, ||, newline, and the opening or closing of a { } script block or ( )/$( ) (sub)expression, inside double quotes too; single-quoted text is literal), and the tier is the MAX across positions. Each position is judged by its LEADING word only: a Verb-Noun cmdlet by its verb, an alias as the cmdlet it names (_PS_ALIASES: ri, del, clc, sp... change; gci, gi, ?, ft... read), .\x.ps1/&.\x.ps1 as pwsh -File (the script-file lane, unreadable -> T3), and any other word as a native command at the verdict of its POSIX twin (detect_mutative_command), so Get-ChildItem; rm -r -fo C:/proj, Get-Item x | ri and Get-ChildItem | foreach { rm $_ } ask exactly as rm does. A keyword (if, foreach (...)) or an expression ($x, a string) leads no command; ForEach-Object/foreach/% with only script blocks is a read whose body is judged as its own positions, while % Delete or -MemberName calls a member by name and is T3. A hyphenated PATH argument (Get-ChildItem C:\x\my-folder) follows the leading word, so it is never read as a cmdlet; (2) method calls -- any .Name( or ::Name( in the payload ((Get-Item x).Delete(), [IO.File]::Delete('x')) is T3 (_PS_METHOD_CALL_RE), because a .NET method can mutate under any name; a property read ($_.Name) has no call parentheses and stays free; (3) 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; (4) positive allowlist -- to drop BELOW T3 no position may mutate AND at least one position must be a recognized read cmdlet or alias; a payload of native commands alone (pwsh -c "git status") or with an 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. The irreversible Windows disk commands -- format C:, diskpart, vssadmin delete shadows, Format-Volume, Clear-Disk -- are not T3 at all: they sit in blocked_commands.py's disk_operations beside mkfs and dd and are permanently blocked (D140), bare, under cmd /c, and at any position of a PowerShell script (_powershell_floor_readings: the -Command script of powershell/pwsh, or a PowerShell-shaped command such as & { Format-Volume ... } or Get-Disk 1 | Clear-Disk).
  • 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_package_manager 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, read alias or script-block invoker (foreach, %) is classified by delegating the WHOLE command string to _classify_powershell_payload, which judges every command position by its leading word, so composition (Get-ChildItem | Remove-Item -Recurse -Force -> T3, MAX across positions), script-block bodies, method calls, the ambiguous Out-*/Format-* noun-split, and obfuscation markers all apply identically to the wrapped lane. Because only the leading word of a position is its command, 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).

Every shell channel passes the same gate. Claude Code's Bash, PowerShell and Monitor tools, and OpenCode's bash and shell tools, all carry tool_input.command. Each one is classified by the same validator (hooks/adapters/tool_policy.py::SHELL_TOOLS, with shell mapped to Bash in OpenCodeAdapter._policy_tool_name). So a Remove-Item -Recurse -Force costs the same consent through PowerShell as through Bash. PowerShell differs in two ways. A subagent's identity is exported in PowerShell syntax (build_powershell_identity_command). A PowerShell command whose output would need secret redaction is refused ([SECRET_OUTPUT]) and routed to the Bash tool, because only Bash output passes through the redactor. A tool outside those channels whose input carries a command string is refused ([UNGATED_SHELL], ungated_command_verdict), so a shell tool Gaia has not named fails closed instead of running ungated. To make that refusal reachable on Claude Code, one PreToolUse matcher (COMMAND_GATE_MATCHER in hooks/adapters/opencode_parity.py, the same string as build/gaia.manifest.json) sends every tool to the hook except a named list. The list holds the tools that have their own entry and the built-ins that carry no command (TodoWrite, Skill, ToolSearch, the Task* tools, ...). So MCP tools and any new host tool reach the check, and the excluded tools spawn no hook. A reached tool outside the Claude Code adapter's _POLICY_TOOLS gets only the command check, so its verdict is the one it had before; every file-writing tool (Write, Edit, MultiEdit, NotebookEdit) is inside that set, so each one reaches the protected-path gate.

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 (gaia/approvals/core.py::protected_write_verdict, consulted for both hosts from hooks/adapters/tool_policy.py::_write_edit_verdict) -- reachable only on that function's SUBAGENT branch; for the primary/orchestrator role (is_subagent=False) the write is refused with no approval_id and the work goes to a specialist, because no T3 verdict ever falls through to the host's own permission prompt. 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; hooks/modules/security/approval_scopes.py::matches_file_path_approval compares the stored path RAW, which is true of that function and misleading about the lane -- the resolution happens UPSTREAM, at hooks/adapters/tool_policy.py::_write_edit_verdict and gaia/approvals/core.py::protected_write_verdict, which resolve the write target through hooks/modules/security/protected_paths.py::resolved_write_target before it looks up a grant, looks up a pending, mints one, or renders the surface the user reads, so the grant binds to the file the link lands on rather than to the spelling that reached it. The raw comparison downstream is precisely what re-blocks a link RE-POINTED after the mint: the retry resolves somewhere else, fails to match, and is blocked again under a new approval_id), 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 the INSTALL, not the source: the .claude hook trees, and EVERY path inside an installed Gaia root (protected_paths.py::_install_root -- any directory whose package.json names @jaguilar87/gaia and that holds no .git, from any channel: the npm package store, the marketplace plugin cache, a dev-plugin cache entry; bin/, gaia/, a planted __pycache__ or .pth; only a hooks/*.md is exempt), decided by path shape, never from where the evaluating module happened to be loaded from; MultiEdit and NotebookEdit take the same gate, and the Bash guard also reads tar targets and inline interpreter writes; plus settings.json / settings.local.json anywhere under a .claude/ path. A Gaia source checkout is never matched (protected_paths.py::is_protected_hook_path), so editing the source is an ordinary edit and the install is what the protection holds. Gaia never hand-edits the user's settings or its own install; a setting Gaia needs is written by the installer into what it owns. 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 (split by shell_parser.parse_command, as every path guard is, so an operator inside quotes is text while a substitution, even inside double quotes, is scanned as a command) 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.

An installed Gaia root is held more strictly than the base-token rule, but by signature, not refusal: a Bash line that names one, or runs with its cwd inside one, asks for a signature naming that install (protected_path_guard.py::install_root_change, verb install-root-change, raised in bash_validator.py after the categorical guards) unless every command in it is a known reader (cat, ls, grep, find without -exec, git status|log|diff|show, ...) and nothing redirects into it (protected_path_guard.py::_names_install_root_unread). "Names" is judged after the line's own NAME=value assignments and well-known environment variables (HOME, TMPDIR, XDG_*, GAIA_*, ...) are substituted, after brace words are expanded and glob words matched on disk, and it includes attached option values (-C<dir>, --work-tree=) and literal paths in inline code. A write target that starts with a variable neither resolves (> $OUT/file) is unprovable and asks for a signature too (unprovable_write_target). Only the .claude hook trees and .claude settings stay refused outright on Bash. A hardlink made earlier is invisible to every path guard.

The harness read spool (protected_paths.py::is_read_spool_path) is protected beside the hook trees: only the PreToolUse recorder writes it, and every surface refuses any other writer for every role, categorically, in any spelling that resolves there ($HOME, a relative path, a cd chain).

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 or protected write is blocked, request it with your phrases through the line the denial carries, then close APPROVAL_REQUEST (agent-protocol) with the approval_id that request prints; subagent-request-approval owns the request.

The main session gets the same denial, never the host's permission prompt (bash_validator.py::decide_t3_outcome). Its request line carries --agent-id, because its shell has no GAIA_DISPATCH_AGENT to seal the request under, and the orchestrator presents the approval_id through gaia approvals question (orchestrator-present-approval).

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.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.