@caddis/cli 0.4.54 → 0.4.56

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 (49) hide show
  1. package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
  2. package/bundles/antigravity-plugin/guard_agy.py +378 -37
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/repo_freshness.py +182 -4
  5. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
  6. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
  7. package/bundles/antigravity-plugin/scripts/caddis_gate.py +6 -1
  8. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +58 -6
  9. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
  10. package/bundles/antigravity-plugin/scripts/caddis_review.py +231 -167
  11. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +226 -26
  12. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +2 -1
  13. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
  14. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
  15. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
  16. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
  17. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
  18. package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
  19. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
  21. package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
  22. package/bundles/antigravity-plugin/scripts/oss_review.py +3 -0
  23. package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
  24. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +2 -1
  25. package/bundles/antigravity-plugin/session_end_agy.py +14 -1
  26. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +2 -1
  27. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +1 -1
  28. package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
  29. package/bundles/antigravity-plugin/skills/implement/SKILL.md +13 -6
  30. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +1 -1
  31. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
  32. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
  33. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
  34. package/bundles/antigravity-plugin/skills/ship/SKILL.md +6 -3
  35. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +2 -2
  37. package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/warm_start_agy.py +14 -1
  39. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  40. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
  41. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  42. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
  43. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +1 -1
  44. package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
  45. package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
  46. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
  47. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
  48. package/bundles/manifest.json +3 -3
  49. package/package.json +1 -1
@@ -42,6 +42,10 @@ import datetime
42
42
  import traceback
43
43
  from pathlib import Path
44
44
 
45
+ # No child process opens a console window on Windows. Inline (not scripts/no_window.py) because this
46
+ # file also runs as a standalone copy with no sibling modules.
47
+ _NO_WINDOW_KW = {"creationflags": 0x08000000} if os.name == "nt" else {}
48
+
45
49
  _DEBUG_LOG = Path.home() / ".caddis" / "statusline_debug.log"
46
50
  def _debug_log(msg: str):
47
51
  if not os.environ.get("CADDIS_STATUSLINE_DEBUG"):
@@ -223,7 +227,7 @@ def git_facts(cwd: str) -> dict:
223
227
  try:
224
228
  proc = subprocess.run(
225
229
  ["git", "-C", cwd, "--no-optional-locks", "status", "--porcelain=v2", "--branch"],
226
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=0.8,
230
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=0.8, **_NO_WINDOW_KW,
227
231
  )
228
232
  text = proc.stdout.decode("utf-8", "replace")
229
233
  except Exception:
@@ -307,8 +311,8 @@ def ram_facts():
307
311
 
308
312
  if sys.platform == "darwin":
309
313
  total = int(subprocess.run(["sysctl", "-n", "hw.memsize"], stdout=subprocess.PIPE,
310
- timeout=1.0).stdout.strip())
311
- vm = subprocess.run(["vm_stat"], stdout=subprocess.PIPE, timeout=1.0)
314
+ timeout=1.0, **_NO_WINDOW_KW).stdout.strip())
315
+ vm = subprocess.run(["vm_stat"], stdout=subprocess.PIPE, timeout=1.0, **_NO_WINDOW_KW)
312
316
  page = 4096
313
317
  free_pages = 0
314
318
  for line in vm.stdout.decode("utf-8", "replace").splitlines():
@@ -100,6 +100,18 @@ _GA_JOINING_SHELLS = {"cmd", "pwsh", "powershell"} # they run ALL the words aft
100
100
  _GA_SHELL_RUN_FLAGS = {"-c", "/c", "/k", "-command", "-commandwithargs"}
101
101
  _GA_GIT_VALUE_OPTS = {"-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env"}
102
102
  _GA_EVERYTHING = {"-A", "--all", ".", "./", ".\\", ":/", "*"}
