@caddis/cli 0.4.56 → 0.4.57

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.
Files changed (31) hide show
  1. package/bundles/antigravity-plugin/guard_agy.py +700 -68
  2. package/bundles/antigravity-plugin/hooks.json +2 -1
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  5. package/bundles/antigravity-plugin/scripts/caddis_gate.py +21 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +35 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  8. package/bundles/antigravity-plugin/scripts/caddis_review.py +59 -9
  9. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +120 -24
  10. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +80 -7
  11. package/bundles/antigravity-plugin/scripts/oss_review.py +300 -79
  12. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +4 -0
  13. package/bundles/antigravity-plugin/session_end_agy.py +13 -10
  14. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +53 -8
  15. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  16. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  17. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +9 -5
  18. package/bundles/antigravity-plugin/skills/implement/SKILL.md +18 -11
  19. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  20. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +44 -4
  21. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  22. package/bundles/antigravity-plugin/skills/ship/SKILL.md +26 -7
  23. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +31 -0
  24. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +42 -4
  25. package/bundles/antigravity-plugin/warm_start_agy.py +26 -15
  26. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  27. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  28. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  29. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +9 -5
  30. package/bundles/manifest.json +3 -3
  31. package/package.json +1 -1
@@ -17,11 +17,19 @@ agy PreToolUse stdout (a decision object):
17
17
  prompt for that call; staying silent falls through to normal handling, which is what
18
18
  the Claude Code guard does too.
19
19
 
