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.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- 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)
|