103
+ _GA_CD = {"cd", "chdir", "pushd", "set-location", "sl"} # change the cwd the next `git add <path>` resolves against
104
+ # `git commit` short options that take the REST of their bundle as a value (`-ma` is the message "a"). The
105
+ # first five also take the next word when they end the bundle (`-m msg`); -S and -u take an attached value only.
106
+ _GC_VALUE_SHORT = set("mFCct")
107
+ _GC_ATTACHED_SHORT = _GC_VALUE_SHORT | set("Su")
108
+ _GC_VALUE_LONG = {"--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash", "--author",
109
+ "--date", "--template", "--cleanup", "--trailer", "--pathspec-from-file"}
110
+ _SAFE_COMMIT = 'stage named files only: `git add <file> ...`, then `git commit -m "..." -- <files>`'
111
+ _WHY_EVERYTHING = f"git add of everything (-A, --all, . or :/): {_SAFE_COMMIT}"
112
+ _WHY_UPDATE = f"git add -u stages every tracked change, a peer's too: {_SAFE_COMMIT}"
113
+ _WHY_COMMIT_ALL = f"git commit -a commits every tracked change, a peer's too: {_SAFE_COMMIT}"
114
+ _GA_QUOTE_CHARS = set(" \t\"';&|(){}$!\n") # a token holding one of these is quoted again for an inner pass
103
115
 
104
116
 
105
117
  def _ga_sep(token: str) -> bool:
@@ -115,7 +127,11 @@ def _ga_name(token: str) -> str:
115
127
 
116
128
  def _ga_tokens(text: str) -> list[str]:
