@caddis/cli 0.4.55 → 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 (58) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
  2. package/bundles/antigravity-plugin/guard_agy.py +1056 -83
  3. package/bundles/antigravity-plugin/hooks.json +2 -1
  4. package/bundles/antigravity-plugin/plugin.json +1 -1
  5. package/bundles/antigravity-plugin/repo_freshness.py +182 -4
  6. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
  7. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
  8. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
  9. package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
  11. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
  12. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
  13. package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
  14. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
  15. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
  16. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
  17. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
  18. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
  19. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
  20. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
  21. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
  22. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
  23. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
  24. package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
  25. package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
  26. package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
  27. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
  28. package/bundles/antigravity-plugin/session_end_agy.py +27 -11
  29. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
  31. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
  33. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
  34. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
  35. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
  37. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
  38. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
  39. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
  40. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
  41. package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
  42. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
  43. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
  44. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
  46. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  48. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
  50. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  51. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
  52. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
  53. package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
  54. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
  55. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
  56. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
  57. package/bundles/manifest.json +3 -3
  58. 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).
@@ -100,6 +176,18 @@ _GA_JOINING_SHELLS = {"cmd", "pwsh", "powershell"} # they run ALL the words aft
100
176
  _GA_SHELL_RUN_FLAGS = {"-c", "/c", "/k", "-command", "-commandwithargs"}
101
177
  _GA_GIT_VALUE_OPTS = {"-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env"}
102
178
  _GA_EVERYTHING = {"-A", "--all", ".", "./", ".\\", ":/", "*"}
179
+ _GA_CD = {"cd", "chdir", "pushd", "set-location", "sl"} # change the cwd the next `git add <path>` resolves against
180
+ # `git commit` short options that take the REST of their bundle as a value (`-ma` is the message "a"). The
181
+ # first five also take the next word when they end the bundle (`-m msg`); -S and -u take an attached value only.
182
+ _GC_VALUE_SHORT = set("mFCct")
183
+ _GC_ATTACHED_SHORT = _GC_VALUE_SHORT | set("Su")
184
+ _GC_VALUE_LONG = {"--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash", "--author",
185
+ "--date", "--template", "--cleanup", "--trailer", "--pathspec-from-file"}
186
+ _SAFE_COMMIT = 'stage named files only: `git add <file> ...`, then `git commit -m "..." -- <files>`'
187
+ _WHY_EVERYTHING = f"git add of everything (-A, --all, . or :/): {_SAFE_COMMIT}"
188
+ _WHY_UPDATE = f"git add -u stages every tracked change, a peer's too: {_SAFE_COMMIT}"
189
+ _WHY_COMMIT_ALL = f"git commit -a commits every tracked change, a peer's too: {_SAFE_COMMIT}"
190
+ _GA_QUOTE_CHARS = set(" \t\"';&|(){}$!\n") # a token holding one of these is quoted again for an inner pass
103
191
 
104
192
 
105
193
  def _ga_sep(token: str) -> bool:
@@ -115,7 +203,11 @@ def _ga_name(token: str) -> str:
115
203
 
116
204
  def _ga_tokens(text: str) -> list[str]:
