@caddis/cli 0.4.12 → 0.4.14

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.
@@ -79,8 +79,9 @@ def classify_write(file_path: str) -> tuple[str, str]:
79
79
  # subpath (E:\proj\dist falls through to ask).
80
80
  _CATASTROPHIC_TARGET = re.compile(
81
81
  r"--no-preserve-root"
82
- r"|(?:^|\s)(?:/|/\*|~/|~|\$\{?HOME\}?)(?:\s|$|;|&|\|)"
83
- r"|(?:^|\s)[A-Za-z]:[\\/]?(?:\s|$|;|&|\|)",
82
+ # A closing bracket also ends the target: `echo $(rm -rf ~)` was ask, not deny (review catch).
83
+ r"|(?:^|\s)(?:/|/\*|~/|~|\$\{?HOME\}?)(?:\s|$|;|&|\||[)}])"
84
+ r"|(?:^|\s)[A-Za-z]:[\\/]?(?:\s|$|;|&|\||[)}])",
84
85
  re.I,
85
86
  )
86
87
  _FORK_BOMB = re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:")
@@ -115,6 +116,61 @@ def _is_win_recursive_delete(cmd: str) -> bool:
115
116
  return ps_recurse_force or cmd_slash_s
116
117
 
117
118
 
119
+ _FIND_DELETE = r"\bfind\b.*?(?:-delete\b|-exec\s+rm\b)"
120
+ # Line continuations join a statement across physical lines: bash `\` and PowerShell backtick.
121
+ _CONTINUATION = re.compile(r"[\\`]\r?\n")
122
+
123
+
124
+ def _statements(c: str) -> list[str]:
125
+ """Split a command on `;`, `&&`, `||` and newlines — ONLY at the top level. Same rule as
126
+ hooks/guard.py: a separator inside quotes, a subshell/block or after a line continuation does
127
+ not end a statement, and an unclosed quote or bracket errs toward one statement (deny)."""
128
+ c = _CONTINUATION.sub(" ", c)
129
+ out: list[str] = []
130
+ cur: list[str] = []
131
+ quote, depth, i = "", 0, 0
132
+ while i < len(c):
133
+ ch = c[i]
134
+ if quote:
135
+ quote = "" if ch == quote else quote
136
+ elif ch in "'\"":
137
+ quote = ch
138
+ elif ch in "({":
139
+ depth += 1
140
+ elif ch in ")}" and depth:
141
+ depth -= 1
142
+ elif depth == 0 and (ch in ";\n" or c[i:i + 2] in ("&&", "||")):
143
+ out.append("".join(cur))
144
+ cur = []
145
+ i += 1 if ch in ";\n" else 2
146
+ continue
147
+ cur.append(ch)
148
+ i += 1
149
+ out.append("".join(cur))
150
+ return [_strip_quotes(s) for s in out]
151
+
152
+
153
+ def _catastrophic_statement(c: str) -> str:
154
+ """The deny reason when ONE statement both destroys recursively and names a root, else ''.
155
+
156
+ Same rule as hooks/guard.py. Tested per statement, not per command: an unrelated `~` or `C:`
157
+ on another line used to turn a scratchpad cleanup into a root/home deny (found 2026-09-10)."""
158
+ for seg in _statements(c):
159
+ if not _CATASTROPHIC_TARGET.search(seg):
160
+ continue
161
+ sl = seg.lower()
162
+ if _is_rm_recursive_force(seg):
163
+ return "recursive force-delete of a root/home path (rm -rf)"
164
+ if re.search(_FIND_DELETE, sl):
165
+ return "find -delete from a root/home path"
166
+ if _is_win_recursive_delete(seg):
167
+ return "recursive force-delete of a root/home path (Windows Remove-Item/rmdir)"
168
+ if (re.search(r"\bchmod\b", sl) and _has_flag(seg, "r", "--recursive")
169
+ and re.search(r"\b0?777\b", sl)):
170
+ return "recursive chmod 777 on a root path"
171
+ return ""
172
+
173
+
118
174
  def classify_bash(command: str) -> tuple[str, str]:
119
175
  """Risk tier (deny|ask|allow) + reason for a shell command."""
120
176
  c = command.strip()
@@ -122,26 +178,19 @@ def classify_bash(command: str) -> tuple[str, str]:
122
178
  n = _strip_quotes(c) # quote-normalized: defeats `rm '-rf' "/"` evasion
123
179
  nl = n.lower()
