raidlock 0.1.0__py3-none-any.whl

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.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/scan.py ADDED
@@ -0,0 +1,357 @@
1
+ """Read-only patch checks; register_cli must be wired by the CLI's owner."""
2
+
3
+ import ast
4
+ import base64
5
+ import collections
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ import zlib
14
+ from pathlib import Path, PurePosixPath
15
+
16
+ from .core.errors import Precondition
17
+ from .runners.policy import check_links, denied
18
+ from .runners.workspace import git
19
+
20
+ MAX_BINARY_BYTES = 1024 * 1024
21
+ RULES = (
22
+ ("aws-access-key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
23
+ ("private-key", re.compile(r"-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----")),
24
+ ("github-token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})")),
25
+ ("anthropic-token", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}")),
26
+ ("openai-token", re.compile(r"\bsk-(?!ant-)[A-Za-z0-9_-]{20,}")),
27
+ )
28
+ ASSIGNMENT = re.compile(r"""[\w.-]+["']?\s*[:=]\s*["']?([A-Za-z0-9_+/=-]{20,})""")
29
+ HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
30
+
31
+
32
+ def _finding(kind, path, line, rule):
33
+ return {"kind": kind, "path": path, "line": line, "rule": rule, "snippet": "[REDACTED]"}
34
+
35
+
36
+ def _path(raw, prefix=False):
37
+ raw = raw.strip()
38
+ if raw.startswith('"'):
39
+ try:
40
+ raw = ast.literal_eval("b" + raw).decode("utf-8", "surrogateescape")
41
+ except (ValueError, SyntaxError, UnicodeError):
42
+ raise Precondition("invalid quoted patch path") from None
43
+ else:
44
+ raw = raw.split("\t", 1)[0]
45
+ if raw == "/dev/null":
46
+ return None
47
+ if prefix:
48
+ if "/" not in raw:
49
+ raise Precondition("patch path has no component to strip with -p1")
50
+ raw = raw.split("/", 1)[1]
51
+ raw = raw.replace("\\", "/")
52
+ if raw.startswith("/") or ".." in PurePosixPath(raw).parts or not raw:
53
+ raise Precondition("patch path escapes the clone")
54
+ return str(PurePosixPath(raw))
55
+
56
+
57
+ def _delta_size(lines):
58
+ try:
59
+ packed = bytearray()
60
+ for line in lines:
61
+ if not line:
62
+ break
63
+ n = ord(line[0]) - (64 if line[0].isupper() else 70)
64
+ if not 1 <= n <= 52:
65
+ raise ValueError
66
+ packed.extend(base64.b85decode(line[1:])[:n])
67
+ data = zlib.decompressobj().decompress(bytes(packed), 32)
68
+ pos = 0
69
+ values = []
70
+ for _ in range(2):
71
+ value = shift = 0
72
+ while True:
73
+ b = data[pos]
74
+ pos += 1
75
+ value |= (b & 127) << shift
76
+ if not b & 128:
77
+ break
78
+ shift += 7
79
+ if shift > 63:
80
+ raise ValueError
81
+ values.append(value)
82
+ return values[1]
83
+ except (ValueError, IndexError, zlib.error):
84
+ raise Precondition("invalid binary delta in patch") from None
85
+
86
+
87
+ def _parse(text):
88
+ paths, added, sizes = set(), [], []
89
+ path = old = None
90
+ remaining = old_remaining = 0
91
+ number = 0
92
+ binary = False
93
+ lines = text.splitlines()
94
+ for i, line in enumerate(lines):
95
+ if remaining or old_remaining:
96
+ if line.startswith("+") and remaining:
97
+ if path:
98
+ added.append((path, number, line[1:]))
99
+ number += 1
100
+ remaining -= 1
101
+ continue
102
+ if line.startswith(" ") and remaining and old_remaining:
103
+ number += 1
104
+ remaining -= 1
105
+ old_remaining -= 1
106
+ continue
107
+ if line.startswith("-") and old_remaining:
108
+ old_remaining -= 1
109
+ continue
110
+ if line.startswith("\\"):
111
+ continue
112
+ raise Precondition("malformed unified patch hunk")
113
+ if line.startswith("diff --git "):
114
+ remaining = 0
115
+ binary = False
116
+ raw = line[11:]
117
+ match = re.match(r'("(?:[^"\\]|\\.)*"|a/.*?) ("(?:[^"\\]|\\.)*"|b/.*)$', raw)
118
+ if not match:
119
+ raise Precondition("invalid git patch header")
120
+ old, path = (_path(s, True) for s in match.groups())
121
+ paths.update(p for p in (old, path) if p)
122
+ elif line.startswith("--- "):
123
+ old = _path(line[4:], True)
124
+ if old:
125
+ paths.add(old)
126
+ elif line.startswith("+++ "):
127
+ path = _path(line[4:], True)
128
+ if path:
129
+ paths.add(path)
130
+ elif line.startswith(("rename from ", "rename to ", "copy from ", "copy to ")):
131
+ raw = line.split(" ", 2)[2]
132
+ name = _path(raw)
133
+ if name:
134
+ paths.add(name)
135
+ elif HUNK.match(line):
136
+ match = HUNK.match(line)
137
+ old_remaining = int(match[2]) if match[2] is not None else 1
138
+ number = int(match[3])
139
+ remaining = int(match[4]) if match[4] is not None else 1
140
+ elif line.startswith("@@"):
141
+ raise Precondition("unsupported or malformed patch hunk")
142
+ elif line == "GIT binary patch":
143
+ binary = True
144
+ elif binary and re.fullmatch(r"(?:literal|delta) \d+", line):
145
+ size = int(line.split()[1]) if line.startswith("literal") else _delta_size(lines[i + 1 :])
146
+ sizes.append((path or old, size))
147
+ binary = False
148
+ elif line.startswith("Binary files "):
149
+ raise Precondition("binary patch omits blob sizes; supply a clone or a git diff --binary patch")
150
+ if remaining or old_remaining:
151
+ raise Precondition("truncated unified patch hunk")
152
+ if text.strip() and not paths:
153
+ raise Precondition("input is not a unified git patch")
154
+ return paths, added, sizes
155
+
156
+
157
+ def _builtin(added):
158
+ findings = []
159
+ for path, number, text in added:
160
+ for rule, pattern in RULES:
161
+ if pattern.search(text):
162
+ findings.append(_finding("secret", path, number, rule))
163
+ for match in ASSIGNMENT.finditer(text):
164
+ value = match[1].rstrip("=")
165
+ counts = collections.Counter(value)
166
+ entropy = -sum((n / len(value)) * math.log2(n / len(value)) for n in counts.values())
167
+ if entropy >= 3.5:
168
+ findings.append(_finding("secret", path, number, "high-entropy-assignment"))
169
+ break
170
+ return findings
171
+
172
+
173
+ def _secrets(added):
174
+ executable = shutil.which("gitleaks")
175
+ if not executable or not added:
176
+ return _builtin(added)
177
+ with tempfile.TemporaryDirectory(prefix="raidlock-scan-") as temp:
178
+ source = Path(temp) / "source"
179
+ source.mkdir()
180
+ mapping = {}
181
+ grouped = collections.defaultdict(list)
182
+ for path, number, text in added:
183
+ grouped[path].append((number, text))
184
+ for index, (path, rows) in enumerate(grouped.items()):
185
+ name = f"added-{index}.txt"
186
+ (source / name).write_text("\n".join(text for _, text in rows) + "\n", encoding="utf-8", errors="replace")
187
+ mapping[name] = (path, rows)
188
+ report = Path(temp) / "report.json"
189
+ try:
190
+ proc = subprocess.run(
191
+ [
192
+ executable,
193
+ "detect",
194
+ "--no-git",
195
+ "--source",
196
+ str(source),
197
+ "--report-format",
198
+ "json",
199
+ "--report-path",
200
+ str(report),
201
+ ],
202
+ cwd=temp,
203
+ capture_output=True,
204
+ timeout=120,
205
+ )
206
+ if proc.returncode not in (0, 1):
207
+ raise ValueError
208
+ rows = json.loads(report.read_text())
209
+ if not isinstance(rows, list) or (proc.returncode == 1 and not rows):
210
+ raise ValueError
211
+ findings = []
212
+ for row in rows:
213
+ path, lines = mapping[Path(row["File"]).name]
214
+ start, end = int(row["StartLine"]), int(row.get("EndLine", row["StartLine"]))
215
+ if not 1 <= start <= end <= len(lines):
216
+ raise ValueError
217
+ for number, _ in lines[start - 1 : end]:
218
+ findings.append(_finding("secret", path, number, str(row["RuleID"])))
219
+ return findings
220
+ except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired):
221
+ raise Precondition("gitleaks scan failed or returned an invalid report") from None
222
+
223
+
224
+ def _clone(repo, base, selected=None):
225
+ repo = Path(repo).resolve()
226
+ revision = git(repo, "rev-parse", "--verify", "--end-of-options", str(base) + "^{commit}").strip()
227
+ args = ["diff", "--binary", "--no-ext-diff", "--no-textconv", "--no-renames", revision, "--"]
228
+ if selected is not None:
229
+ args.extend(":(literal)" + p for p in selected)
230
+ paths, added, sizes = _parse(git(repo, *args)) if selected != [] else (set(), [], [])
231
+ untracked = git(repo, "ls-files", "--others", "--exclude-standard", "-z").split("\0")
232
+ for raw in filter(None, untracked):
233
+ path = _path(raw)
234
+ if selected is not None and path not in selected:
235
+ continue
236
+ paths.add(path)
237
+ target = repo / path
238
+ if target.is_symlink():
239
+ data = os.readlink(target).encode()
240
+ else:
241
+ if not target.resolve().is_relative_to(repo):
242
+ raise Precondition("changed file escapes the clone")
243
+ data = target.read_bytes()
244
+ if b"\0" in data:
245
+ sizes.append((path, len(data)))
246
+ else:
247
+ added.extend((path, n, line) for n, line in enumerate(data.decode("utf-8", "replace").splitlines(), 1))
248
+ return paths, added, sizes
249
+
250
+
251
+ def clone_links(repo):
252
+ links = {}
253
+ for folder, dirs, files in os.walk(repo, followlinks=False):
254
+ dirs[:] = [name for name in dirs if name != ".git"]
255
+ for name in dirs + files:
256
+ path = Path(folder, name)
257
+ if path.is_symlink():
258
+ links[path.relative_to(repo).as_posix()] = os.readlink(path)
259
+ return links
260
+
261
+
262
+ def scan_patch(patch_text, base="HEAD", *, changed_files=None, rels=None, ci=False, allow_protected=()):
263
+ """Return redacted finding dicts for patch text/bytes or a clone directory.
264
+
265
+ For a clone, base is a Git revision (default HEAD); changed_files optionally
266
+ selects repo-relative paths. A list as the first argument uses base as the
267
+ clone directory and compares those files against HEAD. All clone reads are
268
+ read-only, including the index. Binary limit is 1 MiB, strictly greater.
269
+ Symlink targets require explicit lease roots (rels); unknown scope fails closed.
270
+ Invalid input or scanner failures raise Precondition rather than pass.
271
+ """
272
+ links = {}
273
+ if isinstance(patch_text, (list, tuple)):
274
+ selected = [_path(os.fspath(p)) for p in patch_text]
275
+ paths, added, sizes = _clone(base, "HEAD", selected)
276
+ links = clone_links(Path(base))
277
+ elif isinstance(patch_text, os.PathLike) or (
278
+ isinstance(patch_text, str) and "\n" not in patch_text and len(patch_text) < 4096 and os.path.isdir(patch_text)
279
+ ):
280
+ selected = None if changed_files is None else [_path(os.fspath(p)) for p in changed_files]
281
+ paths, added, sizes = _clone(patch_text, base, selected)
282
+ links = clone_links(Path(patch_text))
283
+ else:
284
+ if changed_files is not None:
285
+ raise Precondition("changed_files requires a clone directory")
286
+ text = patch_text.decode("utf-8", "replace") if isinstance(patch_text, bytes) else patch_text
287
+ paths, added, sizes = _parse(text)
288
+ links = patch_links(text)
289
+ findings = [
290
+ _finding("path", path, None, "runner-denylist") for path in sorted(paths) if denied(path, allow_protected)
291
+ ]
292
+ findings.extend(
293
+ _finding("path", path, None, "unsafe-symlink")
294
+ for path in check_links(
295
+ links, rels if rels is not None else [], ci, paths=paths, allow_protected=allow_protected
296
+ )
297
+ )
298
+ findings.extend(
299
+ _finding("binary", path, None, "binary-over-1mb") for path, size in sizes if size > MAX_BINARY_BYTES
300
+ )
301
+ findings.extend(_secrets(added))
302
+ unique = {(f["kind"], f["path"], f["line"], f["rule"]): f for f in findings}
303
+ return sorted(unique.values(), key=lambda f: (f["path"], f["line"] or 0, f["kind"], f["rule"]))
304
+
305
+
306
+ def register_cli(add_command):
307
+ """Register with raidlock.cli.add_command; the host handles --json and _exit."""
308
+
309
+ def setup(parser):
310
+ parser.add_argument("source", help="patch file or clone directory")
311
+ parser.add_argument("--card", help="card whose locks authorize symlink targets")
312
+ parser.add_argument("--base", default="HEAD", help="clone comparison revision (default HEAD)")
313
+
314
+ def handler(ctx, args):
315
+ source = Path(args.source)
316
+ from .runners import policy
317
+
318
+ rels, ci, allowed = [], False, []
319
+ if args.card:
320
+ board = ctx.board
321
+ target = policy.target(board.workspace, board.dir, board.card(args.card)["locks"])
322
+ rels, ci = target["rels"], target["ci"]
323
+ allowed = board.card(args.card).get("allow_protected", [])
324
+ try:
325
+ findings = scan_patch(
326
+ source if source.is_dir() else source.read_bytes(), args.base, rels=rels, ci=ci, allow_protected=allowed
327
+ )
328
+ except OSError:
329
+ raise Precondition("cannot read scan input") from None
330
+ result = {"findings": findings, "_exit": 2 if findings else 0}
331
+ text = (
332
+ "\n".join(f"{f['path']}:{f['line'] or '-'}: {f['kind']} {f['rule']} {f['snippet']}" for f in findings)
333
+ or "No patch safety findings."
334
+ )
335
+ return result, text
336
+
337
+ add_command("scan", "check a patch or clone for secrets, denied paths and large binaries", setup, handler)
338
+
339
+
340
+ def patch_links(text):
341
+ """Extract complete new symlink blobs; reject partial/binary link patches."""
342
+ links = {}
343
+ for block in re.split(r"(?m)(?=^diff --git )", text):
344
+ if not re.search(r"(?m)^(?:new file mode|new mode) 120000$|^index .* 120000$", block):
345
+ continue
346
+ paths, added, _ = _parse(block)
347
+ header = re.search(r"(?m)^\+\+\+ (.*)$", block)
348
+ if not header:
349
+ raise Precondition("symlink patch must contain its target")
350
+ path = _path(header[1], True)
351
+ if path is None:
352
+ continue
353
+ rows = [value for p, _, value in added if p == path]
354
+ if len(rows) != 1:
355
+ raise Precondition("symlink patch must contain one complete target")
356
+ links[path] = rows[0]
357
+ return links
raidlock/snapshot.py ADDED
@@ -0,0 +1,151 @@
1
+ import html
2
+ import json
3
+ import os
4
+ import re
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from .core import Precondition
9
+ from .core.identity import require_lead
10
+ from .core.transcript import redact as redact_secrets
11
+
12
+ _EMAIL = re.compile(r"[\w.!#$%&'*+/=?^`{|}~-]+@[\w.-]+", re.UNICODE)
13
+ _PATH = re.compile(r"(?:[A-Za-z]:[\\/]|\\\\|~[/\\]|\.{1,2}[/\\]|/)[^\s<>\"']+|\b[\w.-]+[/\\][^\s<>\"']+")
14
+
15
+
16
+ def render_snapshot(snapshot, redact=False):
17
+ def text(value):
18
+ value = redact_secrets(str(value if value is not None else ""))
19
+ if redact:
20
+ value = _EMAIL.sub("[redacted]", value)
21
+ value = _PATH.sub("[redacted]", value)
22
+ return html.escape(value, quote=True)
23
+
24
+ sections = []
25
+ cards = snapshot.get("cards", [])
26
+ lanes = list(dict.fromkeys(["todo", "doing", "review", "blocked", "done"] + [c["lane"] for c in cards]))
27
+ for lane in lanes:
28
+ items = []
29
+ for card in cards:
30
+ if card["lane"] != lane:
31
+ continue
32
+ notes = ""
33
+ if not redact:
34
+ notes = "".join('<p class="note">' + text(n.get("text", "")) + "</p>" for n in card.get("notes", []))
35
+ items.append(
36
+ "<article><h3>"
37
+ + text(card.get("id"))
38
+ + ": "
39
+ + text(card.get("title"))
40
+ + "</h3><p>Owner: "
41
+ + text(card.get("owner") or "Unassigned")
42
+ + "</p><p>Team: "
43
+ + text(card.get("team"))
44
+ + "</p>"
45
+ + notes
46
+ + "</article>"
47
+ )
48
+ sections.append(
49
+ '<section class="lane"><h2>'
50
+ + text(lane.title())
51
+ + "</h2>"
52
+ + ("".join(items) or "<p>No cards</p>")
53
+ + "</section>"
54
+ )
55
+ agents = "".join(
56
+ "<tr><td>"
57
+ + text(name)
58
+ + "</td><td>"
59
+ + text(agent.get("label", agent.get("status", "")))
60
+ + "</td><td>"
61
+ + text(agent.get("model", ""))
62
+ + "</td><td>"
63
+ + text(agent.get("card", ""))
64
+ + "</td></tr>"
65
+ for name, agent in snapshot.get("agents", {}).items()
66
+ )
67
+ spend = snapshot.get("costs", {}).get("workspace", {})
68
+ return (
69
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">'
70
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
71
+ "<title>Raidlock board snapshot</title><style>"
72
+ "body{font:16px system-ui;margin:2rem;background:#f5f6fa;color:#172033}"
73
+ ".lanes{display:flex;gap:1rem;flex-wrap:wrap}.lane{flex:1;min-width:220px}"
74
+ "article{background:white;border:1px solid #ccd2df;border-radius:8px;padding:1rem;margin:1rem 0}"
75
+ "h3,.note,td,pre{overflow-wrap:anywhere;white-space:pre-wrap}table{border-collapse:collapse}"
76
+ "th,td{text-align:left;padding:.6rem;border-bottom:1px solid #ccd2df}"
77
+ "</style></head><body><header><h1>"
78
+ + text(snapshot.get("workspace", "Board"))
79
+ + "</h1><p>Board snapshot · "
80
+ + text(snapshot.get("now", ""))
81
+ + (" · Redacted" if redact else "")
82
+ + '</p></header><h2>Cards</h2><div class="lanes">'
83
+ + "".join(sections)
84
+ + "</div><section><h2>Agents</h2><table><thead><tr>"
85
+ "<th>Agent</th><th>Status</th><th>Model</th><th>Card</th></tr></thead><tbody>"
86
+ + agents
87
+ + "</tbody></table></section><section><h2>Spend</h2><pre>"
88
+ + text(json.dumps(spend, ensure_ascii=False, indent=2))
89
+ + "</pre></section></body></html>"
90
+ )
91
+
92
+
93
+ def _setup(parser):
94
+ parser.add_argument("--out", required=True, help="Destination HTML file; inside RAIDLOCK_HOME use exports/")
95
+ parser.add_argument("--redact", action="store_true", help="Remove notes, paths and email addresses")
96
+
97
+
98
+ def _inside(root, target, ignore_case=False):
99
+ try:
100
+ if ignore_case:
101
+ root, target = root.casefold(), target.casefold()
102
+ return os.path.commonpath([root, target]) == root
103
+ except ValueError:
104
+ return False
105
+
106
+
107
+ def _resolve_out(ctx, raw):
108
+ target = os.path.realpath(os.path.expanduser(raw))
109
+ board = ctx.board
110
+ parts = {part.casefold() for part in Path(target).parts}
111
+ if ".board" in parts or ".git" in parts or _inside(os.path.realpath(board.dir), target, ignore_case=True):
112
+ raise Precondition("snapshot output cannot overwrite board or Git metadata")
113
+ if _inside(os.path.realpath(os.path.join(board.home, "tokens")), target, ignore_case=True):
114
+ raise Precondition("snapshot output cannot overwrite agent credentials")
115
+ home = os.path.realpath(board.home)
116
+ if _inside(home, target, ignore_case=True) and not _inside(os.path.join(home, "exports"), target):
117
+ raise Precondition("snapshot output inside RAIDLOCK_HOME must be under exports/")
118
+ roots = [os.path.realpath(r) for r in (board.home, board.workspace) if r]
119
+ if not any(_inside(root, target) for root in roots):
120
+ require_lead(ctx.caller(), "write a snapshot outside RAIDLOCK_HOME and the workspace")
121
+ caller = ctx.caller()
122
+ if not caller.is_lead:
123
+ decision = board.check_write(target, caller)
124
+ if not decision["allowed"] or not decision.get("card"):
125
+ raise Precondition("snapshot output requires a current lease covering the resolved path")
126
+ return Path(target)
127
+
128
+
129
+ def _handle(ctx, args):
130
+ output = _resolve_out(ctx, args.out)
131
+ content = render_snapshot(ctx.board.snapshot(), redact=args.redact)
132
+ temporary = None
133
+ try:
134
+ with tempfile.NamedTemporaryFile(
135
+ mode="w", encoding="utf-8", dir=output.parent, prefix="." + output.name + ".", delete=False
136
+ ) as stream:
137
+ temporary = Path(stream.name)
138
+ stream.write(content)
139
+ stream.flush()
140
+ os.fsync(stream.fileno())
141
+ os.replace(temporary, output)
142
+ except OSError as exc:
143
+ raise Precondition(f"cannot write snapshot: {exc}") from exc
144
+ finally:
145
+ if temporary is not None:
146
+ temporary.unlink(missing_ok=True)
147
+ return {"out": str(output), "redacted": args.redact}, f"Snapshot written to {output}"
148
+
149
+
150
+ def register_cli(add_command):
151
+ add_command("snapshot", "export a self-contained HTML board", _setup, _handle)