@caddis/cli 0.4.76 → 0.4.78

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.
@@ -37,6 +37,7 @@ looks like an edit/write and whose args carry a path. Unknown tools -> allow.
37
37
  """
38
38
  from __future__ import annotations
39
39
 
40
+ import fnmatch
40
41
  import json
41
42
  import math
42
43
  import os
@@ -78,6 +79,9 @@ def classify_write(file_path: str) -> tuple[str, str]:
78
79
  return "deny", f"writes an environment/secret file ({base})"
79
80
  if base in _SECRET_BASENAMES or any(base.endswith(s) for s in _SECRET_SUFFIXES):
80
81
  return "deny", f"writes a credential/key file ({base})"
82
+ why = hub_dir_reason(file_path)
83
+ if why:
84
+ return "deny", why
81
85
 
82
86
  why = guard_config_reason(file_path)
83
87
  if why:
@@ -126,6 +130,114 @@ def guard_config_reason(path: str) -> str:
126
130
  return ""
127
131
 
128
132
 
133
+ # ── the hub folder (hub design 7a) ───────────────────────────────────────────
134
+ # `~/.caddis/hub/**` (and `$CADDIS_HOME/.caddis/hub/**` when CADDIS_HOME is set) holds the hub key, the repo registry,
135
+ # the owner requests and the audit log. An agent that writes there could forge an owner request, so a write by tool or
136
+ # through the shell (redirection, tee, cp/mv, Set-Content, Out-File, rm, Remove-Item, ...) denies. Reads stay free.
137
+ # A run of `python .../caddis_coordinate.py` stays free: the script writes, not a shell redirection. Known limits (it
138
+ # is not a boundary): an interpreter one-liner, a script that writes the file, a variable other than the home ones.
139
+ # Copied from hooks/guard.py; test_guard_agy.py checks they agree.
140
+ _WHY_HUB = "writes the caddis hub folder (~/.caddis/hub); only the hub and caddis_coordinate.py write there"
141
+ # CADDIS_HOME as a shell spells it. It becomes the marker below before the tokenizer splits the text on `$`.
142
+ _CADDIS_HOME_VAR = re.compile(r"\$\{?env:caddis_home\}?|\$\{caddis_home\}|\$caddis_home\b|%caddis_home%", re.I)
143
+ _CADDIS_HOME_MARK = "~caddis-home"
144
+ _HUB_DELETERS = frozenset({"rm", "del", "erase", "rmdir", "rd", "unlink", "shred", "truncate", "remove-item", "ri",
145
+ "clear-content", "clc"})
146
+
147
+
148
+ def _hub_dirs() -> list[str]:
149
+ """The hub folders to protect: under the user home, and under CADDIS_HOME when it is set."""
150
+ homes = [os.path.expanduser("~")]
151
+ if os.environ.get("CADDIS_HOME"):
152
+ homes.append(os.environ["CADDIS_HOME"])
153
+ return [os.path.normcase(os.path.realpath(os.path.join(h, _ARTIFACT_DIR, "hub"))) for h in homes]
154
+
155
+
156
+ # A device prefix that Windows removes before it opens the path: `\\?\`, `\\.\`, `\\?\UNC\`, `\??\` (either slash).
157
+ _HUB_DEVICE_PREFIX = re.compile(r"^[\\/]{2}[?.][\\/](?:unc[\\/])?|^[\\/]\?\?[\\/]", re.I)
158
+ _HUB_GLOB = re.compile(r"[*?\[]")
159
+ # A `$` just before a separator is never a variable: an admin share (`\\localhost\c$\...`). The tokenizer
160
+ # splits on `$`, so the target finders drop it first.
161
+ _SHARE_DOLLAR = re.compile(r"\$(?=[\\/])")
162
+
163
+
164
+ def _hub_clean(raw: str) -> str:
165
+ """``raw`` spelled the way Windows opens it, with `/` separators: no device prefix, each segment cut at its first
166
+ `:` (an NTFS stream such as `::$DATA` or `::$INDEX_ALLOCATION`; a drive letter stays), and trailing dots and
167
+ spaces removed from each segment (`hub.` and `hub ` open `hub`)."""
168
+ raw = _HUB_DEVICE_PREFIX.sub("", raw)
169
+ lead = re.match(r"[\\/]*", raw).group(0)
170
+ segs = []
171
+ for i, seg in enumerate(re.split(r"[\\/]+", raw[len(lead):])):
172
+ drive = seg[:2] if i == 0 and not lead and re.match(r"[A-Za-z]:", seg) else ""
173
+ seg = drive + seg[len(drive):].split(":", 1)[0]
174
+ trimmed = seg.rstrip(" .")
175
+ segs.append(trimmed if trimmed or seg.strip(" ") not in (".", "..") else seg.strip(" "))
176
+ return lead.replace("\\", "/") + "/".join(segs)
177
+
178
+
179
+ def _hub_parts(clean: str) -> list[str]:
180
+ """The lower-case segments of a _hub_clean path, with `.` and `..` applied the way Windows does (by text)."""
181
+ out: list[str] = []
182
+ for seg in clean.lower().split("/"):
183
+ if seg == "..":
184
+ out = out[:-1]
185
+ elif seg and seg != ".":
186
+ out.append(seg)
187
+ return out
188
+
189
+
190
+ def _hub_seg_is(seg: str, name: str, shell: bool) -> bool:
191
+ """``seg`` names ``name``. In a shell, a glob segment (`hu*`, `h?b`, `[h]ub`) names it when it matches; as in
192
+ bash, only a glob that starts with `.` matches a dot name."""
193
+ if seg == name:
194
+ return True
195
+ return (shell and bool(_HUB_GLOB.search(seg)) and (seg.startswith(".") or not name.startswith("."))
196
+ and fnmatch.fnmatchcase(name, seg))
197
+
198
+
199
+ def _hub_spellings(raw: str, shell: bool) -> list[str]:
200
+ """The paths a shell can open for ``raw``: quotes removed, then `\\` read as a separator (cmd, PowerShell) and
201
+ `\\` or a backtick read as an escape (bash, PowerShell). A tool path is opened as it is."""
202
+ if not shell:
203
+ return [raw]
204
+ unquoted = raw.replace('"', "").replace("'", "")
205
+ return [unquoted, re.sub(r"[\\`](.)", r"\1", unquoted)]
206
+
207
+
208
+ def hub_dir_reason(path: str, shell: bool = False) -> str:
209
+ """The deny reason when ``path`` is the hub folder or a file in it, else ''. ``shell`` is True for a path that a
210
+ shell reads (quotes, escapes and globs apply). A path with a `.caddis` segment and then a `hub` segment denies
211
+ whatever its root (a device prefix, an admin share such as `\\\\localhost\\c$`, another home): the backstop."""
212
+ if not isinstance(path, str):
213
+ return ""
214
+ raw = _CADDIS_HOME_VAR.sub(_CADDIS_HOME_MARK, path.strip().strip("'\""))
215
+ for spelling in _hub_spellings(raw, shell):
216
+ clean = _hub_clean(_HOME_VAR.sub("~", spelling))
217
+ parts = _hub_parts(clean)
218
+ hubs = [i for i, seg in enumerate(parts) if _hub_seg_is(seg, "hub", shell)]
219
+ if any(i and _hub_seg_is(parts[i - 1], _ARTIFACT_DIR, shell) for i in hubs):
220
+ return _WHY_HUB
221
+ if not hubs or clean.startswith("//"): # cheap test first; never resolve a UNC path (a network call)
222
+ continue
223
+ if clean.lower().startswith(_CADDIS_HOME_MARK):
224
+ clean = (os.environ.get("CADDIS_HOME") or "~") + clean[len(_CADDIS_HOME_MARK):]
225
+ full = os.path.normcase(os.path.realpath(os.path.abspath(os.path.normpath(_home_path(clean)))))
226
+ for hub in _hub_dirs():
227
+ if full == hub or full.startswith(hub.rstrip(os.sep) + os.sep):
228
+ return _WHY_HUB
229
+ return ""
230
+
231
+
232
+ def _hub_delete_targets(command: str) -> list[str]:
233
+ """The paths that rm / del / Remove-Item / Clear-Content name in ``command`` (their non-option words)."""
234
+ command = _SHARE_DOLLAR.sub("", _CADDIS_HOME_VAR.sub(_CADDIS_HOME_MARK, _HOME_VAR.sub("~", command)))
235
+ out: list[str] = []
236
+ for _name, _cwd, _sub, args in _tool_calls(command, None, _HUB_DELETERS):
237
+ out += [a for a in args if not a.startswith(("-", ">", "<"))] # cmd flags (`/s`) never name the hub
238
+ return out
239
+
240
+
129
241
  # Copied from hooks/guard.py: the Read rule, used by the shell secret-read rule below.
130
242
  def classify_read(file_path: str) -> tuple[str, str]:
131
243
  """Risk tier (deny|allow) + reason for reading ``file_path`` via the Read tool.