117
129
  """Shell tokens, quotes removed (also mid-token: --work-tree="my dir"), backslashes kept (Windows paths).
118
- ; & | ( ) { } $ ! and newlines become separator tokens."""
130
+ ; & | ( ) { } $ ! and newlines become separator tokens.
131
+
132
+ Known limits (the guard catches accidents; it is not a security boundary, see test_known_limits_not_caught):
133
+ an escaped quote inside `bash -c "..."` (`\\"`), a PowerShell backtick escape (`` `-A ``, read as a line
134
+ break), and a git alias (`git -c alias.x=add x -A`) are not read as `git add`."""
119
135
  def lex(quotes: str) -> list[str]:
120
136
  lexer = shlex.shlex(text.replace("`", "\n"), posix=True, punctuation_chars=";&|(){}$!\n")
121
137
  lexer.whitespace, lexer.whitespace_split, lexer.escape, lexer.quotes = " \t\r", True, "", quotes
@@ -126,17 +142,45 @@ def _ga_tokens(text: str) -> list[str]:
126
142
  return lex("")
127
143
 
128
144
 
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 `*`.
145
+ def _ga_quote(token: str) -> str:
146
+ """`token` quoted so _ga_tokens reads it back as the same one token (a cmd/pwsh re-join keeps `"my dir"`)."""
147
+ if token and not set(token) & _GA_QUOTE_CHARS:
148
+ return token
149
+ return f"'{token}'" if '"' in token else f'"{token}"'
150
+
151
+
152
+ def _ga_inside(path: str, folder: str) -> bool:
153
+ """`path` is `folder` or below it (real paths, case-folded on Windows)."""
154
+ inner, outer = os.path.normcase(os.path.realpath(path)), os.path.normcase(os.path.realpath(folder))
155
+ return inner == outer or inner.startswith(outer.rstrip("\\/") + os.sep)
156
+
157
+
158
+ def _ga_cd(cwd: str | None, path: str) -> str | None:
159
+ """The cwd after `cd path` or `git -C path`; None (unknown) when either is unknown."""
160
+ if cwd is None or not path or _ga_sep(path):
161
+ return None
162
+ return os.path.join(cwd, os.path.expanduser(path))
163
+
164
+
165
+ def _git_calls(command: str, cwd: str | None, depth: int = 0):
166
+ """Yield (cwd, subcommand, args) for every git run in `command`.
131
167
 
132
168
  `git` must start a command (after a separator, a shell keyword such as `do`, VAR=value, or a wrapper such as
133
169
  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.
170
+ `cmd /c ...` are read inside. Only git's global options may stand between `git` and its subcommand, so
171
+ `git commit -m "add ."` and `printf "git add ."` hold no `add`. `cwd` follows `cd`/`Set-Location` and
172
+ `git -C`; None means unknown (no directory checks). Linear in the command length.
137
173
  """
174
+ for _name, here, sub, args in _tool_calls(command, cwd, frozenset({"git"}), depth):
175
+ yield here, sub, args
176
+
177
+
178
+ def _tool_calls(command: str, cwd: str | None, programs: frozenset, depth: int = 0):
179
+ """Yield (program, cwd, subcommand, args) for every run of a program in `programs`, found as _git_calls finds
180
+ git (separators, keywords, wrappers, wrapped shells, `cd`). For git, `subcommand` is the word after git's global
181
+ options. For another program it is None, and `args` holds every word up to the next separator."""
138
182
  if depth > 3:
139
- return False
183
+ return
140
184
  text = re.sub(r"\\\r?\n|`\r?\n", " ", command) # line continuations (bash \, PowerShell `)
141
185
  tokens = _ga_tokens(text)
142
186
  i, start = 0, True
@@ -154,7 +198,7 @@ def git_add_everything(command: str, depth: int = 0) -> bool:
154
198
  name = _ga_name(tok)
155
199
  if name in _GA_WRAPPERS: # skip the wrapper's own flags and values up to the real command
156
200
  i += 1
157
- while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in ({"git"} | _GA_SHELLS):
201
+ while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in (programs | _GA_SHELLS):
158
202
  i += 1
159
203
  continue
160
204
  start = False
@@ -166,37 +210,183 @@ def git_add_everything(command: str, depth: int = 0) -> bool:
166
210
  end = next((k for k, t in enumerate(rest) if _ga_sep(t)), len(rest))
167
211
  if end == 0: # `bash -c` with nothing after it
168
212
  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
213
+ # cmd and pwsh run every word after the flag; quote each again so `"my dir"` stays one word.
214
+ joined = name in _GA_JOINING_SHELLS and end > 1
215
+ inner = " ".join(_ga_quote(t) for t in rest[:end]) if joined else rest[0]
216
+ yield from _tool_calls(inner, cwd, programs, depth + 1)
172
217
  break
173
218
  j += 1
174
219
  i = j
175
220
  continue
221
+ if name in _GA_CD: # `cd dir`, `Set-Location -Path dir`, `cd /d dir` (cmd)
222
+ k = i + 1
223
+ while k < len(tokens) and (tokens[k].startswith("-") or tokens[k].lower() == "/d") and not _ga_sep(tokens[k]):
224
+ k += 1
225
+ cwd = _ga_cd(cwd, tokens[k] if k < len(tokens) else "")
226
+ i = k
227
+ continue
228
+ if name not in programs:
229
+ i += 1
230
+ continue
176
231
  if name != "git":
232
+ end = next((k for k in range(i + 1, len(tokens)) if _ga_sep(tokens[k])), len(tokens))
233
+ yield name, cwd, None, tokens[i + 1:end]
177
234
  i += 1
178
235
  continue
179
- j = i + 1
236
+ here, work_tree, j = cwd, "", i + 1
180
237
  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":
238
+ opt, value = tokens[j], tokens[j + 1] if j + 1 < len(tokens) else ""
239
+ if opt == "-C":
240
+ here = _ga_cd(here, value)
241
+ elif opt.startswith("-C"): # `-Cdir`, `-C"my dir"`
242
+ here = _ga_cd(here, opt[2:])
243
+ elif opt == "--work-tree":
244
+ work_tree = value
245
+ elif opt.startswith("--work-tree="):
246
+ work_tree = opt.split("=", 1)[1]
247
+ j += 2 if opt in _GA_GIT_VALUE_OPTS else 1
248
+ if work_tree: # outside the work tree, git reads paths from its top (--git-dir alone keeps the cwd)
249
+ top = _ga_cd(here, work_tree)
250
+ if top is None or here is None or not _ga_inside(here, top):
251
+ here = top
252
+ if j >= len(tokens) or _ga_sep(tokens[j]):
183
253
  i = j
184
254
  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
255
+ end = next((k for k in range(j + 1, len(tokens)) if _ga_sep(tokens[k])), len(tokens))
256
+ yield "git", here, tokens[j], tokens[j + 1:end]
196
257
  i = j + 1
258
+
259
+
260
+ def _plain_directory(cwd: str | None, path: str) -> bool:
261
+ """`path` names an existing directory that is not a nested repository (a submodule pointer is one path).
262
+ The repository that holds `cwd` is never exempt: `git add ..` from a subfolder stages everything."""
263
+ if cwd is None or not path or any(ch in path for ch in "*?[") or path.startswith(":"):
264
+ return False
265
+ full = os.path.join(cwd, os.path.expanduser(path))
266
+ if not os.path.isdir(full):
267
+ return False
268
+ return not os.path.exists(os.path.join(full, ".git")) or _ga_inside(cwd, full)
269
+
270
+
271
+ def _ga_file(cwd: str | None, path: str) -> bool:
272
+ """`path` is an existing file under `cwd` (an unknown cwd is never a file)."""
273
+ return bool(cwd and path) and os.path.isfile(os.path.join(cwd, os.path.expanduser(path)))
274
+
275
+
276
+ def _magic_split(path: str) -> tuple[set[str], str]:
277
+ """`:(top,glob)src` -> ({"top", "glob"}, "src"); `:/!src` -> ({"/", "!"}, "src"). Unclosed `:(` -> rest ''."""
278
+ if path.startswith(":("):
279
+ close = path.find(")")
280
+ if close < 0:
281
+ return {"unclosed"}, ""
282
+ return {w.strip().lower().split(":")[0] for w in path[2:close].split(",")}, path[close + 1:]
283
+ k = 1
284
+ while k < len(path) and path[k] in "/!^":
285
+ k += 1
286
+ rest = path[k + 1:] if path[k:k + 1] == ":" else path[k:]
287
+ return set(path[1:k]), rest
288
+
289
+
290
+ def _pathspec_reason(path: str, cwd: str | None, after_dashdash: bool) -> str:
291
+ """Why one `git add` pathspec can stage more than one named file, or ''."""
292
+ if path.startswith(":"): # pathspec magic: everything, a folder or a pattern unless it is plainly one file
293
+ magic, rest = _magic_split(path)
294
+ if rest in ("", ".", "./", ".\\", "*") or magic & {"!", "^", "exclude", "unclosed"}:
295
+ return _WHY_EVERYTHING
296
+ if "glob" in magic or set(rest) & set("*?["):
297
+ return _why_glob(path)
298
+ if _ga_file(cwd, rest):
299
+ return ""
300
+ return f"git add of a pathspec ({path}) that is not one existing file: {_SAFE_COMMIT}"
301
+ if set(path) & set("*?["): # a glob stages files you did not name; `-- <file>` may name a real file like a[1].py
302
+ return "" if after_dashdash and _ga_file(cwd, path) else _why_glob(path)
303
+ if _plain_directory(cwd, path):
304
+ return f"git add of a directory ({path}) stages every file under it: {_SAFE_COMMIT}"
305
+ return ""
306
+
307
+
308
+ def _why_glob(path: str) -> str:
309
+ return f"git add of a pattern ({path}) can stage files you did not change: name files, {_SAFE_COMMIT}"
310
+
311
+
312
+ def _add_reason(args: list[str], cwd: str | None) -> str:
313
+ """Why this `git add <args>` stages more than named files, or ''."""
314
+ after_dashdash, update, paths = False, False, []
315
+ for arg in args:
316
+ if arg == "--" and not after_dashdash:
317
+ after_dashdash = True
318
+ continue
319
+ if arg.startswith("-") and not after_dashdash:
320
+ long_name = arg[2:].split("=", 1)[0] if arg.startswith("--") else ""
321
+ if (arg in _GA_EVERYTHING or re.fullmatch(r"-[a-zA-Z]*A[a-zA-Z]*", arg)
322
+ or (long_name and "all".startswith(long_name))
323
+ or (len(long_name) >= 4 and "no-ignore-removal".startswith(long_name))):
324
+ return _WHY_EVERYTHING # git takes any unique prefix of a long option: --al, --no-ignore-rem
325
+ if re.fullmatch(r"-[a-zA-Z]*u[a-zA-Z]*", arg) or (long_name and "update".startswith(long_name)):
326
+ update = True
327
+ continue
328
+ if arg in _GA_EVERYTHING and not arg.startswith("-"): # `git add -- -A` names a file called -A
329
+ return _WHY_EVERYTHING
330
+ paths.append((arg, after_dashdash))
331
+ if update and not paths:
332
+ return _WHY_UPDATE
333
+ for path, named in paths:
334
+ why = _pathspec_reason(path, cwd, named)
335
+ if why:
336
+ return why
337
+ return ""
338
+
339
+
340
+ def _commit_all(args: list[str]) -> bool:
341
+ """True when `git commit <args>` carries -a / --all, also bundled (`-am`, `-qam`), not inside a value."""
342
+ skip = False
343
+ for arg in args:
344
+ if skip:
345
+ skip = False
346
+ continue
347
+ if arg == "--":
348
+ return False
349
+ if arg == "--all":
350
+ return True
351
+ if arg.startswith("--"):
352
+ skip = arg in _GC_VALUE_LONG
353
+ continue
354
+ if arg.startswith("-") and len(arg) > 1:
355
+ for pos, ch in enumerate(arg[1:]):
356
+ if ch == "a":
357
+ return True
358
+ if ch in _GC_ATTACHED_SHORT:
359
+ skip = ch in _GC_VALUE_SHORT and pos == len(arg) - 2
360
+ break
197
361
  return False
198
362
 
199
363
 
364
+ def git_staging_reason(command: str, root: str | None = None) -> str:
365
+ """The deny reason when `command` stages or commits more than named files, else ''.
366
+
367
+ Denied: `git add` of everything (-A, --all and its prefixes, --no-ignore-removal, ., :/, *),
368
+ `git add -u`/`--update` with no pathspec, `git commit -a`/`--all` (bundled too), a glob or a magic
369
+ pathspec that is not plainly one existing file, and `git add <dir>` for an existing directory under `root`
370
+ (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
371
+ sweeps in files a peer staged or changed (audit #3). Callers pass heredoc-free text and must treat an
372
+ exception as "no match": this check must never stop the guard's other rules.
373
+ """
374
+ for cwd, sub, args in _git_calls(command, root):
375
+ if sub == "add":
376
+ why = _add_reason(args, cwd)
377
+ if why:
378
+ return why
379
+ elif sub == "commit" and _commit_all(args):
380
+ return _WHY_COMMIT_ALL
381
+ return ""
382
+
383
+
384
+ def git_add_everything(command: str, depth: int = 0) -> bool:
385
+ """True when `command` runs `git add` with -A/--all (also bundled, e.g. -Av), `.`, `./`, `:/` or `*`."""
386
+ return any(sub == "add" and _add_reason(args, None) == _WHY_EVERYTHING
387
+ for _cwd, sub, args in _git_calls(command, None, depth))
388
+
389
+
200
390
  # Copied from hooks/guard.py: a heredoc body that only carries data is not a command.
201
391
  _HEREDOC_MARKER = re.compile(
202
392
  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,12 +451,139 @@ def _without_heredoc_bodies(command: str) -> str:
261
451
  return "".join(kept)
262
452
 
263
453
 
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."""
454
+ def _git_staging_safe(text: str, root: str | None) -> str:
455
+ """git_staging_reason, but an error is "no match": one rule must never switch off the rest of the guard."""
266
456
  try:
267
- return git_add_everything(text)
457
+ return git_staging_reason(text, root)
268
458
  except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
269
- return False
459
+ return ""
460
+
461
+
462
+ # ── headless sessions cannot publish (robustness #4) ──
463
+ # A runner session (CADDIS_HEADLESS=1) can be prompt-injected through a plan or a diff. The runner pushes, opens the
464
+ # PR and merges by itself; a session never needs to. The real boundary is the credential: the runner gives its
465
+ # sessions no GH_TOKEN or NPM_TOKEN. This rule covers the machine's own gh keyring and git credential logins, which
466
+ # a session can still reach. Like the staging rule it uses the tokenizer, so wrapped shells, `cd` and `git -C`
467
+ # count. Known limits (it is not a boundary): a git or gh alias, a script file that pushes, a program it does not name.
468
+ _WHY_HEADLESS = "headless sessions cannot publish; the runner does that"
469
+ _PUBLISH_TOOLS = frozenset({"git", "gh", "npm", "pnpm", "yarn", "npx", "twine", "cargo", "python", "python3",
470
+ "py"})
471
+ _GH_VALUE_FLAGS = {"-R", "--repo", "-H", "--header", "-q", "--jq", "-t", "--template", "-p", "--preview",
472
+ "--hostname", "--cache"}
473
+ _GH_FIELD_FLAGS = {"-f", "-F", "--field", "--raw-field", "--input"}
474
+ _GIT_PUSHES = {"push", "send-pack", "http-push"} # `git subtree push` is checked on its own
475
+ _GH_WRITES = [["pr", "merge"], ["pr", "create"], ["pr", "comment"], ["issue", "create"], ["issue", "comment"],
476
+ ["repo", "create"], ["repo", "delete"], ["repo", "edit"], ["repo", "rename"], ["repo", "archive"],
477
+ ["secret", "set"], ["secret", "delete"], ["variable", "set"], ["variable", "delete"]]
478
+ _NPM_WRITES = {"publish", "unpublish", "deprecate", "dist-tag", "dist-tags", "owner", "access"}
479
+ _NPM_VALUE_FLAGS = {"--registry", "--userconfig", "--prefix", "-w", "--workspace", "--otp", "--tag", "--access",
480
+ "--cache", "-C", "--cwd", "--dir"}
481
+
482
+
483
+ 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"):
488
+ return True
489
+ return str(os.environ.get(f"{_ENV_PREFIX}_ALLOW_PUBLISH", "")).strip() == "1"
490
+
491
+
492
+ def _gh_publish(args: list[str]) -> str:
493
+ """The gh call that publishes, or '': pr merge/create, release, workflow run, and a write through gh api."""
494
+ words, method, fields, i = [], "", False, 0
495
+ while i < len(args):
496
+ arg = args[i]
497
+ if arg in ("-X", "--method"):
498
+ method, i = (args[i + 1] if i + 1 < len(args) else "?"), i + 2
499
+ continue
500
+ if arg in _GH_FIELD_FLAGS:
501
+ fields, i = True, i + 2
502
+ continue
503
+ if arg in _GH_VALUE_FLAGS:
504
+ i += 2
505
+ continue
506
+ if arg.startswith("--method="):
507
+ method = arg.split("=", 1)[1]
508
+ elif arg.startswith("-X"):
509
+ method = arg[2:]
510
+ elif arg.startswith(("--field=", "--raw-field=", "--input=")) or arg[:2] in ("-f", "-F"):
511
+ fields = True
512
+ elif not arg.startswith("-"):
513
+ words.append(arg.lower())
514
+ i += 1
515
+ if words[:2] in _GH_WRITES:
516
+ return f"gh {words[0]} {words[1]}"
517
+ if words[:1] == ["release"]:
518
+ return "gh release"
519
+ if words[:2] == ["workflow", "run"]:
520
+ return "gh workflow run"
521
+ if words[:1] == ["api"]:
522
+ verb = method.strip().upper()
523
+ if verb and verb != "GET":
524
+ return f"gh api -X {verb}"
525
+ if fields and verb != "GET":
526
+ return "gh api with fields (a POST)"
527
+ if words[1:2] == ["graphql"]:
528
+ return "gh api graphql"
529
+ return ""
530
+
531
+
532
+ def _package_publish(name: str, args: list[str]) -> str:
533
+ """The package-registry call that publishes, or ''. `npx npm publish` and `python -m twine upload` count."""
534
+ if name in ("python", "python3", "py"):
535
+ at = args.index("-m") if "-m" in args[:-1] else -1
536
+ return _package_publish("twine", args[at + 2:]) if at >= 0 and args[at + 1].lower() == "twine" else ""
537
+ words, i = [], 0
538
+ while i < len(args):
539
+ if args[i] in _NPM_VALUE_FLAGS:
540
+ i += 2
541
+ continue
542
+ if not args[i].startswith("-"):
543
+ words.append(args[i].lower())
544
+ i += 1
545
+ if name == "npx":
546
+ tool = words[0] if words[:1] in (["npm"], ["pnpm"], ["yarn"]) else ""
547
+ return _package_publish(tool, args[[a.lower() for a in args].index(tool) + 1:]) if tool else ""
548
+ if name in ("npm", "pnpm", "yarn") and words[:1] and words[0] in _NPM_WRITES:
549
+ return f"{name} {words[0]}"
550
+ if name == "yarn" and words[:2] == ["npm", "publish"]:
551
+ return "yarn npm publish"
552
+ if (name, words[:1]) in (("twine", ["upload"]), ("cargo", ["publish"])):
553
+ return f"{name} {words[0]}"
554
+ return ""
555
+
556
+
557
+ def headless_publish_reason(command: str) -> str:
558
+ """The deny reason when `command` pushes, merges or publishes, else ''. It does not read the environment:
559
+ callers check _publish_allowed_headless() first, and pass heredoc-free text."""
560
+ for name, _cwd, sub, args in _tool_calls(command, None, _PUBLISH_TOOLS):
561
+ if name == "git":
562
+ verb = sub.lower()
563
+ if verb in _GIT_PUSHES:
564
+ what = f"git {verb}"
565
+ elif verb == "subtree" and "push" in (a.lower() for a in args):
566
+ what = "git subtree push"
567
+ else:
568
+ what = ""
569
+ elif name == "gh":
570
+ what = _gh_publish(args)
571
+ else:
572
+ what = _package_publish(name, args)
573
+ if what:
574
+ return f"{_WHY_HEADLESS} ({what})"
575
+ return ""
576
+
577
+
578
+ def _headless_publish_safe(text: str) -> str:
579
+ """headless_publish_reason in a headless session, else ''. An error is "no match", as for staging: one rule must
580
+ never switch off the rest of the guard (the credential, not this rule, is the boundary)."""
581
+ if _publish_allowed_headless():
582
+ return ""
583
+ try:
584
+ return headless_publish_reason(text)
585
+ except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
586
+ return ""
270
587
 
271
588
 
272
589
  def _strip_quotes(s: str) -> str:
@@ -353,8 +670,9 @@ def _catastrophic_statement(c: str) -> str:
353
670
  return ""
354
671
 
355
672
 
356
- def classify_bash(command: str) -> tuple[str, str]:
357
- """Risk tier (deny|ask|allow) + reason for a shell command."""
673
+ def classify_bash(command: str, root: str | None = None) -> tuple[str, str]:
674
+ """Risk tier (deny|ask|allow) + reason for a shell command. ``root`` is the cwd the command runs in
675
+ (default: this process's cwd); `git add <dir>` resolves against it."""
358
676
  c = command.strip()
359
677
  cl = c.lower()
360
678
  n = _strip_quotes(c) # quote-normalized: defeats `rm '-rf' "/"` evasion
@@ -373,8 +691,13 @@ def classify_bash(command: str) -> tuple[str, str]:
373
691
  return "deny", "fork bomb"
374
692
  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
693
  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"
694
+ data_free = _without_heredoc_bodies(c)
695
+ _staging = _git_staging_safe(data_free, os.getcwd() if root is None else root)
696
+ if _staging:
697
+ return "deny", _staging
698
+ _publish = _headless_publish_safe(data_free)
699
+ if _publish:
700
+ return "deny", _publish
378
701
 
379
702
  # ── ask: destructive-but-legit ──
380
703
  if rm_rf:
@@ -409,6 +732,7 @@ def classify_bash(command: str) -> tuple[str, str]:
409
732
  # shell (a missed tool name = no guard at all, which is the failure we most want to avoid).
410
733
  _COMMAND_TOOLS = {"run_command", "runcommand", "run_terminal_command", "bash", "shell", "terminal"}
411
734
  _COMMAND_ARG_KEYS = ("CommandLine", "commandLine", "command_line", "command", "Command")
735
+ _CWD_ARG_KEYS = ("Cwd", "cwd", "WorkingDirectory", "workingDirectory")
412
736
 
413
737
  # Write-ish tool detection is heuristic until the live check pins agy's file-edit tool name.
414
738
  _WRITE_NAME_RE = re.compile(r"(edit|write|create|replace|patch|append)", re.I)
@@ -425,7 +749,7 @@ def _first_str(args: dict, keys) -> str:
425
749
  return ""
426
750
 
427
751
 
428
- def decide(tool_name: str, args, allow_patterns: list[str]) -> tuple[str, str]:
752
+ def decide(tool_name: str, args, allow_patterns: list[str], root: str | None = None) -> tuple[str, str]:
429
753
  """Top-level decision. Classify FIRST; the allow escape hatch may only DOWNGRADE an `ask` to
430
754
  `allow` (never override a `deny`), and it matches the command/path value — not the JSON envelope —
431
755
  so a key name like "command" can't disable the guard. Never raises on a malformed payload."""
@@ -435,7 +759,8 @@ def decide(tool_name: str, args, allow_patterns: list[str]) -> tuple[str, str]:
435
759
 
436
760
  if name in _COMMAND_TOOLS:
437
761
  target = _first_str(args, _COMMAND_ARG_KEYS)
438
- tier, reason = classify_bash(target) if target else ("allow", "")
762
+ cwd = _first_str(args, _CWD_ARG_KEYS) or root # the command's own cwd, else the workspace root
763
+ tier, reason = classify_bash(target, cwd) if target else ("allow", "")
439
764
  elif _WRITE_NAME_RE.search(name) and not _READONLY_NAME_RE.search(name):
440
765
  target = _first_str(args, _PATH_ARG_KEYS)
441
766
  tier, reason = classify_write(target) if target else ("allow", "")
@@ -558,6 +883,13 @@ def _workspace_roots(data):
558
883
  return []
559
884
  return [str(r) for r in roots if isinstance(r, (str, bytes)) and str(r).strip()]
560
885
 
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()))
891
+
892
+
561
893
  def main() -> None:
562
894
  tier, reason = "allow", ""
563
895
  try:
@@ -585,7 +917,12 @@ def main() -> None:
585
917
  # repo B. Require UNANIMITY instead: a relaxation applies only when every root asks
586
918
  # for it. One root is the common case and behaves exactly as before.
587
919
  if all(guard_disabled(r) for r in roots):
588
- return # kill switch: bypass every tier
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
589
926
 
590
927
  # The allow-list downgrades an ask, so with several roots take only the entries
591
928
  # present in ALL of them — the strictest reading, not the most permissive.
@@ -593,7 +930,11 @@ def main() -> None:
593
930
  for other in roots[1:]:
594
931
  allow = [a for a in allow if a in _load_allow(other)]
595
932
 
596
- tier, reason = decide(str(call.get("name") or ""), call.get("args"), allow)
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))
597
938
  if tier == "ask" and all(deny_only(r) for r in roots):
598
939
  tier = "allow" # deny-only: keep catastrophe blocks, never force_ask (no plan interruption)
599
940
  except Exception:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.140",
3
+ "version": "1.3.142",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }