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/runners/base.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from ..core import Usage
|
|
11
|
+
from ..core.portable import open_nofollow
|
|
12
|
+
from ..core.transcript import REDACTED, redact
|
|
13
|
+
|
|
14
|
+
BASE_ENV = (
|
|
15
|
+
"PATH",
|
|
16
|
+
"HOME",
|
|
17
|
+
"USER",
|
|
18
|
+
"LOGNAME",
|
|
19
|
+
"SHELL",
|
|
20
|
+
"LANG",
|
|
21
|
+
"LC_ALL",
|
|
22
|
+
"LC_CTYPE",
|
|
23
|
+
"TERM",
|
|
24
|
+
"TMPDIR",
|
|
25
|
+
"TEMP",
|
|
26
|
+
"TMP",
|
|
27
|
+
"SYSTEMROOT",
|
|
28
|
+
"COMSPEC",
|
|
29
|
+
"PATHEXT",
|
|
30
|
+
"APPDATA",
|
|
31
|
+
"LOCALAPPDATA",
|
|
32
|
+
"USERPROFILE",
|
|
33
|
+
"SSL_CERT_FILE",
|
|
34
|
+
"SSL_CERT_DIR",
|
|
35
|
+
"NODE_EXTRA_CA_CERTS",
|
|
36
|
+
"REQUESTS_CA_BUNDLE",
|
|
37
|
+
"HTTPS_PROXY",
|
|
38
|
+
"HTTP_PROXY",
|
|
39
|
+
"NO_PROXY",
|
|
40
|
+
"https_proxy",
|
|
41
|
+
"http_proxy",
|
|
42
|
+
"no_proxy",
|
|
43
|
+
)
|
|
44
|
+
SUMMARY_CAP = 500
|
|
45
|
+
SECRET_NAME = re.compile(r"(?i)(token|secret|password|passwd|api[_-]?key|access[_-]?key|credential|session[_-]?key)")
|
|
46
|
+
MIN_SECRET = 8
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Redactor:
|
|
50
|
+
"""Masks the given literal secrets, then the common key and token patterns, in strings and JSON values."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, secrets=()):
|
|
53
|
+
self.secrets = sorted(
|
|
54
|
+
{s for s in secrets if isinstance(s, str) and len(s.strip()) >= MIN_SECRET}, key=len, reverse=True
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def for_env(cls, env, extra=()):
|
|
59
|
+
values = [v for k, v in (env or {}).items() if SECRET_NAME.search(k)]
|
|
60
|
+
return cls(list(values) + list(extra))
|
|
61
|
+
|
|
62
|
+
def text(self, value):
|
|
63
|
+
if not isinstance(value, str) or not value:
|
|
64
|
+
return value
|
|
65
|
+
for secret in self.secrets:
|
|
66
|
+
value = value.replace(secret, REDACTED)
|
|
67
|
+
return redact(value)
|
|
68
|
+
|
|
69
|
+
def stream(self, mask=True):
|
|
70
|
+
"""A separate PEM-block state for each output stream."""
|
|
71
|
+
private = False
|
|
72
|
+
|
|
73
|
+
def clean(line):
|
|
74
|
+
nonlocal private
|
|
75
|
+
begin = re.search(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", line)
|
|
76
|
+
end = re.search(r"-----END [A-Z ]*PRIVATE KEY-----", line)
|
|
77
|
+
if private:
|
|
78
|
+
if end:
|
|
79
|
+
private = False
|
|
80
|
+
return REDACTED + self.text(line[end.end() :])
|
|
81
|
+
return REDACTED + ("\n" if line.endswith("\n") else "")
|
|
82
|
+
if begin and not end:
|
|
83
|
+
private = True
|
|
84
|
+
return self.text(line) if mask or begin else line
|
|
85
|
+
|
|
86
|
+
return clean
|
|
87
|
+
|
|
88
|
+
def __call__(self, value):
|
|
89
|
+
if isinstance(value, str):
|
|
90
|
+
return self.text(value)
|
|
91
|
+
if isinstance(value, list):
|
|
92
|
+
return [self(v) for v in value]
|
|
93
|
+
if isinstance(value, tuple):
|
|
94
|
+
return tuple(self(v) for v in value)
|
|
95
|
+
if isinstance(value, dict):
|
|
96
|
+
return {
|
|
97
|
+
self(k) if isinstance(k, str) else k: REDACTED
|
|
98
|
+
if isinstance(k, str) and SECRET_NAME.search(k) and isinstance(v, str)
|
|
99
|
+
else self(v)
|
|
100
|
+
for k, v in value.items()
|
|
101
|
+
}
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Adapter:
|
|
106
|
+
"""One external runtime. Subclasses set the identity fields and implement `command` and `parse`."""
|
|
107
|
+
|
|
108
|
+
prompt_stdin = True
|
|
109
|
+
name = ""
|
|
110
|
+
runtime = ""
|
|
111
|
+
provider = ""
|
|
112
|
+
binary = ""
|
|
113
|
+
env_keys = ()
|
|
114
|
+
api_keys = ()
|
|
115
|
+
sandboxes = ("read-only", "workspace-write")
|
|
116
|
+
|
|
117
|
+
def executable(self, env=None):
|
|
118
|
+
env = os.environ if env is None else env
|
|
119
|
+
override = env.get(f"RAIDLOCK_{self.name.upper()}_BIN", "")
|
|
120
|
+
return shlex.split(override) if override else [self.binary]
|
|
121
|
+
|
|
122
|
+
def child_env(self, env=None):
|
|
123
|
+
env = os.environ if env is None else env
|
|
124
|
+
keep = BASE_ENV + tuple(self.env_keys) + tuple(self.api_keys)
|
|
125
|
+
out = {k: env[k] for k in keep if k in env}
|
|
126
|
+
out.update(NO_COLOR="1", CI="1")
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def prepare(self, run):
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
def command(self, run):
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
|
|
135
|
+
def parse(self, obj):
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
def final(self, run, collected):
|
|
139
|
+
return collected.structured if collected.structured is not None else collected.final
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def short(text, limit=SUMMARY_CAP):
|
|
143
|
+
text = " ".join(str(text or "").split())
|
|
144
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Collector:
|
|
148
|
+
"""Accumulates normalized events of one run."""
|
|
149
|
+
|
|
150
|
+
def __init__(self):
|
|
151
|
+
self.messages = []
|
|
152
|
+
self.commands = []
|
|
153
|
+
self.usage = Usage()
|
|
154
|
+
self.usd = None
|
|
155
|
+
self.final = ""
|
|
156
|
+
self.structured = None
|
|
157
|
+
self.error = ""
|
|
158
|
+
self.model = ""
|
|
159
|
+
self.billing = ""
|
|
160
|
+
self.files = []
|
|
161
|
+
self.events = 0
|
|
162
|
+
|
|
163
|
+
def add(self, event):
|
|
164
|
+
kind = event.get("kind")
|
|
165
|
+
self.events += 1
|
|
166
|
+
if kind == "message" and event.get("text"):
|
|
167
|
+
self.messages.append(event["text"])
|
|
168
|
+
self.final = event["text"]
|
|
169
|
+
elif kind == "command":
|
|
170
|
+
self.commands.append({"command": event.get("command", ""), "exit": event.get("exit")})
|
|
171
|
+
elif kind == "usage":
|
|
172
|
+
u = event.get("usage") or Usage()
|
|
173
|
+
self.usage = Usage(**{k: getattr(self.usage, k) + getattr(u, k) for k in self.usage.as_dict()})
|
|
174
|
+
if event.get("usd") is not None:
|
|
175
|
+
self.usd = (self.usd or 0.0) + float(event["usd"])
|
|
176
|
+
elif kind == "result":
|
|
177
|
+
if event.get("text"):
|
|
178
|
+
self.final = event["text"]
|
|
179
|
+
if event.get("structured") is not None:
|
|
180
|
+
self.structured = event["structured"]
|
|
181
|
+
if event.get("error"):
|
|
182
|
+
self.error = event["error"]
|
|
183
|
+
elif kind == "error":
|
|
184
|
+
self.error = event.get("text") or "error"
|
|
185
|
+
elif kind == "model":
|
|
186
|
+
self.model = event.get("model") or self.model
|
|
187
|
+
self.billing = event.get("billing") or self.billing
|
|
188
|
+
elif kind == "files":
|
|
189
|
+
self.files.extend(event.get("paths", []))
|
|
190
|
+
|
|
191
|
+
def summary(self):
|
|
192
|
+
return {
|
|
193
|
+
"messages": len(self.messages),
|
|
194
|
+
"commands": len(self.commands),
|
|
195
|
+
"usage": self.usage.as_dict(),
|
|
196
|
+
"usd": self.usd,
|
|
197
|
+
"model": self.model,
|
|
198
|
+
"error": self.error,
|
|
199
|
+
"events": self.events,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def decode(line):
|
|
204
|
+
text = line.rstrip("\r\n")
|
|
205
|
+
if not text.strip():
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
obj = json.loads(text)
|
|
209
|
+
except ValueError:
|
|
210
|
+
return {"type": "raw", "text": text}
|
|
211
|
+
return obj if isinstance(obj, dict) else {"type": "raw", "text": text}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Process:
|
|
215
|
+
"""Runs one adapter command: prompt on stdin, redacted events to disk, parsed events to `on_event`."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, argv, cwd, env, prompt, events_path, stderr_path, timeout, redactor=None, raw=False):
|
|
218
|
+
self.argv, self.cwd, self.env, self.prompt = argv, cwd, env, prompt
|
|
219
|
+
self.events_path, self.stderr_path, self.timeout = events_path, stderr_path, timeout
|
|
220
|
+
self.redactor = redactor or Redactor()
|
|
221
|
+
self.raw = raw
|
|
222
|
+
self.proc = None
|
|
223
|
+
self.killed = ""
|
|
224
|
+
self._lock = threading.Lock()
|
|
225
|
+
|
|
226
|
+
def stop(self, reason):
|
|
227
|
+
with self._lock:
|
|
228
|
+
if self.killed or self.proc is None or self.proc.poll() is not None:
|
|
229
|
+
return
|
|
230
|
+
self.killed = reason
|
|
231
|
+
_terminate(self.proc)
|
|
232
|
+
|
|
233
|
+
def _raw_path(self, path):
|
|
234
|
+
root, ext = os.path.splitext(path)
|
|
235
|
+
return root + ".raw" + ext
|
|
236
|
+
|
|
237
|
+
def _open_raw(self, path, mode):
|
|
238
|
+
if not self.raw:
|
|
239
|
+
return None
|
|
240
|
+
fd = open_nofollow(
|
|
241
|
+
self._raw_path(path), os.O_WRONLY | os.O_CREAT | (os.O_APPEND if mode == "a" else os.O_TRUNC), 0o600
|
|
242
|
+
)
|
|
243
|
+
return os.fdopen(fd, mode, encoding="utf-8")
|
|
244
|
+
|
|
245
|
+
def _drain_stderr(self, stream, err, raw):
|
|
246
|
+
clean = self.redactor.stream()
|
|
247
|
+
for line in stream:
|
|
248
|
+
err.write(clean(line))
|
|
249
|
+
err.flush()
|
|
250
|
+
if raw:
|
|
251
|
+
raw.write(line)
|
|
252
|
+
raw.flush()
|
|
253
|
+
|
|
254
|
+
def run(self, adapter, on_event):
|
|
255
|
+
posix = os.name == "posix"
|
|
256
|
+
raw_events = self._open_raw(self.events_path, "a")
|
|
257
|
+
raw_err = self._open_raw(self.stderr_path, "w")
|
|
258
|
+
try:
|
|
259
|
+
return self._run(adapter, on_event, posix, raw_events, raw_err)
|
|
260
|
+
finally:
|
|
261
|
+
for f in (raw_events, raw_err):
|
|
262
|
+
if f:
|
|
263
|
+
f.close()
|
|
264
|
+
|
|
265
|
+
def _run(self, adapter, on_event, posix, raw_events, raw_err):
|
|
266
|
+
with (
|
|
267
|
+
open(self.events_path, "a", encoding="utf-8") as events,
|
|
268
|
+
open(self.stderr_path, "w", encoding="utf-8") as err,
|
|
269
|
+
):
|
|
270
|
+
try:
|
|
271
|
+
self.proc = subprocess.Popen(
|
|
272
|
+
self.argv,
|
|
273
|
+
cwd=self.cwd,
|
|
274
|
+
env=self.env,
|
|
275
|
+
stdin=subprocess.PIPE if adapter.prompt_stdin else subprocess.DEVNULL,
|
|
276
|
+
stdout=subprocess.PIPE,
|
|
277
|
+
stderr=subprocess.PIPE,
|
|
278
|
+
text=True,
|
|
279
|
+
encoding="utf-8",
|
|
280
|
+
errors="replace",
|
|
281
|
+
start_new_session=posix,
|
|
282
|
+
)
|
|
283
|
+
except OSError as e:
|
|
284
|
+
return None, f"could not start {self.argv[0]}: {e}"
|
|
285
|
+
drain = threading.Thread(target=self._drain_stderr, args=(self.proc.stderr, err, raw_err), daemon=True)
|
|
286
|
+
drain.start()
|
|
287
|
+
timer = threading.Timer(self.timeout, self.stop, args=(f"timed out after {self.timeout:g}s",))
|
|
288
|
+
timer.daemon = True
|
|
289
|
+
timer.start()
|
|
290
|
+
try:
|
|
291
|
+
try:
|
|
292
|
+
if adapter.prompt_stdin:
|
|
293
|
+
self.proc.stdin.write(self.prompt)
|
|
294
|
+
self.proc.stdin.close()
|
|
295
|
+
except (BrokenPipeError, OSError):
|
|
296
|
+
pass
|
|
297
|
+
clean = self.redactor.stream(mask=False)
|
|
298
|
+
for line in self.proc.stdout:
|
|
299
|
+
obj = decode(line)
|
|
300
|
+
if obj is None:
|
|
301
|
+
continue
|
|
302
|
+
if raw_events:
|
|
303
|
+
raw_events.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
304
|
+
raw_events.flush()
|
|
305
|
+
normalized = decode(clean(line))
|
|
306
|
+
if normalized is None:
|
|
307
|
+
continue
|
|
308
|
+
obj = self.redactor(normalized)
|
|
309
|
+
events.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
310
|
+
events.flush()
|
|
311
|
+
for event in adapter.parse(normalized):
|
|
312
|
+
on_event(self.redactor(event))
|
|
313
|
+
code = self.proc.wait(timeout=self.timeout)
|
|
314
|
+
except BaseException:
|
|
315
|
+
self.stop("interrupted")
|
|
316
|
+
raise
|
|
317
|
+
finally:
|
|
318
|
+
timer.cancel()
|
|
319
|
+
drain.join(timeout=10)
|
|
320
|
+
return code, self.killed
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _terminate(proc):
|
|
324
|
+
try:
|
|
325
|
+
if os.name == "posix":
|
|
326
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
327
|
+
else:
|
|
328
|
+
proc.terminate()
|
|
329
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
330
|
+
return
|
|
331
|
+
deadline = time.monotonic() + 5
|
|
332
|
+
while proc.poll() is None and time.monotonic() < deadline:
|
|
333
|
+
time.sleep(0.05)
|
|
334
|
+
if proc.poll() is None:
|
|
335
|
+
try:
|
|
336
|
+
if os.name == "posix":
|
|
337
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
338
|
+
else:
|
|
339
|
+
proc.kill()
|
|
340
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
341
|
+
pass
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from ..core import redact
|
|
5
|
+
from ..core.brief import UNTRUSTED_HEADER, UNTRUSTED_RULE, quote_data
|
|
6
|
+
from ..lore.inject import HEADER as LORE_HEADER
|
|
7
|
+
|
|
8
|
+
HANDBACK_CAP = 4096
|
|
9
|
+
ITEM_CAP = 400
|
|
10
|
+
LIST_CAP = 20
|
|
11
|
+
STATUSES = ("done", "partial", "blocked")
|
|
12
|
+
VERDICTS = ("approve", "changes", "comment")
|
|
13
|
+
|
|
14
|
+
WORKER_CONTRACT = """# Worker contract (raidlock)
|
|
15
|
+
|
|
16
|
+
You are a worker agent. The raidlock runner delegated one card to you.
|
|
17
|
+
The current directory is a private clone of the repository. Nobody else works in it.
|
|
18
|
+
|
|
19
|
+
- Change only files under the allowed paths listed at the end. Lock paths in the card are workspace paths;
|
|
20
|
+
the allowed paths are the same locks relative to this clone. A change anywhere else fails the run.
|
|
21
|
+
- Never touch .github/workflows/, .claude/, .board/, *.pem, .env* or anything named secrets.
|
|
22
|
+
- Do not commit, push, merge, apply infrastructure, change settings or permissions, or read secrets.
|
|
23
|
+
The runner collects your uncommitted changes as a patch and does all board work (notes, moves) for you.
|
|
24
|
+
- Assume there is no network. Do not install packages. The runner runs the card's verify command after you finish.
|
|
25
|
+
- Keep the final answer short: cite paths and commands, never paste file contents.
|
|
26
|
+
- End with only the handback JSON object:
|
|
27
|
+
{"status": "done|partial|blocked", "evidence": ["..."], "paths_changed": ["..."], "open_questions": ["..."]}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
REVIEWER_CONTRACT = """# Reviewer contract (raidlock)
|
|
31
|
+
|
|
32
|
+
You are a reviewer agent. The current directory is a private, read-only clone of the repository.
|
|
33
|
+
The changes under review are the difference between {base} and HEAD: run `git diff {base} HEAD` and read the files.
|
|
34
|
+
|
|
35
|
+
- Do not change files. Do not commit, push, merge, or read secrets.
|
|
36
|
+
- Check correctness first, then the card's acceptance criteria, then risk.
|
|
37
|
+
- Keep the final answer short: cite paths and lines, never paste file contents.
|
|
38
|
+
- End with only the review JSON object:
|
|
39
|
+
{{"verdict": "approve|changes|comment", "summary": "...", "findings": ["path:line problem"]}}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
HANDBACK_SCHEMA = {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": False,
|
|
45
|
+
"required": ["status", "evidence", "paths_changed", "open_questions"],
|
|
46
|
+
"properties": {
|
|
47
|
+
"status": {"type": "string", "enum": list(STATUSES)},
|
|
48
|
+
"evidence": {"type": "array", "items": {"type": "string"}},
|
|
49
|
+
"paths_changed": {"type": "array", "items": {"type": "string"}},
|
|
50
|
+
"open_questions": {"type": "array", "items": {"type": "string"}},
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
REVIEW_SCHEMA = {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"additionalProperties": False,
|
|
57
|
+
"required": ["verdict", "summary", "findings"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"verdict": {"type": "string", "enum": list(VERDICTS)},
|
|
60
|
+
"summary": {"type": "string"},
|
|
61
|
+
"findings": {"type": "array", "items": {"type": "string"}},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def schema(role):
|
|
67
|
+
return REVIEW_SCHEMA if role == "reviewer" else HANDBACK_SCHEMA
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render(card_brief, role, rels, base):
|
|
71
|
+
"""Prompt text: the stable contract first (cacheable), then the core card brief without its board-command loop
|
|
72
|
+
(the runner does the board work), then the allowed paths as they appear inside the clone."""
|
|
73
|
+
contract = REVIEWER_CONTRACT.format(base=base) if role == "reviewer" else WORKER_CONTRACT
|
|
74
|
+
allowed = ", ".join(r or "(the whole repository)" for r in rels)
|
|
75
|
+
return redact(contract + "\n" + _quoted_brief(card_brief) + "\nAllowed paths in this clone: " + allowed + "\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _quoted_brief(text):
|
|
79
|
+
return (
|
|
80
|
+
UNTRUSTED_HEADER
|
|
81
|
+
+ "\n"
|
|
82
|
+
+ UNTRUSTED_RULE
|
|
83
|
+
+ "\n"
|
|
84
|
+
+ "\n".join(quote_data(line) for line in _without_loop(text).splitlines())
|
|
85
|
+
+ "\n"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _without_loop(text):
|
|
90
|
+
out, skip = [], False
|
|
91
|
+
for line in text.splitlines():
|
|
92
|
+
if line.startswith("## ") or line == LORE_HEADER:
|
|
93
|
+
skip = line.strip() == "## Loop"
|
|
94
|
+
if not skip:
|
|
95
|
+
out.append(line)
|
|
96
|
+
return "\n".join(out).rstrip() + "\n"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _clip(text, limit):
|
|
100
|
+
text = " ".join(str(text).split())
|
|
101
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find_json(text):
|
|
105
|
+
"""The last JSON object in `text` (whole text first, then fenced or brace-delimited candidates)."""
|
|
106
|
+
if isinstance(text, dict):
|
|
107
|
+
return text
|
|
108
|
+
if not text:
|
|
109
|
+
return None
|
|
110
|
+
text = text.strip()
|
|
111
|
+
try:
|
|
112
|
+
value = json.loads(text)
|
|
113
|
+
return value if isinstance(value, dict) else None
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
for match in reversed(list(re.finditer(r"\{", text))):
|
|
117
|
+
try:
|
|
118
|
+
value, _ = json.JSONDecoder().raw_decode(text[match.start() :])
|
|
119
|
+
except ValueError:
|
|
120
|
+
continue
|
|
121
|
+
if isinstance(value, dict):
|
|
122
|
+
return value
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _strings(value):
|
|
127
|
+
if not isinstance(value, list):
|
|
128
|
+
value = [value] if value else []
|
|
129
|
+
return [_clip(redact(str(v)), ITEM_CAP) for v in value if str(v).strip()][:LIST_CAP]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def handback(raw, fallback_text=""):
|
|
133
|
+
"""Normalize an agent handback to {status, evidence[], paths_changed[], open_questions[]}."""
|
|
134
|
+
data = find_json(raw) or {}
|
|
135
|
+
status = data.get("status") if data.get("status") in STATUSES else ""
|
|
136
|
+
out = {
|
|
137
|
+
"status": status or "partial",
|
|
138
|
+
"evidence": _strings(data.get("evidence")),
|
|
139
|
+
"paths_changed": _strings(data.get("paths_changed")),
|
|
140
|
+
"open_questions": _strings(data.get("open_questions")),
|
|
141
|
+
}
|
|
142
|
+
if not data and fallback_text:
|
|
143
|
+
out["evidence"] = [_clip(redact(fallback_text), ITEM_CAP)]
|
|
144
|
+
out["open_questions"] = ["the agent returned no handback JSON"]
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def review(raw, fallback_text=""):
|
|
149
|
+
"""Normalize a review to {verdict, summary, findings[]}; anything unparseable is a comment."""
|
|
150
|
+
data = find_json(raw) or {}
|
|
151
|
+
verdict = data.get("verdict") if data.get("verdict") in VERDICTS else "comment"
|
|
152
|
+
summary = data.get("summary") or (fallback_text if not data else "") or "no summary"
|
|
153
|
+
return {
|
|
154
|
+
"verdict": verdict,
|
|
155
|
+
"summary": _clip(redact(str(summary)), 1500),
|
|
156
|
+
"findings": _strings(data.get("findings")),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cap(obj, limit=HANDBACK_CAP):
|
|
161
|
+
"""Trim list items (longest lists first) until the JSON encoding fits in `limit` bytes."""
|
|
162
|
+
obj = json.loads(json.dumps(obj))
|
|
163
|
+
while len(json.dumps(obj, ensure_ascii=False).encode("utf-8")) > limit:
|
|
164
|
+
lists = [(len(v), k) for k, v in obj.items() if isinstance(v, list) and v]
|
|
165
|
+
if not lists:
|
|
166
|
+
for k, v in obj.items():
|
|
167
|
+
if isinstance(v, str) and len(v) > 200:
|
|
168
|
+
obj[k] = v[: len(v) // 2] + "..."
|
|
169
|
+
break
|
|
170
|
+
else:
|
|
171
|
+
break
|
|
172
|
+
continue
|
|
173
|
+
_, key = max(lists)
|
|
174
|
+
obj[key].pop()
|
|
175
|
+
obj.setdefault("truncated", True)
|
|
176
|
+
return obj
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from ..core import Usage
|
|
5
|
+
from ..core.errors import Precondition
|
|
6
|
+
from .base import Adapter, short
|
|
7
|
+
|
|
8
|
+
READ_TOOLS = (
|
|
9
|
+
"Read",
|
|
10
|
+
"Grep",
|
|
11
|
+
"Glob",
|
|
12
|
+
"Bash(git diff *)",
|
|
13
|
+
"Bash(git log *)",
|
|
14
|
+
"Bash(git show *)",
|
|
15
|
+
"Bash(git status *)",
|
|
16
|
+
"Bash(ls *)",
|
|
17
|
+
)
|
|
18
|
+
WRITE_TOOLS = READ_TOOLS + ("Edit(./**)", "Write(./**)", "NotebookEdit(./**)")
|
|
19
|
+
READ_NAMES = ("Read", "Grep", "Glob", "WebSearch", "WebFetch", "TodoWrite", "Bash")
|
|
20
|
+
WRITE_NAMES = ("Edit", "Write", "NotebookEdit")
|
|
21
|
+
_RULE = re.compile(r"^([A-Za-z]+)(?:\(([A-Za-z0-9 ._/*:@=+-]+)\))?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClaudeCode(Adapter):
|
|
25
|
+
name = "claude"
|
|
26
|
+
runtime = "claude-code"
|
|
27
|
+
provider = "anthropic"
|
|
28
|
+
binary = "claude"
|
|
29
|
+
env_keys = ("CLAUDE_CONFIG_DIR",)
|
|
30
|
+
api_keys = ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
|
|
31
|
+
|
|
32
|
+
def tools(self, sandbox, override=None):
|
|
33
|
+
if override:
|
|
34
|
+
return tuple(override)
|
|
35
|
+
return WRITE_TOOLS if sandbox == "workspace-write" else READ_TOOLS
|
|
36
|
+
|
|
37
|
+
def check_tools(self, value, sandbox):
|
|
38
|
+
"""Validate a --tools override: known tool names, optional plain rule specs, Bash only with a command
|
|
39
|
+
prefix, write tools only in a workspace-write sandbox."""
|
|
40
|
+
items = [t.strip() for t in (value.split(",") if isinstance(value, str) else value) if t.strip()]
|
|
41
|
+
if not items:
|
|
42
|
+
raise Precondition("--tools is empty")
|
|
43
|
+
for item in items:
|
|
44
|
+
m = _RULE.match(item)
|
|
45
|
+
name = m.group(1) if m else ""
|
|
46
|
+
if name not in READ_NAMES + WRITE_NAMES:
|
|
47
|
+
raise Precondition(
|
|
48
|
+
f"--tools: {item!r} is not an allowed tool rule; allowed tools: "
|
|
49
|
+
+ ", ".join(READ_NAMES + WRITE_NAMES),
|
|
50
|
+
tool=item,
|
|
51
|
+
)
|
|
52
|
+
if name == "Bash" and not m.group(2):
|
|
53
|
+
raise Precondition("--tools: Bash needs a command prefix, e.g. Bash(pytest *)", tool=item)
|
|
54
|
+
if name in WRITE_NAMES and sandbox != "workspace-write":
|
|
55
|
+
raise Precondition(f"--tools: {name} needs --sandbox workspace-write", tool=item)
|
|
56
|
+
return items
|
|
57
|
+
|
|
58
|
+
def command(self, run):
|
|
59
|
+
argv = self.executable(run.parent_env) + [
|
|
60
|
+
"-p",
|
|
61
|
+
"--output-format",
|
|
62
|
+
"stream-json",
|
|
63
|
+
"--verbose",
|
|
64
|
+
"--permission-mode",
|
|
65
|
+
"dontAsk",
|
|
66
|
+
"--safe-mode",
|
|
67
|
+
"--no-session-persistence",
|
|
68
|
+
"--setting-sources",
|
|
69
|
+
"",
|
|
70
|
+
"--strict-mcp-config",
|
|
71
|
+
"--allowedTools",
|
|
72
|
+
",".join(self.tools(run.sandbox, getattr(run, "tools", None))),
|
|
73
|
+
"--json-schema",
|
|
74
|
+
json.dumps(run.schema, separators=(",", ":")),
|
|
75
|
+
]
|
|
76
|
+
if run.model:
|
|
77
|
+
argv += ["--model", run.model]
|
|
78
|
+
return argv
|
|
79
|
+
|
|
80
|
+
def parse(self, obj):
|
|
81
|
+
kind = obj.get("type", "")
|
|
82
|
+
if kind == "system" and obj.get("subtype") == "init":
|
|
83
|
+
source = obj.get("apiKeySource")
|
|
84
|
+
billing = "" if source is None else ("plan" if source == "none" else "api")
|
|
85
|
+
return [{"kind": "model", "model": obj.get("model", ""), "billing": billing}]
|
|
86
|
+
if kind == "assistant":
|
|
87
|
+
out = []
|
|
88
|
+
for block in (obj.get("message") or {}).get("content") or []:
|
|
89
|
+
if block.get("type") == "text" and block.get("text"):
|
|
90
|
+
out.append({"kind": "message", "text": block["text"]})
|
|
91
|
+
elif block.get("type") == "tool_use" and block.get("name") != "StructuredOutput":
|
|
92
|
+
out.append({"kind": "command", "command": _tool(block), "exit": None})
|
|
93
|
+
return out
|
|
94
|
+
if kind == "result":
|
|
95
|
+
usage = {
|
|
96
|
+
"kind": "usage",
|
|
97
|
+
"usage": Usage.from_anthropic(obj.get("usage") or {}),
|
|
98
|
+
"usd": obj.get("total_cost_usd"),
|
|
99
|
+
}
|
|
100
|
+
error = ""
|
|
101
|
+
if obj.get("is_error") or (obj.get("subtype") and obj.get("subtype") != "success"):
|
|
102
|
+
error = short(obj.get("result") or obj.get("subtype") or "error", 300)
|
|
103
|
+
return [
|
|
104
|
+
usage,
|
|
105
|
+
{
|
|
106
|
+
"kind": "result",
|
|
107
|
+
"text": obj.get("result") or "",
|
|
108
|
+
"structured": obj.get("structured_output"),
|
|
109
|
+
"error": error,
|
|
110
|
+
},
|
|
111
|
+
]
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
def final(self, run, collected):
|
|
115
|
+
return collected.structured if collected.structured is not None else collected.final
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _tool(block):
|
|
119
|
+
data = block.get("input") or {}
|
|
120
|
+
detail = data.get("command") or data.get("file_path") or data.get("pattern") or data.get("path") or ""
|
|
121
|
+
return short(f"{block.get('name', 'tool')} {detail}".strip(), 300)
|