@@ -483,7 +595,8 @@ def _shell_write_targets(command: str) -> list[str]:
483
595
  targets of the writer programs the config rule knows (cp, mv, Copy-Item, Set-Content, New-Item, Tee-Object, sed -i,
484
596
  ...). Not the regex's `-Path` group: `Get-Content -Path x` reads. Best-effort by design: a miss degrades to the old
485
597
  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
598
+ # The tokenizer splits on `$`, so in `$HOME/x` the home part would be lost.
599
+ command = _SHARE_DOLLAR.sub("", _CADDIS_HOME_VAR.sub(_CADDIS_HOME_MARK, _HOME_VAR.sub("~", command)))
487
600
  out = [m.group(g).strip("'\"") for m in _SHELL_WRITE_TARGET.finditer(command)
488
601
  for g in ("redir", "tee", "outfile") if m.group(g)]
489
602
  try:
@@ -556,6 +669,18 @@ def _copier_targets(args: list[str]) -> list[str]:
556
669
  return [dest] + [os.path.join(dest, os.path.basename(src.replace("\\", "/"))) for src in sources]
557
670
 
558
671
 
672
+ def _hub_delete_safe(text: str) -> str:
673
+ """The deny reason when a delete command (rm, Remove-Item, ...) names the hub folder, else ''. An error is
674
+ "no match": one rule must never switch off the rest."""
675
+ try:
676
+ for target in _hub_delete_targets(text):
677
+ if hub_dir_reason(target, shell=True):
678
+ return f"shell command deletes in the caddis hub folder (~/.caddis/hub); {_WHY_HUB.split('; ', 1)[1]}"
679
+ except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
680
+ return ""
681
+ return ""
682
+
683
+
559
684
  def guard_config_shell_reason(command: str) -> str:
