@caddis/cli 0.4.55 → 0.4.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +1056 -83
- package/bundles/antigravity-plugin/hooks.json +2 -1
- 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_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
- 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 +303 -79
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
- package/bundles/antigravity-plugin/session_end_agy.py +27 -11
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
- 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 +10 -6
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- 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 +32 -10
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
- 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/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 +10 -6
- 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
|
@@ -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).
|
|
@@ -100,6 +176,18 @@ _GA_JOINING_SHELLS = {"cmd", "pwsh", "powershell"} # they run ALL the words aft
|
|
|
100
176
|
_GA_SHELL_RUN_FLAGS = {"-c", "/c", "/k", "-command", "-commandwithargs"}
|
|
101
177
|
_GA_GIT_VALUE_OPTS = {"-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env"}
|
|
102
178
|
_GA_EVERYTHING = {"-A", "--all", ".", "./", ".\\", ":/", "*"}
|
|
179
|
+
_GA_CD = {"cd", "chdir", "pushd", "set-location", "sl"} # change the cwd the next `git add <path>` resolves against
|
|
180
|
+
# `git commit` short options that take the REST of their bundle as a value (`-ma` is the message "a"). The
|
|
181
|
+
# first five also take the next word when they end the bundle (`-m msg`); -S and -u take an attached value only.
|
|
182
|
+
_GC_VALUE_SHORT = set("mFCct")
|
|
183
|
+
_GC_ATTACHED_SHORT = _GC_VALUE_SHORT | set("Su")
|
|
184
|
+
_GC_VALUE_LONG = {"--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash", "--author",
|
|
185
|
+
"--date", "--template", "--cleanup", "--trailer", "--pathspec-from-file"}
|
|
186
|
+
_SAFE_COMMIT = 'stage named files only: `git add <file> ...`, then `git commit -m "..." -- <files>`'
|
|
187
|
+
_WHY_EVERYTHING = f"git add of everything (-A, --all, . or :/): {_SAFE_COMMIT}"
|
|
188
|
+
_WHY_UPDATE = f"git add -u stages every tracked change, a peer's too: {_SAFE_COMMIT}"
|
|
189
|
+
_WHY_COMMIT_ALL = f"git commit -a commits every tracked change, a peer's too: {_SAFE_COMMIT}"
|
|
190
|
+
_GA_QUOTE_CHARS = set(" \t\"';&|(){}$!\n") # a token holding one of these is quoted again for an inner pass
|
|
103
191
|
|
|
104
192
|
|
|
105
193
|
def _ga_sep(token: str) -> bool:
|
|
@@ -115,7 +203,11 @@ def _ga_name(token: str) -> str:
|
|
|
115
203
|
|
|
116
204
|
def _ga_tokens(text: str) -> list[str]:
|
|
117
205
|
"""Shell tokens, quotes removed (also mid-token: --work-tree="my dir"), backslashes kept (Windows paths).
|
|
118
|
-
; & | ( ) { } $ ! and newlines become separator tokens.
|
|
206
|
+
; & | ( ) { } $ ! and newlines become separator tokens.
|
|
207
|
+
|
|
208
|
+
Known limits (the guard catches accidents; it is not a security boundary, see test_known_limits_not_caught):
|
|
209
|
+
an escaped quote inside `bash -c "..."` (`\\"`), a PowerShell backtick escape (`` `-A ``, read as a line
|
|
210
|
+
break), and a git alias (`git -c alias.x=add x -A`) are not read as `git add`."""
|
|
119
211
|
def lex(quotes: str) -> list[str]:
|
|
120
212
|
lexer = shlex.shlex(text.replace("`", "\n"), posix=True, punctuation_chars=";&|(){}$!\n")
|
|
121
213
|
lexer.whitespace, lexer.whitespace_split, lexer.escape, lexer.quotes = " \t\r", True, "", quotes
|
|
@@ -126,17 +218,45 @@ def _ga_tokens(text: str) -> list[str]:
|
|
|
126
218
|
return lex("")
|
|
127
219
|
|
|
128
220
|
|
|
129
|
-
def
|
|
130
|
-
"""
|
|
221
|
+
def _ga_quote(token: str) -> str:
|
|
222
|
+
"""`token` quoted so _ga_tokens reads it back as the same one token (a cmd/pwsh re-join keeps `"my dir"`)."""
|
|
223
|
+
if token and not set(token) & _GA_QUOTE_CHARS:
|
|
224
|
+
return token
|
|
225
|
+
return f"'{token}'" if '"' in token else f'"{token}"'
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _ga_inside(path: str, folder: str) -> bool:
|
|
229
|
+
"""`path` is `folder` or below it (real paths, case-folded on Windows)."""
|
|
230
|
+
inner, outer = os.path.normcase(os.path.realpath(path)), os.path.normcase(os.path.realpath(folder))
|
|
231
|
+
return inner == outer or inner.startswith(outer.rstrip("\\/") + os.sep)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _ga_cd(cwd: str | None, path: str) -> str | None:
|
|
235
|
+
"""The cwd after `cd path` or `git -C path`; None (unknown) when either is unknown."""
|
|
236
|
+
if cwd is None or not path or _ga_sep(path):
|
|
237
|
+
return None
|
|
238
|
+
return os.path.join(cwd, os.path.expanduser(path))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _git_calls(command: str, cwd: str | None, depth: int = 0):
|
|
242
|
+
"""Yield (cwd, subcommand, args) for every git run in `command`.
|
|
131
243
|
|
|
132
244
|
`git` must start a command (after a separator, a shell keyword such as `do`, VAR=value, or a wrapper such as
|
|
133
245
|
sudo, env, xargs, nice -n 10), with any path, case or .exe. `bash -c "..."`, `pwsh -Command ...` and
|
|
134
|
-
`cmd /c ...` are read inside. Only git's global options may stand between `git` and
|
|
135
|
-
`git commit -m "add ."` and `printf "git add ."`
|
|
136
|
-
|
|
246
|
+
`cmd /c ...` are read inside. Only git's global options may stand between `git` and its subcommand, so
|
|
247
|
+
`git commit -m "add ."` and `printf "git add ."` hold no `add`. `cwd` follows `cd`/`Set-Location` and
|
|
248
|
+
`git -C`; None means unknown (no directory checks). Linear in the command length.
|
|
137
249
|
"""
|
|
250
|
+
for _name, here, sub, args in _tool_calls(command, cwd, frozenset({"git"}), depth):
|
|
251
|
+
yield here, sub, args
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _tool_calls(command: str, cwd: str | None, programs: frozenset, depth: int = 0):
|
|
255
|
+
"""Yield (program, cwd, subcommand, args) for every run of a program in `programs`, found as _git_calls finds
|
|
256
|
+
git (separators, keywords, wrappers, wrapped shells, `cd`). For git, `subcommand` is the word after git's global
|
|
257
|
+
options. For another program it is None, and `args` holds every word up to the next separator."""
|
|
138
258
|
if depth > 3:
|
|
139
|
-
return
|
|
259
|
+
return
|
|
140
260
|
text = re.sub(r"\\\r?\n|`\r?\n", " ", command) # line continuations (bash \, PowerShell `)
|
|
141
261
|
tokens = _ga_tokens(text)
|
|
142
262
|
i, start = 0, True
|
|
@@ -154,7 +274,7 @@ def git_add_everything(command: str, depth: int = 0) -> bool:
|
|
|
154
274
|
name = _ga_name(tok)
|
|
155
275
|
if name in _GA_WRAPPERS: # skip the wrapper's own flags and values up to the real command
|
|
156
276
|
i += 1
|
|
157
|
-
while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in (
|
|
277
|
+
while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in (programs | _GA_SHELLS):
|
|
158
278
|
i += 1
|
|
159
279
|
continue
|
|
160
280
|
start = False
|
|
@@ -166,37 +286,295 @@ def git_add_everything(command: str, depth: int = 0) -> bool:
|
|
|
166
286
|
end = next((k for k, t in enumerate(rest) if _ga_sep(t)), len(rest))
|
|
167
287
|
if end == 0: # `bash -c` with nothing after it
|
|
168
288
|
break
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
289
|
+
# cmd and pwsh run every word after the flag; quote each again so `"my dir"` stays one word.
|
|
290
|
+
joined = name in _GA_JOINING_SHELLS and end > 1
|
|
291
|
+
inner = " ".join(_ga_quote(t) for t in rest[:end]) if joined else rest[0]
|
|
292
|
+
yield from _tool_calls(inner, cwd, programs, depth + 1)
|
|
172
293
|
break
|
|
173
294
|
j += 1
|
|
174
295
|
i = j
|
|
175
296
|
continue
|
|
297
|
+
if name in _GA_CD: # `cd dir`, `Set-Location -Path dir`, `cd /d dir` (cmd)
|
|
298
|
+
k = i + 1
|
|
299
|
+
while k < len(tokens) and (tokens[k].startswith("-") or tokens[k].lower() == "/d") and not _ga_sep(tokens[k]):
|
|
300
|
+
k += 1
|
|
301
|
+
cwd = _ga_cd(cwd, tokens[k] if k < len(tokens) else "")
|
|
302
|
+
i = k
|
|
303
|
+
continue
|
|
304
|
+
if name not in programs:
|
|
305
|
+
i += 1
|
|
306
|
+
continue
|
|
176
307
|
if name != "git":
|
|
308
|
+
end = next((k for k in range(i + 1, len(tokens)) if _ga_sep(tokens[k])), len(tokens))
|
|
309
|
+
yield name, cwd, None, tokens[i + 1:end]
|
|
177
310
|
i += 1
|
|
178
311
|
continue
|
|
179
|
-
j = i + 1
|
|
312
|
+
here, work_tree, j = cwd, "", i + 1
|
|
180
313
|
while j < len(tokens) and tokens[j].startswith("-") and not _ga_sep(tokens[j]):
|
|
181
|
-
|
|
182
|
-
|
|
314
|
+
opt, value = tokens[j], tokens[j + 1] if j + 1 < len(tokens) else ""
|
|
315
|
+
if opt == "-C":
|
|
316
|
+
here = _ga_cd(here, value)
|
|
317
|
+
elif opt.startswith("-C"): # `-Cdir`, `-C"my dir"`
|
|
318
|
+
here = _ga_cd(here, opt[2:])
|
|
319
|
+
elif opt == "--work-tree":
|
|
320
|
+
work_tree = value
|
|
321
|
+
elif opt.startswith("--work-tree="):
|
|
322
|
+
work_tree = opt.split("=", 1)[1]
|
|
323
|
+
j += 2 if opt in _GA_GIT_VALUE_OPTS else 1
|
|
324
|
+
if work_tree: # outside the work tree, git reads paths from its top (--git-dir alone keeps the cwd)
|
|
325
|
+
top = _ga_cd(here, work_tree)
|
|
326
|
+
if top is None or here is None or not _ga_inside(here, top):
|
|
327
|
+
here = top
|
|
328
|
+
if j >= len(tokens) or _ga_sep(tokens[j]):
|
|
183
329
|
i = j
|
|
184
330
|
continue
|
|
185
|
-
|
|
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
|
|
331
|
+
end = next((k for k in range(j + 1, len(tokens)) if _ga_sep(tokens[k])), len(tokens))
|
|
332
|
+
yield "git", here, tokens[j], tokens[j + 1:end]
|
|
196
333
|
i = j + 1
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _plain_directory(cwd: str | None, path: str) -> bool:
|
|
337
|
+
"""`path` names an existing directory that is not a nested repository (a submodule pointer is one path).
|
|
338
|
+
The repository that holds `cwd` is never exempt: `git add ..` from a subfolder stages everything."""
|
|
339
|
+
if cwd is None or not path or any(ch in path for ch in "*?[") or path.startswith(":"):
|
|
340
|
+
return False
|
|
341
|
+
full = os.path.join(cwd, os.path.expanduser(path))
|
|
342
|
+
if not os.path.isdir(full):
|
|
343
|
+
return False
|
|
344
|
+
return not os.path.exists(os.path.join(full, ".git")) or _ga_inside(cwd, full)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _ga_file(cwd: str | None, path: str) -> bool:
|
|
348
|
+
"""`path` is an existing file under `cwd` (an unknown cwd is never a file)."""
|
|
349
|
+
return bool(cwd and path) and os.path.isfile(os.path.join(cwd, os.path.expanduser(path)))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _magic_split(path: str) -> tuple[set[str], str]:
|
|
353
|
+
"""`:(top,glob)src` -> ({"top", "glob"}, "src"); `:/!src` -> ({"/", "!"}, "src"). Unclosed `:(` -> rest ''."""
|
|
354
|
+
if path.startswith(":("):
|
|
355
|
+
close = path.find(")")
|
|
356
|
+
if close < 0:
|
|
357
|
+
return {"unclosed"}, ""
|
|
358
|
+
return {w.strip().lower().split(":")[0] for w in path[2:close].split(",")}, path[close + 1:]
|
|
359
|
+
k = 1
|
|
360
|
+
while k < len(path) and path[k] in "/!^":
|
|
361
|
+
k += 1
|
|
362
|
+
rest = path[k + 1:] if path[k:k + 1] == ":" else path[k:]
|
|
363
|
+
return set(path[1:k]), rest
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _pathspec_reason(path: str, cwd: str | None, after_dashdash: bool) -> str:
|
|
367
|
+
"""Why one `git add` pathspec can stage more than one named file, or ''."""
|
|
368
|
+
if path.startswith(":"): # pathspec magic: everything, a folder or a pattern unless it is plainly one file
|
|
369
|
+
magic, rest = _magic_split(path)
|
|
370
|
+
if rest in ("", ".", "./", ".\\", "*") or magic & {"!", "^", "exclude", "unclosed"}:
|
|
371
|
+
return _WHY_EVERYTHING
|
|
372
|
+
if "glob" in magic or set(rest) & set("*?["):
|
|
373
|
+
return _why_glob(path)
|
|
374
|
+
if _ga_file(cwd, rest):
|
|
375
|
+
return ""
|
|
376
|
+
return f"git add of a pathspec ({path}) that is not one existing file: {_SAFE_COMMIT}"
|
|
377
|
+
if set(path) & set("*?["): # a glob stages files you did not name; `-- <file>` may name a real file like a[1].py
|
|
378
|
+
return "" if after_dashdash and _ga_file(cwd, path) else _why_glob(path)
|
|
379
|
+
if _plain_directory(cwd, path):
|
|
380
|
+
return f"git add of a directory ({path}) stages every file under it: {_SAFE_COMMIT}"
|
|
381
|
+
return ""
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _why_glob(path: str) -> str:
|
|
385
|
+
return f"git add of a pattern ({path}) can stage files you did not change: name files, {_SAFE_COMMIT}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _add_reason(args: list[str], cwd: str | None) -> str:
|
|
389
|
+
"""Why this `git add <args>` stages more than named files, or ''."""
|
|
390
|
+
after_dashdash, update, paths = False, False, []
|
|
391
|
+
for arg in args:
|
|
392
|
+
if arg == "--" and not after_dashdash:
|
|
393
|
+
after_dashdash = True
|
|
394
|
+
continue
|
|
395
|
+
if arg.startswith("-") and not after_dashdash:
|
|
396
|
+
long_name = arg[2:].split("=", 1)[0] if arg.startswith("--") else ""
|
|
397
|
+
if (arg in _GA_EVERYTHING or re.fullmatch(r"-[a-zA-Z]*A[a-zA-Z]*", arg)
|
|
398
|
+
or (long_name and "all".startswith(long_name))
|
|
399
|
+
or (len(long_name) >= 4 and "no-ignore-removal".startswith(long_name))):
|
|
400
|
+
return _WHY_EVERYTHING # git takes any unique prefix of a long option: --al, --no-ignore-rem
|
|
401
|
+
if re.fullmatch(r"-[a-zA-Z]*u[a-zA-Z]*", arg) or (long_name and "update".startswith(long_name)):
|
|
402
|
+
update = True
|
|
403
|
+
continue
|
|
404
|
+
if arg in _GA_EVERYTHING and not arg.startswith("-"): # `git add -- -A` names a file called -A
|
|
405
|
+
return _WHY_EVERYTHING
|
|
406
|
+
paths.append((arg, after_dashdash))
|
|
407
|
+
if update and not paths:
|
|
408
|
+
return _WHY_UPDATE
|
|
409
|
+
for path, named in paths:
|
|
410
|
+
why = _pathspec_reason(path, cwd, named)
|
|
411
|
+
if why:
|
|
412
|
+
return why
|
|
413
|
+
return ""
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _commit_all(args: list[str]) -> bool:
|
|
417
|
+
"""True when `git commit <args>` carries -a / --all, also bundled (`-am`, `-qam`), not inside a value."""
|
|
418
|
+
skip = False
|
|
419
|
+
for arg in args:
|
|
420
|
+
if skip:
|
|
421
|
+
skip = False
|
|
422
|
+
continue
|
|
423
|
+
if arg == "--":
|
|
424
|
+
return False
|
|
425
|
+
if arg == "--all":
|
|
426
|
+
return True
|
|
427
|
+
if arg.startswith("--"):
|
|
428
|
+
skip = arg in _GC_VALUE_LONG
|
|
429
|
+
continue
|
|
430
|
+
if arg.startswith("-") and len(arg) > 1:
|
|
431
|
+
for pos, ch in enumerate(arg[1:]):
|
|
432
|
+
if ch == "a":
|
|
433
|
+
return True
|
|
434
|
+
if ch in _GC_ATTACHED_SHORT:
|
|
435
|
+
skip = ch in _GC_VALUE_SHORT and pos == len(arg) - 2
|
|
436
|
+
break
|
|
197
437
|
return False
|
|
198
438
|
|
|
199
439
|
|
|
440
|
+
def git_staging_reason(command: str, root: str | None = None) -> str:
|
|
441
|
+
"""The deny reason when `command` stages or commits more than named files, else ''.
|
|
442
|
+
|
|
443
|
+
Denied: `git add` of everything (-A, --all and its prefixes, --no-ignore-removal, ., :/, *),
|
|
444
|
+
`git add -u`/`--update` with no pathspec, `git commit -a`/`--all` (bundled too), a glob or a magic
|
|
445
|
+
pathspec that is not plainly one existing file, and `git add <dir>` for an existing directory under `root`
|
|
446
|
+
(the command's cwd; `cd`, `cd /d`, `git -C`, `-C<dir>` and `--work-tree` in the command are followed). In a shared checkout each of these
|
|
447
|
+
sweeps in files a peer staged or changed (audit #3). Callers pass heredoc-free text and must treat an
|
|
448
|
+
exception as "no match": this check must never stop the guard's other rules.
|
|
449
|
+
"""
|
|
450
|
+
for cwd, sub, args in _git_calls(command, root):
|
|
451
|
+
if sub == "add":
|
|
452
|
+
why = _add_reason(args, cwd)
|
|
453
|
+
if why:
|
|
454
|
+
return why
|
|
455
|
+
elif sub == "commit" and _commit_all(args):
|
|
456
|
+
return _WHY_COMMIT_ALL
|
|
457
|
+
return ""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def git_add_everything(command: str, depth: int = 0) -> bool:
|
|
461
|
+
"""True when `command` runs `git add` with -A/--all (also bundled, e.g. -Av), `.`, `./`, `:/` or `*`."""
|
|
462
|
+
return any(sub == "add" and _add_reason(args, None) == _WHY_EVERYTHING
|
|
463
|
+
for _cwd, sub, args in _git_calls(command, None, depth))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# Copied from hooks/guard.py (robustness #13): shell writes onto the guard's config.
|
|
467
|
+
# Shell constructs that WRITE to a named target: redirections, and the usual suspects that take
|
|
468
|
+
# a path argument. Matching the target lets classify_write judge it, so the shell path and the
|
|
469
|
+
# Write/Edit path can never disagree about what counts as a secret.
|
|
470
|
+
_SHELL_WRITE_TARGET = re.compile(
|
|
471
|
+
r"""(?:
|
|
472
|
+
>>?\s*(?P<redir>[^\s|;&<>]+) # > file >> file
|
|
473
|
+
| \|\s*tee\s+(?:-a\s+)?(?P<tee>[^\s|;&<>]+) # | tee file
|
|
474
|
+
| -(?:Path|FilePath|LiteralPath)\s+(?P<ps>[^\s|;&]+) # Set-Content -Path file
|
|
475
|
+
| \bOut-File\s+(?P<outfile>[^\s|;&]+) # Out-File file
|
|
476
|
+
)""",
|
|
477
|
+
re.IGNORECASE | re.VERBOSE,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _shell_write_targets(command: str) -> list[str]:
|
|
482
|
+
"""Every path this command appears to write to, quotes removed: a redirection, `| tee`, `| Out-File`, and the
|
|
483
|
+
targets of the writer programs the config rule knows (cp, mv, Copy-Item, Set-Content, New-Item, Tee-Object, sed -i,
|
|
484
|
+
...). Not the regex's `-Path` group: `Get-Content -Path x` reads. Best-effort by design: a miss degrades to the old
|
|
485
|
+
behaviour, it never invents a denial. guard_agy.py carries a copy; test_guard_agy.py checks they agree."""
|
|
486
|
+
command = _HOME_VAR.sub("~", command) # the tokenizer splits on `$`, so in `$HOME/x` the home part would be lost
|
|
487
|
+
out = [m.group(g).strip("'\"") for m in _SHELL_WRITE_TARGET.finditer(command)
|
|
488
|
+
for g in ("redir", "tee", "outfile") if m.group(g)]
|
|
489
|
+
try:
|
|
490
|
+
for name, _cwd, _sub, args in _tool_calls(command, None, _CONFIG_WRITERS):
|
|
491
|
+
if name == "sed":
|
|
492
|
+
if any(a.startswith(("-i", "--in-place")) for a in args):
|
|
493
|
+
out += [a for a in args if not a.startswith("-")]
|
|
494
|
+
elif name == "tee":
|
|
495
|
+
out += [a for a in args if not a.startswith(("-", "<", ">"))]
|
|
496
|
+
elif name in _CONFIG_SETTERS:
|
|
497
|
+
out += _setter_targets(args)
|
|
498
|
+
else:
|
|
499
|
+
out += _copier_targets(args)
|
|
500
|
+
except Exception: # noqa: BLE001 - a tokenizer failure keeps the regex targets; it never switches the rule off
|
|
501
|
+
pass
|
|
502
|
+
return out
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# Shell writes onto the guard's config: redirections and `| tee` / `| Out-File` (the regex above, not its -Path
|
|
506
|
+
# group: Get-Content -Path reads), then the programs below, found with the tokenizer (wrapped shells and `cd` count).
|
|
507
|
+
# Known limits: an interpreter one-liner (`python -c "open(...,'w')"`), a script that writes the file, a variable
|
|
508
|
+
# other than the home folder (_HOME_VAR), a symlink or glob that points at the file.
|
|
509
|
+
_CONFIG_COPIERS = frozenset({"cp", "copy", "mv", "move", "copy-item", "move-item", "cpi", "mi", "install"})
|
|
510
|
+
_CONFIG_SETTERS = frozenset({"set-content", "sc", "add-content", "ac", "out-file", "new-item", "ni", "tee-object"})
|
|
511
|
+
_CONFIG_WRITERS = _CONFIG_COPIERS | _CONFIG_SETTERS | {"tee", "sed"}
|
|
512
|
+
_PS_PATH_PARAMS = {"-path", "-literalpath", "-filepath", "-pspath"}
|
|
513
|
+
_PS_VALUE_PARAMS = {"-value", "-encoding", "-inputobject", "-width", "-stream", "-delimiter", "-itemtype",
|
|
514
|
+
"-filter", "-include", "-exclude", "-credential", "-variable"}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _setter_targets(args: list[str]) -> list[str]:
|
|
518
|
+
"""Set-Content / Out-File / New-Item: the -Path value, else the first positional word (+ New-Item -Name)."""
|
|
519
|
+
path, name, positional, i = "", "", [], 0
|
|
520
|
+
while i < len(args):
|
|
521
|
+
word = args[i].lower()
|
|
522
|
+
if word in _PS_PATH_PARAMS and i + 1 < len(args):
|
|
523
|
+
path, i = args[i + 1], i + 2
|
|
524
|
+
elif word == "-name" and i + 1 < len(args):
|
|
525
|
+
name, i = args[i + 1], i + 2
|
|
526
|
+
elif word in _PS_VALUE_PARAMS:
|
|
527
|
+
i += 2
|
|
528
|
+
else:
|
|
529
|
+
if not word.startswith(("-", ">", "<")):
|
|
530
|
+
positional.append(args[i])
|
|
531
|
+
i += 1
|
|
532
|
+
path = path or (positional[0] if positional else "")
|
|
533
|
+
return [os.path.join(path or ".", name) if name else path] if (path or name) else []
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _copier_targets(args: list[str]) -> list[str]:
|
|
537
|
+
"""cp / mv / Copy-Item / Move-Item: the destination, and destination/<source name> for a folder destination."""
|
|
538
|
+
sources, dest, i = [], "", 0
|
|
539
|
+
while i < len(args):
|
|
540
|
+
word = args[i].lower()
|
|
541
|
+
if word in ("-destination", "-t", "--target-directory") and i + 1 < len(args):
|
|
542
|
+
dest, i = args[i + 1], i + 2
|
|
543
|
+
continue
|
|
544
|
+
if word.startswith("--target-directory="):
|
|
545
|
+
dest = args[i].split("=", 1)[1]
|
|
546
|
+
elif word in ("-path", "-literalpath") and i + 1 < len(args):
|
|
547
|
+
sources.append(args[i + 1])
|
|
548
|
+
i += 1
|
|
549
|
+
elif not word.startswith(("-", ">", "<")):
|
|
550
|
+
sources.append(args[i])
|
|
551
|
+
i += 1
|
|
552
|
+
if not dest and len(sources) >= 2:
|
|
553
|
+
dest = sources.pop()
|
|
554
|
+
if not dest:
|
|
555
|
+
return []
|
|
556
|
+
return [dest] + [os.path.join(dest, os.path.basename(src.replace("\\", "/"))) for src in sources]
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def guard_config_shell_reason(command: str) -> str:
|
|
560
|
+
"""The ask reason when a shell command writes a `.caddis/config.toml` or `~/.caddis/*.toml`, else ''.
|
|
561
|
+
Callers pass heredoc-free text and treat an exception as "no match"."""
|
|
562
|
+
targets = _shell_write_targets(command)
|
|
563
|
+
for target in targets:
|
|
564
|
+
why = guard_config_reason(target)
|
|
565
|
+
if why:
|
|
566
|
+
return f"shell command {why}"
|
|
567
|
+
return ""
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _config_shell_safe(text: str) -> str:
|
|
571
|
+
"""guard_config_shell_reason, but an error is "no match": one rule must never switch off the rest."""
|
|
572
|
+
try:
|
|
573
|
+
return guard_config_shell_reason(text)
|
|
574
|
+
except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
|
|
575
|
+
return ""
|
|
576
|
+
|
|
577
|
+
|
|
200
578
|
# Copied from hooks/guard.py: a heredoc body that only carries data is not a command.
|
|
201
579
|
_HEREDOC_MARKER = re.compile(
|
|
202
580
|
r"<<(-?)[ \t]*(?:'([A-Za-z_][A-Za-z_0-9]*)'|\"([A-Za-z_][A-Za-z_0-9]*)\"|([A-Za-z_][A-Za-z_0-9]*))"
|
|
@@ -261,14 +639,332 @@ def _without_heredoc_bodies(command: str) -> str:
|
|
|
261
639
|
return "".join(kept)
|
|
262
640
|
|
|
263
641
|
|
|
264
|
-
def
|
|
265
|
-
"""
|
|
642
|
+
def _git_staging_safe(text: str, root: str | None) -> str:
|
|
643
|
+
"""git_staging_reason, but an error is "no match": one rule must never switch off the rest of the guard."""
|
|
644
|
+
try:
|
|
645
|
+
return git_staging_reason(text, root)
|
|
646
|
+
except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
|
|
647
|
+
return ""
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ── headless sessions cannot publish (robustness #4) ──
|
|
651
|
+
# A runner session (CADDIS_HEADLESS=1) can be prompt-injected through a plan or a diff. The runner pushes, opens the
|
|
652
|
+
# PR and merges by itself; a session never needs to. The real boundary is the credential: the runner gives its
|
|
653
|
+
# sessions no GH_TOKEN or NPM_TOKEN. This rule covers the machine's own gh keyring and git credential logins, which
|
|
654
|
+
# a session can still reach. Like the staging rule it uses the tokenizer, so wrapped shells, `cd` and `git -C`
|
|
655
|
+
# count. Known limits (it is not a boundary): a git or gh alias, a script file that pushes, a program it does not name.
|
|
656
|
+
_WHY_HEADLESS = "headless sessions cannot publish; the runner does that"
|
|
657
|
+
_PUBLISH_TOOLS = frozenset({"git", "gh", "npm", "pnpm", "yarn", "npx", "twine", "cargo", "python", "python3",
|
|
658
|
+
"py"})
|
|
659
|
+
_GH_VALUE_FLAGS = {"-R", "--repo", "-H", "--header", "-q", "--jq", "-t", "--template", "-p", "--preview",
|
|
660
|
+
"--hostname", "--cache"}
|
|
661
|
+
_GH_FIELD_FLAGS = {"-f", "-F", "--field", "--raw-field", "--input"}
|
|
662
|
+
_GIT_PUSHES = {"push", "send-pack", "http-push"} # `git subtree push` is checked on its own
|
|
663
|
+
_GH_WRITES = [["pr", "merge"], ["pr", "create"], ["pr", "comment"], ["issue", "create"], ["issue", "comment"],
|
|
664
|
+
["repo", "create"], ["repo", "delete"], ["repo", "edit"], ["repo", "rename"], ["repo", "archive"],
|
|
665
|
+
["secret", "set"], ["secret", "delete"], ["variable", "set"], ["variable", "delete"]]
|
|
666
|
+
_NPM_WRITES = {"publish", "unpublish", "deprecate", "dist-tag", "dist-tags", "owner", "access"}
|
|
667
|
+
_NPM_VALUE_FLAGS = {"--registry", "--userconfig", "--prefix", "-w", "--workspace", "--otp", "--tag", "--access",
|
|
668
|
+
"--cache", "-C", "--cwd", "--dir"}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _headless() -> bool:
|
|
672
|
+
"""A headless session: CADDIS_HEADLESS set, not empty, not 0. The hook reads the marker from its own
|
|
673
|
+
environment: Claude Code and agy start hooks with the session's env, which the model's shell cannot change."""
|
|
674
|
+
return str(os.environ.get(f"{_ENV_PREFIX}_HEADLESS", "")).strip() not in ("", "0")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _publish_allowed_headless() -> bool:
|
|
678
|
+
"""False only in a headless session without CADDIS_ALLOW_PUBLISH=1."""
|
|
679
|
+
if not _headless():
|
|
680
|
+
return True
|
|
681
|
+
return str(os.environ.get(f"{_ENV_PREFIX}_ALLOW_PUBLISH", "")).strip() == "1"
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _gh_publish(args: list[str]) -> str:
|
|
685
|
+
"""The gh call that publishes, or '': pr merge/create, release, workflow run, and a write through gh api."""
|
|
686
|
+
words, method, fields, i = [], "", False, 0
|
|
687
|
+
while i < len(args):
|
|
688
|
+
arg = args[i]
|
|
689
|
+
if arg in ("-X", "--method"):
|
|
690
|
+
method, i = (args[i + 1] if i + 1 < len(args) else "?"), i + 2
|
|
691
|
+
continue
|
|
692
|
+
if arg in _GH_FIELD_FLAGS:
|
|
693
|
+
fields, i = True, i + 2
|
|
694
|
+
continue
|
|
695
|
+
if arg in _GH_VALUE_FLAGS:
|
|
696
|
+
i += 2
|
|
697
|
+
continue
|
|
698
|
+
if arg.startswith("--method="):
|
|
699
|
+
method = arg.split("=", 1)[1]
|
|
700
|
+
elif arg.startswith("-X"):
|
|
701
|
+
method = arg[2:]
|
|
702
|
+
elif arg.startswith(("--field=", "--raw-field=", "--input=")) or arg[:2] in ("-f", "-F"):
|
|
703
|
+
fields = True
|
|
704
|
+
elif not arg.startswith("-"):
|
|
705
|
+
words.append(arg.lower())
|
|
706
|
+
i += 1
|
|
707
|
+
if words[:2] in _GH_WRITES:
|
|
708
|
+
return f"gh {words[0]} {words[1]}"
|
|
709
|
+
if words[:1] == ["release"]:
|
|
710
|
+
return "gh release"
|
|
711
|
+
if words[:2] == ["workflow", "run"]:
|
|
712
|
+
return "gh workflow run"
|
|
713
|
+
if words[:1] == ["api"]:
|
|
714
|
+
verb = method.strip().upper()
|
|
715
|
+
if verb and verb != "GET":
|
|
716
|
+
return f"gh api -X {verb}"
|
|
717
|
+
if fields and verb != "GET":
|
|
718
|
+
return "gh api with fields (a POST)"
|
|
719
|
+
if words[1:2] == ["graphql"]:
|
|
720
|
+
return "gh api graphql"
|
|
721
|
+
return ""
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _package_publish(name: str, args: list[str]) -> str:
|
|
725
|
+
"""The package-registry call that publishes, or ''. `npx npm publish` and `python -m twine upload` count."""
|
|
726
|
+
if name in ("python", "python3", "py"):
|
|
727
|
+
at = args.index("-m") if "-m" in args[:-1] else -1
|
|
728
|
+
return _package_publish("twine", args[at + 2:]) if at >= 0 and args[at + 1].lower() == "twine" else ""
|
|
729
|
+
words, i = [], 0
|
|
730
|
+
while i < len(args):
|
|
731
|
+
if args[i] in _NPM_VALUE_FLAGS:
|
|
732
|
+
i += 2
|
|
733
|
+
continue
|
|
734
|
+
if not args[i].startswith("-"):
|
|
735
|
+
words.append(args[i].lower())
|
|
736
|
+
i += 1
|
|
737
|
+
if name == "npx":
|
|
738
|
+
tool = words[0] if words[:1] in (["npm"], ["pnpm"], ["yarn"]) else ""
|
|
739
|
+
return _package_publish(tool, args[[a.lower() for a in args].index(tool) + 1:]) if tool else ""
|
|
740
|
+
if name in ("npm", "pnpm", "yarn") and words[:1] and words[0] in _NPM_WRITES:
|
|
741
|
+
return f"{name} {words[0]}"
|
|
742
|
+
if name == "yarn" and words[:2] == ["npm", "publish"]:
|
|
743
|
+
return "yarn npm publish"
|
|
744
|
+
if (name, words[:1]) in (("twine", ["upload"]), ("cargo", ["publish"])):
|
|
745
|
+
return f"{name} {words[0]}"
|
|
746
|
+
return ""
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def headless_publish_reason(command: str) -> str:
|
|
750
|
+
"""The deny reason when `command` pushes, merges or publishes, else ''. It does not read the environment:
|
|
751
|
+
callers check _publish_allowed_headless() first, and pass heredoc-free text."""
|
|
752
|
+
for name, _cwd, sub, args in _tool_calls(command, None, _PUBLISH_TOOLS):
|
|
753
|
+
if name == "git":
|
|
754
|
+
verb = sub.lower()
|
|
755
|
+
if verb in _GIT_PUSHES:
|
|
756
|
+
what = f"git {verb}"
|
|
757
|
+
elif verb == "subtree" and "push" in (a.lower() for a in args):
|
|
758
|
+
what = "git subtree push"
|
|
759
|
+
else:
|
|
760
|
+
what = ""
|
|
761
|
+
elif name == "gh":
|
|
762
|
+
what = _gh_publish(args)
|
|
763
|
+
else:
|
|
764
|
+
what = _package_publish(name, args)
|
|
765
|
+
if what:
|
|
766
|
+
return f"{_WHY_HEADLESS} ({what})"
|
|
767
|
+
return ""
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _headless_publish_safe(text: str) -> str:
|
|
771
|
+
"""headless_publish_reason in a headless session, else ''. An error is "no match", as for staging: one rule must
|
|
772
|
+
never switch off the rest of the guard (the credential, not this rule, is the boundary)."""
|
|
773
|
+
if _publish_allowed_headless():
|
|
774
|
+
return ""
|
|
266
775
|
try:
|
|
267
|
-
return
|
|
776
|
+
return headless_publish_reason(text)
|
|
268
777
|
except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
|
|
778
|
+
return ""
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# ── no tree moves in a shared checkout (robustness #11) ──
|
|
782
|
+
# A checkout whose AGENTS.md says `Shared checkout: yes` has a dev service serving from it, and often a peer's
|
|
783
|
+
# uncommitted edits. `git switch`, `checkout <branch>`, `rebase`, `pull`, `merge`, `reset --hard` and `stash` move
|
|
784
|
+
# or discard its files. Interactive: ask. Headless (CADDIS_HEADLESS=1): deny, because deny-only mode turns every ask
|
|
785
|
+
# into a silent allow and a runner session has no one to ask. `restore`/`checkout` of `.` or a folder discard a
|
|
786
|
+
# peer's edits too; `reset --keep`/`--merge` count as `--hard`; `stash apply`/`pop` count. Free: `switch -c <new>` and
|
|
787
|
+
# `checkout -b <new>` with NO start point (a start point checks that commit out), `checkout [-- ] <file>`,
|
|
788
|
+
# `git worktree add`, `pull --dry-run`, and the recovery verbs (`rebase`/`merge` `--abort`/`--continue`/`--skip`/
|
|
789
|
+
# `--quit`), so a headless runner can always finish or undo a stopped operation. `checkout <name>` where <name> is
|
|
790
|
+
# both a path and a ref is a branch switch (one `git rev-parse`, run only then). The checkout is the one the command acts on (`cd`, `git -C`, as for staging). A
|
|
791
|
+
# linked worktree is never shared: AGENTS.md is tracked, so the worktree has the line too, but it is the safe place.
|
|
792
|
+
# Known limits (it catches accidents, it is not a boundary): a cwd from a variable (`cd $X`) is not followed, and a
|
|
793
|
+
# git alias is not read. The regex is the one in scripts/caddis_checkout_guard.py (SHARED_RE); guard_agy.py carries
|
|
794
|
+
# a copy, and test_guard_agy.py checks they agree.
|
|
795
|
+
_SHARED_RE = re.compile(r"^[ \t]*(?:[-*][ \t]+)?(?:\*\*)?Shared checkout:[ \t]*yes(?:\*\*)?[ \t]*(?:$|[(\-–—.:,;#])",
|
|
796
|
+
re.IGNORECASE | re.MULTILINE)
|
|
797
|
+
_SWITCH_CREATE = {"-c", "-C", "--create", "--force-create"}
|
|
798
|
+
_CHECKOUT_CREATE = {"-b", "-B", "--orphan"}
|
|
799
|
+
_GIT_RECOVERY = {"--abort", "--continue", "--skip", "--quit"} # a stopped rebase/merge must be finishable headless
|
|
800
|
+
_RESET_MOVES = {"--hard", "--keep", "--merge"}
|
|
801
|
+
_FORCE = {"-f", "--force", "--discard-changes"} # `switch -f -c x` / `checkout -f -b x` throw changes away
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _options(args: list[str]) -> list[str]:
|
|
805
|
+
"""The words before `--`. After `--` every word is a pathspec, even `-b` or `--staged`."""
|
|
806
|
+
return args[:args.index("--")] if "--" in args else args
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _create_flag(word: str, creates: set[str]) -> bool:
|
|
810
|
+
"""`word` makes a branch: `-c`, attached `-cfoo`, or `--create=foo`."""
|
|
811
|
+
return word in creates or any(word.startswith(c + "=") if c.startswith("--") else
|
|
812
|
+
(word.startswith(c) and len(word) > len(c)) for c in creates)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _create_moves(args: list[str], creates: set[str]) -> bool:
|
|
816
|
+
"""`switch -c <new> [<start>]` / `checkout -b <new> [<start>]`: True when a start point follows the new name, or
|
|
817
|
+
`-f`/`--force`/`--discard-changes` throws the tree's changes away. The bare form starts from HEAD and keeps
|
|
818
|
+
the tree; a start point checks that commit out."""
|
|
819
|
+
opts = _options(args)
|
|
820
|
+
if any(a in _FORCE for a in opts):
|
|
821
|
+
return True
|
|
822
|
+
named, skip = [], False
|
|
823
|
+
for a in opts:
|
|
824
|
+
if skip:
|
|
825
|
+
skip = False
|
|
826
|
+
elif a in creates:
|
|
827
|
+
skip = True # the next word is the new branch name (`-cfoo` / `--create=foo` carry it)
|
|
828
|
+
elif not a.startswith("-"):
|
|
829
|
+
named.append(a)
|
|
830
|
+
return bool(named)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _broad_path(path: str, cwd: str) -> bool:
|
|
834
|
+
"""A pathspec that can hold files other than the one named: `.`, a folder, a glob or pathspec magic (`:/`)."""
|
|
835
|
+
if path.startswith(":") or any(c in path for c in "*?["):
|
|
836
|
+
return True
|
|
837
|
+
return os.path.isdir(os.path.join(cwd, path))
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# No console window for the git call (a hook in a console-less session would flash one).
|
|
841
|
+
_NO_WINDOW_KW = {"creationflags": 0x08000000} if os.name == "nt" else {}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _is_ref(name: str, cwd: str) -> bool:
|
|
845
|
+
"""True when `name` resolves as a commit in the repo at `cwd`. Git cannot run (missing, hung): True, so the
|
|
846
|
+
caller asks. Called only when `name` is also a path, so most checkouts never pay for the git call."""
|
|
847
|
+
try:
|
|
848
|
+
r = subprocess.run(["git", "-C", cwd, "rev-parse", "--verify", "--quiet", f"{name}^{{commit}}"],
|
|
849
|
+
capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL,
|
|
850
|
+
**_NO_WINDOW_KW)
|
|
851
|
+
except (OSError, subprocess.SubprocessError):
|
|
852
|
+
return True
|
|
853
|
+
return r.returncode == 0
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _from_file(opts: list[str]) -> bool:
|
|
857
|
+
"""`--pathspec-from-file`: the paths are in a file the guard does not read, so assume they are broad."""
|
|
858
|
+
return any(a == "--pathspec-from-file" or a.startswith("--pathspec-from-file=") for a in opts)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _checkout_moves(args: list[str], cwd: str) -> bool:
|
|
862
|
+
opts = _options(args)
|
|
863
|
+
if _from_file(opts):
|
|
864
|
+
return True
|
|
865
|
+
if any(_create_flag(a, _CHECKOUT_CREATE) for a in opts):
|
|
866
|
+
paths = args[len(opts) + 1:]
|
|
867
|
+
return _create_moves(args, _CHECKOUT_CREATE) or any(_broad_path(p, cwd) for p in paths)
|
|
868
|
+
if "--" in args: # `checkout [<tree-ish>] -- <paths>`: only a broad pathspec discards more than the named file
|
|
869
|
+
paths = args[args.index("--") + 1:]
|
|
870
|
+
return not paths or any(_broad_path(p, cwd) for p in paths)
|
|
871
|
+
named = [a for a in args if not a.startswith("-")]
|
|
872
|
+
if not named or not all(os.path.exists(os.path.join(cwd, a)) for a in named):
|
|
873
|
+
return True # `checkout <branch>`, `checkout -`
|
|
874
|
+
if _is_ref(named[0], cwd): # a branch and a path share the name: git takes the branch
|
|
875
|
+
return len(named) == 1 or any(_broad_path(p, cwd) for p in named[1:])
|
|
876
|
+
return any(_broad_path(p, cwd) for p in named)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _restore_moves(args: list[str], cwd: str) -> bool:
|
|
880
|
+
opts = _options(args)
|
|
881
|
+
staged = any(a in ("-S", "--staged") for a in opts)
|
|
882
|
+
if staged and not any(a in ("-W", "--worktree") for a in opts):
|
|
883
|
+
return False # `restore --staged` changes the index only
|
|
884
|
+
if _from_file(opts):
|
|
885
|
+
return True
|
|
886
|
+
paths, skip = list(args[len(opts) + 1:]), False
|
|
887
|
+
for a in opts:
|
|
888
|
+
if skip:
|
|
889
|
+
skip = False
|
|
890
|
+
elif a in ("-s", "--source"):
|
|
891
|
+
skip = True
|
|
892
|
+
elif not a.startswith("-"):
|
|
893
|
+
paths.append(a)
|
|
894
|
+
return any(_broad_path(p, cwd) for p in paths)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _moves_tree(sub: str, args: list[str], cwd: str) -> bool:
|
|
898
|
+
"""True when `git <sub> <args>` moves or discards the work tree (see the block note)."""
|
|
899
|
+
verb = sub.lower()
|
|
900
|
+
if verb == "switch":
|
|
901
|
+
if any(_create_flag(a, _SWITCH_CREATE) for a in _options(args)):
|
|
902
|
+
return _create_moves(args, _SWITCH_CREATE)
|
|
903
|
+
return True
|
|
904
|
+
if verb == "checkout":
|
|
905
|
+
return _checkout_moves(args, cwd)
|
|
906
|
+
if verb == "restore":
|
|
907
|
+
return _restore_moves(args, cwd)
|
|
908
|
+
if verb in ("rebase", "merge"):
|
|
909
|
+
return not any(a in _GIT_RECOVERY for a in args)
|
|
910
|
+
if verb == "pull":
|
|
911
|
+
return "--dry-run" not in args
|
|
912
|
+
if verb == "reset":
|
|
913
|
+
return any(a in _RESET_MOVES for a in args)
|
|
914
|
+
if verb == "stash":
|
|
915
|
+
return not (args[:1] and args[0].lower() in ("list", "show"))
|
|
916
|
+
return False
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _declares_shared(cwd: str) -> bool:
|
|
920
|
+
"""The repo that holds `cwd` says `Shared checkout: yes` in its top AGENTS.md, and is not a linked worktree."""
|
|
921
|
+
here = os.path.abspath(cwd)
|
|
922
|
+
top = here
|
|
923
|
+
while True:
|
|
924
|
+
if os.path.exists(os.path.join(top, ".git")):
|
|
925
|
+
break
|
|
926
|
+
parent = os.path.dirname(top)
|
|
927
|
+
if parent == top:
|
|
928
|
+
top = here
|
|
929
|
+
break
|
|
930
|
+
top = parent
|
|
931
|
+
gitfile = os.path.join(top, ".git")
|
|
932
|
+
if os.path.isfile(gitfile):
|
|
933
|
+
try:
|
|
934
|
+
with open(gitfile, encoding="utf-8", errors="replace") as fh:
|
|
935
|
+
line = fh.read().strip()
|
|
936
|
+
except OSError:
|
|
937
|
+
line = ""
|
|
938
|
+
if line.lower().startswith("gitdir:") and "/worktrees/" in line.replace("\\", "/").lower():
|
|
939
|
+
return False
|
|
940
|
+
try:
|
|
941
|
+
with open(os.path.join(top, "AGENTS.md"), encoding="utf-8", errors="replace") as fh:
|
|
942
|
+
return bool(_SHARED_RE.search(fh.read()))
|
|
943
|
+
except OSError:
|
|
269
944
|
return False
|
|
270
945
|
|
|
271
946
|
|
|
947
|
+
def shared_checkout_reason(command: str, root: str | None) -> str:
|
|
948
|
+
"""The reason when `command` moves the tree of a checkout that says `Shared checkout: yes`, else ''.
|
|
949
|
+
Callers pass heredoc-free text and treat an exception as "no match"."""
|
|
950
|
+
for cwd, sub, args in _git_calls(command, root):
|
|
951
|
+
if cwd is None or "$" in cwd or "%" in cwd or not os.path.isdir(cwd):
|
|
952
|
+
continue
|
|
953
|
+
if _moves_tree(sub, args, cwd) and _declares_shared(cwd):
|
|
954
|
+
return (f"`git {sub.lower()}` moves the tree of a shared checkout (AGENTS.md says Shared checkout: yes; "
|
|
955
|
+
"a dev service or a peer uses it). Work in your own worktree instead: "
|
|
956
|
+
"`git worktree add \"$TEMP/<name>\" -b <branch> origin/<default>`, and remove it when done")
|
|
957
|
+
return ""
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _shared_checkout_safe(text: str, root: str | None) -> str:
|
|
961
|
+
"""shared_checkout_reason, but an error is "no match": one rule must never switch off the rest of the guard."""
|
|
962
|
+
try:
|
|
963
|
+
return shared_checkout_reason(text, root)
|
|
964
|
+
except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
|
|
965
|
+
return ""
|
|
966
|
+
|
|
967
|
+
|
|
272
968
|
def _strip_quotes(s: str) -> str:
|
|
273
969
|
"""Drop quotes so quoted flags/targets still match (rm '-rf' \"/\" → rm -rf /)."""
|
|
274
970
|
return s.replace('"', "").replace("'", "")
|
|
@@ -353,12 +1049,145 @@ def _catastrophic_statement(c: str) -> str:
|
|
|
353
1049
|
return ""
|
|
354
1050
|
|
|
355
1051
|
|
|
356
|
-
|
|
357
|
-
|
|
1052
|
+
# Copied from hooks/guard.py; test_guard_agy.py checks they agree.
|
|
1053
|
+
# ── Phase 6: reading or copying onto a secret env file through the shell ─────
|
|
1054
|
+
# Match copy/move verbs as whole tokens. Source and destination paths are checked below with the
|
|
1055
|
+
# same path classifiers used by Read and Write.
|
|
1056
|
+
_COPY_DEST = re.compile(r"(?i)(?:cp|mv|copy|move|copy-item|move-item)\Z")
|
|
1057
|
+
|
|
1058
|
+
# These verbs take file operands. Pattern tools (select-string/sls, grep/rg/findstr/awk/sed) take
|
|
1059
|
+
# search patterns too, so matching an env-looking argument would block ordinary searches.
|
|
1060
|
+
# Known limits: free text containing both a verb and `.env` (for example a commit message saying
|
|
1061
|
+
# `never cat .env`) is denied; `git diff .env`, `git log -p .env`, bare `tee .env`, and
|
|
1062
|
+
# `docker --env-file` are not blocked. Command substitution
|
|
1063
|
+
# (`cat $(echo .env)`), PowerShell .NET file calls
|
|
1064
|
+
# (`[IO.File]::ReadAllText`), and scripts that open the file themselves are not parsed here.
|
|
1065
|
+
_ENV_READ = re.compile(
|
|
1066
|
+
r"(?i)(?:cat|type|more|less|head|tail|get-content|gc|base64|xxd|od|strings|hexdump|sort|nl|tac|source|\.)\Z"
|
|
1067
|
+
)
|
|
1068
|
+
_INPUT_REDIRECT = re.compile(r"(?<!<)<(?!<)\s*([^\s|;&<>]+)")
|
|
1069
|
+
|
|
1070
|
+
# python/node/etc. run inline with -c/-e that touches a `.env*` file, e.g. `open('.env')`.
|
|
1071
|
+
# The mandatory [\s'"/\\] immediately before "\.env" is load-bearing: without it, `.env` would
|
|
1072
|
+
# match inside `process.env.HOME` (preceded by "s" from "process") and deny an ordinary command
|
|
1073
|
+
# that merely mentions an environment VARIABLE, not the FILE. Found while testing this phase.
|
|
1074
|
+
_INLINE_ENV = re.compile(
|
|
1075
|
+
r"(?i)\b(python\d?|py|node|ruby|perl)\b\s+(-c|-e)\s+.*[\s'\"/\\]\.env"
|
|
1076
|
+
r"(?!\.(example|sample|template|dist))\b",
|
|
1077
|
+
re.DOTALL,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _copy_segments(c: str) -> list[str]:
|
|
1082
|
+
"""Segments of ``c`` split on ``;``, ``&&``, a bare newline and a bare ``|`` (quote/paren-aware).
|
|
1083
|
+
|
|
1084
|
+
Unlike ``_statements`` (used for the catastrophic-delete check), a pipe IS a boundary here:
|
|
1085
|
+
`some_cmd | cp x .env` still has `cp x .env` naming its own destination at the end of ITS
|
|
1086
|
+
segment, and `_COPY_DEST`'s trailing `$` anchor needs that segment on its own to match."""
|
|
1087
|
+
c = _CONTINUATION.sub(" ", c)
|
|
1088
|
+
out: list[str] = []
|
|
1089
|
+
cur: list[str] = []
|
|
1090
|
+
quote, depth, i = "", 0, 0
|
|
1091
|
+
while i < len(c):
|
|
1092
|
+
ch = c[i]
|
|
1093
|
+
if quote:
|
|
1094
|
+
quote = "" if ch == quote else quote
|
|
1095
|
+
elif ch in "'\"":
|
|
1096
|
+
quote = ch
|
|
1097
|
+
elif ch in "({":
|
|
1098
|
+
depth += 1
|
|
1099
|
+
elif ch in ")}" and depth:
|
|
1100
|
+
depth -= 1
|
|
1101
|
+
elif depth == 0 and (ch in ";\n|" or c[i:i + 2] == "&&"):
|
|
1102
|
+
out.append("".join(cur))
|
|
1103
|
+
cur = []
|
|
1104
|
+
i += 2 if c[i:i + 2] == "&&" else 1
|
|
1105
|
+
continue
|
|
1106
|
+
cur.append(ch)
|
|
1107
|
+
i += 1
|
|
1108
|
+
out.append("".join(cur))
|
|
1109
|
+
return [_strip_quotes(s).strip() for s in out]
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _shell_env_access(command: str) -> tuple[str, str]:
|
|
1113
|
+
"""Check simple shell file operands in one pass per command segment."""
|
|
1114
|
+
for seg in _copy_segments(command):
|
|
1115
|
+
# Redirection can precede or follow the command: `< .env cat` and `cat < .env`.
|
|
1116
|
+
for match in _INPUT_REDIRECT.finditer(seg):
|
|
1117
|
+
if classify_read(match.group(1))[0] == "deny":
|
|
1118
|
+
return "deny", "shell command reads a secret env file"
|
|
1119
|
+
|
|
1120
|
+
words = seg.split()
|
|
1121
|
+
for index, word in enumerate(words):
|
|
1122
|
+
if word.lower() == "dd":
|
|
1123
|
+
for arg in words[index + 1:]:
|
|
1124
|
+
if arg.startswith("if=") and classify_read(arg[3:])[0] == "deny":
|
|
1125
|
+
return "deny", "shell command reads a secret env file"
|
|
1126
|
+
break
|
|
1127
|
+
|
|
1128
|
+
if word.lower() == "git" and index + 1 < len(words) and words[index + 1].lower() == "show":
|
|
1129
|
+
for arg in words[index + 2:]:
|
|
1130
|
+
if ":" in arg and classify_read(arg.split(":", 1)[1])[0] == "deny":
|
|
1131
|
+
return "deny", "shell command reads a secret env file"
|
|
1132
|
+
break
|
|
1133
|
+
|
|
1134
|
+
if _COPY_DEST.fullmatch(word):
|
|
1135
|
+
operands: list[str] = []
|
|
1136
|
+
destination = ""
|
|
1137
|
+
i = index + 1
|
|
1138
|
+
while i < len(words):
|
|
1139
|
+
arg = words[i]
|
|
1140
|
+
if arg.lower() in ("-path", "-literalpath", "-source", "-destination") and i + 1 < len(words):
|
|
1141
|
+
if arg.lower() == "-destination":
|
|
1142
|
+
destination = words[i + 1]
|
|
1143
|
+
else:
|
|
1144
|
+
operands.append(words[i + 1])
|
|
1145
|
+
i += 2
|
|
1146
|
+
elif not arg.startswith("-"):
|
|
1147
|
+
operands.append(arg)
|
|
1148
|
+
i += 1
|
|
1149
|
+
else:
|
|
1150
|
+
i += 1
|
|
1151
|
+
if not destination and len(operands) >= 2:
|
|
1152
|
+
destination = operands.pop()
|
|
1153
|
+
if destination and classify_write(destination)[0] == "deny":
|
|
1154
|
+
return "deny", f"shell command copies onto a secret file ({destination})"
|
|
1155
|
+
if any(classify_read(source)[0] == "deny" for source in operands):
|
|
1156
|
+
return "deny", "shell command copies a secret env file"
|
|
1157
|
+
break
|
|
1158
|
+
|
|
1159
|
+
if _ENV_READ.fullmatch(word):
|
|
1160
|
+
if any(classify_read(path)[0] == "deny" for path in words[index + 1:]):
|
|
1161
|
+
return "deny", "shell command reads a secret env file"
|
|
1162
|
+
break
|
|
1163
|
+
return "allow", ""
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def classify_bash(command: str, root: str | None = None) -> tuple[str, str]:
|
|
1167
|
+
"""Risk tier (deny|ask|allow) + reason for a shell command. ``root`` is the cwd the command runs in
|
|
1168
|
+
(default: this process's cwd); `git add <dir>` resolves against it."""
|
|
358
1169
|
c = command.strip()
|
|
1170
|
+
data_free = _without_heredoc_bodies(c)
|
|
359
1171
|
cl = c.lower()
|
|
360
1172
|
n = _strip_quotes(c) # quote-normalized: defeats `rm '-rf' "/"` evasion
|
|
361
1173
|
nl = n.lower()
|
|
1174
|
+
|
|
1175
|
+
# ── deny: copying or reading a secret env file through the shell (copied from hooks/guard.py) ──
|
|
1176
|
+
_tier, _reason = _shell_env_access(_strip_quotes(data_free))
|
|
1177
|
+
if _tier == "deny":
|
|
1178
|
+
return _tier, _reason
|
|
1179
|
+
|
|
1180
|
+
# ── deny: writing a secret through the shell (copied from hooks/guard.py) ──
|
|
1181
|
+
# The same file must not be denied via a write tool and waved through via `echo ... > .env`.
|
|
1182
|
+
for _target in _shell_write_targets(data_free):
|
|
1183
|
+
_tier, _why = classify_write(_target)
|
|
1184
|
+
if _tier == "deny":
|
|
1185
|
+
return "deny", f"shell command {_why}"
|
|
1186
|
+
|
|
1187
|
+
# ── deny: an inline python/node/etc. one-liner that touches a secret env file ──
|
|
1188
|
+
if _INLINE_ENV.search(c):
|
|
1189
|
+
return "deny", "inline interpreter touches a secret env file"
|
|
1190
|
+
|
|
362
1191
|
rm_rf = _is_rm_recursive_force(n)
|
|
363
1192
|
find_delete = bool(re.search(_FIND_DELETE, nl))
|
|
364
1193
|
win_del = _is_win_recursive_delete(n)
|
|
@@ -373,10 +1202,22 @@ def classify_bash(command: str) -> tuple[str, str]:
|
|
|
373
1202
|
return "deny", "fork bomb"
|
|
374
1203
|
if re.search(r"\bdd\b.*\bof=/dev/", nl) or re.search(r"\bmkfs", nl) or re.search(r">\s*/dev/sd[a-z]", nl):
|
|
375
1204
|
return "deny", "writes directly to a disk device"
|
|
376
|
-
|
|
377
|
-
|
|
1205
|
+
_staging = _git_staging_safe(data_free, os.getcwd() if root is None else root)
|
|
1206
|
+
if _staging:
|
|
1207
|
+
return "deny", _staging
|
|
1208
|
+
_publish = _headless_publish_safe(data_free)
|
|
1209
|
+
if _publish:
|
|
1210
|
+
return "deny", _publish
|
|
1211
|
+
_shared = _shared_checkout_safe(data_free, os.getcwd() if root is None else root)
|
|
1212
|
+
if _shared and _headless():
|
|
1213
|
+
return "deny", f"headless session: {_shared}"
|
|
378
1214
|
|
|
379
1215
|
# ── ask: destructive-but-legit ──
|
|
1216
|
+
_config = _config_shell_safe(data_free)
|
|
1217
|
+
if _config:
|
|
1218
|
+
return "ask", _config
|
|
1219
|
+
if _shared:
|
|
1220
|
+
return "ask", _shared
|
|
380
1221
|
if rm_rf:
|
|
381
1222
|
return "ask", "recursive force-delete (rm -rf)"
|
|
382
1223
|
if find_delete:
|
|
@@ -403,12 +1244,107 @@ def classify_bash(command: str) -> tuple[str, str]:
|
|
|
403
1244
|
return "allow", ""
|
|
404
1245
|
|
|
405
1246
|
|
|
1247
|
+
# Copied from hooks/guard.py: ── the shared-tree lock during a spawn-session handover ─────────────────────
|
|
1248
|
+
# `spawn-session.md` told the child not to move the shared checkout while both sessions are live.
|
|
1249
|
+
# The parent pasted it verbatim and the successor switched branches within the hour (2026-09-10);
|
|
1250
|
+
# four files the parent had corrected reverted under it. A rule that has to be remembered is not a
|
|
1251
|
+
# gate, so while a handshake is open the guard refuses the git commands that move or discard the
|
|
1252
|
+
# tree. DENY, not ask: deny-only mode turns every ask into a silent allow, and that is the mode the
|
|
1253
|
+
# failure happened in.
|
|
1254
|
+
#
|
|
1255
|
+
# The verb must be git's SUBCOMMAND (first word after global options), so `git commit -m "reset x"`
|
|
1256
|
+
# and `git worktree add ... -b feat` pass. `stash list` / `stash show` only read.
|
|
1257
|
+
_TREE_MOVING_GIT = re.compile(
|
|
1258
|
+
r"\bgit(?:\s+-[Cc]\s+\S+|\s+--?[\w-]+(?:=\S+)?)*\s+"
|
|
1259
|
+
r"(?:switch|checkout|restore|rebase|reset|clean|stash(?!\s+(?:list|show)\b))(?=\s|$|[;&|])",
|
|
1260
|
+
re.I,
|
|
1261
|
+
)
|
|
1262
|
+
# Older than this is an abandoned handshake, not a live one — two REJECTs end a handshake that
|
|
1263
|
+
# `close` can never accept, and it must not lock the tree forever.
|
|
1264
|
+
_HANDSHAKE_LIVE_S = 24 * 3600
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _open_handshake(start: str) -> str:
|
|
1268
|
+
"""Id of a spawn-session handshake still open in the repo holding ``start``, or ''."""
|
|
1269
|
+
here = os.path.abspath(start)
|
|
1270
|
+
while True:
|
|
1271
|
+
d = os.path.join(here, _ARTIFACT_DIR, "spawn-session")
|
|
1272
|
+
if os.path.isdir(d):
|
|
1273
|
+
now = time.time()
|
|
1274
|
+
try:
|
|
1275
|
+
names = sorted(os.listdir(d))
|
|
1276
|
+
except FileNotFoundError: # gone since isdir: no handshake. Any other OSError reaches tree_lock_check.
|
|
1277
|
+
return ""
|
|
1278
|
+
for name in names:
|
|
1279
|
+
if not name.endswith("-handshake.json"):
|
|
1280
|
+
continue
|
|
1281
|
+
# A file that is not a lock (unreadable, not an object, no `tree_lock: true`) is skipped. It must
|
|
1282
|
+
# never raise: the hook would fail closed and deny every tree-moving git command.
|
|
1283
|
+
try:
|
|
1284
|
+
with open(os.path.join(d, name), encoding="utf-8") as fh:
|
|
1285
|
+
st = json.load(fh)
|
|
1286
|
+
except Exception:
|
|
1287
|
+
continue
|
|
1288
|
+
# `tree_lock` is written by handshakes opened since the lock existed. Older files
|
|
1289
|
+
# never stamped `closed_at`, so without this opt-in a handshake finished under the
|
|
1290
|
+
# old code would lock a live session's tree — measured on a real one, 11h old.
|
|
1291
|
+
if not isinstance(st, dict) or st.get("tree_lock") is not True or st.get("closed_at"):
|
|
1292
|
+
continue
|
|
1293
|
+
# An explicit open lock fails closed: a missing or bad `opened_at` is live, never abandoned.
|
|
1294
|
+
try:
|
|
1295
|
+
opened = float(st["opened_at"])
|
|
1296
|
+
except (KeyError, TypeError, ValueError):
|
|
1297
|
+
opened = now
|
|
1298
|
+
if math.isfinite(opened) and now - opened > _HANDSHAKE_LIVE_S:
|
|
1299
|
+
continue
|
|
1300
|
+
return str(st.get("id") or name[: -len("-handshake.json")])
|
|
1301
|
+
return ""
|
|
1302
|
+
parent = os.path.dirname(here)
|
|
1303
|
+
if parent == here:
|
|
1304
|
+
return ""
|
|
1305
|
+
here = parent
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def tree_lock_check(command: str, root: str) -> tuple[str, str]:
|
|
1309
|
+
"""("deny", why) when ``command`` would move a checkout a live handshake shares; ("ask", why) when the handshake
|
|
1310
|
+
folder cannot be read, so a live lock may be hidden; else ("", "")."""
|
|
1311
|
+
m = _TREE_MOVING_GIT.search(command)
|
|
1312
|
+
if not m:
|
|
1313
|
+
return "", ""
|
|
1314
|
+
# `git -C <path>` acts on <path>, not on the checkout the command was issued from.
|
|
1315
|
+
target = re.search(r"\s-C\s+(\S+)", m.group(0))
|
|
1316
|
+
if target:
|
|
1317
|
+
root = os.path.join(root, target.group(1).strip("'\""))
|
|
1318
|
+
try:
|
|
1319
|
+
hs = _open_handshake(root)
|
|
1320
|
+
except OSError as exc:
|
|
1321
|
+
return "ask", (f"cannot read the spawn-session handshakes ({type(exc).__name__}), so another session may "
|
|
1322
|
+
"share this checkout; confirm that no handshake is open before you move the tree")
|
|
1323
|
+
if not hs:
|
|
1324
|
+
return "", ""
|
|
1325
|
+
return "deny", _tree_lock_why(hs)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def tree_lock_reason(command: str, root: str) -> str:
|
|
1329
|
+
"""The deny reason when ``command`` would move a checkout a live handshake shares, else ''."""
|
|
1330
|
+
tier, why = tree_lock_check(command, root)
|
|
1331
|
+
return why if tier == "deny" else ""
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _tree_lock_why(hs: str) -> str:
|
|
1335
|
+
return (f"spawn-session handshake `{hs}` is open, so two sessions share this checkout. Moving or "
|
|
1336
|
+
"discarding the tree here carries or destroys the other session's uncommitted work. "
|
|
1337
|
+
"Work in your own tree instead: `git worktree add <scratch>/<name> -b <branch> "
|
|
1338
|
+
"origin/main`, and remove it when the work lands. The lock lifts at `handshake close`")
|
|
1339
|
+
|
|
1340
|
+
|
|
406
1341
|
# ── agy tool-shape mapping ───────────────────────────────────────────────────
|
|
407
1342
|
# The shell tool: step type CORTEX_STEP_TYPE_RUN_COMMAND -> "run_command". The command lives at
|
|
408
1343
|
# args.CommandLine. Extra spellings are accepted so a step-type rename can't silently un-guard the
|
|
409
1344
|
# shell (a missed tool name = no guard at all, which is the failure we most want to avoid).
|
|
410
1345
|
_COMMAND_TOOLS = {"run_command", "runcommand", "run_terminal_command", "bash", "shell", "terminal"}
|
|
411
1346
|
_COMMAND_ARG_KEYS = ("CommandLine", "commandLine", "command_line", "command", "Command")
|
|
1347
|
+
_CWD_ARG_KEYS = ("Cwd", "cwd", "WorkingDirectory", "workingDirectory")
|
|
412
1348
|
|
|
413
1349
|
# Write-ish tool detection is heuristic until the live check pins agy's file-edit tool name.
|
|
414
1350
|
_WRITE_NAME_RE = re.compile(r"(edit|write|create|replace|patch|append)", re.I)
|
|
@@ -417,6 +1353,11 @@ _PATH_ARG_KEYS = ("FilePath", "filePath", "file_path", "AbsolutePath", "absolute
|
|
|
417
1353
|
"TargetFile", "targetFile", "Path", "path")
|
|
418
1354
|
|
|
419
1355
|
|
|
1356
|
+
def _is_write_tool(name: str) -> bool:
|
|
1357
|
+
"""A tool whose name looks like an edit/write and not like a read (the heuristic decide() uses)."""
|
|
1358
|
+
return bool(_WRITE_NAME_RE.search(name)) and not _READONLY_NAME_RE.search(name)
|
|
1359
|
+
|
|
1360
|
+
|
|
420
1361
|
def _first_str(args: dict, keys) -> str:
|
|
421
1362
|
for k in keys:
|
|
422
1363
|
v = args.get(k)
|
|
@@ -425,7 +1366,7 @@ def _first_str(args: dict, keys) -> str:
|
|
|
425
1366
|
return ""
|
|
426
1367
|
|
|
427
1368
|
|
|
428
|
-
def decide(tool_name: str, args, allow_patterns: list[str]) -> tuple[str, str]:
|
|
1369
|
+
def decide(tool_name: str, args, allow_patterns: list[str], root: str | None = None) -> tuple[str, str]:
|
|
429
1370
|
"""Top-level decision. Classify FIRST; the allow escape hatch may only DOWNGRADE an `ask` to
|
|
430
1371
|
`allow` (never override a `deny`), and it matches the command/path value — not the JSON envelope —
|
|
431
1372
|
so a key name like "command" can't disable the guard. Never raises on a malformed payload."""
|
|
@@ -435,8 +1376,9 @@ def decide(tool_name: str, args, allow_patterns: list[str]) -> tuple[str, str]:
|
|
|
435
1376
|
|
|
436
1377
|
if name in _COMMAND_TOOLS:
|
|
437
1378
|
target = _first_str(args, _COMMAND_ARG_KEYS)
|
|
438
|
-
|
|
439
|
-
|
|
1379
|
+
cwd = _first_str(args, _CWD_ARG_KEYS) or root # the command's own cwd, else the workspace root
|
|
1380
|
+
tier, reason = classify_bash(target, cwd) if target else ("allow", "")
|
|
1381
|
+
elif _is_write_tool(name):
|
|
440
1382
|
target = _first_str(args, _PATH_ARG_KEYS)
|
|
441
1383
|
tier, reason = classify_write(target) if target else ("allow", "")
|
|
442
1384
|
else:
|
|
@@ -477,31 +1419,35 @@ def _load_allow(root: str) -> list[str]:
|
|
|
477
1419
|
return [str(x) for x in allow] if isinstance(allow, list) else []
|
|
478
1420
|
|
|
479
1421
|
|
|
480
|
-
def
|
|
481
|
-
"""
|
|
1422
|
+
def _env_off() -> bool:
|
|
1423
|
+
return os.environ.get(f"{_ENV_PREFIX}_GUARD_DISABLED", "").strip().lower() in _TRUTHY
|
|
482
1424
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1425
|
+
|
|
1426
|
+
def _config_off(section: dict) -> bool:
|
|
1427
|
+
"""A config off switch in the ``[guard]`` table: ``enabled = false`` or ``mode = "off"``."""
|
|
1428
|
+
return section.get("enabled") is False or str(section.get("mode", "")).strip().lower() == "off"
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def guard_disabled(root: str | None = None) -> bool:
|
|
1432
|
+
"""True when the guard is turned off entirely (all tiers bypassed): env CADDIS_GUARD_DISABLED, interactively.
|
|
1433
|
+
|
|
1434
|
+
Robustness #13: the config file cannot do this any more (a model can write it; see ``deny_only``), and a
|
|
1435
|
+
headless session ignores the env var too. ``root`` is kept for callers; the answer no longer reads it."""
|
|
1436
|
+
return _env_off() and not _headless()
|
|
494
1437
|
|
|
495
1438
|
|
|
496
1439
|
def deny_only(root: str) -> bool:
|
|
497
|
-
"""True when the guard keeps DENY (silent hard-block) but suppresses the ASK tier
|
|
498
|
-
|
|
499
|
-
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."""
|
|
500
1446
|
_mode = os.environ.get(f"{_ENV_PREFIX}_GUARD_MODE", "")
|
|
501
|
-
if _mode.strip().lower() == "deny-only":
|
|
1447
|
+
if _mode.strip().lower() == "deny-only" or (_env_off() and _headless()):
|
|
502
1448
|
return True
|
|
503
1449
|
section = _load_guard_config(root)
|
|
504
|
-
return str(section.get("mode", "")).strip().lower() == "deny-only"
|
|
1450
|
+
return _config_off(section) or str(section.get("mode", "")).strip().lower() == "deny-only"
|
|
505
1451
|
|
|
506
1452
|
|
|
507
1453
|
# ── hook I/O glue (agy envelope) ─────────────────────────────────────────────
|
|
@@ -558,6 +1504,23 @@ def _workspace_roots(data):
|
|
|
558
1504
|
return []
|
|
559
1505
|
return [str(r) for r in roots if isinstance(r, (str, bytes)) and str(r).strip()]
|
|
560
1506
|
|
|
1507
|
+
def decide_call(tool_name: str, args, allow_patterns: list[str], root: str) -> tuple[str, str]:
|
|
1508
|
+
"""decide() plus the spawn-session tree lock for a shell call (guard.py applies it in hook_decision)."""
|
|
1509
|
+
tier, reason = decide(tool_name, args, allow_patterns, root)
|
|
1510
|
+
if tier != "deny" and (tool_name or "").strip().lower() in _COMMAND_TOOLS and isinstance(args, dict):
|
|
1511
|
+
cwd = _first_str(args, _CWD_ARG_KEYS) or root # the command's own cwd wins, as in decide()
|
|
1512
|
+
lock_tier, lock = tree_lock_check(_first_str(args, _COMMAND_ARG_KEYS), cwd)
|
|
1513
|
+
if lock_tier == "deny" or (lock_tier == "ask" and tier == "allow"):
|
|
1514
|
+
tier, reason = lock_tier, lock
|
|
1515
|
+
return tier, reason
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def _fails_closed(tool_name: str) -> bool:
|
|
1519
|
+
"""A shell or write call: an internal error while judging it denies it (robustness #13)."""
|
|
1520
|
+
name = (tool_name or "").strip().lower()
|
|
1521
|
+
return name in _COMMAND_TOOLS or _is_write_tool(name)
|
|
1522
|
+
|
|
1523
|
+
|
|
561
1524
|
def main() -> None:
|
|
562
1525
|
tier, reason = "allow", ""
|
|
563
1526
|
try:
|
|
@@ -567,37 +1530,47 @@ def main() -> None:
|
|
|
567
1530
|
_reconfig(encoding="utf-8")
|
|
568
1531
|
except Exception as _exc:
|
|
569
1532
|
_hook_note("agy stdout utf-8 reconfigure", _exc)
|
|
1533
|
+
# Silent ONLY for a payload it cannot read: there is no call to judge.
|
|
570
1534
|
try:
|
|
571
1535
|
data = json.load(sys.stdin)
|
|
572
|
-
except
|
|
573
|
-
|
|
1536
|
+
except ValueError as _exc: # JSONDecodeError and UnicodeDecodeError are ValueErrors
|
|
1537
|
+
_hook_note("agy guard unparseable payload", _exc)
|
|
1538
|
+
return
|
|
574
1539
|
if not isinstance(data, dict):
|
|
575
1540
|
return
|
|
576
1541
|
call = data.get("toolCall")
|
|
577
1542
|
if not isinstance(call, dict):
|
|
578
1543
|
return
|
|
1544
|
+
name, args = str(call.get("name") or ""), call.get("args")
|
|
579
1545
|
roots = _workspace_roots(data) or [os.getcwd()]
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
tier =
|
|
599
|
-
|
|
600
|
-
|
|
1546
|
+
try:
|
|
1547
|
+
# Only the interactive env var turns the guard fully off (robustness #13); a config file cannot.
|
|
1548
|
+
if guard_disabled(roots[0]):
|
|
1549
|
+
return
|
|
1550
|
+
|
|
1551
|
+
# Every per-repo guard setting RELAXES the guard: `mode = "deny-only"` (and now a config off switch)
|
|
1552
|
+
# drops the ask tier, and `[guard] allow` downgrades an ask. Reading them from workspacePaths[0]
|
|
1553
|
+
# meant one repo's relaxation silently applied to every other repo in the workspace. Require
|
|
1554
|
+
# UNANIMITY instead: a relaxation applies only when every root asks for it.
|
|
1555
|
+
# The allow-list downgrades an ask, so with several roots take only the entries
|
|
1556
|
+
# present in ALL of them — the strictest reading, not the most permissive.
|
|
1557
|
+
allow = _load_allow(roots[0])
|
|
1558
|
+
for other in roots[1:]:
|
|
1559
|
+
allow = [a for a in allow if a in _load_allow(other)]
|
|
1560
|
+
|
|
1561
|
+
# A path such as `git add src` may sit in any workspace root: decide once per root, keep the strictest.
|
|
1562
|
+
# (guard.py has one cwd. A run_command `Cwd` argument wins inside decide, so every root agrees then.)
|
|
1563
|
+
_rank = {"deny": 0, "ask": 1}
|
|
1564
|
+
tier, reason = min((decide_call(name, args, allow, r) for r in roots),
|
|
1565
|
+
key=lambda d: _rank.get(d[0], 2))
|
|
1566
|
+
if tier == "ask" and all(deny_only(r) for r in roots):
|
|
1567
|
+
tier = "allow" # deny-only: keep catastrophe blocks, never force_ask (no plan interruption)
|
|
1568
|
+
except Exception as _exc: # noqa: BLE001 - fail CLOSED for a shell or write call, open otherwise
|
|
1569
|
+
_hook_note("agy guard decision", _exc, roots[0])
|
|
1570
|
+
tier, reason = ("deny", f"guard error: {type(_exc).__name__}: {str(_exc)[:120]}") if _fails_closed(name) else ("allow", "")
|
|
1571
|
+
except Exception as _exc:
|
|
1572
|
+
_hook_note("agy guard payload", _exc)
|
|
1573
|
+
tier, reason = "allow", "" # a payload shape it cannot read: no call to judge, never a crash
|
|
601
1574
|
finally:
|
|
602
1575
|
try:
|
|
603
1576
|
_emit(tier, reason)
|