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