560
685
  """The ask reason when a shell command writes a `.caddis/config.toml` or `~/.caddis/*.toml`, else ''.
561
686
  Callers pass heredoc-free text and treat an exception as "no match"."""
@@ -1181,9 +1306,14 @@ def classify_bash(command: str, root: str | None = None) -> tuple[str, str]:
1181
1306
  # ── deny: writing a secret through the shell (copied from hooks/guard.py) ──
1182
1307
  # The same file must not be denied via a write tool and waved through via `echo ... > .env`.
1183
1308
  for _target in _shell_write_targets(data_free):
1309
+ if hub_dir_reason(_target, shell=True): # the shell reads quotes, escapes and globs; a tool path does not
1310
+ return "deny", f"shell command {_WHY_HUB}"
1184
1311
  _tier, _why = classify_write(_target)
1185
1312
  if _tier == "deny":
1186
1313
  return "deny", f"shell command {_why}"
1314
+ _hub = _hub_delete_safe(data_free)
1315
+ if _hub:
1316
+ return "deny", _hub
1187
1317
 
1188
1318
  # ── deny: an inline python/node/etc. one-liner that touches a secret env file ──
1189
1319
  if _INLINE_ENV.search(c):
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.162",
3
+ "version": "1.3.163",
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
  }
@@ -55,6 +55,7 @@ CADDIS_TEMP_PREFIXES = {
55
55
  "caddis-codex-": "cli/test/codex.test.ts mkdtempSync",
56
56
  "caddis-doctor-home-": "cli/test/doctor.test.ts mkdtempSync",
57
57
  "caddis-doctor-sessions-": "cli/test/doctor.test.ts mkdtempSync",
58
+ "caddis-hub-": "cli/test/hub/*.test.ts mkdtempSync",
58
59
  "caddis-keys-": "cli/test/providers.test.ts mkdtempSync",
59
60
  "caddis-sessions-": "cli/test/sessions.test.ts mkdtempSync",
60
61
  "caddis-stub-": "cli/test/stub.test.ts mkdtempSync",
@@ -1,7 +1,7 @@
1
1
  {
2
- "poolVersion": "1.3.162",
2
+ "poolVersion": "1.3.163",
3
3
  "bundles": {
4
- "antigravity-plugin": "1.3.162",
4
+ "antigravity-plugin": "1.3.163",
5
5
  "antigravity-plugin-extras": "1.3.46",
6
6
  "codex": "unknown"
7
7
  }
@@ -467,6 +467,34 @@ var theme = {
467
467
  isColorSupported: import_picocolors.default.isColorSupported
468
468
  };
469
469
 
470
+ // ui/src/brand/palette.ts
471
+ var PALETTE = {
472
+ blue: "#0D42FC",
473
+ cyan: "#00CAD5",
474
+ green: "#91D202",
475
+ amber: "#FDAD01",
476
+ orange: "#FC4E23",
477
+ violet: "#8730FB"
478
+ };
479
+
480
+ // src/util/banner.ts
481
+ var BANNER_COLORS = {
482
+ mark: PALETTE.green,
483
+ wordmarkTop: PALETTE.cyan,
484
+ wordmarkMid: PALETTE.green,
485
+ wordmarkBottom: PALETTE.violet,
486
+ cliVersion: PALETTE.cyan,
487
+ poolVersion: PALETTE.violet
488
+ };
489
+ var color = {
490
+ mark: hex(BANNER_COLORS.mark),
491
+ top: hex(BANNER_COLORS.wordmarkTop),
492
+ mid: hex(BANNER_COLORS.wordmarkMid),
493
+ bottom: hex(BANNER_COLORS.wordmarkBottom),
494
+ cli: hex(BANNER_COLORS.cliVersion),
495
+ pool: hex(BANNER_COLORS.poolVersion)
496
+ };
497
+
470
498
  // src/util/log.ts
471
499
  var OK = theme.mint("OK");
472
500
  var WARN = theme.amber("!!");
@@ -483,7 +511,7 @@ function errorLine(text) {
483
511
  process.stderr.write(`${theme.coral("error")} ${text}
484
512
  `);
485
513
  }
486
- var color = Object.assign(import_picocolors2.default, {
514
+ var color2 = Object.assign(import_picocolors2.default, {
487
515
  mint: theme.mint,
488
516
  violet: theme.violet,
489
517
  magenta: theme.magenta,
@@ -467,6 +467,34 @@ var theme = {
467
467
  isColorSupported: import_picocolors.default.isColorSupported
468
468
  };
469
469
 
470
+ // ui/src/brand/palette.ts
471
+ var PALETTE = {
472
+ blue: "#0D42FC",
473
+ cyan: "#00CAD5",
474
+ green: "#91D202",
475
+ amber: "#FDAD01",
476
+ orange: "#FC4E23",
477
+ violet: "#8730FB"
478
+ };
479
+
480
+ // src/util/banner.ts
481
+ var BANNER_COLORS = {
482
+ mark: PALETTE.green,
483
+ wordmarkTop: PALETTE.cyan,
484
+ wordmarkMid: PALETTE.green,
485
+ wordmarkBottom: PALETTE.violet,
486
+ cliVersion: PALETTE.cyan,
487
+ poolVersion: PALETTE.violet
488
+ };
489
+ var color = {
490
+ mark: hex(BANNER_COLORS.mark),
491
+ top: hex(BANNER_COLORS.wordmarkTop),
492
+ mid: hex(BANNER_COLORS.wordmarkMid),
493
+ bottom: hex(BANNER_COLORS.wordmarkBottom),
494
+ cli: hex(BANNER_COLORS.cliVersion),
495
+ pool: hex(BANNER_COLORS.poolVersion)
496
+ };
497
+
470
498
  // src/util/log.ts
471
499
  var OK = theme.mint("OK");
472
500
  var WARN = theme.amber("!!");
@@ -483,7 +511,7 @@ function errorLine(text) {
483
511
  process.stderr.write(`${theme.coral("error")} ${text}
484
512
  `);
485
513
  }
486
- var color = Object.assign(import_picocolors2.default, {
514
+ var color2 = Object.assign(import_picocolors2.default, {
487
515
  mint: theme.mint,
488
516
  violet: theme.violet,
489
517
  magenta: theme.magenta,
@@ -467,6 +467,34 @@ var theme = {
467
467
  isColorSupported: import_picocolors.default.isColorSupported
468
468
  };
469
469
 
470
+ // ui/src/brand/palette.ts
471
+ var PALETTE = {
472
+ blue: "#0D42FC",
473
+ cyan: "#00CAD5",
474
+ green: "#91D202",
475
+ amber: "#FDAD01",
476
+ orange: "#FC4E23",
477
+ violet: "#8730FB"
478
+ };
479
+
480
+ // src/util/banner.ts
481
+ var BANNER_COLORS = {
482
+ mark: PALETTE.green,
483
+ wordmarkTop: PALETTE.cyan,
484
+ wordmarkMid: PALETTE.green,
485
+ wordmarkBottom: PALETTE.violet,
486
+ cliVersion: PALETTE.cyan,
487
+ poolVersion: PALETTE.violet
488
+ };
489
+ var color = {
490
+ mark: hex(BANNER_COLORS.mark),
491
+ top: hex(BANNER_COLORS.wordmarkTop),
492
+ mid: hex(BANNER_COLORS.wordmarkMid),
493
+ bottom: hex(BANNER_COLORS.wordmarkBottom),
494
+ cli: hex(BANNER_COLORS.cliVersion),
495
+ pool: hex(BANNER_COLORS.poolVersion)
496
+ };
497
+
470
498
  // src/util/log.ts
471
499
  var OK = theme.mint("OK");
472
500
  var WARN = theme.amber("!!");
@@ -483,7 +511,7 @@ function errorLine(text) {
483
511
  process.stderr.write(`${theme.coral("error")} ${text}
484
512
  `);
485
513
  }
486
- var color = Object.assign(import_picocolors2.default, {
514
+ var color2 = Object.assign(import_picocolors2.default, {
487
515
  mint: theme.mint,
488
516
  violet: theme.violet,
489
517
  magenta: theme.magenta,