124
180
  rm_rf = _is_rm_recursive_force(n)
125
- catastrophic = bool(_CATASTROPHIC_TARGET.search(n))
126
- find_delete = bool(re.search(r"\bfind\b.*?(?:-delete\b|-exec\s+rm\b)", nl))
181
+ find_delete = bool(re.search(_FIND_DELETE, nl))
127
182
  win_del = _is_win_recursive_delete(n)
128
183
 
129
184
  # ── deny: catastrophic ──
130
- if rm_rf and catastrophic:
131
- return "deny", "recursive force-delete of a root/home path (rm -rf)"
132
- if find_delete and catastrophic:
133
- return "deny", "find -delete from a root/home path"
134
- if win_del and catastrophic:
135
- return "deny", "recursive force-delete of a root/home path (Windows Remove-Item/rmdir)"
185
+ why = _catastrophic_statement(c)
186
+ if why:
187
+ return "deny", why
136
188
  if re.search(r"\brmtree\s*\(\s*['\"]?(?:/|~|[A-Za-z]:\\?)['\"]?\s*\)", c, re.I):
137
189
  return "deny", "recursive tree delete of a root/home path (rmtree)"
138
190
  if _FORK_BOMB.search(c):
139
191
  return "deny", "fork bomb"
140
192
  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):
141
193
  return "deny", "writes directly to a disk device"
142
- if (re.search(r"\bchmod\b", nl) and _has_flag(n, "r", "--recursive")
143
- and re.search(r"\b0?777\b", nl) and catastrophic):
144
- return "deny", "recursive chmod 777 on a root path"
145
194
 
146
195
  # ── ask: destructive-but-legit ──
147
196
  if rm_rf:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.101",