117
205
  """Shell tokens, quotes removed (also mid-token: --work-tree="my dir"), backslashes kept (Windows paths).
118
- ; & | ( ) { } $ ! and newlines become separator tokens."""
206
+ ; & | ( ) { } $ ! and newlines become separator tokens.
207
+
208
+ Known limits (the guard catches accidents; it is not a security boundary, see test_known_limits_not_caught):
209
+ an escaped quote inside `bash -c "..."` (`\\"`), a PowerShell backtick escape (`` `-A ``, read as a line
210
+ break), and a git alias (`git -c alias.x=add x -A`) are not read as `git add`."""
119
211
  def lex(quotes: str) -> list[str]:
120
212
  lexer = shlex.shlex(text.replace("`", "\n"), posix=True, punctuation_chars=";&|(){}$!\n")
121
213
  lexer.whitespace, lexer.whitespace_split, lexer.escape, lexer.quotes = " \t\r", True, "", quotes
@@ -126,17 +218,45 @@ def _ga_tokens(text: str) -> list[str]:
126
218
  return lex("")
127
219
 
128
220
 
129
- def git_add_everything(command: str, depth: int = 0) -> bool:
130
- """True when `command` runs `git add` with -A/--all (also bundled, e.g. -Av), `.`, `./`, `:/` or `*`.
221
+ def _ga_quote(token: str) -> str:
222
+ """`token` quoted so _ga_tokens reads it back as the same one token (a cmd/pwsh re-join keeps `"my dir"`)."""
223
+ if token and not set(token) & _GA_QUOTE_CHARS:
224
+ return token
225
+ return f"'{token}'" if '"' in token else f'"{token}"'
226
+
227
+
228
+ def _ga_inside(path: str, folder: str) -> bool:
229
+ """`path` is `folder` or below it (real paths, case-folded on Windows)."""
230
+ inner, outer = os.path.normcase(os.path.realpath(path)), os.path.normcase(os.path.realpath(folder))
231
+ return inner == outer or inner.startswith(outer.rstrip("\\/") + os.sep)
232
+
233
+
234
+ def _ga_cd(cwd: str | None, path: str) -> str | None:
235
+ """The cwd after `cd path` or `git -C path`; None (unknown) when either is unknown."""
236
+ if cwd is None or not path or _ga_sep(path):
237
+ return None
238
+ return os.path.join(cwd, os.path.expanduser(path))
239
+
240
+
241
+ def _git_calls(command: str, cwd: str | None, depth: int = 0):
242
+ """Yield (cwd, subcommand, args) for every git run in `command`.
131
243
 
132
244
  `git` must start a command (after a separator, a shell keyword such as `do`, VAR=value, or a wrapper such as
133
245
  sudo, env, xargs, nice -n 10), with any path, case or .exe. `bash -c "..."`, `pwsh -Command ...` and
134
- `cmd /c ...` are read inside. Only git's global options may stand between `git` and `add`, so
135
- `git commit -m "add ."` and `printf "git add ."` pass. Callers pass heredoc-free text and must treat an
136
- exception as "no match": this check must never stop the guard's other rules.
246
+ `cmd /c ...` are read inside. Only git's global options may stand between `git` and its subcommand, so
247
+ `git commit -m "add ."` and `printf "git add ."` hold no `add`. `cwd` follows `cd`/`Set-Location` and
248
+ `git -C`; None means unknown (no directory checks). Linear in the command length.
137
249
  """
250
+ for _name, here, sub, args in _tool_calls(command, cwd, frozenset({"git"}), depth):
251
+ yield here, sub, args
252
+
253
+
254
+ def _tool_calls(command: str, cwd: str | None, programs: frozenset, depth: int = 0):
255
+ """Yield (program, cwd, subcommand, args) for every run of a program in `programs`, found as _git_calls finds
256
+ git (separators, keywords, wrappers, wrapped shells, `cd`). For git, `subcommand` is the word after git's global
257
+ options. For another program it is None, and `args` holds every word up to the next separator."""
138
258
  if depth > 3:
139
- return False
259
+ return
140
260
  text = re.sub(r"\\\r?\n|`\r?\n", " ", command) # line continuations (bash \, PowerShell `)
141
261
  tokens = _ga_tokens(text)
142
262
  i, start = 0, True
@@ -154,7 +274,7 @@ def git_add_everything(command: str, depth: int = 0) -> bool:
154
274
  name = _ga_name(tok)
155
275
  if name in _GA_WRAPPERS: # skip the wrapper's own flags and values up to the real command
156
276
  i += 1
157
- while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in ({"git"} | _GA_SHELLS):
277
+ while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in (programs | _GA_SHELLS):
158
278
  i += 1
159
279
  continue
160
280
  start = False
@@ -166,37 +286,295 @@ def git_add_everything(command: str, depth: int = 0) -> bool:
166
286
  end = next((k for k, t in enumerate(rest) if _ga_sep(t)), len(rest))
167
287
  if end == 0: # `bash -c` with nothing after it
168
288
  break
169
- inner = " ".join(rest[:end]) if name in _GA_JOINING_SHELLS else rest[0]
170
- if git_add_everything(inner, depth + 1):
171
- return True
289
+ # cmd and pwsh run every word after the flag; quote each again so `"my dir"` stays one word.
290
+ joined = name in _GA_JOINING_SHELLS and end > 1
291
+ inner = " ".join(_ga_quote(t) for t in rest[:end]) if joined else rest[0]
292
+ yield from _tool_calls(inner, cwd, programs, depth + 1)
172
293
  break
173
294
  j += 1
174
295
  i = j
175
296
  continue
297
+ if name in _GA_CD: # `cd dir`, `Set-Location -Path dir`, `cd /d dir` (cmd)
298
+ k = i + 1
299
+ while k < len(tokens) and (tokens[k].startswith("-") or tokens[k].lower() == "/d") and not _ga_sep(tokens[k]):
300
+ k += 1
301
+ cwd = _ga_cd(cwd, tokens[k] if k < len(tokens) else "")
302
+ i = k
303
+ continue
304
+ if name not in programs:
305
+ i += 1
306
+ continue
176
307
  if name != "git":
308
+ end = next((k for k in range(i + 1, len(tokens)) if _ga_sep(tokens[k])), len(tokens))
309
+ yield name, cwd, None, tokens[i + 1:end]
177
310
  i += 1
178
311
  continue
179
- j = i + 1
312
+ here, work_tree, j = cwd, "", i + 1
180
313
  while j < len(tokens) and tokens[j].startswith("-") and not _ga_sep(tokens[j]):
181
- j += 2 if tokens[j] in _GA_GIT_VALUE_OPTS else 1
182
- if j >= len(tokens) or tokens[j] != "add":
314
+ opt, value = tokens[j], tokens[j + 1] if j + 1 < len(tokens) else ""
315
+ if opt == "-C":
316
+ here = _ga_cd(here, value)
317
+ elif opt.startswith("-C"): # `-Cdir`, `-C"my dir"`
318
+ here = _ga_cd(here, opt[2:])
319
+ elif opt == "--work-tree":
320
+ work_tree = value
321
+ elif opt.startswith("--work-tree="):
322
+ work_tree = opt.split("=", 1)[1]
323
+ j += 2 if opt in _GA_GIT_VALUE_OPTS else 1
324
+ if work_tree: # outside the work tree, git reads paths from its top (--git-dir alone keeps the cwd)
325
+ top = _ga_cd(here, work_tree)
326
+ if top is None or here is None or not _ga_inside(here, top):
327
+ here = top
328
+ if j >= len(tokens) or _ga_sep(tokens[j]):
183
329
  i = j
184
330
  continue
185
- after_dashdash = False
186
- for arg in tokens[j + 1:]:
187
- if _ga_sep(arg):
188
- break
189
- if arg == "--":
190
- after_dashdash = True
191
- continue
192
- if after_dashdash and arg.startswith("-"): # `git add -- -A` names a file called -A
193
- continue
194
- if arg in _GA_EVERYTHING or re.fullmatch(r"-[a-zA-Z]*A[a-zA-Z]*", arg):
195
- return True
331
+ end = next((k for k in range(j + 1, len(tokens)) if _ga_sep(tokens[k])), len(tokens))
332
+ yield "git", here, tokens[j], tokens[j + 1:end]
196
333
  i = j + 1
334
+
335
+
336
+ def _plain_directory(cwd: str | None, path: str) -> bool:
337
+ """`path` names an existing directory that is not a nested repository (a submodule pointer is one path).
338
+ The repository that holds `cwd` is never exempt: `git add ..` from a subfolder stages everything."""
339
+ if cwd is None or not path or any(ch in path for ch in "*?[") or path.startswith(":"):
340
+ return False
341
+ full = os.path.join(cwd, os.path.expanduser(path))
342
+ if not os.path.isdir(full):
343
+ return False
344
+ return not os.path.exists(os.path.join(full, ".git")) or _ga_inside(cwd, full)
345
+
346
+
347
+ def _ga_file(cwd: str | None, path: str) -> bool:
348
+ """`path` is an existing file under `cwd` (an unknown cwd is never a file)."""
349
+ return bool(cwd and path) and os.path.isfile(os.path.join(cwd, os.path.expanduser(path)))
350
+
351
+
352
+ def _magic_split(path: str) -> tuple[set[str], str]:
353
+ """`:(top,glob)src` -> ({"top", "glob"}, "src"); `:/!src` -> ({"/", "!"}, "src"). Unclosed `:(` -> rest ''."""
354
+ if path.startswith(":("):
355
+ close = path.find(")")
356
+ if close < 0:
357
+ return {"unclosed"}, ""
358
+ return {w.strip().lower().split(":")[0] for w in path[2:close].split(",")}, path[close + 1:]
359
+ k = 1
360
+ while k < len(path) and path[k] in "/!^":
361
+ k += 1
362
+ rest = path[k + 1:] if path[k:k + 1] == ":" else path[k:]
363
+ return set(path[1:k]), rest
364
+
365
+
366
+ def _pathspec_reason(path: str, cwd: str | None, after_dashdash: bool) -> str:
367
+ """Why one `git add` pathspec can stage more than one named file, or ''."""
368
+ if path.startswith(":"): # pathspec magic: everything, a folder or a pattern unless it is plainly one file
369
+ magic, rest = _magic_split(path)
370
+ if rest in ("", ".", "./", ".\\", "*") or magic & {"!", "^", "exclude", "unclosed"}:
371
+ return _WHY_EVERYTHING
372
+ if "glob" in magic or set(rest) & set("*?["):
373
+ return _why_glob(path)
374
+ if _ga_file(cwd, rest):
375
+ return ""
376
+ return f"git add of a pathspec ({path}) that is not one existing file: {_SAFE_COMMIT}"
377
+ if set(path) & set("*?["): # a glob stages files you did not name; `-- <file>` may name a real file like a[1].py
378
+ return "" if after_dashdash and _ga_file(cwd, path) else _why_glob(path)
379
+ if _plain_directory(cwd, path):
380
+ return f"git add of a directory ({path}) stages every file under it: {_SAFE_COMMIT}"
381
+ return ""
382
+
383
+
384
+ def _why_glob(path: str) -> str:
385
+ return f"git add of a pattern ({path}) can stage files you did not change: name files, {_SAFE_COMMIT}"
386
+
387
+
388
+ def _add_reason(args: list[str], cwd: str | None) -> str:
389
+ """Why this `git add <args>` stages more than named files, or ''."""
390
+ after_dashdash, update, paths = False, False, []
391
+ for arg in args:
392
+ if arg == "--" and not after_dashdash:
393
+ after_dashdash = True
394
+ continue
395
+ if arg.startswith("-") and not after_dashdash:
396
+ long_name = arg[2:].split("=", 1)[0] if arg.startswith("--") else ""
397
+ if (arg in _GA_EVERYTHING or re.fullmatch(r"-[a-zA-Z]*A[a-zA-Z]*", arg)
398
+ or (long_name and "all".startswith(long_name))
399
+ or (len(long_name) >= 4 and "no-ignore-removal".startswith(long_name))):
400
+ return _WHY_EVERYTHING # git takes any unique prefix of a long option: --al, --no-ignore-rem
401
+ if re.fullmatch(r"-[a-zA-Z]*u[a-zA-Z]*", arg) or (long_name and "update".startswith(long_name)):
402
+ update = True
403
+ continue
404
+ if arg in _GA_EVERYTHING and not arg.startswith("-"): # `git add -- -A` names a file called -A
405
+ return _WHY_EVERYTHING
406
+ paths.append((arg, after_dashdash))
407
+ if update and not paths:
408
+ return _WHY_UPDATE
409
+ for path, named in paths:
410
+ why = _pathspec_reason(path, cwd, named)
411
+ if why:
412
+ return why
413
+ return ""
414
+
415
+
416
+ def _commit_all(args: list[str]) -> bool:
417
+ """True when `git commit <args>` carries -a / --all, also bundled (`-am`, `-qam`), not inside a value."""
418
+ skip = False
419
+ for arg in args:
420
+ if skip:
421
+ skip = False
422
+ continue
423
+ if arg == "--":
424
+ return False
425
+ if arg == "--all":
426
+ return True
427
+ if arg.startswith("--"):
428
+ skip = arg in _GC_VALUE_LONG
429
+ continue
430
+ if arg.startswith("-") and len(arg) > 1:
431
+ for pos, ch in enumerate(arg[1:]):
432
+ if ch == "a":
433
+ return True
434
+ if ch in _GC_ATTACHED_SHORT:
435
+ skip = ch in _GC_VALUE_SHORT and pos == len(arg) - 2
436
+ break
197
437
  return False
198
438
 
199
439
 
440
+ def git_staging_reason(command: str, root: str | None = None) -> str:
441
+ """The deny reason when `command` stages or commits more than named files, else ''.
442
+
443
+ Denied: `git add` of everything (-A, --all and its prefixes, --no-ignore-removal, ., :/, *),
444
+ `git add -u`/`--update` with no pathspec, `git commit -a`/`--all` (bundled too), a glob or a magic
445
+ pathspec that is not plainly one existing file, and `git add <dir>` for an existing directory under `root`
446
+ (the command's cwd; `cd`, `cd /d`, `git -C`, `-C<dir>` and `--work-tree` in the command are followed). In a shared checkout each of these
447
+ sweeps in files a peer staged or changed (audit #3). Callers pass heredoc-free text and must treat an
448
+ exception as "no match": this check must never stop the guard's other rules.
449
+ """
450
+ for cwd, sub, args in _git_calls(command, root):
451
+ if sub == "add":
452
+ why = _add_reason(args, cwd)
453
+ if why:
454
+ return why
455
+ elif sub == "commit" and _commit_all(args):
456
+ return _WHY_COMMIT_ALL
457
+ return ""
458
+
459
+
460
+ def git_add_everything(command: str, depth: int = 0) -> bool:
461
+ """True when `command` runs `git add` with -A/--all (also bundled, e.g. -Av), `.`, `./`, `:/` or `*`."""
462
+ return any(sub == "add" and _add_reason(args, None) == _WHY_EVERYTHING
463
+ for _cwd, sub, args in _git_calls(command, None, depth))
464
+
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
+
200
578
  # Copied from hooks/guard.py: a heredoc body that only carries data is not a command.
201
579
  _HEREDOC_MARKER = re.compile(
202
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]*))"
@@ -261,14 +639,332 @@ def _without_heredoc_bodies(command: str) -> str:
261
639
  return "".join(kept)
262
640
 
263
641
 
264
- def _git_add_everything_safe(text: str) -> bool:
265
- """git_add_everything, but an error is "no match": one rule must never switch off the rest of the guard."""
642
+ def _git_staging_safe(text: str, root: str | None) -> str:
643
+ """git_staging_reason, but an error is "no match": one rule must never switch off the rest of the guard."""
644
+ try:
645
+ return git_staging_reason(text, root)
646
+ except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
647
+ return ""
648
+
649
+
650
+ # ── headless sessions cannot publish (robustness #4) ──
651
+ # A runner session (CADDIS_HEADLESS=1) can be prompt-injected through a plan or a diff. The runner pushes, opens the
652
+ # PR and merges by itself; a session never needs to. The real boundary is the credential: the runner gives its
653
+ # sessions no GH_TOKEN or NPM_TOKEN. This rule covers the machine's own gh keyring and git credential logins, which
654
+ # a session can still reach. Like the staging rule it uses the tokenizer, so wrapped shells, `cd` and `git -C`
655
+ # count. Known limits (it is not a boundary): a git or gh alias, a script file that pushes, a program it does not name.
656
+ _WHY_HEADLESS = "headless sessions cannot publish; the runner does that"
657
+ _PUBLISH_TOOLS = frozenset({"git", "gh", "npm", "pnpm", "yarn", "npx", "twine", "cargo", "python", "python3",
658
+ "py"})
659
+ _GH_VALUE_FLAGS = {"-R", "--repo", "-H", "--header", "-q", "--jq", "-t", "--template", "-p", "--preview",
660
+ "--hostname", "--cache"}
661
+ _GH_FIELD_FLAGS = {"-f", "-F", "--field", "--raw-field", "--input"}
662
+ _GIT_PUSHES = {"push", "send-pack", "http-push"} # `git subtree push` is checked on its own
663
+ _GH_WRITES = [["pr", "merge"], ["pr", "create"], ["pr", "comment"], ["issue", "create"], ["issue", "comment"],
664
+ ["repo", "create"], ["repo", "delete"], ["repo", "edit"], ["repo", "rename"], ["repo", "archive"],
665
+ ["secret", "set"], ["secret", "delete"], ["variable", "set"], ["variable", "delete"]]
666
+ _NPM_WRITES = {"publish", "unpublish", "deprecate", "dist-tag", "dist-tags", "owner", "access"}
667
+ _NPM_VALUE_FLAGS = {"--registry", "--userconfig", "--prefix", "-w", "--workspace", "--otp", "--tag", "--access",
668
+ "--cache", "-C", "--cwd", "--dir"}
669
+
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
+
677
+ def _publish_allowed_headless() -> bool:
678
+ """False only in a headless session without CADDIS_ALLOW_PUBLISH=1."""
679
+ if not _headless():
680
+ return True
681
+ return str(os.environ.get(f"{_ENV_PREFIX}_ALLOW_PUBLISH", "")).strip() == "1"
682
+
683
+
684
+ def _gh_publish(args: list[str]) -> str:
685
+ """The gh call that publishes, or '': pr merge/create, release, workflow run, and a write through gh api."""
686
+ words, method, fields, i = [], "", False, 0
687
+ while i < len(args):
688
+ arg = args[i]
689
+ if arg in ("-X", "--method"):
690
+ method, i = (args[i + 1] if i + 1 < len(args) else "?"), i + 2
691
+ continue
692
+ if arg in _GH_FIELD_FLAGS:
693
+ fields, i = True, i + 2
694
+ continue
695
+ if arg in _GH_VALUE_FLAGS:
696
+ i += 2
697
+ continue
698
+ if arg.startswith("--method="):
699
+ method = arg.split("=", 1)[1]
700
+ elif arg.startswith("-X"):
701
+ method = arg[2:]
702
+ elif arg.startswith(("--field=", "--raw-field=", "--input=")) or arg[:2] in ("-f", "-F"):
703
+ fields = True
704
+ elif not arg.startswith("-"):
705
+ words.append(arg.lower())
706
+ i += 1
707
+ if words[:2] in _GH_WRITES:
708
+ return f"gh {words[0]} {words[1]}"
709
+ if words[:1] == ["release"]:
710
+ return "gh release"
711
+ if words[:2] == ["workflow", "run"]:
712
+ return "gh workflow run"
713
+ if words[:1] == ["api"]:
714
+ verb = method.strip().upper()
715
+ if verb and verb != "GET":
716
+ return f"gh api -X {verb}"
717
+ if fields and verb != "GET":
718
+ return "gh api with fields (a POST)"
719
+ if words[1:2] == ["graphql"]:
720
+ return "gh api graphql"
721
+ return ""
722
+
723
+
724
+ def _package_publish(name: str, args: list[str]) -> str:
725
+ """The package-registry call that publishes, or ''. `npx npm publish` and `python -m twine upload` count."""
726
+ if name in ("python", "python3", "py"):
727
+ at = args.index("-m") if "-m" in args[:-1] else -1
728
+ return _package_publish("twine", args[at + 2:]) if at >= 0 and args[at + 1].lower() == "twine" else ""
729
+ words, i = [], 0
730
+ while i < len(args):
731
+ if args[i] in _NPM_VALUE_FLAGS:
732
+ i += 2
733
+ continue
734
+ if not args[i].startswith("-"):
735
+ words.append(args[i].lower())
736
+ i += 1
737
+ if name == "npx":
738
+ tool = words[0] if words[:1] in (["npm"], ["pnpm"], ["yarn"]) else ""
739
+ return _package_publish(tool, args[[a.lower() for a in args].index(tool) + 1:]) if tool else ""
740
+ if name in ("npm", "pnpm", "yarn") and words[:1] and words[0] in _NPM_WRITES:
741
+ return f"{name} {words[0]}"
742
+ if name == "yarn" and words[:2] == ["npm", "publish"]:
743
+ return "yarn npm publish"
744
+ if (name, words[:1]) in (("twine", ["upload"]), ("cargo", ["publish"])):
745
+ return f"{name} {words[0]}"
746
+ return ""
747
+
748
+
749
+ def headless_publish_reason(command: str) -> str:
750
+ """The deny reason when `command` pushes, merges or publishes, else ''. It does not read the environment:
751
+ callers check _publish_allowed_headless() first, and pass heredoc-free text."""
752
+ for name, _cwd, sub, args in _tool_calls(command, None, _PUBLISH_TOOLS):
753
+ if name == "git":
754
+ verb = sub.lower()
755
+ if verb in _GIT_PUSHES:
756
+ what = f"git {verb}"
757
+ elif verb == "subtree" and "push" in (a.lower() for a in args):
758
+ what = "git subtree push"
759
+ else:
760
+ what = ""
761
+ elif name == "gh":
762
+ what = _gh_publish(args)
763
+ else:
764
+ what = _package_publish(name, args)
765
+ if what:
766
+ return f"{_WHY_HEADLESS} ({what})"
767
+ return ""
768
+
769
+
770
+ def _headless_publish_safe(text: str) -> str:
771
+ """headless_publish_reason in a headless session, else ''. An error is "no match", as for staging: one rule must
772
+ never switch off the rest of the guard (the credential, not this rule, is the boundary)."""
773
+ if _publish_allowed_headless():
774
+ return ""
266
775
  try:
267
- return git_add_everything(text)
776
+ return headless_publish_reason(text)
268
777
  except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
778
+ return ""
779
+
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:
269
944
  return False
270
945
 
271
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
+
272
968
  def _strip_quotes(s: str) -> str:
273
969
  """Drop quotes so quoted flags/targets still match (rm '-rf' \"/\" → rm -rf /)."""
274
970
  return s.replace('"', "").replace("'", "")
@@ -353,12 +1049,145 @@ def _catastrophic_statement(c: str) -> str:
353
1049
  return ""
354
1050
 
355
1051
 
356
- def classify_bash(command: str) -> tuple[str, str]:
357
- """Risk tier (deny|ask|allow) + reason for a shell command."""
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
+
1166
+ def classify_bash(command: str, root: str | None = None) -> tuple[str, str]:
1167
+ """Risk tier (deny|ask|allow) + reason for a shell command. ``root`` is the cwd the command runs in
1168
+ (default: this process's cwd); `git add <dir>` resolves against it."""
358
1169
  c = command.strip()
1170
+ data_free = _without_heredoc_bodies(c)
359
1171
  cl = c.lower()
360
1172
  n = _strip_quotes(c) # quote-normalized: defeats `rm '-rf' "/"` evasion
361
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
+
362
1191
  rm_rf = _is_rm_recursive_force(n)
363
1192
  find_delete = bool(re.search(_FIND_DELETE, nl))
364
1193
  win_del = _is_win_recursive_delete(n)
@@ -373,10 +1202,22 @@ def classify_bash(command: str) -> tuple[str, str]:
373
1202
  return "deny", "fork bomb"
374
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):
375
1204
  return "deny", "writes directly to a disk device"
376
- if _git_add_everything_safe(_without_heredoc_bodies(c)):
377
- return "deny", "git add of everything (-A, --all, . or :/): stage named files only"
1205
+ _staging = _git_staging_safe(data_free, os.getcwd() if root is None else root)
1206
+ if _staging:
1207
+ return "deny", _staging
1208
+ _publish = _headless_publish_safe(data_free)
1209
+ if _publish:
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}"
378
1214
 
379
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
380
1221
  if rm_rf:
381
1222
  return "ask", "recursive force-delete (rm -rf)"
382
1223
  if find_delete:
@@ -403,12 +1244,107 @@ def classify_bash(command: str) -> tuple[str, str]:
403
1244
  return "allow", ""
404
1245
 
405
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
+
406
1341
  # ── agy tool-shape mapping ───────────────────────────────────────────────────
407
1342
  # The shell tool: step type CORTEX_STEP_TYPE_RUN_COMMAND -> "run_command". The command lives at
408
1343
  # args.CommandLine. Extra spellings are accepted so a step-type rename can't silently un-guard the
409
1344
  # shell (a missed tool name = no guard at all, which is the failure we most want to avoid).
410
1345
  _COMMAND_TOOLS = {"run_command", "runcommand", "run_terminal_command", "bash", "shell", "terminal"}
411
1346
  _COMMAND_ARG_KEYS = ("CommandLine", "commandLine", "command_line", "command", "Command")
1347
+ _CWD_ARG_KEYS = ("Cwd", "cwd", "WorkingDirectory", "workingDirectory")
412
1348
 
413
1349
  # Write-ish tool detection is heuristic until the live check pins agy's file-edit tool name.
414
1350
  _WRITE_NAME_RE = re.compile(r"(edit|write|create|replace|patch|append)", re.I)
@@ -417,6 +1353,11 @@ _PATH_ARG_KEYS = ("FilePath", "filePath", "file_path", "AbsolutePath", "absolute
417
1353
  "TargetFile", "targetFile", "Path", "path")
418
1354
 
419
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
+
420
1361
  def _first_str(args: dict, keys) -> str:
421
1362
  for k in keys:
422
1363
  v = args.get(k)
@@ -425,7 +1366,7 @@ def _first_str(args: dict, keys) -> str:
425
1366
  return ""
426
1367
 
427
1368
 
428
- def decide(tool_name: str, args, allow_patterns: list[str]) -> tuple[str, str]:
1369
+ def decide(tool_name: str, args, allow_patterns: list[str], root: str | None = None) -> tuple[str, str]:
429
1370
  """Top-level decision. Classify FIRST; the allow escape hatch may only DOWNGRADE an `ask` to
430
1371
  `allow` (never override a `deny`), and it matches the command/path value — not the JSON envelope —
431
1372
  so a key name like "command" can't disable the guard. Never raises on a malformed payload."""
@@ -435,8 +1376,9 @@ def decide(tool_name: str, args, allow_patterns: list[str]) -> tuple[str, str]:
435
1376
 
436
1377
  if name in _COMMAND_TOOLS:
437
1378
  target = _first_str(args, _COMMAND_ARG_KEYS)
438
- tier, reason = classify_bash(target) if target else ("allow", "")
439
- elif _WRITE_NAME_RE.search(name) and not _READONLY_NAME_RE.search(name):
1379
+ cwd = _first_str(args, _CWD_ARG_KEYS) or root # the command's own cwd, else the workspace root
1380
+ tier, reason = classify_bash(target, cwd) if target else ("allow", "")
1381
+ elif _is_write_tool(name):
440
1382
  target = _first_str(args, _PATH_ARG_KEYS)
441
1383
  tier, reason = classify_write(target) if target else ("allow", "")
442
1384
  else:
@@ -477,31 +1419,35 @@ def _load_allow(root: str) -> list[str]:
477
1419
  return [str(x) for x in allow] if isinstance(allow, list) else []
478
1420
 
479
1421
 
480
- def guard_disabled(root: str) -> bool:
481
- """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
482
1424
 
483
- Precedence: the env var wins (global); otherwise the per-repo `[guard]` table — `enabled = false`
484
- or `mode = "off"`. Any parse problem → not disabled (fail safe). Env var: CADDIS_GUARD_DISABLED."""
485
- _disabled = os.environ.get(f"{_ENV_PREFIX}_GUARD_DISABLED", "")
486
- if _disabled.strip().lower() in _TRUTHY:
487
- return True
488
- section = _load_guard_config(root)
489
- if section.get("enabled") is False:
490
- return True
491
- if str(section.get("mode", "")).strip().lower() == "off":
492
- return True
493
- return False
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"
1429
+
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()
494
1437
 
495
1438
 
496
1439
  def deny_only(root: str) -> bool:
497
- """True when the guard keeps DENY (silent hard-block) but suppresses the ASK tier (ask -> allow =
498
- silence), so it NEVER prompts — for autonomous / no-prompt workflows that still want a catastrophe
499
- 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."""
500
1446
  _mode = os.environ.get(f"{_ENV_PREFIX}_GUARD_MODE", "")
501
- if _mode.strip().lower() == "deny-only":
1447
+ if _mode.strip().lower() == "deny-only" or (_env_off() and _headless()):
502
1448
  return True
503
1449
  section = _load_guard_config(root)
504
- return str(section.get("mode", "")).strip().lower() == "deny-only"
1450
+ return _config_off(section) or str(section.get("mode", "")).strip().lower() == "deny-only"
505
1451
 
506
1452
 
507
1453
  # ── hook I/O glue (agy envelope) ─────────────────────────────────────────────
@@ -558,6 +1504,23 @@ def _workspace_roots(data):
558
1504
  return []
559
1505
  return [str(r) for r in roots if isinstance(r, (str, bytes)) and str(r).strip()]
560
1506
 
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)
1522
+
1523
+
561
1524
  def main() -> None:
562
1525
  tier, reason = "allow", ""
563
1526
  try:
@@ -567,37 +1530,47 @@ def main() -> None:
567
1530
  _reconfig(encoding="utf-8")
568
1531
  except Exception as _exc:
569
1532
  _hook_note("agy stdout utf-8 reconfigure", _exc)
1533
+ # Silent ONLY for a payload it cannot read: there is no call to judge.
570
1534
  try:
571
1535
  data = json.load(sys.stdin)
572
- except Exception:
573
- 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
574
1539
  if not isinstance(data, dict):
575
1540
  return
576
1541
  call = data.get("toolCall")
577
1542
  if not isinstance(call, dict):
578
1543
  return
1544
+ name, args = str(call.get("name") or ""), call.get("args")
579
1545
  roots = _workspace_roots(data) or [os.getcwd()]
580
-
581
- # Every per-repo guard setting RELAXES the guard: `enabled = false` bypasses all
582
- # tiers, `mode = "deny-only"` drops the ask tier, and `[guard] allow` downgrades an
583
- # ask. Reading them from workspacePaths[0] meant one repo's relaxation silently
584
- # applied to every other repo in the workspace — repo A's kill switch unguarding
585
- # repo B. Require UNANIMITY instead: a relaxation applies only when every root asks
586
- # for it. One root is the common case and behaves exactly as before.
587
- if all(guard_disabled(r) for r in roots):
588
- return # kill switch: bypass every tier
589
-
590
- # The allow-list downgrades an ask, so with several roots take only the entries
591
- # present in ALL of them — the strictest reading, not the most permissive.
592
- allow = _load_allow(roots[0])
593
- for other in roots[1:]:
594
- allow = [a for a in allow if a in _load_allow(other)]
595
-
596
- tier, reason = decide(str(call.get("name") or ""), call.get("args"), allow)
597
- if tier == "ask" and all(deny_only(r) for r in roots):
598
- tier = "allow" # deny-only: keep catastrophe blocks, never force_ask (no plan interruption)
599
- except Exception:
600
- 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
601
1574
  finally:
602
1575
  try:
603
1576
  _emit(tier, reason)