一键导入
security-tiers
Use when classifying any operation before executing it, or deciding whether user approval is required
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Use when classifying any operation before executing it, or deciding whether user approval is required
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Use when constructing or interpreting the approval handoff envelope between subagent and orchestrator -- sealed_payload schema, approval_id format, APPROVAL_REQUEST contract shape, and reading a granted approval from the DB
Use when producing any agent response
Use when a mutative command was blocked by the hook and you need to request user approval, or when presenting a plan for a T3 operation before executing it
Use when the user wants to build, design, or extend a diagram — an architecture overview, a timeline, a planner board, a flow diagram, a presentation, a comparison, or a mind-map — as a portable, data-driven deck rendered from plain YAML. Triggers — "build a diagram", "architecture diagram", "diagram deck", "timeline", "flow diagram", "planner board", "add a page/section/component to the diagram".
Use when the user wants something to run routinely / on a schedule rather than once now -- "tarea programada", "rutinariamente", "cada mañana", "cada N horas", "todas las noches", "schedule", "cron". Covers mounting, structuring, and running an unattended headless task that reports back, plus consuming its reports. NOT for a live in-session agentic loop (that is agentic-loop).
Use when writing or updating a README for a Gaia component folder (agents/, skills/, hooks/, commands/, config/, bin/, tests/, build/, or the repo root)
| name | security-tiers |
| description | Use when classifying any operation before executing it, or deciding whether user approval is required |
| metadata | {"user-invocable":false,"type":"reference"} |
security-tiers classifies every operation into four tiers so an agent knows whether it can run freely or must request the user's consent.
| Tier | What it is | Approval? | Example verbs |
|---|---|---|---|
| T0 | Read-only; observes state, changes nothing | No | get, list, describe, show, logs, status |
| T1 | Local validation; no remote calls, no state | No | validate, lint, fmt, check |
| T2 | Simulation / dry-run; may read remote, never writes | No | plan, diff, dry-run, template |
| T3 | State-mutating; creates, updates, or destroys | Yes | apply, create, delete, push, deploy |
git commit and git add are not T3 -- they are local-only operations (they touch the working tree and local refs, never remote state), so they classify as safe by elimination. Only git push mutates remote state and is T3. This matches GIT_LOCAL_SAFE_SUBCOMMANDS in mutative_verbs.py, where commit and add are listed as local-safe.
T3 gates a direction, not a category of verb. An operation needs consent because it moves the system toward more capability (it grants) or less recoverability (it destroys). An operation that only moves the other way -- that reduces capability already granted -- does not need consent, because the worst it can do is take back power that was given. So within Gaia's own consent layer, gaia approvals revoke|reject|reject-all|clean are not T3: they only revoke or discard grants Gaia itself issued, never reaching outside the local approval store. The asymmetry is deliberate -- gaia approvals approve grants capability without the AskUserQuestion flow, 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. (The one hard exception is orthogonal to tiers: writes under .claude/ are blocked unconditionally by the sensitive-path guard, regardless of tier -- see "The .claude rule" below.)
Ask, in order -- the first "yes" wins:
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 with -D, -d, -m. For cloud-specific verb patterns (kubectl, terraform, gcloud, helm, flux), see reference.md.
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).mutative_verbs.py -- CLI-agnostic detection of mutative verbs; drives the nonce / approval flow for T3. Includes script-file detection (Step 1d, _check_script_file): when a command is <interpreter> <script-file> (python3 deploy.py, bash setup.sh, node migrate.js) or ./script.ext, the file is read and classified by its real invocations -- AST analysis for Python, the blocked/mutative regex layer for shells and other interpreters. A script that is missing, unreadable, or whose interpreter is unrecognized defaults to T3 (conservative). This prevents the evasion path where <interp> <file> bypasses the verb scanner because the filename token has no recognizable subcommand. A RELATIVE script token is resolved against the cd TARGET of its command chain, NOT the hook's own cwd: detect_mutative_command peels a leading cd <dir> chain (_peel_leading_cd, on && / ;, || excluded) and threads the resulting cwd into _read_script_content; the compound validator (bash_validator._validate_compound_command) additionally folds the cwd across the SEPARATE components a chain splits into (via cwd_after_component), so cd /repo && node engine/build.mjs reads /repo/engine/build.mjs and classifies at its true tier instead of a false script-file-unreadable T3. Gaia governs arbitrary workspaces, so this must not assume the install dir; when no cd is present the process cwd is still used, and a path that is unreadable AFTER honoring the cd keeps the conservative T3 fallback. Before reading the body, _check_script_file first checks _INTERP_SYNTAX_CHECK_FLAGS: a leading syntax-check-only flag (bash -n, sh -n, node --check / node -c) that precedes the script positional never executes the script, so the invocation downgrades to T0 without reading the file's contents at all -- a flag appearing after the script positional is an argument to the script and does not qualify. One narrowly-scoped script is re-dispatched rather than AST-scanned: the Gaia CLI dispatcher bin/gaia (recognized by basename gaia + parent dir bin + a body signature, via _check_gaia_cli_dispatcher) has its own subprocess.run(...) for the lazy DB bootstrap, which AST analysis would flag as mutative -- turning EVERY python3 <path>/bin/gaia <subcmd> into a false T3, including read-only subcommands (doctor, release check, 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 stays T3 via COMMAND_SUBCOMMAND_MUTATIVE_UPGRADES, install T3 via MUTATIVE_VERBS, read-only subcommands T0). 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. Step 1e (_check_npm_script_runner) applies the same real-effect standard to npm: npm run <script> is resolved to its package.json scripts.<script> body (the package.json is read under the same cd-honored cwd, so cd /repo && npm run build reads /repo/package.json) and that body is classified by the same regex engine used for script files (an unresolvable body -- missing/unparseable package.json or absent entry -- falls back to conservative T3), while npm ci is unconditionally mutative (T3) because it rewrites node_modules regardless of the verb taxonomy. Non-shell source files (.js/.mjs/.cjs/.rb/.pl/.php) route through the "code" lane, which splits by language. Each of the four registered families -- the JS family (.js/.mjs/.cjs, or a node interpreter token), plus php (.php/php), ruby (.rb/ruby), and perl (.pl/.pm/perl) -- resolves a LanguageSpec via source_lexer.spec_for_script and is classified by _classify_source_with_lexer; a language with no registered spec (spec_for_script returns None) falls through to the older regex lane, _classify_script_content_by_regex with from_source_code=True. For any lexed family, source_lexer.strip_source runs a single left-to-right state machine over the file and produces two line-aligned projections: verb_view (comments blanked, string/template-literal CONTENTS blanked) and exec_view (comments blanked, string CONTENTS KEPT). _classify_source_with_lexer then runs, per line: (1) is_blocked_command on verb_view as a defense-in-depth safety net for a permanently-blocked pattern; (2) _scan_exec_sink_string_args on exec_view with shell_backticks=spec.backticks_are_exec -- JS_SPEC.backticks_are_exec is False, because a JS backtick delimits a template literal, not shell execution, so backtick/%x{} bodies are NOT treated as exec sinks for JS. Deliberately not run for JS: the whole-token mutative-verb scan (detect_mutative_command) that the regex lane uses -- in JS a bare word at subcommand position is a language identifier, not a CLI subcommand (const label = ..., let close = ...), and the scan caught no real JS mutation, only these identifier collisions, so it is removed entirely for this lane rather than merely down-weighted. A real JS mutation still reaches the shell through an exec sink whose argument is a string literal (execSync("kubectl delete ...")), which exec_view preserves (including ${…} interpolation) and re-classifies, so removing the whole-token scan does not open a false negative. Ruby/perl/php are now comment/string-aware exactly like JS: each resolves its own LanguageSpec (RUBY_SPEC/PERL_SPEC/PHP_SPEC) and routes through _classify_source_with_lexer, closing the false-T3 class where a mutative verb mentioned only inside a comment (php with // update the user cache, ruby =begin ... delete ... =end, perl POD) was read as an invocation. Their comment grammars exceed JS's // + /* */, so the spec carries three extensions (all defaulting off, leaving JS_SPEC unchanged): extra_line_comments for PHP's second line marker # alongside //; line_block_comments for Ruby's column-0 =begin/=end; and pod_style for Perl POD (a line starting with =+letter, closed by =cut). Heredocs (<<</<<~) and q{}/qq{} string forms are an accepted limitation that can only cost a residual false positive, never a false negative. Crucially, unlike JS_SPEC, these three specs set backticks_are_exec=True and do NOT list the backtick in string_quotes: a ruby/perl/php backtick body -- and Ruby's %x{} -- is left verbatim in the exec view and re-classified as a shell command (_EXEC_SINK_BACKTICK_RE/_EXEC_SINK_PERCENT_X_RE, shell_backticks=True), since a backtick in these languages IS shell execution. This preserves the exec detection the old regex lane had (system()/shell_exec()/backticks/%x{} still classify T3) while dropping the whole-token verb scan, which -- as in JS -- produced only language-identifier collisions and caught no real mutation (those go through exec sinks). Because the quote making a command one token would otherwise hide a mutation passed to a subprocess as a string literal, _scan_exec_sink_string_args is one detector shared across three callers -- the inline -c/-e path, the shell/other-language regex code lane, and the (JS/ruby/perl/php) lexer lane: the command handed to an exec sink (execSync/execFile/spawn/system/shell_exec/passthru/backticks/%x{}, backticks gated by shell_backticks) is extracted and re-classified, escalating to T3 only when the inner command is itself mutative or blocked (so execSync("kubectl delete ...") is T3 while a benign execSync("ls") stays T0 -- the false-positive gate). This makes node deploy.js classify identically to node -e "...". 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.composition_rules.py -- check_composition / classify_stage classify pipe compositions (FILE_READ→EXEC_SINK, network→exec, decode→exec); triggers T3 on dangerous pipelines such as file_to_exec.flag_classifiers.py -- _classify_curl / classify_by_flags detect flag-dependent mutations; triggers T3 on commands whose flags make them mutative (e.g., curl -X POST).Safe by elimination, with no allow-list: anything not blocked and not mutative is T0. Runtime is the single source of truth for nonce handling, grant scope, and approval enforcement -- this skill teaches how to think about the tier; it does not enforce it.
.claude ruleDo 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:
find . -path '*/.claude/*' -prune -o ...), orThere 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: even if attempted, the write cannot succeed. _is_protected() in hooks/adapters/claude_code.py hard-protects the most critical paths -- the Gaia hooks directory (which .claude/hooks/ resolves into) and settings.json / settings.local.json anywhere under a .claude/ path -- and it fires regardless of permissionMode. An agent running with acceptEdits is still blocked. The enforcement is unconditional and does not depend on the agent's intent or the operation's surface area.
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.
When a T3 command is blocked with an approval_id, emit plan_status: APPROVAL_REQUEST with the approval_id in approval_request, per the response envelope in agent-protocol/SKILL.md. See subagent-request-approval/SKILL.md for the full request schema.