@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.
- package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +378 -37
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +182 -4
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +6 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +58 -6
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +231 -167
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +226 -26
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +2 -1
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
- package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +3 -0
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +2 -1
- package/bundles/antigravity-plugin/session_end_agy.py +14 -1
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +13 -6
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +6 -3
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +2 -2
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +14 -1
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
- package/bundles/manifest.json +3 -3
- 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
|
|
130
|
-
"""
|
|
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
|
|
135
|
-
`git commit -m "add ."` and `printf "git add ."`
|
|
136
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
265
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|