3
+ "version": "1.3.105",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -0,0 +1,146 @@
1
+ """SessionStart: where this checkout stands against the remote default branch.
2
+
3
+ WHY IT FETCHES. Nobody runs git by hand before opening an agent — so without a fetch,
4
+ `origin/main` is whatever it was last time. The agent then branches from a stale ref and
5
+ believes it is current. The fetch is what makes every later "branch from origin/main"
6
+ mean the newest main. One consumer repo carries the same lesson as a rule of its own:
7
+ audit `origin/main`, never the working tree.
8
+
9
+ FAIL OPEN, ALWAYS. Every path exits 0 and every git call is wrapped. A hook that can hang
10
+ or fail a session start is worse than the staleness it prevents, so a failed or slow fetch
11
+ reports and continues.
12
+
13
+ IT ONLY PRINTS. It never switches, stashes, resets, commits or creates anything except one
14
+ throttle stamp inside .git/. A SessionStart hook cannot ask a question, and these checkouts
15
+ are shared with other live sessions — acting without an answer could take work with it.
16
+
17
+ QUIET WHEN THERE IS NOTHING TO SAY. On the default branch, clean and level, it prints
18
+ nothing at all, so the output stays worth reading.
19
+
20
+ Ported into caddis 2026-09-12 from a personal hook that existed on one machine only
21
+ (~/.claude/hooks/repo-freshness.sh), so agy gets it too and it is versioned and tested.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import subprocess
28
+ import sys
29
+ import time
30
+
31
+ # A narrow Windows console (cp1252) must not raise on a branch name with any Unicode in it.
32
+ _reconfig = getattr(sys.stdout, "reconfigure", None)
33
+ if _reconfig:
34
+ try:
35
+ _reconfig(encoding="utf-8")
36
+ except Exception:
37
+ pass
38
+
39
+ FETCH_TIMEOUT_S = 15 # ceiling: a slow or unreachable remote must not stall a session
40
+ FETCH_MAX_AGE_S = 600 # a restart within 10 minutes reuses the last fetch
41
+ STAMP_NAME = "caddis-freshness-stamp"
42
+
43
+
44
+ def _git(cwd: str, *args: str, timeout: int = 15) -> tuple[int, str]:
45
+ """(exit code, stdout). Never raises; a timeout is just a non-zero code."""
46
+ try:
47
+ r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
48
+ encoding="utf-8", errors="replace", timeout=timeout)
49
+ except (OSError, subprocess.SubprocessError):
50
+ return 1, ""
51
+ return r.returncode, (r.stdout or "").strip()
52
+
53
+
54
+ def _default_ref(cwd: str) -> str:
55
+ """The remote's default branch, e.g. origin/main. '' when there is no usable one."""
56
+ code, out = _git(cwd, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
57
+ if code == 0 and out:
58
+ return out
59
+ for cand in ("origin/main", "origin/master"):
60
+ if _git(cwd, "rev-parse", "--verify", "--quiet", cand)[0] == 0:
61
+ return cand
62
+ return ""
63
+
64
+
65
+ def _fetch_is_due(git_dir: str) -> bool:
66
+ stamp = os.path.join(git_dir, STAMP_NAME)
67
+ try:
68
+ return (time.time() - os.path.getmtime(stamp)) > FETCH_MAX_AGE_S
69
+ except OSError:
70
+ return True
71
+
72
+
73
+ def _mark_fetched(git_dir: str) -> None:
74
+ try:
75
+ with open(os.path.join(git_dir, STAMP_NAME), "w", encoding="utf-8") as fh:
76
+ fh.write(str(int(time.time())))
77
+ except OSError:
78
+ pass # the throttle is an optimisation, never a reason to fail
79
+
80
+
81
+ def _count(cwd: str, rev_range: str) -> int:
82
+ code, out = _git(cwd, "rev-list", "--count", rev_range)
83
+ return int(out) if code == 0 and out.isdigit() else 0
84
+
85
+
86
+ def report(cwd: str) -> list[str]:
87
+ """The lines to print. Empty when there is nothing worth saying."""
88
+ if _git(cwd, "rev-parse", "--is-inside-work-tree")[0] != 0:
89
+ return [] # not a git repository
90
+ branch = _git(cwd, "branch", "--show-current")[1]
91
+ if not branch:
92
+ return [] # detached HEAD: nothing useful to say
93
+ lines: list[str] = []
94
+ # A repo with no `origin` has nothing to be behind. Fetching it would fail every time and
95
+ # the "could not fetch" note would be pure noise — common for scratch and local-only repos.
96
+ has_origin = "origin" in _git(cwd, "remote")[1].split()
97
+ git_dir = _git(cwd, "rev-parse", "--absolute-git-dir")[1] or os.path.join(cwd, ".git")
98
+ if has_origin and _fetch_is_due(git_dir):
99
+ if _git(cwd, "fetch", "origin", "--quiet", timeout=FETCH_TIMEOUT_S)[0] == 0:
100
+ _mark_fetched(git_dir)
101
+ else:
102
+ lines.append("[HARNESS] could not fetch origin - the figures below may be stale.")
103
+
104
+ ref = _default_ref(cwd) if has_origin else ""
105
+ dirty = len([ln for ln in _git(cwd, "status", "--porcelain")[1].splitlines() if ln.strip()])
106
+ behind = _count(cwd, f"HEAD..{ref}") if ref else 0
107
+ ahead = _count(cwd, f"{ref}..HEAD") if ref else 0
108
+ default_branch = ref.split("/", 1)[-1] if ref else ""
109
+
110
+ # With no remote to compare against, the only honest line left is the dirty warning.
111
+ if ref and (branch != default_branch or behind or dirty):
112
+ lines.append(f"[HARNESS] branch '{branch}' | {behind} behind {ref} | {ahead} ahead "
113
+ f"| {dirty} uncommitted")
114
+ if dirty:
115
+ lines.append("[HARNESS] There are uncommitted changes here. They may not be yours - these "
116
+ "checkouts are shared. Look before you touch: git status")
117
+ # The recommendation, only when following it can lose nothing: behind, clean, and with
118
+ # nothing of your own unpushed. Suggesting a branch switch to someone holding uncommitted
119
+ # work in a shared checkout is how the branch-switch hazard gets triggered.
120
+ if behind and not dirty and not ahead:
121
+ lines.append(f"[HARNESS] This checkout is behind and clean. Start new work with: "
122
+ f"git checkout -b <type>/<name> {ref}")
123
+ if ahead:
124
+ lines.append(f"[HARNESS] {ahead} local commit(s) are not on {ref}. Do not reset or "
125
+ f"re-branch without checking them: git log --oneline {ref}..HEAD")
126
+ return lines
127
+
128
+
129
+ def main() -> int:
130
+ # The session's cwd, not the hook process's: a session launched in one repo but working
131
+ # in another must be reported on the repo it is actually in (same rule as inject_relay).
132
+ try:
133
+ payload = json.load(sys.stdin)
134
+ except Exception:
135
+ payload = {}
136
+ cwd = (payload.get("cwd") if isinstance(payload, dict) else None) or os.getcwd()
137
+ try:
138
+ for line in report(cwd):
139
+ print(line)
140
+ except Exception:
141
+ pass # fail open: a freshness report must never break a session start
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ raise SystemExit(main())
@@ -0,0 +1,11 @@
1
+ # caddis-run — start an agent session inside a capped Job Object (see docs/guide/resource-protection.md in the caddis source repo).
2
+ # Usage: caddis-run claude | caddis-run codex | caddis-run agy | caddis-run claude-glm
3
+ # Always goes through the governor: an environment variable is not proof of a cap. Prefers the
4
+ # admin-installed governor copy, which an agent running as the user cannot edit.
5
+ if ($args.Count -eq 0) { Write-Error "usage: caddis-run <command> [args...]"; exit 2 }
6
+ $installed = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'caddis\caddis_governor.py'
7
+ $gov = if (Test-Path $installed) { $installed } else { Join-Path $PSScriptRoot 'caddis_governor.py' }
8
+ $py = (Get-Command python -ErrorAction SilentlyContinue).Source
9
+ if (-not $py) { Write-Error "caddis-run: python not found on PATH; refusing to start ungoverned"; exit 3 }
10
+ & $py $gov exec --scope session -- @args
11
+ exit $LASTEXITCODE
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ # caddis-run — start an agent session inside a capped scope (see docs/guide/resource-protection.md in the caddis source repo).
3
+ # Always goes through the governor: an environment variable is not proof of a cap.
4
+ [ "$#" -eq 0 ] && { echo "usage: caddis-run <command> [args...]" >&2; exit 2; }
5
+ here=$(dirname "$0")
6
+ gov="$here/caddis_governor.py"
7
+ [ -f /etc/caddis/caddis_governor.py ] && gov=/etc/caddis/caddis_governor.py
8
+ py=$(command -v python3 || command -v python) || { echo "caddis-run: python not found; refusing to start ungoverned" >&2; exit 3; }
9
+ exec "$py" "$gov" exec --scope session -- "$@"
@@ -83,11 +83,20 @@ def phase_lane(block: str) -> str:
83
83
 
84
84
 
85
85
  def launch_command(block: str) -> str:
86
- """The literal command for a non-claude lane, if the phase names one."""
87
- m = re.search(r"`(claude-(?:glm|oss|deepseek)[^`]*)`", block)
86
+ """The literal command for a non-claude lane, if the phase names one.
87
+
88
+ Also matches a caddis_lanes.py run command for the codex, agy and glm lanes.
89
+ """
90
+ m = re.search(
91
+ r'`((?:claude-(?:glm|oss|deepseek)|' + _LANES_RUN + r')[^`]*)`',
92
+ block,
93
+ )
88
94
  return m.group(1).strip() if m else ""
89
95
 
90
96
 
97
+ _LANES_RUN = r'python\s+"?\$\{CLAUDE_PLUGIN_ROOT\}/scripts/caddis_lanes\.py"?\s+run'
98
+
99
+
91
100
  # ── gate: lane-check ────────────────────────────────────────────────────────
92
101
 
93
102
  def gate_lane(plan: Path, phase: int) -> int:
@@ -110,7 +119,19 @@ def gate_lane(plan: Path, phase: int) -> int:
110
119
  if not cmd:
111
120
  sys.stderr.write(f"[caddis-gate] phase {phase} is lane '{lane}' but names no launch command\n")
112
121
  return EXIT_MALFORMED
113
- if not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
122
+ if re.match(_LANES_RUN, cmd):
123
+ named = re.search(r"--phase[\s=]+(\d+)", cmd)
124
+ if not named:
125
+ sys.stderr.write(
126
+ f"[caddis-gate] phase {phase}'s caddis_lanes command has no --phase N: {cmd}\n"
127
+ " Without it the lane would run the whole plan. Refusing to run it.\n")
128
+ return EXIT_MALFORMED
129
+ if int(named.group(1)) != phase:
130
+ sys.stderr.write(
131
+ f"[caddis-gate] phase {phase}'s caddis_lanes command names --phase {named.group(1)}: {cmd}\n"
132
+ " The lane would run another phase's work. Refusing to run it.\n")
133
+ return EXIT_MALFORMED
134
+ elif not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
114
135
  sys.stderr.write(
115
136
  f"[caddis-gate] phase {phase}'s launch command has no -p/--print: {cmd}\n"
116
137
  " Without it the child is not marked headless, reads the same Lane line, and spawns\n"