20
- *** FAIL OPEN — the load-bearing property of this file ***
21
- agy may fail-closed on a hook error, so a bug here must degrade to "no guard" (agy's state today),
22
- never to a bricked agent. Every stage is wrapped: on ANY exception, malformed payload, unknown shape
23
- or encoding problem this prints NOTHING and exits 0. It never raises, never exits non-zero, and never
24
- emits a partial/malformed decision (the JSON is built and serialized before a single byte is printed).
20
+ *** NEVER A HOOK ERROR — the load-bearing property of this file ***
21
+ agy may fail-closed on a hook error, so this hook never raises, never exits non-zero, and never emits a
22
+ partial/malformed decision (the JSON is built and serialized before a single byte is printed). Within that:
23
+ * a payload it cannot read (not JSON, no toolCall, wrong shapes) prints NOTHING: there is no call to judge;
24
+ * an internal error while judging a shell or write call prints a well-formed DENY ("guard error: ..."),
25
+ as guard.py does (robustness #13). A deny is a decision, not a hook error, so it blocks one call and
26
+ never bricks the agent. Any other tool stays open. agy has no `onFailure` hook field (checked in the
27
+ binary, 19 Oct), so this deny, plus `timeout: 30` in hooks.json, is the whole fail-closed story here.
28
+
29
+ Kill switch (robustness #13), as guard.py: only env CADDIS_GUARD_DISABLED=1 turns the guard fully off, and
30
+ only interactively; under CADDIS_HEADLESS=1 it acts as deny-only. `[guard] enabled = false` / `mode = "off"`
31
+ in `.caddis/config.toml` acts as deny-only. A write to `.caddis/config.toml` or `~/.caddis/*.toml` asks.
32
+ The spawn-session tree lock is ported from guard.py too.
25
33
 
26
34
  Write-tier gating (classify_write) is wired defensively: agy's file-edit tool name is still
27
35
  LIVE-CHECK-PENDING (see .caddis/plans/agy-hooks-port.md), so it applies only to a tool whose name
@@ -30,10 +38,13 @@ looks like an edit/write and whose args carry a path. Unknown tools -> allow.
30
38
  from __future__ import annotations
31
39
 
32
40
  import json
41
+ import math
33
42
  import os
34
43
  import re
35
44
  import shlex
45
+ import subprocess
36
46
  import sys
47
+ import time
37
48
 
38
49
  # Functional identity, mirrored from scripts/claudster_config.py.
39
50
  _ARTIFACT_DIR = ".caddis"
@@ -44,7 +55,7 @@ _SECRET_BASENAMES = {"id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "credentials"
44
55
  ".npmrc", ".pypirc", ".netrc", ".pgpass", ".htpasswd"}
45
56
  _SECRET_SUFFIXES = (".pem", ".key", ".p12", ".pfx", ".keystore", ".jks")
46
57
  _ENV_RE = re.compile(r"(^|/)\.env(\.|$)", re.I)
47
- _ENV_EXAMPLE_RE = re.compile(r"\.env\.(example|sample|template|dist)$", re.I)
58
+ _ENV_EXAMPLE_RE = re.compile(r"\.env(\.[\w-]+)*\.(example|sample|template|dist)$", re.I)
48
59
 
49
60
  # Lockfiles are normally regenerated by the package manager, so a hand-edit is unusual enough to
50
61
  # confirm. Routine manifests (package.json/tsconfig) are deliberately NOT gated — too frequent, not risky.
@@ -55,7 +66,7 @@ _CI_WORKFLOW_RE = re.compile(r"(^|/)(\.github|\.gitea)/workflows/", re.I)
55
66
 
56
67
  def classify_write(file_path: str) -> tuple[str, str]:
57
68
  """Risk tier (deny|ask|allow) + reason for writing/editing ``file_path``."""
58
- p = file_path.replace("\\", "/")
69
+ p = file_path.replace("\\", "/").rstrip(". ") # Windows drops a trailing dot or space: `.env.` is `.env`
59
70
  parts = [seg.lower() for seg in p.split("/") if seg]
60
71
  base = parts[-1] if parts else ""
61
72
 
@@ -68,6 +79,9 @@ def classify_write(file_path: str) -> tuple[str, str]:
68
79
  if base in _SECRET_BASENAMES or any(base.endswith(s) for s in _SECRET_SUFFIXES):
69
80
  return "deny", f"writes a credential/key file ({base})"
70
81
 
82
+ why = guard_config_reason(file_path)
83
+ if why:
84
+ return "ask", why
71
85
  if base in _ASK_BASENAMES:
72
86
  return "ask", f"edits a lockfile ({base})"
73
87
  if _CI_WORKFLOW_RE.search(p):
@@ -75,6 +89,68 @@ def classify_write(file_path: str) -> tuple[str, str]:
75
89
  return "allow", ""
76
90
 
77
91
 
92
+ # ── the guard's own config (robustness #13) — copied from hooks/guard.py ─────
93
+ _WHY_CONFIG = "edits caddis config ({}); a guard setting there can relax the guard, so confirm the change is yours"
94
+
95
+
96
+ # The home folder as a shell spells it: PowerShell `$env:USERPROFILE` / `$env:HOME` / `$HOME`, bash `$HOME` /
97
+ # `${HOME}` / `$USERPROFILE` / `${USERPROFILE}`, cmd `%USERPROFILE%` / `%HOME%`; `~user` is out of scope. Each
98
+ # becomes `~`, which _home_path expands from os.path.expanduser("~") (os.path.expandvars knows neither `$env:` nor
99
+ # an unset HOME on Windows).
100
+ _HOME_VAR = re.compile(r"\$\{?env:(?:userprofile|home)\}?|\$\{(?:home|userprofile)\}|\$(?:home|userprofile)\b"
101
+ r"|%(?:userprofile|home)%", re.I)
102
+
103
+
104
+ def _home_path(raw: str) -> str:
105
+ """``raw`` with a leading home spelling (`~`, `$HOME`, `$env:USERPROFILE`, `%USERPROFILE%`) made absolute."""
106
+ raw = _HOME_VAR.sub("~", raw)
107
+ if raw == "~" or raw[:2] in ("~/", "~\\"):
108
+ raw = os.path.expanduser("~") + raw[1:]
109
+ return raw
110
+
111
+
112
+ def guard_config_reason(path: str) -> str:
113
+ """The ask reason when ``path`` is a `.caddis/config.toml` or a `~/.caddis/*.toml`, else ''."""
114
+ if not isinstance(path, str):
115
+ return ""
116
+ raw = path.strip().strip("'\"")
117
+ parts = [seg.lower() for seg in raw.replace("\\", "/").rstrip(". ").split("/") if seg]
118
+ if not parts or not parts[-1].endswith(".toml"):
119
+ return ""
120
+ if len(parts) >= 2 and parts[-2] == _ARTIFACT_DIR and parts[-1] == "config.toml":
121
+ return _WHY_CONFIG.format(parts[-1])
122
+ full = os.path.abspath(os.path.normpath(_home_path(raw).replace("\\", "/")))
123
+ home = os.path.join(os.path.expanduser("~"), _ARTIFACT_DIR)
124
+ if os.path.normcase(os.path.realpath(os.path.dirname(full))) == os.path.normcase(os.path.realpath(home)):
125
+ return _WHY_CONFIG.format(f"~/{_ARTIFACT_DIR}/{parts[-1]}")
126
+ return ""
127
+
128
+
129
+ # Copied from hooks/guard.py: the Read rule, used by the shell secret-read rule below.
130
+ def classify_read(file_path: str) -> tuple[str, str]:
131
+ """Risk tier (deny|allow) + reason for reading ``file_path`` via the Read tool.
132
+
133
+ Mirrors classify_write's env, credential, and secrets-directory rules, minus .git internals
134
+ and the lockfile/CI `ask` tier. Reading .git internals is normal. Never
135
+ returns "ask": Read is either fine or a secret disclosure, there's no confirm-and-proceed tier
136
+ for a read that already happened by the time the user could answer.
137
+
138
+ ``.envrc`` (direnv) is NOT ``.env``: _ENV_RE requires the char right after ".env" to be "." or
139
+ end-of-segment, and "rc" is neither, so `.envrc` falls through to allow — intentional, direnv
140
+ files are config, not the secret-bearing `.env`/`.env.*` family."""
141
+ p = file_path.replace("\\", "/").rstrip(". ")
142
+ parts = [seg.lower() for seg in p.split("/") if seg]
143
+ base = parts[-1] if parts else ""
144
+
145
+ if any(seg in ("secrets", ".secrets") for seg in parts[:-1]):
146
+ return "deny", f"reads from a secrets directory ({base})"
147
+ if _ENV_RE.search(p) and not _ENV_EXAMPLE_RE.search(p):
148
+ return "deny", "reads a secret env file"
149
+ if base in _SECRET_BASENAMES or any(base.endswith(s) for s in _SECRET_SUFFIXES):
150
+ return "deny", f"reads a credential/key file ({base})"
151
+ return "allow", ""
152
+
153
+
78
154
  # ── bash rules ───────────────────────────────────────────────────────────────
79
155
  # Catastrophic delete targets: a filesystem/home root or a *bare* drive root (C:\ ), NOT a drive
80
156
  # subpath (E:\proj\dist falls through to ask).
@@ -387,6 +463,118 @@ def git_add_everything(command: str, depth: int = 0) -> bool:
387
463
  for _cwd, sub, args in _git_calls(command, None, depth))
388
464
 
389
465
 
466
+ # Copied from hooks/guard.py (robustness #13): shell writes onto the guard's config.
467
+ # Shell constructs that WRITE to a named target: redirections, and the usual suspects that take
468
+ # a path argument. Matching the target lets classify_write judge it, so the shell path and the
469
+ # Write/Edit path can never disagree about what counts as a secret.
470
+ _SHELL_WRITE_TARGET = re.compile(
471
+ r"""(?:
472
+ >>?\s*(?P<redir>[^\s|;&<>]+) # > file >> file
473
+ | \|\s*tee\s+(?:-a\s+)?(?P<tee>[^\s|;&<>]+) # | tee file
474
+ | -(?:Path|FilePath|LiteralPath)\s+(?P<ps>[^\s|;&]+) # Set-Content -Path file
475
+ | \bOut-File\s+(?P<outfile>[^\s|;&]+) # Out-File file
476
+ )""",
477
+ re.IGNORECASE | re.VERBOSE,
478
+ )
479
+
480
+
481
+ def _shell_write_targets(command: str) -> list[str]:
482
+ """Every path this command appears to write to, quotes removed: a redirection, `| tee`, `| Out-File`, and the
483
+ targets of the writer programs the config rule knows (cp, mv, Copy-Item, Set-Content, New-Item, Tee-Object, sed -i,
484
+ ...). Not the regex's `-Path` group: `Get-Content -Path x` reads. Best-effort by design: a miss degrades to the old
485
+ behaviour, it never invents a denial. guard_agy.py carries a copy; test_guard_agy.py checks they agree."""
486
+ command = _HOME_VAR.sub("~", command) # the tokenizer splits on `$`, so in `$HOME/x` the home part would be lost
487
+ out = [m.group(g).strip("'\"") for m in _SHELL_WRITE_TARGET.finditer(command)
488
+ for g in ("redir", "tee", "outfile") if m.group(g)]
489
+ try:
490
+ for name, _cwd, _sub, args in _tool_calls(command, None, _CONFIG_WRITERS):
491
+ if name == "sed":
492
+ if any(a.startswith(("-i", "--in-place")) for a in args):
493
+ out += [a for a in args if not a.startswith("-")]
494
+ elif name == "tee":
495
+ out += [a for a in args if not a.startswith(("-", "<", ">"))]
496
+ elif name in _CONFIG_SETTERS:
497
+ out += _setter_targets(args)
498
+ else:
499
+ out += _copier_targets(args)
500
+ except Exception: # noqa: BLE001 - a tokenizer failure keeps the regex targets; it never switches the rule off
501
+ pass
502
+ return out
503
+
504
+
505
+ # Shell writes onto the guard's config: redirections and `| tee` / `| Out-File` (the regex above, not its -Path
506
+ # group: Get-Content -Path reads), then the programs below, found with the tokenizer (wrapped shells and `cd` count).
507
+ # Known limits: an interpreter one-liner (`python -c "open(...,'w')"`), a script that writes the file, a variable
508
+ # other than the home folder (_HOME_VAR), a symlink or glob that points at the file.
509
+ _CONFIG_COPIERS = frozenset({"cp", "copy", "mv", "move", "copy-item", "move-item", "cpi", "mi", "install"})
510
+ _CONFIG_SETTERS = frozenset({"set-content", "sc", "add-content", "ac", "out-file", "new-item", "ni", "tee-object"})
511
+ _CONFIG_WRITERS = _CONFIG_COPIERS | _CONFIG_SETTERS | {"tee", "sed"}
512
+ _PS_PATH_PARAMS = {"-path", "-literalpath", "-filepath", "-pspath"}
513
+ _PS_VALUE_PARAMS = {"-value", "-encoding", "-inputobject", "-width", "-stream", "-delimiter", "-itemtype",
514
+ "-filter", "-include", "-exclude", "-credential", "-variable"}
515
+
516
+
517
+ def _setter_targets(args: list[str]) -> list[str]:
518
+ """Set-Content / Out-File / New-Item: the -Path value, else the first positional word (+ New-Item -Name)."""
519
+ path, name, positional, i = "", "", [], 0
520
+ while i < len(args):
521
+ word = args[i].lower()
522
+ if word in _PS_PATH_PARAMS and i + 1 < len(args):
523
+ path, i = args[i + 1], i + 2
524
+ elif word == "-name" and i + 1 < len(args):
525
+ name, i = args[i + 1], i + 2
526
+ elif word in _PS_VALUE_PARAMS:
527
+ i += 2
528
+ else:
529
+ if not word.startswith(("-", ">", "<")):
530
+ positional.append(args[i])
531
+ i += 1
532
+ path = path or (positional[0] if positional else "")
533
+ return [os.path.join(path or ".", name) if name else path] if (path or name) else []
534
+
535
+
536
+ def _copier_targets(args: list[str]) -> list[str]:
537
+ """cp / mv / Copy-Item / Move-Item: the destination, and destination/<source name> for a folder destination."""
538
+ sources, dest, i = [], "", 0
539
+ while i < len(args):
540
+ word = args[i].lower()
541
+ if word in ("-destination", "-t", "--target-directory") and i + 1 < len(args):
542
+ dest, i = args[i + 1], i + 2
543
+ continue
544
+ if word.startswith("--target-directory="):
545
+ dest = args[i].split("=", 1)[1]
546
+ elif word in ("-path", "-literalpath") and i + 1 < len(args):
547
+ sources.append(args[i + 1])
548
+ i += 1
549
+ elif not word.startswith(("-", ">", "<")):
550
+ sources.append(args[i])
551
+ i += 1
552
+ if not dest and len(sources) >= 2:
553
+ dest = sources.pop()
554
+ if not dest:
555
+ return []
556
+ return [dest] + [os.path.join(dest, os.path.basename(src.replace("\\", "/"))) for src in sources]
557
+
558
+
559
+ def guard_config_shell_reason(command: str) -> str:
560
+ """The ask reason when a shell command writes a `.caddis/config.toml` or `~/.caddis/*.toml`, else ''.
561
+ Callers pass heredoc-free text and treat an exception as "no match"."""
562
+ targets = _shell_write_targets(command)
563
+ for target in targets:
564
+ why = guard_config_reason(target)
565
+ if why:
566
+ return f"shell command {why}"
567
+ return ""
568
+
569
+
570
+ def _config_shell_safe(text: str) -> str:
571
+ """guard_config_shell_reason, but an error is "no match": one rule must never switch off the rest."""
572
+ try:
573
+ return guard_config_shell_reason(text)
574
+ except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
575
+ return ""
576
+
577
+
390
578
  # Copied from hooks/guard.py: a heredoc body that only carries data is not a command.
391
579
  _HEREDOC_MARKER = re.compile(
392
580
  r"<<(-?)[ \t]*(?:'([A-Za-z_][A-Za-z_0-9]*)'|\"([A-Za-z_][A-Za-z_0-9]*)\"|([A-Za-z_][A-Za-z_0-9]*))"
@@ -480,11 +668,15 @@ _NPM_VALUE_FLAGS = {"--registry", "--userconfig", "--prefix", "-w", "--workspace
480
668
  "--cache", "-C", "--cwd", "--dir"}
481
669
 
482
670
 
671
+ def _headless() -> bool:
672
+ """A headless session: CADDIS_HEADLESS set, not empty, not 0. The hook reads the marker from its own
673
+ environment: Claude Code and agy start hooks with the session's env, which the model's shell cannot change."""
674
+ return str(os.environ.get(f"{_ENV_PREFIX}_HEADLESS", "")).strip() not in ("", "0")
675
+
676
+
483
677
  def _publish_allowed_headless() -> bool:
484
- """False only in a headless session (CADDIS_HEADLESS set, not empty, not 0) without CADDIS_ALLOW_PUBLISH=1.
485
- The hook reads the marker from its own environment: Claude Code and agy start hooks with the session's env."""
486
- headless = str(os.environ.get(f"{_ENV_PREFIX}_HEADLESS", "")).strip()
487
- if headless in ("", "0"):
678
+ """False only in a headless session without CADDIS_ALLOW_PUBLISH=1."""
679
+ if not _headless():
488
680
  return True
489
681
  return str(os.environ.get(f"{_ENV_PREFIX}_ALLOW_PUBLISH", "")).strip() == "1"
490
682
 
@@ -586,6 +778,193 @@ def _headless_publish_safe(text: str) -> str:
586
778
  return ""
587
779
 
588
780
 
781
+ # ── no tree moves in a shared checkout (robustness #11) ──
782
+ # A checkout whose AGENTS.md says `Shared checkout: yes` has a dev service serving from it, and often a peer's
783
+ # uncommitted edits. `git switch`, `checkout <branch>`, `rebase`, `pull`, `merge`, `reset --hard` and `stash` move
784
+ # or discard its files. Interactive: ask. Headless (CADDIS_HEADLESS=1): deny, because deny-only mode turns every ask
785
+ # into a silent allow and a runner session has no one to ask. `restore`/`checkout` of `.` or a folder discard a
786
+ # peer's edits too; `reset --keep`/`--merge` count as `--hard`; `stash apply`/`pop` count. Free: `switch -c <new>` and
787
+ # `checkout -b <new>` with NO start point (a start point checks that commit out), `checkout [-- ] <file>`,
788
+ # `git worktree add`, `pull --dry-run`, and the recovery verbs (`rebase`/`merge` `--abort`/`--continue`/`--skip`/
789
+ # `--quit`), so a headless runner can always finish or undo a stopped operation. `checkout <name>` where <name> is
790
+ # both a path and a ref is a branch switch (one `git rev-parse`, run only then). The checkout is the one the command acts on (`cd`, `git -C`, as for staging). A
791
+ # linked worktree is never shared: AGENTS.md is tracked, so the worktree has the line too, but it is the safe place.
792
+ # Known limits (it catches accidents, it is not a boundary): a cwd from a variable (`cd $X`) is not followed, and a
793
+ # git alias is not read. The regex is the one in scripts/caddis_checkout_guard.py (SHARED_RE); guard_agy.py carries
794
+ # a copy, and test_guard_agy.py checks they agree.
795
+ _SHARED_RE = re.compile(r"^[ \t]*(?:[-*][ \t]+)?(?:\*\*)?Shared checkout:[ \t]*yes(?:\*\*)?[ \t]*(?:$|[(\-–—.:,;#])",
796
+ re.IGNORECASE | re.MULTILINE)
797
+ _SWITCH_CREATE = {"-c", "-C", "--create", "--force-create"}
798
+ _CHECKOUT_CREATE = {"-b", "-B", "--orphan"}
799
+ _GIT_RECOVERY = {"--abort", "--continue", "--skip", "--quit"} # a stopped rebase/merge must be finishable headless
800
+ _RESET_MOVES = {"--hard", "--keep", "--merge"}
801
+ _FORCE = {"-f", "--force", "--discard-changes"} # `switch -f -c x` / `checkout -f -b x` throw changes away
802
+
803
+
804
+ def _options(args: list[str]) -> list[str]:
805
+ """The words before `--`. After `--` every word is a pathspec, even `-b` or `--staged`."""
806
+ return args[:args.index("--")] if "--" in args else args
807
+
808
+
809
+ def _create_flag(word: str, creates: set[str]) -> bool:
810
+ """`word` makes a branch: `-c`, attached `-cfoo`, or `--create=foo`."""
811
+ return word in creates or any(word.startswith(c + "=") if c.startswith("--") else
812
+ (word.startswith(c) and len(word) > len(c)) for c in creates)
813
+
814
+
815
+ def _create_moves(args: list[str], creates: set[str]) -> bool:
816
+ """`switch -c <new> [<start>]` / `checkout -b <new> [<start>]`: True when a start point follows the new name, or
817
+ `-f`/`--force`/`--discard-changes` throws the tree's changes away. The bare form starts from HEAD and keeps
818
+ the tree; a start point checks that commit out."""
819
+ opts = _options(args)
820
+ if any(a in _FORCE for a in opts):
821
+ return True
822
+ named, skip = [], False
823
+ for a in opts:
824
+ if skip:
825
+ skip = False
826
+ elif a in creates:
827
+ skip = True # the next word is the new branch name (`-cfoo` / `--create=foo` carry it)
828
+ elif not a.startswith("-"):
829
+ named.append(a)
830
+ return bool(named)
831
+
832
+
833
+ def _broad_path(path: str, cwd: str) -> bool:
834
+ """A pathspec that can hold files other than the one named: `.`, a folder, a glob or pathspec magic (`:/`)."""
835
+ if path.startswith(":") or any(c in path for c in "*?["):
836
+ return True
837
+ return os.path.isdir(os.path.join(cwd, path))
838
+
839
+
840
+ # No console window for the git call (a hook in a console-less session would flash one).
841
+ _NO_WINDOW_KW = {"creationflags": 0x08000000} if os.name == "nt" else {}
842
+
843
+
844
+ def _is_ref(name: str, cwd: str) -> bool:
845
+ """True when `name` resolves as a commit in the repo at `cwd`. Git cannot run (missing, hung): True, so the
846
+ caller asks. Called only when `name` is also a path, so most checkouts never pay for the git call."""
847
+ try:
848
+ r = subprocess.run(["git", "-C", cwd, "rev-parse", "--verify", "--quiet", f"{name}^{{commit}}"],
849
+ capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL,
850
+ **_NO_WINDOW_KW)
851
+ except (OSError, subprocess.SubprocessError):
852
+ return True
853
+ return r.returncode == 0
854
+
855
+
856
+ def _from_file(opts: list[str]) -> bool:
857
+ """`--pathspec-from-file`: the paths are in a file the guard does not read, so assume they are broad."""
858
+ return any(a == "--pathspec-from-file" or a.startswith("--pathspec-from-file=") for a in opts)
859
+
860
+
861
+ def _checkout_moves(args: list[str], cwd: str) -> bool:
862
+ opts = _options(args)
863
+ if _from_file(opts):
864
+ return True
865
+ if any(_create_flag(a, _CHECKOUT_CREATE) for a in opts):
866
+ paths = args[len(opts) + 1:]
867
+ return _create_moves(args, _CHECKOUT_CREATE) or any(_broad_path(p, cwd) for p in paths)
868
+ if "--" in args: # `checkout [<tree-ish>] -- <paths>`: only a broad pathspec discards more than the named file
869
+ paths = args[args.index("--") + 1:]
870
+ return not paths or any(_broad_path(p, cwd) for p in paths)
871
+ named = [a for a in args if not a.startswith("-")]
872
+ if not named or not all(os.path.exists(os.path.join(cwd, a)) for a in named):
873
+ return True # `checkout <branch>`, `checkout -`
874
+ if _is_ref(named[0], cwd): # a branch and a path share the name: git takes the branch
875
+ return len(named) == 1 or any(_broad_path(p, cwd) for p in named[1:])
876
+ return any(_broad_path(p, cwd) for p in named)
877
+
878
+
879
+ def _restore_moves(args: list[str], cwd: str) -> bool:
880
+ opts = _options(args)
881
+ staged = any(a in ("-S", "--staged") for a in opts)
882
+ if staged and not any(a in ("-W", "--worktree") for a in opts):
883
+ return False # `restore --staged` changes the index only
884
+ if _from_file(opts):
885
+ return True
886
+ paths, skip = list(args[len(opts) + 1:]), False
887
+ for a in opts:
888
+ if skip:
889
+ skip = False
890
+ elif a in ("-s", "--source"):
891
+ skip = True
892
+ elif not a.startswith("-"):
893
+ paths.append(a)
894
+ return any(_broad_path(p, cwd) for p in paths)
895
+
896
+
897
+ def _moves_tree(sub: str, args: list[str], cwd: str) -> bool:
898
+ """True when `git <sub> <args>` moves or discards the work tree (see the block note)."""
899
+ verb = sub.lower()
900
+ if verb == "switch":
901
+ if any(_create_flag(a, _SWITCH_CREATE) for a in _options(args)):
902
+ return _create_moves(args, _SWITCH_CREATE)
903
+ return True
904
+ if verb == "checkout":
905
+ return _checkout_moves(args, cwd)
906
+ if verb == "restore":
907
+ return _restore_moves(args, cwd)
908
+ if verb in ("rebase", "merge"):
909
+ return not any(a in _GIT_RECOVERY for a in args)
910
+ if verb == "pull":
911
+ return "--dry-run" not in args
912
+ if verb == "reset":
913
+ return any(a in _RESET_MOVES for a in args)
914
+ if verb == "stash":
915
+ return not (args[:1] and args[0].lower() in ("list", "show"))
916
+ return False
917
+
918
+
919
+ def _declares_shared(cwd: str) -> bool:
920
+ """The repo that holds `cwd` says `Shared checkout: yes` in its top AGENTS.md, and is not a linked worktree."""
921
+ here = os.path.abspath(cwd)
922
+ top = here
923
+ while True:
924
+ if os.path.exists(os.path.join(top, ".git")):
925
+ break
926
+ parent = os.path.dirname(top)
927
+ if parent == top:
928
+ top = here
929
+ break
930
+ top = parent
931
+ gitfile = os.path.join(top, ".git")
932
+ if os.path.isfile(gitfile):
933
+ try:
934
+ with open(gitfile, encoding="utf-8", errors="replace") as fh:
935
+ line = fh.read().strip()
936
+ except OSError:
937
+ line = ""
938
+ if line.lower().startswith("gitdir:") and "/worktrees/" in line.replace("\\", "/").lower():
939
+ return False
940
+ try:
941
+ with open(os.path.join(top, "AGENTS.md"), encoding="utf-8", errors="replace") as fh:
942
+ return bool(_SHARED_RE.search(fh.read()))
943
+ except OSError:
944
+ return False
945
+
946
+
947
+ def shared_checkout_reason(command: str, root: str | None) -> str:
948
+ """The reason when `command` moves the tree of a checkout that says `Shared checkout: yes`, else ''.
949
+ Callers pass heredoc-free text and treat an exception as "no match"."""
950
+ for cwd, sub, args in _git_calls(command, root):
951
+ if cwd is None or "$" in cwd or "%" in cwd or not os.path.isdir(cwd):
952
+ continue
953
+ if _moves_tree(sub, args, cwd) and _declares_shared(cwd):
954
+ return (f"`git {sub.lower()}` moves the tree of a shared checkout (AGENTS.md says Shared checkout: yes; "
955
+ "a dev service or a peer uses it). Work in your own worktree instead: "
956
+ "`git worktree add \"$TEMP/<name>\" -b <branch> origin/<default>`, and remove it when done")
957
+ return ""
958
+
959
+
960
+ def _shared_checkout_safe(text: str, root: str | None) -> str:
961
+ """shared_checkout_reason, but an error is "no match": one rule must never switch off the rest of the guard."""
962
+ try:
963
+ return shared_checkout_reason(text, root)
964
+ except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
965
+ return ""
966
+
967
+
589
968
  def _strip_quotes(s: str) -> str:
590
969
  """Drop quotes so quoted flags/targets still match (rm '-rf' \"/\" → rm -rf /)."""
591
970
  return s.replace('"', "").replace("'", "")
@@ -670,13 +1049,145 @@ def _catastrophic_statement(c: str) -> str:
670
1049
  return ""
671
1050
 
672
1051
 
1052
+ # Copied from hooks/guard.py; test_guard_agy.py checks they agree.
1053
+ # ── Phase 6: reading or copying onto a secret env file through the shell ─────
1054
+ # Match copy/move verbs as whole tokens. Source and destination paths are checked below with the
1055
+ # same path classifiers used by Read and Write.
1056
+ _COPY_DEST = re.compile(r"(?i)(?:cp|mv|copy|move|copy-item|move-item)\Z")
1057
+
1058
+ # These verbs take file operands. Pattern tools (select-string/sls, grep/rg/findstr/awk/sed) take
1059
+ # search patterns too, so matching an env-looking argument would block ordinary searches.
1060
+ # Known limits: free text containing both a verb and `.env` (for example a commit message saying
1061
+ # `never cat .env`) is denied; `git diff .env`, `git log -p .env`, bare `tee .env`, and
1062
+ # `docker --env-file` are not blocked. Command substitution
1063
+ # (`cat $(echo .env)`), PowerShell .NET file calls
1064
+ # (`[IO.File]::ReadAllText`), and scripts that open the file themselves are not parsed here.
1065
+ _ENV_READ = re.compile(
1066
+ r"(?i)(?:cat|type|more|less|head|tail|get-content|gc|base64|xxd|od|strings|hexdump|sort|nl|tac|source|\.)\Z"
1067
+ )
1068
+ _INPUT_REDIRECT = re.compile(r"(?<!<)<(?!<)\s*([^\s|;&<>]+)")
1069
+
1070
+ # python/node/etc. run inline with -c/-e that touches a `.env*` file, e.g. `open('.env')`.
1071
+ # The mandatory [\s'"/\\] immediately before "\.env" is load-bearing: without it, `.env` would
1072
+ # match inside `process.env.HOME` (preceded by "s" from "process") and deny an ordinary command
1073
+ # that merely mentions an environment VARIABLE, not the FILE. Found while testing this phase.
1074
+ _INLINE_ENV = re.compile(
1075
+ r"(?i)\b(python\d?|py|node|ruby|perl)\b\s+(-c|-e)\s+.*[\s'\"/\\]\.env"
1076
+ r"(?!\.(example|sample|template|dist))\b",
1077
+ re.DOTALL,
1078
+ )
1079
+
1080
+
1081
+ def _copy_segments(c: str) -> list[str]:
1082
+ """Segments of ``c`` split on ``;``, ``&&``, a bare newline and a bare ``|`` (quote/paren-aware).
1083
+
1084
+ Unlike ``_statements`` (used for the catastrophic-delete check), a pipe IS a boundary here:
1085
+ `some_cmd | cp x .env` still has `cp x .env` naming its own destination at the end of ITS
1086
+ segment, and `_COPY_DEST`'s trailing `$` anchor needs that segment on its own to match."""
1087
+ c = _CONTINUATION.sub(" ", c)
1088
+ out: list[str] = []
1089
+ cur: list[str] = []
1090
+ quote, depth, i = "", 0, 0
1091
+ while i < len(c):
1092
+ ch = c[i]
1093
+ if quote:
1094
+ quote = "" if ch == quote else quote
1095
+ elif ch in "'\"":
1096
+ quote = ch
1097
+ elif ch in "({":
1098
+ depth += 1
1099
+ elif ch in ")}" and depth:
1100
+ depth -= 1
1101
+ elif depth == 0 and (ch in ";\n|" or c[i:i + 2] == "&&"):
1102
+ out.append("".join(cur))
1103
+ cur = []
1104
+ i += 2 if c[i:i + 2] == "&&" else 1
1105
+ continue
1106
+ cur.append(ch)
1107
+ i += 1
1108
+ out.append("".join(cur))
1109
+ return [_strip_quotes(s).strip() for s in out]
1110
+
1111
+
1112
+ def _shell_env_access(command: str) -> tuple[str, str]:
1113
+ """Check simple shell file operands in one pass per command segment."""
1114
+ for seg in _copy_segments(command):
1115
+ # Redirection can precede or follow the command: `< .env cat` and `cat < .env`.
1116
+ for match in _INPUT_REDIRECT.finditer(seg):
1117
+ if classify_read(match.group(1))[0] == "deny":
1118
+ return "deny", "shell command reads a secret env file"
1119
+
1120
+ words = seg.split()
1121
+ for index, word in enumerate(words):
1122
+ if word.lower() == "dd":
1123
+ for arg in words[index + 1:]:
1124
+ if arg.startswith("if=") and classify_read(arg[3:])[0] == "deny":
1125
+ return "deny", "shell command reads a secret env file"
1126
+ break
1127
+
1128
+ if word.lower() == "git" and index + 1 < len(words) and words[index + 1].lower() == "show":
1129
+ for arg in words[index + 2:]:
1130
+ if ":" in arg and classify_read(arg.split(":", 1)[1])[0] == "deny":
1131
+ return "deny", "shell command reads a secret env file"
1132
+ break
1133
+
1134
+ if _COPY_DEST.fullmatch(word):
1135
+ operands: list[str] = []
1136
+ destination = ""
1137
+ i = index + 1
1138
+ while i < len(words):
1139
+ arg = words[i]
1140
+ if arg.lower() in ("-path", "-literalpath", "-source", "-destination") and i + 1 < len(words):
1141
+ if arg.lower() == "-destination":
1142
+ destination = words[i + 1]
1143
+ else:
1144
+ operands.append(words[i + 1])
1145
+ i += 2
1146
+ elif not arg.startswith("-"):
1147
+ operands.append(arg)
1148
+ i += 1
1149
+ else:
1150
+ i += 1
1151
+ if not destination and len(operands) >= 2:
1152
+ destination = operands.pop()
1153
+ if destination and classify_write(destination)[0] == "deny":
1154
+ return "deny", f"shell command copies onto a secret file ({destination})"
1155
+ if any(classify_read(source)[0] == "deny" for source in operands):
1156
+ return "deny", "shell command copies a secret env file"
1157
+ break
1158
+
1159
+ if _ENV_READ.fullmatch(word):
1160
+ if any(classify_read(path)[0] == "deny" for path in words[index + 1:]):
1161
+ return "deny", "shell command reads a secret env file"
1162
+ break
1163
+ return "allow", ""
1164
+
1165
+
673
1166
  def classify_bash(command: str, root: str | None = None) -> tuple[str, str]:
674
1167
  """Risk tier (deny|ask|allow) + reason for a shell command. ``root`` is the cwd the command runs in
675
1168
  (default: this process's cwd); `git add <dir>` resolves against it."""
676
1169
  c = command.strip()
1170
+ data_free = _without_heredoc_bodies(c)
677
1171
  cl = c.lower()
678
1172
  n = _strip_quotes(c) # quote-normalized: defeats `rm '-rf' "/"` evasion
679
1173
  nl = n.lower()
1174
+
1175
+ # ── deny: copying or reading a secret env file through the shell (copied from hooks/guard.py) ──
1176
+ _tier, _reason = _shell_env_access(_strip_quotes(data_free))
1177
+ if _tier == "deny":
1178
+ return _tier, _reason
1179
+
1180
+ # ── deny: writing a secret through the shell (copied from hooks/guard.py) ──
1181
+ # The same file must not be denied via a write tool and waved through via `echo ... > .env`.
1182
+ for _target in _shell_write_targets(data_free):
1183
+ _tier, _why = classify_write(_target)
1184
+ if _tier == "deny":
1185
+ return "deny", f"shell command {_why}"
1186
+
1187
+ # ── deny: an inline python/node/etc. one-liner that touches a secret env file ──
1188
+ if _INLINE_ENV.search(c):
1189
+ return "deny", "inline interpreter touches a secret env file"
1190
+
680
1191
  rm_rf = _is_rm_recursive_force(n)
681
1192
  find_delete = bool(re.search(_FIND_DELETE, nl))
682
1193
  win_del = _is_win_recursive_delete(n)
@@ -691,15 +1202,22 @@ def classify_bash(command: str, root: str | None = None) -> tuple[str, str]:
691
1202
  return "deny", "fork bomb"
692
1203
  if re.search(r"\bdd\b.*\bof=/dev/", nl) or re.search(r"\bmkfs", nl) or re.search(r">\s*/dev/sd[a-z]", nl):
693
1204
  return "deny", "writes directly to a disk device"
694
- data_free = _without_heredoc_bodies(c)
695
1205
  _staging = _git_staging_safe(data_free, os.getcwd() if root is None else root)
696
1206
  if _staging:
697
1207
  return "deny", _staging
698
1208
  _publish = _headless_publish_safe(data_free)
699
1209
  if _publish:
700
1210
  return "deny", _publish
1211
+ _shared = _shared_checkout_safe(data_free, os.getcwd() if root is None else root)
1212
+ if _shared and _headless():
1213
+ return "deny", f"headless session: {_shared}"
701
1214
 
702
1215
  # ── ask: destructive-but-legit ──
1216
+ _config = _config_shell_safe(data_free)
1217
+ if _config:
1218
+ return "ask", _config
1219
+ if _shared:
1220
+ return "ask", _shared
703
1221
  if rm_rf:
704
1222
  return "ask", "recursive force-delete (rm -rf)"
705
1223
  if find_delete:
@@ -726,6 +1244,100 @@ def classify_bash(command: str, root: str | None = None) -> tuple[str, str]:
726
1244
  return "allow", ""
727
1245
 
728
1246
 
1247
+ # Copied from hooks/guard.py: ── the shared-tree lock during a spawn-session handover ─────────────────────
1248
+ # `spawn-session.md` told the child not to move the shared checkout while both sessions are live.
1249
+ # The parent pasted it verbatim and the successor switched branches within the hour (2026-09-10);
1250
+ # four files the parent had corrected reverted under it. A rule that has to be remembered is not a
1251
+ # gate, so while a handshake is open the guard refuses the git commands that move or discard the
1252
+ # tree. DENY, not ask: deny-only mode turns every ask into a silent allow, and that is the mode the
1253
+ # failure happened in.
1254
+ #
1255
+ # The verb must be git's SUBCOMMAND (first word after global options), so `git commit -m "reset x"`
1256
+ # and `git worktree add ... -b feat` pass. `stash list` / `stash show` only read.
1257
+ _TREE_MOVING_GIT = re.compile(
1258
+ r"\bgit(?:\s+-[Cc]\s+\S+|\s+--?[\w-]+(?:=\S+)?)*\s+"
1259
+ r"(?:switch|checkout|restore|rebase|reset|clean|stash(?!\s+(?:list|show)\b))(?=\s|$|[;&|])",
1260
+ re.I,
1261
+ )
1262
+ # Older than this is an abandoned handshake, not a live one — two REJECTs end a handshake that
1263
+ # `close` can never accept, and it must not lock the tree forever.
1264
+ _HANDSHAKE_LIVE_S = 24 * 3600
1265
+
1266
+
1267
+ def _open_handshake(start: str) -> str:
1268
+ """Id of a spawn-session handshake still open in the repo holding ``start``, or ''."""
1269
+ here = os.path.abspath(start)
1270
+ while True:
1271
+ d = os.path.join(here, _ARTIFACT_DIR, "spawn-session")
1272
+ if os.path.isdir(d):
1273
+ now = time.time()
1274
+ try:
1275
+ names = sorted(os.listdir(d))
1276
+ except FileNotFoundError: # gone since isdir: no handshake. Any other OSError reaches tree_lock_check.
1277
+ return ""
1278
+ for name in names:
1279
+ if not name.endswith("-handshake.json"):
1280
+ continue
1281
+ # A file that is not a lock (unreadable, not an object, no `tree_lock: true`) is skipped. It must
1282
+ # never raise: the hook would fail closed and deny every tree-moving git command.
1283
+ try:
1284
+ with open(os.path.join(d, name), encoding="utf-8") as fh:
1285
+ st = json.load(fh)
1286
+ except Exception:
1287
+ continue
1288
+ # `tree_lock` is written by handshakes opened since the lock existed. Older files
1289
+ # never stamped `closed_at`, so without this opt-in a handshake finished under the
1290
+ # old code would lock a live session's tree — measured on a real one, 11h old.
1291
+ if not isinstance(st, dict) or st.get("tree_lock") is not True or st.get("closed_at"):
1292
+ continue
1293
+ # An explicit open lock fails closed: a missing or bad `opened_at` is live, never abandoned.
1294
+ try:
1295
+ opened = float(st["opened_at"])
1296
+ except (KeyError, TypeError, ValueError):
1297
+ opened = now
1298
+ if math.isfinite(opened) and now - opened > _HANDSHAKE_LIVE_S:
1299
+ continue
1300
+ return str(st.get("id") or name[: -len("-handshake.json")])
1301
+ return ""
1302
+ parent = os.path.dirname(here)
1303
+ if parent == here:
1304
+ return ""
1305
+ here = parent
1306
+
1307
+
1308
+ def tree_lock_check(command: str, root: str) -> tuple[str, str]:
1309
+ """("deny", why) when ``command`` would move a checkout a live handshake shares; ("ask", why) when the handshake
1310
+ folder cannot be read, so a live lock may be hidden; else ("", "")."""
1311
+ m = _TREE_MOVING_GIT.search(command)
1312
+ if not m:
1313
+ return "", ""
1314
+ # `git -C <path>` acts on <path>, not on the checkout the command was issued from.
1315
+ target = re.search(r"\s-C\s+(\S+)", m.group(0))
1316
+ if target:
1317
+ root = os.path.join(root, target.group(1).strip("'\""))
1318
+ try:
1319
+ hs = _open_handshake(root)
1320
+ except OSError as exc:
1321
+ return "ask", (f"cannot read the spawn-session handshakes ({type(exc).__name__}), so another session may "
1322
+ "share this checkout; confirm that no handshake is open before you move the tree")
1323
+ if not hs:
1324
+ return "", ""
1325
+ return "deny", _tree_lock_why(hs)
1326
+
1327
+
1328
+ def tree_lock_reason(command: str, root: str) -> str:
1329
+ """The deny reason when ``command`` would move a checkout a live handshake shares, else ''."""
1330
+ tier, why = tree_lock_check(command, root)
1331
+ return why if tier == "deny" else ""
1332
+
1333
+
1334
+ def _tree_lock_why(hs: str) -> str:
1335
+ return (f"spawn-session handshake `{hs}` is open, so two sessions share this checkout. Moving or "
1336
+ "discarding the tree here carries or destroys the other session's uncommitted work. "
1337
+ "Work in your own tree instead: `git worktree add <scratch>/<name> -b <branch> "
1338
+ "origin/main`, and remove it when the work lands. The lock lifts at `handshake close`")
1339
+
1340
+
729
1341
  # ── agy tool-shape mapping ───────────────────────────────────────────────────
730
1342
  # The shell tool: step type CORTEX_STEP_TYPE_RUN_COMMAND -> "run_command". The command lives at
731
1343
  # args.CommandLine. Extra spellings are accepted so a step-type rename can't silently un-guard the
@@ -741,6 +1353,11 @@ _PATH_ARG_KEYS = ("FilePath", "filePath", "file_path", "AbsolutePath", "absolute
741
1353
  "TargetFile", "targetFile", "Path", "path")
742
1354
 
743
1355
 
1356
+ def _is_write_tool(name: str) -> bool:
1357
+ """A tool whose name looks like an edit/write and not like a read (the heuristic decide() uses)."""
1358
+ return bool(_WRITE_NAME_RE.search(name)) and not _READONLY_NAME_RE.search(name)
1359
+
1360
+
744
1361
  def _first_str(args: dict, keys) -> str:
745
1362
  for k in keys:
746
1363
  v = args.get(k)
@@ -761,7 +1378,7 @@ def decide(tool_name: str, args, allow_patterns: list[str], root: str | None = N
761
1378
  target = _first_str(args, _COMMAND_ARG_KEYS)
762
1379
  cwd = _first_str(args, _CWD_ARG_KEYS) or root # the command's own cwd, else the workspace root
763
1380
  tier, reason = classify_bash(target, cwd) if target else ("allow", "")
764
- elif _WRITE_NAME_RE.search(name) and not _READONLY_NAME_RE.search(name):
1381
+ elif _is_write_tool(name):
765
1382
  target = _first_str(args, _PATH_ARG_KEYS)
766
1383
  tier, reason = classify_write(target) if target else ("allow", "")
767
1384
  else:
@@ -802,31 +1419,35 @@ def _load_allow(root: str) -> list[str]:
802
1419
  return [str(x) for x in allow] if isinstance(allow, list) else []
803
1420
 
804
1421
 
805
- def guard_disabled(root: str) -> bool:
806
- """True when the guard is turned off entirely (all tiers bypassed).
1422
+ def _env_off() -> bool:
1423
+ return os.environ.get(f"{_ENV_PREFIX}_GUARD_DISABLED", "").strip().lower() in _TRUTHY
1424
+
1425
+
1426
+ def _config_off(section: dict) -> bool:
1427
+ """A config off switch in the ``[guard]`` table: ``enabled = false`` or ``mode = "off"``."""
1428
+ return section.get("enabled") is False or str(section.get("mode", "")).strip().lower() == "off"
807
1429
 
808
- Precedence: the env var wins (global); otherwise the per-repo `[guard]` table — `enabled = false`
809
- or `mode = "off"`. Any parse problem → not disabled (fail safe). Env var: CADDIS_GUARD_DISABLED."""
810
- _disabled = os.environ.get(f"{_ENV_PREFIX}_GUARD_DISABLED", "")
811
- if _disabled.strip().lower() in _TRUTHY:
812
- return True
813
- section = _load_guard_config(root)
814
- if section.get("enabled") is False:
815
- return True
816
- if str(section.get("mode", "")).strip().lower() == "off":
817
- return True
818
- return False
1430
+
1431
+ def guard_disabled(root: str | None = None) -> bool:
1432
+ """True when the guard is turned off entirely (all tiers bypassed): env CADDIS_GUARD_DISABLED, interactively.
1433
+
1434
+ Robustness #13: the config file cannot do this any more (a model can write it; see ``deny_only``), and a
1435
+ headless session ignores the env var too. ``root`` is kept for callers; the answer no longer reads it."""
1436
+ return _env_off() and not _headless()
819
1437
 
820
1438
 
821
1439
  def deny_only(root: str) -> bool:
822
- """True when the guard keeps DENY (silent hard-block) but suppresses the ASK tier (ask -> allow =
823
- silence), so it NEVER prompts — for autonomous / no-prompt workflows that still want a catastrophe
824
- net. Env `CADDIS_GUARD_MODE=deny-only`; config `[guard] mode = "deny-only"`. Read silently."""
1440
+ """True when the guard keeps DENY (silent hard-block of catastrophes) but suppresses the ASK tier
1441
+ (downgrades ask -> allow), so it NEVER prompts. For autonomous / no-prompt workflows that still
1442
+ want a catastrophe net: a big plan runs uninterrupted, only a truly destructive call is blocked.
1443
+
1444
+ On: env ``CADDIS_GUARD_MODE=deny-only``; config ``[guard] mode = "deny-only"``; a config off switch
1445
+ (``enabled = false`` / ``mode = "off"``); the env off switch in a headless session. Read silently."""
825
1446
  _mode = os.environ.get(f"{_ENV_PREFIX}_GUARD_MODE", "")
826
- if _mode.strip().lower() == "deny-only":
1447
+ if _mode.strip().lower() == "deny-only" or (_env_off() and _headless()):
827
1448
  return True
828
1449
  section = _load_guard_config(root)
829
- return str(section.get("mode", "")).strip().lower() == "deny-only"
1450
+ return _config_off(section) or str(section.get("mode", "")).strip().lower() == "deny-only"
830
1451
 
831
1452
 
832
1453
  # ── hook I/O glue (agy envelope) ─────────────────────────────────────────────
@@ -883,11 +1504,21 @@ def _workspace_roots(data):
883
1504
  return []
884
1505
  return [str(r) for r in roots if isinstance(r, (str, bytes)) and str(r).strip()]
885
1506
 
886
- def headless_publish_decision(tool_name: str, args) -> str:
887
- """The headless publish deny reason for a shell tool call, else ''. Used when the guard is switched off."""
888
- if (tool_name or "").strip().lower() not in _COMMAND_TOOLS or not isinstance(args, dict):
889
- return ""
890
- return _headless_publish_safe(_without_heredoc_bodies(_first_str(args, _COMMAND_ARG_KEYS).strip()))
1507
+ def decide_call(tool_name: str, args, allow_patterns: list[str], root: str) -> tuple[str, str]:
1508
+ """decide() plus the spawn-session tree lock for a shell call (guard.py applies it in hook_decision)."""
1509
+ tier, reason = decide(tool_name, args, allow_patterns, root)
1510
+ if tier != "deny" and (tool_name or "").strip().lower() in _COMMAND_TOOLS and isinstance(args, dict):
1511
+ cwd = _first_str(args, _CWD_ARG_KEYS) or root # the command's own cwd wins, as in decide()
1512
+ lock_tier, lock = tree_lock_check(_first_str(args, _COMMAND_ARG_KEYS), cwd)
1513
+ if lock_tier == "deny" or (lock_tier == "ask" and tier == "allow"):
1514
+ tier, reason = lock_tier, lock
1515
+ return tier, reason
1516
+
1517
+
1518
+ def _fails_closed(tool_name: str) -> bool:
1519
+ """A shell or write call: an internal error while judging it denies it (robustness #13)."""
1520
+ name = (tool_name or "").strip().lower()
1521
+ return name in _COMMAND_TOOLS or _is_write_tool(name)
891
1522
 
892
1523
 
893
1524
  def main() -> None:
@@ -899,46 +1530,47 @@ def main() -> None:
899
1530
  _reconfig(encoding="utf-8")
900
1531
  except Exception as _exc:
901
1532
  _hook_note("agy stdout utf-8 reconfigure", _exc)
1533
+ # Silent ONLY for a payload it cannot read: there is no call to judge.
902
1534
  try:
903
1535
  data = json.load(sys.stdin)
904
- except Exception:
905
- return # malformed payload → fail open, silently
1536
+ except ValueError as _exc: # JSONDecodeError and UnicodeDecodeError are ValueErrors
1537
+ _hook_note("agy guard unparseable payload", _exc)
1538
+ return
906
1539
  if not isinstance(data, dict):
907
1540
  return
908
1541
  call = data.get("toolCall")
909
1542
  if not isinstance(call, dict):
910
1543
  return
1544
+ name, args = str(call.get("name") or ""), call.get("args")
911
1545
  roots = _workspace_roots(data) or [os.getcwd()]
912
-
913
- # Every per-repo guard setting RELAXES the guard: `enabled = false` bypasses all
914
- # tiers, `mode = "deny-only"` drops the ask tier, and `[guard] allow` downgrades an
915
- # ask. Reading them from workspacePaths[0] meant one repo's relaxation silently
916
- # applied to every other repo in the workspace — repo A's kill switch unguarding
917
- # repo B. Require UNANIMITY instead: a relaxation applies only when every root asks
918
- # for it. One root is the common case and behaves exactly as before.
919
- if all(guard_disabled(r) for r in roots):
920
- # Kill switch: bypass every tier. Not for a headless session's push, merge or publish (robustness #4):
921
- # a session can write .caddis/config.toml itself.
922
- why = headless_publish_decision(str(call.get("name") or ""), call.get("args"))
923
- if why:
924
- tier, reason = "deny", why
925
- return
926
-
927
- # The allow-list downgrades an ask, so with several roots take only the entries
928
- # present in ALL of them — the strictest reading, not the most permissive.
929
- allow = _load_allow(roots[0])
930
- for other in roots[1:]:
931
- allow = [a for a in allow if a in _load_allow(other)]
932
-
933
- # A path such as `git add src` may sit in any workspace root: decide once per root, keep the strictest.
934
- # (guard.py has one cwd. A run_command `Cwd` argument wins inside decide, so every root agrees then.)
935
- _rank = {"deny": 0, "ask": 1}
936
- tier, reason = min((decide(str(call.get("name") or ""), call.get("args"), allow, r) for r in roots),
937
- key=lambda d: _rank.get(d[0], 2))
938
- if tier == "ask" and all(deny_only(r) for r in roots):
939
- tier = "allow" # deny-only: keep catastrophe blocks, never force_ask (no plan interruption)
940
- except Exception:
941
- return # ANY unexpected shape/error → no guard, never a crash and never a bad decision
1546
+ try:
1547
+ # Only the interactive env var turns the guard fully off (robustness #13); a config file cannot.
1548
+ if guard_disabled(roots[0]):
1549
+ return
1550
+
1551
+ # Every per-repo guard setting RELAXES the guard: `mode = "deny-only"` (and now a config off switch)
1552
+ # drops the ask tier, and `[guard] allow` downgrades an ask. Reading them from workspacePaths[0]
1553
+ # meant one repo's relaxation silently applied to every other repo in the workspace. Require
1554
+ # UNANIMITY instead: a relaxation applies only when every root asks for it.
1555
+ # The allow-list downgrades an ask, so with several roots take only the entries
1556
+ # present in ALL of them — the strictest reading, not the most permissive.
1557
+ allow = _load_allow(roots[0])
1558
+ for other in roots[1:]:
1559
+ allow = [a for a in allow if a in _load_allow(other)]
1560
+
1561
+ # A path such as `git add src` may sit in any workspace root: decide once per root, keep the strictest.
1562
+ # (guard.py has one cwd. A run_command `Cwd` argument wins inside decide, so every root agrees then.)
1563
+ _rank = {"deny": 0, "ask": 1}
1564
+ tier, reason = min((decide_call(name, args, allow, r) for r in roots),
1565
+ key=lambda d: _rank.get(d[0], 2))
1566
+ if tier == "ask" and all(deny_only(r) for r in roots):
1567
+ tier = "allow" # deny-only: keep catastrophe blocks, never force_ask (no plan interruption)
1568
+ except Exception as _exc: # noqa: BLE001 - fail CLOSED for a shell or write call, open otherwise
1569
+ _hook_note("agy guard decision", _exc, roots[0])
1570
+ tier, reason = ("deny", f"guard error: {type(_exc).__name__}: {str(_exc)[:120]}") if _fails_closed(name) else ("allow", "")
1571
+ except Exception as _exc:
1572
+ _hook_note("agy guard payload", _exc)
1573
+ tier, reason = "allow", "" # a payload shape it cannot read: no call to judge, never a crash
942
1574
  finally:
943
1575
  try:
944
1576
  _emit(tier, reason)