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/hooks.py
ADDED
|
@@ -0,0 +1,1526 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from . import gitguard
|
|
11
|
+
from .core.portable import open_nofollow
|
|
12
|
+
|
|
13
|
+
HEARTBEAT_SECONDS = 300
|
|
14
|
+
TAIL_BYTES = 65536
|
|
15
|
+
HANDBACK_CHARS = 8000
|
|
16
|
+
URGENT_CHARS = 8000
|
|
17
|
+
PLUGIN_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
|
18
|
+
LEAD_ONLY = ("migrate", "done", "reopen", "verify", "pr", "pr-link", "pr-sync", "delegate", "duel", "resurrect")
|
|
19
|
+
READ_ONLY = (
|
|
20
|
+
"",
|
|
21
|
+
"show",
|
|
22
|
+
"who",
|
|
23
|
+
"claimable",
|
|
24
|
+
"help",
|
|
25
|
+
"version",
|
|
26
|
+
"transcript",
|
|
27
|
+
"check",
|
|
28
|
+
"workspaces",
|
|
29
|
+
"wait",
|
|
30
|
+
"next",
|
|
31
|
+
"approvals",
|
|
32
|
+
)
|
|
33
|
+
BINDING_COMMANDS = ("claim", "join", "up", "heartbeat", "note", "lock")
|
|
34
|
+
RELEASING_LANES = ("review", "blocked", "todo")
|
|
35
|
+
VALUE_OPTIONS = (
|
|
36
|
+
"--board",
|
|
37
|
+
"--agent",
|
|
38
|
+
"--token",
|
|
39
|
+
"--cap",
|
|
40
|
+
"--fence",
|
|
41
|
+
"--note",
|
|
42
|
+
"--ttl",
|
|
43
|
+
"--for",
|
|
44
|
+
"--checkout",
|
|
45
|
+
"--team",
|
|
46
|
+
"--role",
|
|
47
|
+
"--runtime",
|
|
48
|
+
"--model",
|
|
49
|
+
"--verdict",
|
|
50
|
+
"--assign",
|
|
51
|
+
"--locks",
|
|
52
|
+
"--depends",
|
|
53
|
+
"--scope",
|
|
54
|
+
"--verify",
|
|
55
|
+
"--title",
|
|
56
|
+
"--id",
|
|
57
|
+
"--to",
|
|
58
|
+
"--from",
|
|
59
|
+
"--by",
|
|
60
|
+
"--verify-cmd",
|
|
61
|
+
"--output",
|
|
62
|
+
"--ticket",
|
|
63
|
+
"--reviewers",
|
|
64
|
+
"--provider",
|
|
65
|
+
"--runner-model",
|
|
66
|
+
"--reported-model",
|
|
67
|
+
"--effort",
|
|
68
|
+
"--session",
|
|
69
|
+
"--card",
|
|
70
|
+
"--max",
|
|
71
|
+
"--mission",
|
|
72
|
+
"--repos",
|
|
73
|
+
"--task",
|
|
74
|
+
"--timeout",
|
|
75
|
+
"--interval",
|
|
76
|
+
"--limit",
|
|
77
|
+
"--kind",
|
|
78
|
+
"--out",
|
|
79
|
+
"-o",
|
|
80
|
+
"--dest",
|
|
81
|
+
"--export",
|
|
82
|
+
"--file",
|
|
83
|
+
"--save",
|
|
84
|
+
"--lead-secret",
|
|
85
|
+
)
|
|
86
|
+
WRITE_VERBS = (
|
|
87
|
+
"rm",
|
|
88
|
+
"rmdir",
|
|
89
|
+
"mv",
|
|
90
|
+
"cp",
|
|
91
|
+
"tee",
|
|
92
|
+
"touch",
|
|
93
|
+
"truncate",
|
|
94
|
+
"ln",
|
|
95
|
+
"chmod",
|
|
96
|
+
"chown",
|
|
97
|
+
"dd",
|
|
98
|
+
"install",
|
|
99
|
+
"rsync",
|
|
100
|
+
"unlink",
|
|
101
|
+
"mkdir",
|
|
102
|
+
"shred",
|
|
103
|
+
)
|
|
104
|
+
IN_PLACE = ("sed", "perl", "gsed")
|
|
105
|
+
SEPARATORS = (";", "&&", "||", "|", "&", "|&")
|
|
106
|
+
REDIRECTS = (">", ">>", "&>", "&>>", ">|", ">&")
|
|
107
|
+
WRAPPERS = ("env", "command", "exec", "nohup", "time")
|
|
108
|
+
OUTPUT_OPTIONS = ("--out", "-o", "--output", "--dest", "--export", "--file", "--save")
|
|
109
|
+
READ_COMMANDS = ("cd", "ls", "cat", "head", "tail", "wc", "pwd", "grep", "rg", "diff")
|
|
110
|
+
WORKER_TYPES = ("raid-worker", "raidlock:raid-worker")
|
|
111
|
+
ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
112
|
+
EXPORT_LINE = re.compile(r"^export RAIDLOCK_(AGENT|FENCE|BOARD)=([^\s#]+)", re.M)
|
|
113
|
+
CLAIMED_LINE = re.compile(r"\bClaimed ([A-Za-z0-9_-]+)[:.]")
|
|
114
|
+
DENY_BYTES = 200
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class NoBoard(Exception):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def state_dir(env):
|
|
122
|
+
base = env.get("CLAUDE_PLUGIN_DATA") or os.path.join(
|
|
123
|
+
env.get("RAIDLOCK_HOME") or os.path.join(os.path.expanduser("~"), ".raidlock"), "claude-code"
|
|
124
|
+
)
|
|
125
|
+
return base
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _safe(name):
|
|
129
|
+
return re.sub(r"[^A-Za-z0-9_.-]", "_", name or "main")[:120]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def binding_path(env, session, agent_key):
|
|
133
|
+
return os.path.join(state_dir(env), "bindings", _safe(session), _safe(agent_key) + ".json")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_binding(env, session, agent_key):
|
|
137
|
+
try:
|
|
138
|
+
with open(binding_path(env, session, agent_key), encoding="utf-8") as f:
|
|
139
|
+
return json.load(f)
|
|
140
|
+
except (OSError, ValueError):
|
|
141
|
+
return {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def save_binding(env, session, agent_key, data):
|
|
145
|
+
path = binding_path(env, session, agent_key)
|
|
146
|
+
folder = os.path.dirname(path)
|
|
147
|
+
os.makedirs(folder, mode=0o700, exist_ok=True)
|
|
148
|
+
os.chmod(folder, 0o700)
|
|
149
|
+
os.chmod(os.path.dirname(folder), 0o700)
|
|
150
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
151
|
+
fd = open_nofollow(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
152
|
+
try:
|
|
153
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
154
|
+
json.dump(data, f, sort_keys=True)
|
|
155
|
+
os.replace(tmp, path)
|
|
156
|
+
finally:
|
|
157
|
+
if os.path.lexists(tmp):
|
|
158
|
+
os.unlink(tmp)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def log_error(env, event, err):
|
|
162
|
+
try:
|
|
163
|
+
folder = state_dir(env)
|
|
164
|
+
os.makedirs(folder, exist_ok=True)
|
|
165
|
+
with open(os.path.join(folder, "errors.log"), "a", encoding="utf-8") as f:
|
|
166
|
+
f.write(f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} {event} {type(err).__name__}: {err}\n")
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def agent_key(data):
|
|
172
|
+
return data.get("agent_id") or "main"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def is_subagent(data):
|
|
176
|
+
return bool(data.get("agent_id"))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def find_board(*starts):
|
|
180
|
+
for start in starts:
|
|
181
|
+
if not start:
|
|
182
|
+
continue
|
|
183
|
+
cur = os.path.abspath(os.path.expanduser(start))
|
|
184
|
+
if os.path.isfile(cur) or not os.path.exists(cur):
|
|
185
|
+
cur = os.path.dirname(cur)
|
|
186
|
+
while True:
|
|
187
|
+
if os.path.basename(cur) == ".board" and os.path.isfile(os.path.join(cur, "board.json")):
|
|
188
|
+
return cur
|
|
189
|
+
candidate = os.path.join(cur, ".board")
|
|
190
|
+
if os.path.isdir(candidate):
|
|
191
|
+
return candidate
|
|
192
|
+
parent = os.path.dirname(cur)
|
|
193
|
+
if parent == cur:
|
|
194
|
+
break
|
|
195
|
+
cur = parent
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def open_board(board_dir, env):
|
|
200
|
+
from .core import Board
|
|
201
|
+
|
|
202
|
+
return Board.open(board_dir, env=env)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def worker_env(data, env):
|
|
206
|
+
if is_subagent(data):
|
|
207
|
+
try:
|
|
208
|
+
from .core.lead_env import assert_worker_env, scrub_worker_env
|
|
209
|
+
except ImportError:
|
|
210
|
+
pass
|
|
211
|
+
else:
|
|
212
|
+
clean = scrub_worker_env(env)
|
|
213
|
+
assert_worker_env(clean)
|
|
214
|
+
return {k: v for k, v in env.items() if k == "RAIDLOCK_HOME"}
|
|
215
|
+
return dict(env)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def lead_session(board):
|
|
219
|
+
try:
|
|
220
|
+
lease = board.leadlease().lease()
|
|
221
|
+
except Exception:
|
|
222
|
+
return ""
|
|
223
|
+
return str(lease.get("session") or "")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def lead_live(board):
|
|
227
|
+
try:
|
|
228
|
+
summary = board.leadlease().summary()
|
|
229
|
+
except Exception:
|
|
230
|
+
return False
|
|
231
|
+
return bool(summary.get("held") and summary.get("live") and summary.get("session"))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def session_role(data, env, board, binding):
|
|
235
|
+
"""Classify host sessions against the current lead lease."""
|
|
236
|
+
if is_subagent(data):
|
|
237
|
+
return "subagent"
|
|
238
|
+
name = binding.get("name", "")
|
|
239
|
+
sid = str(data.get("session_id") or "")
|
|
240
|
+
lease = board.leadlease().lease()
|
|
241
|
+
if name == board.lead:
|
|
242
|
+
if (
|
|
243
|
+
lead_live(board)
|
|
244
|
+
and sid == lease.get("session")
|
|
245
|
+
and binding.get("lead_session") == sid
|
|
246
|
+
and binding.get("lead_fence") == lease.get("fence")
|
|
247
|
+
):
|
|
248
|
+
return "lead"
|
|
249
|
+
return "unbound"
|
|
250
|
+
if sid and sid == lease.get("session") and lead_live(board):
|
|
251
|
+
return "lead"
|
|
252
|
+
if name:
|
|
253
|
+
return "worker"
|
|
254
|
+
if env.get("RAIDLOCK_AGENT_ID") or env.get("RAIDLOCK_CAP"):
|
|
255
|
+
return "worker"
|
|
256
|
+
agent = env.get("RAIDLOCK_AGENT", "")
|
|
257
|
+
if agent and agent != board.lead and agent in board.agents():
|
|
258
|
+
return "worker"
|
|
259
|
+
return "unbound"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def without_lead_material(env):
|
|
263
|
+
from .core.lead_env import lead_material
|
|
264
|
+
|
|
265
|
+
drop = set(lead_material(env))
|
|
266
|
+
return {k: v for k, v in env.items() if k not in drop}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def classify(data, env, *paths):
|
|
270
|
+
binding = load_binding(env, data.get("session_id", ""), agent_key(data))
|
|
271
|
+
starts = [binding.get("board")] + list(paths) + [data.get("_host_cwd"), data.get("cwd")]
|
|
272
|
+
if not is_subagent(data):
|
|
273
|
+
starts.append(env.get("RAIDLOCK_BOARD") or env.get("BOARD_DIR"))
|
|
274
|
+
board_dir = find_board(*starts)
|
|
275
|
+
if not board_dir:
|
|
276
|
+
raise NoBoard()
|
|
277
|
+
wenv = worker_env(data, env)
|
|
278
|
+
board = open_board(board_dir, wenv)
|
|
279
|
+
return board, binding, session_role(data, env, board, binding)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def resolve_role(data, env, *paths):
|
|
283
|
+
board, binding, role = classify(data, env, *paths)
|
|
284
|
+
wenv = worker_env(data, env)
|
|
285
|
+
if role in ("worker", "unbound"):
|
|
286
|
+
wenv = without_lead_material(wenv)
|
|
287
|
+
board.env = wenv
|
|
288
|
+
if binding.get("name") and role != "unbound":
|
|
289
|
+
caller = binding_caller(board, binding, subagent=is_subagent(data), env=wenv)
|
|
290
|
+
else:
|
|
291
|
+
caller = board.caller(env=wenv)
|
|
292
|
+
return board, caller, binding, role
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def resolve(data, env, *paths):
|
|
296
|
+
board, caller, binding, _ = resolve_role(data, env, *paths)
|
|
297
|
+
return board, caller, binding
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def binding_caller(board, binding, subagent=False, env=None):
|
|
301
|
+
"""Authenticate only a host binding established with a valid worker credential."""
|
|
302
|
+
from .core.errors import IdentityError
|
|
303
|
+
|
|
304
|
+
name = binding.get("name", "")
|
|
305
|
+
if name not in board.agents() or (subagent and name == board.lead):
|
|
306
|
+
raise IdentityError("the host binding does not identify a joined worker")
|
|
307
|
+
if name == board.lead:
|
|
308
|
+
caller = board.caller(agent=name, env=env or {})
|
|
309
|
+
if not caller.is_lead:
|
|
310
|
+
raise IdentityError("the host must present its lead credential")
|
|
311
|
+
return caller
|
|
312
|
+
pinned = {"RAIDLOCK_AGENT_ID": name} if subagent else {}
|
|
313
|
+
if binding.get("cap"):
|
|
314
|
+
caller = board.caller(agent=name, cap=binding["cap"], env=pinned)
|
|
315
|
+
board._auth(caller, scoped_ok=True)
|
|
316
|
+
return caller
|
|
317
|
+
secret = agent_token(board, name) or ""
|
|
318
|
+
proof = hashlib.sha256(secret.encode()).hexdigest() if secret else ""
|
|
319
|
+
if not proof or not hmac.compare_digest(proof, binding.get("proof", "")):
|
|
320
|
+
raise IdentityError("the host binding has no valid authentication proof")
|
|
321
|
+
return board.caller(agent=name, token=secret, fence=binding.get("fence"), env=pinned)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def deny(reason):
|
|
325
|
+
return {
|
|
326
|
+
"hookSpecificOutput": {
|
|
327
|
+
"hookEventName": "PreToolUse",
|
|
328
|
+
"permissionDecision": "deny",
|
|
329
|
+
"permissionDecisionReason": "raidlock: " + reason,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def write_decision(data, env, path):
|
|
335
|
+
if not path:
|
|
336
|
+
return None
|
|
337
|
+
full = path if os.path.isabs(path) else os.path.join(data.get("cwd") or os.getcwd(), path)
|
|
338
|
+
if has_board_segment(full) or has_board_segment(os.path.realpath(full)):
|
|
339
|
+
return deny(".board/ is written only by the raidlock CLI")
|
|
340
|
+
try:
|
|
341
|
+
board, caller, _, role = resolve_role(data, env, full)
|
|
342
|
+
except NoBoard:
|
|
343
|
+
return None
|
|
344
|
+
from .core import repos
|
|
345
|
+
|
|
346
|
+
loc = repos.locate(full)
|
|
347
|
+
lead_owned = False
|
|
348
|
+
if loc:
|
|
349
|
+
for conflict in board.index.conflicts([{**loc, "path": full}]):
|
|
350
|
+
here = conflict["workspace"] in (board.name, board.uid)
|
|
351
|
+
if role == "lead" and here and conflict["agent"] == board.lead:
|
|
352
|
+
lead_owned = True
|
|
353
|
+
continue
|
|
354
|
+
if conflict["agent"] != caller.name or not here:
|
|
355
|
+
return deny(
|
|
356
|
+
f"held by {conflict['agent']} on {conflict['card']} in workspace {conflict['workspace']}. "
|
|
357
|
+
"Ask the lead or pick another card."
|
|
358
|
+
)
|
|
359
|
+
if lead_owned:
|
|
360
|
+
return None
|
|
361
|
+
if role == "lead" and loc and board.settings.get("require_card_for_lead_edits"):
|
|
362
|
+
from .core.board import LEAD_CARD_RULE
|
|
363
|
+
|
|
364
|
+
return deny(LEAD_CARD_RULE)
|
|
365
|
+
if loc and caller.name and not caller.is_lead and (not caller.authenticated or not caller.fence):
|
|
366
|
+
return deny("a verified worker identity and current fence are required")
|
|
367
|
+
d = board.check_write(full, caller)
|
|
368
|
+
if d["allowed"]:
|
|
369
|
+
return None
|
|
370
|
+
if not d.get("card"):
|
|
371
|
+
if role == "lead":
|
|
372
|
+
return None
|
|
373
|
+
if role == "unbound" and board.settings.get("auth") == "off":
|
|
374
|
+
return None
|
|
375
|
+
if role == "unbound":
|
|
376
|
+
return deny(
|
|
377
|
+
"this session has no raidlock identity, so it may write only inside a lease. "
|
|
378
|
+
"Claim a card that locks it: raid claimable"
|
|
379
|
+
)
|
|
380
|
+
if d.get("holder") and d["holder"] != caller.name:
|
|
381
|
+
return deny(f"{d['reason']}. Ask the lead or pick another card.")
|
|
382
|
+
if d.get("card"):
|
|
383
|
+
return deny(f"{d['reason']}. Run: raidlock claim {d['card']}")
|
|
384
|
+
return deny(f"{d['reason']}. Claim a card that locks it first: raid claimable")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def guarded_read_paths(data, env):
|
|
388
|
+
"""Resolve each read against every directory in which its segment may run."""
|
|
389
|
+
tool = data.get("tool_input") or {}
|
|
390
|
+
cwd = data.get("cwd") or os.getcwd()
|
|
391
|
+
paths = [
|
|
392
|
+
(tool[k], cwd, False, True, env)
|
|
393
|
+
for k in ("file_path", "notebook_path", "path", "glob")
|
|
394
|
+
if isinstance(tool.get(k), str)
|
|
395
|
+
]
|
|
396
|
+
if data.get("tool_name") == "Glob" and isinstance(tool.get("pattern"), str):
|
|
397
|
+
paths.append((tool["pattern"], cwd, False, True, env))
|
|
398
|
+
command = tool.get("command")
|
|
399
|
+
if isinstance(command, str) and data.get("tool_name") != "apply_patch":
|
|
400
|
+
for op in gitguard.analyze(command, [cwd], all_commands=True):
|
|
401
|
+
argv = op["argv"]
|
|
402
|
+
verb = os.path.basename(argv[0]).casefold() if argv else ""
|
|
403
|
+
navigation = verb in ("cd", "pushd")
|
|
404
|
+
recursive = verb in ("find", "rg", "tar", "rsync", "du", "tree", "zip", "7z") or any(
|
|
405
|
+
a in ("--recursive", "--dereference-recursive", "--archive")
|
|
406
|
+
or (a.startswith("-") and not a.startswith("--") and any(c in a[1:] for c in "rRa"))
|
|
407
|
+
for a in argv[1:]
|
|
408
|
+
)
|
|
409
|
+
for directory in op["cwds"]:
|
|
410
|
+
if directory is None:
|
|
411
|
+
raise gitguard.Ambiguous("cannot determine read directory")
|
|
412
|
+
words = list(op["raw"][1:])
|
|
413
|
+
if recursive or verb in ("find", "rg", "grep", "tar", "rsync"):
|
|
414
|
+
words.append(".")
|
|
415
|
+
for word in words:
|
|
416
|
+
if word in SEPARATORS + REDIRECTS or word.startswith("-") and "=" not in word:
|
|
417
|
+
continue
|
|
418
|
+
if word.startswith("-"):
|
|
419
|
+
word = word.split("=", 1)[1]
|
|
420
|
+
paths.append((word, directory, navigation, recursive, {**env, **op["env"]}))
|
|
421
|
+
out = []
|
|
422
|
+
for path, directory, navigation, recursive, values in paths:
|
|
423
|
+
path = re.sub(
|
|
424
|
+
r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)",
|
|
425
|
+
lambda m, values=values: values.get(m.group(1) or m.group(2), m.group(0)),
|
|
426
|
+
path,
|
|
427
|
+
)
|
|
428
|
+
if "$" in path:
|
|
429
|
+
continue
|
|
430
|
+
recursive = recursive or bool(gitguard.GLOB.search(path))
|
|
431
|
+
path = os.path.expanduser(literal_prefix(path))
|
|
432
|
+
out.append((path if os.path.isabs(path) else os.path.join(directory, path), navigation, recursive))
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def read_guard(data, env):
|
|
437
|
+
try:
|
|
438
|
+
board, _, role = classify(data, env)
|
|
439
|
+
except NoBoard:
|
|
440
|
+
return
|
|
441
|
+
if role == "lead":
|
|
442
|
+
return
|
|
443
|
+
paths = guarded_read_paths(data, env)
|
|
444
|
+
from .core.errors import IdentityError
|
|
445
|
+
from .core.lead_hook import protected_lead_path
|
|
446
|
+
|
|
447
|
+
tokens = os.path.realpath(os.path.join(board.home, "tokens")).casefold()
|
|
448
|
+
secret = os.path.realpath(board.lead_status()["secret_file"]).casefold()
|
|
449
|
+
recovering = role == "unbound" and not lead_live(board)
|
|
450
|
+
for path, navigation, recursive in paths:
|
|
451
|
+
real = os.path.realpath(os.path.expanduser(path)).casefold()
|
|
452
|
+
if recovering and real == secret:
|
|
453
|
+
continue
|
|
454
|
+
in_tokens = real == tokens or real.startswith(tokens + os.sep)
|
|
455
|
+
ancestor = (
|
|
456
|
+
protected_lead_path(board, path) or tokens.startswith(real.rstrip(os.sep) + os.sep)
|
|
457
|
+
) and not navigation
|
|
458
|
+
reachable = recursive and not navigation and not (ancestor or in_tokens or real == secret)
|
|
459
|
+
if ancestor or in_tokens or real == secret or (reachable and reaches_credentials(path, tokens, secret)):
|
|
460
|
+
if role == "subagent":
|
|
461
|
+
raise IdentityError("subagents may not read the lead secret or agent tokens")
|
|
462
|
+
raise IdentityError(
|
|
463
|
+
"only the lead session may read the lead secret or agent tokens; "
|
|
464
|
+
"raidlock lead status shows which session holds the lease"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def reaches_credentials(path, tokens, secret):
|
|
469
|
+
pending, seen = [path], set()
|
|
470
|
+
while pending:
|
|
471
|
+
current = pending.pop()
|
|
472
|
+
real = os.path.realpath(current).casefold()
|
|
473
|
+
if real in (secret, tokens) or real.startswith(tokens + os.sep):
|
|
474
|
+
return True
|
|
475
|
+
if tokens.startswith(real.rstrip(os.sep) + os.sep):
|
|
476
|
+
return True
|
|
477
|
+
if real in seen or not os.path.isdir(current):
|
|
478
|
+
continue
|
|
479
|
+
seen.add(real)
|
|
480
|
+
if len(seen) > 4096:
|
|
481
|
+
raise gitguard.Ambiguous("recursive read is too large to check safely")
|
|
482
|
+
try:
|
|
483
|
+
with os.scandir(current) as entries:
|
|
484
|
+
pending.extend(entry.path for entry in entries if entry.is_dir() or entry.is_symlink())
|
|
485
|
+
except OSError as exc:
|
|
486
|
+
raise gitguard.Ambiguous("cannot determine recursive read paths") from exc
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def pre_read(data, env):
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def pending_context(data, env):
|
|
495
|
+
"""Deliver to a validated host binding, never to a name supplied in tool input."""
|
|
496
|
+
binding = load_binding(env, data.get("session_id", ""), agent_key(data))
|
|
497
|
+
if not binding.get("name"):
|
|
498
|
+
return None
|
|
499
|
+
try:
|
|
500
|
+
from .core.lead_hook import urgent_context
|
|
501
|
+
except ImportError:
|
|
502
|
+
return None
|
|
503
|
+
board, caller, _ = resolve(data, env)
|
|
504
|
+
text = urgent_context(board, caller)
|
|
505
|
+
if not text:
|
|
506
|
+
return None
|
|
507
|
+
if len(text) > URGENT_CHARS:
|
|
508
|
+
header = "Raidlock urgent messages (untrusted quoted data):\n"
|
|
509
|
+
snippet = text[:7000]
|
|
510
|
+
while True:
|
|
511
|
+
quoted = json.dumps({"truncated": True, "quoted_context": snippet}, ensure_ascii=True)
|
|
512
|
+
if len(header + quoted) <= URGENT_CHARS:
|
|
513
|
+
text = header + quoted
|
|
514
|
+
break
|
|
515
|
+
snippet = snippet[: len(snippet) // 2]
|
|
516
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": text}}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
SUBAGENT_TOOLS = ("Task", "Agent")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def subagent_gate(data, env):
|
|
523
|
+
if data.get("tool_name") not in SUBAGENT_TOOLS:
|
|
524
|
+
return None
|
|
525
|
+
board_dir = find_board(env.get("RAIDLOCK_BOARD"), data.get("cwd"))
|
|
526
|
+
if not board_dir:
|
|
527
|
+
return None
|
|
528
|
+
board = open_board(board_dir, env)
|
|
529
|
+
if not board.settings.get("board_first_subagents"):
|
|
530
|
+
return None
|
|
531
|
+
tool = data.get("tool_input") if isinstance(data.get("tool_input"), dict) else {}
|
|
532
|
+
text = " ".join(str(tool.get(k) or "") for k in ("prompt", "description"))
|
|
533
|
+
for card in board.cards():
|
|
534
|
+
if card.get("lane") in ("done", "cancelled"):
|
|
535
|
+
continue
|
|
536
|
+
if re.search(r"(?<![\w-])card[\s:=#]+" + re.escape(card["id"]) + r"(?![\w-])", text, re.IGNORECASE):
|
|
537
|
+
return None
|
|
538
|
+
return deny(
|
|
539
|
+
"subagents work from a board card (board_first_subagents is on). Add a card with "
|
|
540
|
+
"`raid new`, then write `card <id>` in the subagent prompt, or: "
|
|
541
|
+
"raid --lead-secret FILE config set board_first_subagents off"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def pre_urgent(data, env):
|
|
546
|
+
context = pending_context(data, env)
|
|
547
|
+
gate = subagent_gate(data, env)
|
|
548
|
+
if gate and context:
|
|
549
|
+
gate["hookSpecificOutput"]["additionalContext"] = context["hookSpecificOutput"]["additionalContext"]
|
|
550
|
+
return gate or context
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def pre_write(data, env):
|
|
554
|
+
tool_input = data.get("tool_input") or {}
|
|
555
|
+
if isinstance(tool_input, str):
|
|
556
|
+
tool_input = {"command": tool_input}
|
|
557
|
+
patch = tool_input.get("command") if isinstance(tool_input.get("command"), str) else ""
|
|
558
|
+
if data.get("tool_name") == "apply_patch" or (patch and "*** Begin Patch" in patch):
|
|
559
|
+
from .bridge import patch_paths
|
|
560
|
+
|
|
561
|
+
targets = patch_paths(patch)
|
|
562
|
+
if not targets:
|
|
563
|
+
return deny("cannot determine apply_patch targets")
|
|
564
|
+
for target in targets:
|
|
565
|
+
result = write_decision(data, env, target)
|
|
566
|
+
if result:
|
|
567
|
+
return result
|
|
568
|
+
return None
|
|
569
|
+
return write_decision(data, env, tool_input.get("file_path") or tool_input.get("notebook_path"))
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def tokenize(command):
|
|
573
|
+
text = command.replace("\n", " ; ")
|
|
574
|
+
try:
|
|
575
|
+
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|<>")
|
|
576
|
+
lexer.whitespace_split = True
|
|
577
|
+
return list(lexer)
|
|
578
|
+
except ValueError:
|
|
579
|
+
return text.split()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def segments(command):
|
|
583
|
+
out, cur = [], []
|
|
584
|
+
for tok in tokenize(command):
|
|
585
|
+
if tok in SEPARATORS:
|
|
586
|
+
if cur:
|
|
587
|
+
out.append(cur)
|
|
588
|
+
cur = []
|
|
589
|
+
else:
|
|
590
|
+
cur.append(tok)
|
|
591
|
+
if cur:
|
|
592
|
+
out.append(cur)
|
|
593
|
+
return out
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def split_env(seg):
|
|
597
|
+
env, i = {}, 0
|
|
598
|
+
while i < len(seg):
|
|
599
|
+
if ENV_ASSIGN.match(seg[i]):
|
|
600
|
+
key, _, value = seg[i].partition("=")
|
|
601
|
+
env[key] = value
|
|
602
|
+
elif seg[i] not in WRAPPERS:
|
|
603
|
+
break
|
|
604
|
+
i += 1
|
|
605
|
+
return env, seg[i:]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def walk(command):
|
|
609
|
+
carried = {}
|
|
610
|
+
for seg in segments(command):
|
|
611
|
+
prefix, argv = split_env(seg)
|
|
612
|
+
argv, redirects = strip_redirects(argv)
|
|
613
|
+
if argv and argv[0] == "export":
|
|
614
|
+
for item in argv[1:]:
|
|
615
|
+
if ENV_ASSIGN.match(item):
|
|
616
|
+
key, _, value = item.partition("=")
|
|
617
|
+
carried[key] = value
|
|
618
|
+
yield {**carried, **prefix}, argv, redirects
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def exports_only(argv):
|
|
622
|
+
return bool(argv) and argv[0] == "export" and all(a.startswith("RAIDLOCK_") for a in argv[1:])
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def board_lead(data, binding_board=""):
|
|
626
|
+
from .core import store
|
|
627
|
+
|
|
628
|
+
board_dir = find_board(binding_board, data.get("cwd"))
|
|
629
|
+
meta = store.read_json(os.path.join(board_dir, "board.json")) if board_dir else None
|
|
630
|
+
return (meta or {}).get("lead", "")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def session_worker(data, env, ctx, opts):
|
|
634
|
+
"""The worker a main session acts as, or "": its binding, a worker credential in its environment, or an agent
|
|
635
|
+
named on the command that is not the board's lead."""
|
|
636
|
+
binding = load_binding(env, data.get("session_id", ""), agent_key(data))
|
|
637
|
+
lead = board_lead(data, binding.get("board", ""))
|
|
638
|
+
if not lead:
|
|
639
|
+
return ""
|
|
640
|
+
for source in (ctx, env):
|
|
641
|
+
if source.get("RAIDLOCK_AGENT_ID") or source.get("RAIDLOCK_CAP"):
|
|
642
|
+
return source.get("RAIDLOCK_AGENT_ID") or "capability holder"
|
|
643
|
+
names = (
|
|
644
|
+
opts.get("--agent"),
|
|
645
|
+
ctx.get("RAIDLOCK_AGENT"),
|
|
646
|
+
env.get("RAIDLOCK_AGENT"),
|
|
647
|
+
binding.get("name"),
|
|
648
|
+
)
|
|
649
|
+
return next((n for n in names if isinstance(n, str) and n and n != lead), "")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def strip_redirects(argv):
|
|
653
|
+
args, targets, i = [], [], 0
|
|
654
|
+
while i < len(argv):
|
|
655
|
+
tok = argv[i]
|
|
656
|
+
if tok in REDIRECTS or (tok.isdigit() and i + 1 < len(argv) and argv[i + 1] in REDIRECTS):
|
|
657
|
+
if tok.isdigit():
|
|
658
|
+
i += 1
|
|
659
|
+
tok = argv[i]
|
|
660
|
+
if i + 1 < len(argv):
|
|
661
|
+
target = argv[i + 1]
|
|
662
|
+
if not target.startswith("&") and not target.isdigit():
|
|
663
|
+
targets.append(target)
|
|
664
|
+
i += 2
|
|
665
|
+
continue
|
|
666
|
+
if tok in ("<", "<<", "<<<") and i + 1 < len(argv):
|
|
667
|
+
i += 2
|
|
668
|
+
continue
|
|
669
|
+
args.append(tok)
|
|
670
|
+
i += 1
|
|
671
|
+
return args, targets
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def raidlock_args(argv):
|
|
675
|
+
if not argv:
|
|
676
|
+
return None
|
|
677
|
+
base = os.path.basename(argv[0]).casefold()
|
|
678
|
+
if base in ("raid", "raidlock"):
|
|
679
|
+
return argv[1:]
|
|
680
|
+
if base.startswith("python") and len(argv) >= 3 and argv[1] == "-m" and argv[2] == "raidlock":
|
|
681
|
+
return argv[3:]
|
|
682
|
+
return None
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def parse_raidlock(args):
|
|
686
|
+
opts, pos, i = {}, [], 0
|
|
687
|
+
while i < len(args):
|
|
688
|
+
a = args[i]
|
|
689
|
+
if a.startswith("--") and "=" in a:
|
|
690
|
+
key, _, value = a.partition("=")
|
|
691
|
+
opts[key] = value
|
|
692
|
+
elif a in VALUE_OPTIONS:
|
|
693
|
+
opts[a] = args[i + 1] if i + 1 < len(args) else ""
|
|
694
|
+
i += 1
|
|
695
|
+
elif a.startswith("-"):
|
|
696
|
+
opts[a] = True
|
|
697
|
+
else:
|
|
698
|
+
pos.append(a)
|
|
699
|
+
i += 1
|
|
700
|
+
return (pos[0] if pos else ""), pos[1:], opts
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def has_board_segment(path):
|
|
704
|
+
return ".board" in path.replace("\\", "/").split("/")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def is_tokens_path(text, env):
|
|
708
|
+
home = env.get("RAIDLOCK_HOME") or os.path.join(os.path.expanduser("~"), ".raidlock")
|
|
709
|
+
norm = text.replace("\\", "/")
|
|
710
|
+
return (
|
|
711
|
+
"/.raidlock/tokens" in norm
|
|
712
|
+
or norm.startswith("~/.raidlock/tokens")
|
|
713
|
+
or (os.path.join(home, "tokens") in norm)
|
|
714
|
+
or "$RAIDLOCK_HOME/tokens" in norm
|
|
715
|
+
or "${RAIDLOCK_HOME}/tokens" in norm
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def written_paths(argv):
|
|
720
|
+
if not argv:
|
|
721
|
+
return []
|
|
722
|
+
verb = os.path.basename(argv[0]).casefold()
|
|
723
|
+
rest = [a for a in argv[1:] if not a.startswith("-")]
|
|
724
|
+
if verb in ("cp", "mv", "install", "ln"):
|
|
725
|
+
destinations = []
|
|
726
|
+
for i, arg in enumerate(argv[1:], 1):
|
|
727
|
+
if arg in ("-t", "--target-directory"):
|
|
728
|
+
destinations.append(argv[i + 1] if i + 1 < len(argv) else "$unknown")
|
|
729
|
+
elif arg.startswith("--target-directory="):
|
|
730
|
+
destinations.append(arg.split("=", 1)[1])
|
|
731
|
+
elif arg.startswith("-t") and len(arg) > 2:
|
|
732
|
+
destinations.append(arg[2:])
|
|
733
|
+
return (rest if verb == "mv" else rest[-1:]) + destinations
|
|
734
|
+
if verb == "rsync":
|
|
735
|
+
return rest[-1:]
|
|
736
|
+
if verb == "dd":
|
|
737
|
+
return [a[3:] for a in argv[1:] if a.startswith("of=")]
|
|
738
|
+
if verb in WRITE_VERBS:
|
|
739
|
+
return rest
|
|
740
|
+
if verb in IN_PLACE and any(a == "-i" or a.startswith("-i") or a == "--in-place" for a in argv[1:]):
|
|
741
|
+
return rest
|
|
742
|
+
return []
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def lease_check(data, env, board, caller, full):
|
|
746
|
+
from .core import repos
|
|
747
|
+
|
|
748
|
+
loc = repos.locate(full)
|
|
749
|
+
if not loc:
|
|
750
|
+
if os.path.isdir(full):
|
|
751
|
+
for root in repos.discover(full).values():
|
|
752
|
+
result = lease_check(data, env, board, caller, root)
|
|
753
|
+
if result:
|
|
754
|
+
return result
|
|
755
|
+
return None
|
|
756
|
+
key = {**loc, "path": full}
|
|
757
|
+
for c in board.index.conflicts([key]):
|
|
758
|
+
here = c["workspace"] in (board.name, board.uid)
|
|
759
|
+
if here and c["agent"] == board.lead:
|
|
760
|
+
continue
|
|
761
|
+
if c["agent"] != caller.name or not here:
|
|
762
|
+
return deny(
|
|
763
|
+
f"{full} overlaps {c['held'] or 'the repo'} held by {c['agent']} on {c['card']}. "
|
|
764
|
+
"Ask the lead or pick another card."
|
|
765
|
+
)
|
|
766
|
+
return write_decision(data, env, full)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def repo_lease_held(board, caller, root):
|
|
770
|
+
from .core import repos
|
|
771
|
+
|
|
772
|
+
loc = repos.locate(root)
|
|
773
|
+
if not loc:
|
|
774
|
+
return True
|
|
775
|
+
key = {**loc, "path": root}
|
|
776
|
+
if not caller.authenticated or not caller.fence:
|
|
777
|
+
return False
|
|
778
|
+
leases = board.leases()
|
|
779
|
+
return any(
|
|
780
|
+
c["agent"] == caller.name
|
|
781
|
+
and c["workspace"] in (board.name, board.uid)
|
|
782
|
+
and leases.get(c["card"], {}).get("fence") == caller.fence
|
|
783
|
+
and (not getattr(caller, "scope_card", "") or caller.scope_card == c["card"])
|
|
784
|
+
for c in board.index.conflicts([key])
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def literal_prefix(path):
|
|
789
|
+
parts = re.split(r"[\\/]", path) if os.sep == "\\" else path.split("/")
|
|
790
|
+
for i, part in enumerate(parts):
|
|
791
|
+
if gitguard.GLOB.search(part):
|
|
792
|
+
return os.sep.join(parts[:i]) or (os.sep if os.path.isabs(path) else ".")
|
|
793
|
+
return path
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def git_op_decision(data, env, board, caller, op):
|
|
797
|
+
sub_agent = is_subagent(data)
|
|
798
|
+
if any(k.startswith("GIT_") for k in op["env"]):
|
|
799
|
+
raise gitguard.Forbidden("GIT_* environment overrides are not allowed on git commands")
|
|
800
|
+
cdirs, sub, args = gitguard.parse_git(op["argv"])
|
|
801
|
+
kind, paths, written = gitguard.classify(sub, args)
|
|
802
|
+
workspace = os.path.realpath(board.workspace)
|
|
803
|
+
cwds = set()
|
|
804
|
+
for c in op["cwds"]:
|
|
805
|
+
for d in cdirs:
|
|
806
|
+
if c is None or "$" in d or gitguard.SUBST in d:
|
|
807
|
+
c = None
|
|
808
|
+
break
|
|
809
|
+
c = os.path.normpath(os.path.join(c, os.path.expanduser(d)))
|
|
810
|
+
if c is not None and cdirs:
|
|
811
|
+
real = os.path.realpath(c)
|
|
812
|
+
if real != workspace and not real.startswith(workspace + os.sep):
|
|
813
|
+
raise gitguard.Forbidden(f"git -C {c} is outside the workspace")
|
|
814
|
+
cwds.add(c)
|
|
815
|
+
if kind == gitguard.READ and not written:
|
|
816
|
+
return None
|
|
817
|
+
if None in cwds:
|
|
818
|
+
raise gitguard.Ambiguous("cannot tell which directory git runs in")
|
|
819
|
+
from .core import repos
|
|
820
|
+
|
|
821
|
+
for c in sorted(cwds):
|
|
822
|
+
for target in written:
|
|
823
|
+
result = write_decision(data, env, os.path.normpath(os.path.join(c, os.path.expanduser(target))))
|
|
824
|
+
if result:
|
|
825
|
+
return result
|
|
826
|
+
if kind == gitguard.READ:
|
|
827
|
+
continue
|
|
828
|
+
if kind == gitguard.PATHS:
|
|
829
|
+
for p in paths:
|
|
830
|
+
full = os.path.normpath(os.path.join(c, os.path.expanduser(literal_prefix(p))))
|
|
831
|
+
result = lease_check(data, env, board, caller, full)
|
|
832
|
+
if result:
|
|
833
|
+
return result
|
|
834
|
+
if sub_agent and not board.check_write(full, caller)["allowed"] and repos.locate(full):
|
|
835
|
+
return deny(f"git {sub} {p}: you hold no lease covering {full}")
|
|
836
|
+
continue
|
|
837
|
+
root = repos.find_root(c)
|
|
838
|
+
if not root:
|
|
839
|
+
root = c
|
|
840
|
+
if kind == gitguard.REF:
|
|
841
|
+
if sub_agent and not repo_lease_held(board, caller, root):
|
|
842
|
+
return deny(f"git {sub} needs a lease in {root}; claim a card that locks part of it")
|
|
843
|
+
continue
|
|
844
|
+
if kind == gitguard.WHOLE:
|
|
845
|
+
result = lease_check(data, env, board, caller, root)
|
|
846
|
+
if result:
|
|
847
|
+
return deny(
|
|
848
|
+
f"git {sub} can touch the whole repo; it needs a lease covering all of {root} "
|
|
849
|
+
f"({result['hookSpecificOutput']['permissionDecisionReason'][10:]})"
|
|
850
|
+
)
|
|
851
|
+
if sub_agent and not board.check_write(root, caller)["allowed"]:
|
|
852
|
+
return deny(f"git {sub} can touch the whole repo; it needs a lease covering all of {root}")
|
|
853
|
+
continue
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def git_decision(data, env, command):
|
|
858
|
+
try:
|
|
859
|
+
ops = gitguard.analyze(command, [data.get("cwd") or os.getcwd()])
|
|
860
|
+
except gitguard.Ambiguous as e:
|
|
861
|
+
if not gitguard.mentions_git(re.sub(r"[\"'\\]", "", command)):
|
|
862
|
+
return None, False
|
|
863
|
+
ops, err = None, e
|
|
864
|
+
if ops == []:
|
|
865
|
+
return None, False
|
|
866
|
+
try:
|
|
867
|
+
board, caller, _ = resolve(data, env)
|
|
868
|
+
except NoBoard:
|
|
869
|
+
return None, True
|
|
870
|
+
if ops is None:
|
|
871
|
+
return deny(f"ambiguous git command ({err}); run plain git commands one by one"), True
|
|
872
|
+
try:
|
|
873
|
+
for op in ops:
|
|
874
|
+
result = git_op_decision(data, env, board, caller, op)
|
|
875
|
+
if result:
|
|
876
|
+
return result, True
|
|
877
|
+
except gitguard.Forbidden as e:
|
|
878
|
+
return deny(str(e)), True
|
|
879
|
+
except gitguard.Ambiguous as e:
|
|
880
|
+
return deny(f"ambiguous git command ({e}); run plain git commands one by one"), True
|
|
881
|
+
return None, bool(ops)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def bash_role(data, env):
|
|
885
|
+
try:
|
|
886
|
+
board, _, role = classify(data, env)
|
|
887
|
+
except NoBoard:
|
|
888
|
+
return None, ""
|
|
889
|
+
return board, role
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def pre_bash(data, env):
|
|
893
|
+
command = (data.get("tool_input") or {}).get("command") or ""
|
|
894
|
+
sub = is_subagent(data)
|
|
895
|
+
board, role = bash_role(data, env)
|
|
896
|
+
guarded_lead = bool(board) and role in ("worker", "unbound") and lead_live(board)
|
|
897
|
+
unbound = role == "unbound"
|
|
898
|
+
if (sub or guarded_lead) and is_tokens_path(command, env):
|
|
899
|
+
return deny("agent tokens are private; use your own RAIDLOCK_TOKEN")
|
|
900
|
+
if (sub or unbound) and ("$RAIDLOCK_TOKEN" in command or "${RAIDLOCK_TOKEN}" in command):
|
|
901
|
+
return deny("pass your own token literally; the inherited RAIDLOCK_TOKEN is not yours")
|
|
902
|
+
targets = []
|
|
903
|
+
verdict, has_git = git_decision(data, env, command)
|
|
904
|
+
if verdict:
|
|
905
|
+
return verdict
|
|
906
|
+
try:
|
|
907
|
+
commands = gitguard.analyze(command, [data.get("cwd") or os.getcwd()], all_commands=True)
|
|
908
|
+
except gitguard.Ambiguous as exc:
|
|
909
|
+
return deny(f"cannot determine write targets: {exc}")
|
|
910
|
+
for op in commands:
|
|
911
|
+
ctx, argv = op["env"], op["argv"]
|
|
912
|
+
_, redirects = strip_redirects(op["raw"])
|
|
913
|
+
verb = os.path.basename(argv[0]).casefold() if argv else ""
|
|
914
|
+
opaque = raidlock_args(argv) is None and (
|
|
915
|
+
gitguard.INTERPRETER.match(verb)
|
|
916
|
+
or verb in gitguard.SHELLS + ("eval", "source", ".", "rscript")
|
|
917
|
+
or verb.endswith((".py", ".js", ".sh", ".pl", ".rb"))
|
|
918
|
+
or "$" in verb
|
|
919
|
+
or gitguard.SUBST in verb
|
|
920
|
+
or (argv and "/" in argv[0] and verb not in READ_COMMANDS + WRITE_VERBS + ("git", "printf", "echo"))
|
|
921
|
+
)
|
|
922
|
+
if opaque and role in ("worker", "unbound"):
|
|
923
|
+
if unbound:
|
|
924
|
+
return deny("unbound sessions may not execute opaque interpreters or scripts")
|
|
925
|
+
_, caller, _ = resolve(data, env)
|
|
926
|
+
if not caller.authenticated:
|
|
927
|
+
return deny("a verified worker identity is required to execute interpreters or scripts")
|
|
928
|
+
if verb == "ln" and any(
|
|
929
|
+
a == "--symbolic" or (a.startswith("-") and not a.startswith("--") and "s" in a) for a in argv[1:]
|
|
930
|
+
):
|
|
931
|
+
return deny("symlink creation cannot be safely checked before subsequent writes")
|
|
932
|
+
writes = written_paths(argv)
|
|
933
|
+
if verb in WRITE_VERBS and not writes:
|
|
934
|
+
return deny("cannot determine write targets")
|
|
935
|
+
local_targets = redirects + writes
|
|
936
|
+
rl_args = raidlock_args(argv)
|
|
937
|
+
if rl_args is not None:
|
|
938
|
+
_, _, output_opts = parse_raidlock(rl_args)
|
|
939
|
+
for key in OUTPUT_OPTIONS:
|
|
940
|
+
if key in output_opts:
|
|
941
|
+
value = output_opts[key]
|
|
942
|
+
if not isinstance(value, str) or not value:
|
|
943
|
+
return deny("cannot determine write targets")
|
|
944
|
+
local_targets.append(value)
|
|
945
|
+
for target in local_targets:
|
|
946
|
+
if "$" in target or gitguard.SUBST in target or gitguard.GLOB.search(target) or None in op["cwds"]:
|
|
947
|
+
return deny("cannot determine the target directory")
|
|
948
|
+
targets.extend(os.path.normpath(os.path.join(c, os.path.expanduser(target))) for c in op["cwds"])
|
|
949
|
+
rl = raidlock_args(argv)
|
|
950
|
+
if rl is not None:
|
|
951
|
+
cmd, pos, opts = parse_raidlock(rl)
|
|
952
|
+
lead_only = (
|
|
953
|
+
cmd in LEAD_ONLY
|
|
954
|
+
or (cmd == "move" and len(pos) > 1 and pos[1] == "done")
|
|
955
|
+
or (cmd in ("routing", "resurrect-policy", "budget") and pos[:1] == ["set"])
|
|
956
|
+
or (cmd == "bridge" and pos[:1] == ["pool"])
|
|
957
|
+
)
|
|
958
|
+
restricted = lead_only or opts.get("--for") or opts.get("--urgent")
|
|
959
|
+
if guarded_lead and (opts.get("--lead-secret") or ctx.get("RAIDLOCK_LEAD_SECRET")):
|
|
960
|
+
return deny(
|
|
961
|
+
"only the lead session may present the lead secret; "
|
|
962
|
+
"raidlock lead status shows which session holds the lease"
|
|
963
|
+
)
|
|
964
|
+
worker = (
|
|
965
|
+
sub
|
|
966
|
+
or (restricted and role in ("worker", "unbound"))
|
|
967
|
+
or (restricted and session_worker(data, env, ctx, opts))
|
|
968
|
+
)
|
|
969
|
+
if worker and lead_only:
|
|
970
|
+
return deny(f"only the lead can run raidlock {cmd}. Hand over with: raidlock move <id> review")
|
|
971
|
+
if worker and (opts.get("--for") or opts.get("--urgent")):
|
|
972
|
+
return deny("--for and --urgent are lead-only")
|
|
973
|
+
recovering = unbound and cmd == "lead" and not lead_live(board)
|
|
974
|
+
named = sub or (unbound and not recovering)
|
|
975
|
+
if named and cmd not in READ_ONLY and "--help" not in opts and "-h" not in opts:
|
|
976
|
+
name = next(
|
|
977
|
+
(
|
|
978
|
+
v
|
|
979
|
+
for v in (
|
|
980
|
+
opts.get("--agent"),
|
|
981
|
+
opts.get("--from"),
|
|
982
|
+
opts.get("--by"),
|
|
983
|
+
ctx.get("RAIDLOCK_AGENT_ID"),
|
|
984
|
+
ctx.get("RAIDLOCK_AGENT"),
|
|
985
|
+
)
|
|
986
|
+
if isinstance(v, str) and v
|
|
987
|
+
),
|
|
988
|
+
"",
|
|
989
|
+
)
|
|
990
|
+
if not name:
|
|
991
|
+
return deny(
|
|
992
|
+
"name yourself on every board command: RAIDLOCK_AGENT=<name> RAIDLOCK_TOKEN=<token> "
|
|
993
|
+
f"raidlock {cmd} ..."
|
|
994
|
+
)
|
|
995
|
+
if name == board_lead(
|
|
996
|
+
data, load_binding(env, data.get("session_id", ""), agent_key(data)).get("board", "")
|
|
997
|
+
):
|
|
998
|
+
return deny("subagents and unbound sessions cannot act as the lead")
|
|
999
|
+
continue
|
|
1000
|
+
if exports_only(argv):
|
|
1001
|
+
continue
|
|
1002
|
+
if any(has_board_segment(p) for p in written_paths(argv)):
|
|
1003
|
+
return deny(".board/ is written only by the raidlock CLI")
|
|
1004
|
+
|
|
1005
|
+
for target in targets:
|
|
1006
|
+
if target.startswith("/dev/"):
|
|
1007
|
+
continue
|
|
1008
|
+
result = write_decision(data, env, os.path.expanduser(target))
|
|
1009
|
+
if result:
|
|
1010
|
+
return result
|
|
1011
|
+
return None
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def tail_model(path):
|
|
1015
|
+
try:
|
|
1016
|
+
with open(path, "rb") as f:
|
|
1017
|
+
f.seek(0, os.SEEK_END)
|
|
1018
|
+
size = f.tell()
|
|
1019
|
+
f.seek(max(0, size - TAIL_BYTES))
|
|
1020
|
+
chunk = f.read().decode("utf-8", "replace")
|
|
1021
|
+
except OSError:
|
|
1022
|
+
return ""
|
|
1023
|
+
for line in reversed(chunk.splitlines()):
|
|
1024
|
+
line = line.strip()
|
|
1025
|
+
if not line.startswith("{") or '"assistant"' not in line:
|
|
1026
|
+
continue
|
|
1027
|
+
try:
|
|
1028
|
+
rec = json.loads(line)
|
|
1029
|
+
except ValueError:
|
|
1030
|
+
continue
|
|
1031
|
+
msg = rec.get("message") or {}
|
|
1032
|
+
if rec.get("type") == "assistant" or msg.get("role") == "assistant":
|
|
1033
|
+
model = msg.get("model") or ""
|
|
1034
|
+
if model and not model.startswith("<"):
|
|
1035
|
+
return model
|
|
1036
|
+
return ""
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def transcript_for(data):
|
|
1040
|
+
if data.get("agent_transcript_path"):
|
|
1041
|
+
return data["agent_transcript_path"]
|
|
1042
|
+
main = data.get("transcript_path") or ""
|
|
1043
|
+
if not is_subagent(data) or not main:
|
|
1044
|
+
return main
|
|
1045
|
+
stem = main[:-6] if main.endswith(".jsonl") else main
|
|
1046
|
+
return os.path.join(stem, "subagents", f"agent-{data['agent_id']}.jsonl")
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def bind_from_bash(data, env, binding):
|
|
1050
|
+
from .core import errors
|
|
1051
|
+
|
|
1052
|
+
command = (data.get("tool_input") or {}).get("command") or ""
|
|
1053
|
+
response = data.get("tool_response") or {}
|
|
1054
|
+
stdout = response.get("stdout") if isinstance(response, dict) else str(response)
|
|
1055
|
+
stdout = stdout or ""
|
|
1056
|
+
changed = False
|
|
1057
|
+
try:
|
|
1058
|
+
result = json.loads(stdout)
|
|
1059
|
+
except ValueError:
|
|
1060
|
+
result = {}
|
|
1061
|
+
if not isinstance(result, dict):
|
|
1062
|
+
result = {}
|
|
1063
|
+
shell_dir = data.get("cwd") or ""
|
|
1064
|
+
for prefix, argv, _ in walk(command):
|
|
1065
|
+
if argv and argv[0] == "cd" and len(argv) > 1:
|
|
1066
|
+
shell_dir = os.path.join(shell_dir, os.path.expanduser(argv[1]))
|
|
1067
|
+
continue
|
|
1068
|
+
rl = raidlock_args(argv)
|
|
1069
|
+
if rl is None:
|
|
1070
|
+
continue
|
|
1071
|
+
cmd, pos, opts = parse_raidlock(rl)
|
|
1072
|
+
if cmd in ("move", "release", "done") and pos and pos[0] == binding.get("card"):
|
|
1073
|
+
if cmd != "move" or (len(pos) > 1 and pos[1] in RELEASING_LANES + ("done",)):
|
|
1074
|
+
binding["last_card"] = binding["card"]
|
|
1075
|
+
binding["card"] = ""
|
|
1076
|
+
changed = True
|
|
1077
|
+
continue
|
|
1078
|
+
if cmd not in BINDING_COMMANDS:
|
|
1079
|
+
continue
|
|
1080
|
+
exports = dict(EXPORT_LINE.findall(stdout))
|
|
1081
|
+
name = (
|
|
1082
|
+
exports.get("AGENT")
|
|
1083
|
+
or opts.get("--agent")
|
|
1084
|
+
or prefix.get("RAIDLOCK_AGENT_ID")
|
|
1085
|
+
or prefix.get("RAIDLOCK_AGENT")
|
|
1086
|
+
or ("" if is_subagent(data) or binding.get("name") else env.get("RAIDLOCK_AGENT", ""))
|
|
1087
|
+
)
|
|
1088
|
+
board = (
|
|
1089
|
+
exports.get("BOARD")
|
|
1090
|
+
or (opts.get("--board") if isinstance(opts.get("--board"), str) else "")
|
|
1091
|
+
or prefix.get("RAIDLOCK_BOARD", "")
|
|
1092
|
+
)
|
|
1093
|
+
found = find_board(os.path.expanduser(board) if board else "", shell_dir, data.get("cwd"))
|
|
1094
|
+
if found and binding.get("board") != found:
|
|
1095
|
+
binding["board"] = found
|
|
1096
|
+
changed = True
|
|
1097
|
+
if name and name is not True:
|
|
1098
|
+
board_obj = open_board(found, {}) if found else None
|
|
1099
|
+
if board_obj is None or name not in board_obj.agents() or (is_subagent(data) and name == board_obj.lead):
|
|
1100
|
+
continue
|
|
1101
|
+
cap_match = re.search(r"^export RAIDLOCK_CAP=([^\s#]+)", stdout, re.M)
|
|
1102
|
+
capability = (
|
|
1103
|
+
cap_match.group(1)
|
|
1104
|
+
if cap_match
|
|
1105
|
+
else (result.get("cap") if cmd == "claim" else None) or opts.get("--cap") or prefix.get("RAIDLOCK_CAP")
|
|
1106
|
+
)
|
|
1107
|
+
presented = (
|
|
1108
|
+
opts.get("--token")
|
|
1109
|
+
or prefix.get("RAIDLOCK_TOKEN")
|
|
1110
|
+
or (env.get("RAIDLOCK_TOKEN", "") if not is_subagent(data) else "")
|
|
1111
|
+
)
|
|
1112
|
+
candidate = {"name": name, "fence": binding.get("fence", 0)}
|
|
1113
|
+
if capability:
|
|
1114
|
+
candidate["cap"] = capability
|
|
1115
|
+
elif presented and "$" not in presented:
|
|
1116
|
+
candidate["proof"] = hashlib.sha256(presented.encode()).hexdigest()
|
|
1117
|
+
elif name == binding.get("name"):
|
|
1118
|
+
candidate.update({k: binding[k] for k in ("cap", "proof") if k in binding})
|
|
1119
|
+
try:
|
|
1120
|
+
binding_caller(board_obj, candidate, subagent=is_subagent(data))
|
|
1121
|
+
except (errors.RaidlockError, OSError, ValueError):
|
|
1122
|
+
continue
|
|
1123
|
+
if capability:
|
|
1124
|
+
scoped = binding_caller(board_obj, candidate, subagent=is_subagent(data))
|
|
1125
|
+
candidate.update(fence=scoped.fence, card=getattr(scoped, "scope_card", ""))
|
|
1126
|
+
binding.update(candidate)
|
|
1127
|
+
changed = True
|
|
1128
|
+
claimed = CLAIMED_LINE.search(stdout)
|
|
1129
|
+
if cmd == "claim" and not binding.get("cap") and (claimed or result.get("card")):
|
|
1130
|
+
claimed_card = claimed.group(1) if claimed else result["card"]
|
|
1131
|
+
if isinstance(claimed_card, str):
|
|
1132
|
+
binding["card"] = claimed_card
|
|
1133
|
+
changed = True
|
|
1134
|
+
fence = exports.get("FENCE") or result.get("fence") or opts.get("--fence") or prefix.get("RAIDLOCK_FENCE")
|
|
1135
|
+
if not binding.get("cap") and fence and str(fence).isdigit():
|
|
1136
|
+
binding["fence"] = int(fence)
|
|
1137
|
+
changed = True
|
|
1138
|
+
return changed
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def agent_token(board, name):
|
|
1142
|
+
from .core import identity
|
|
1143
|
+
|
|
1144
|
+
try:
|
|
1145
|
+
with open(identity.token_path(board.home, board.uid, name), encoding="utf-8") as f:
|
|
1146
|
+
return f.read().strip()
|
|
1147
|
+
except OSError:
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def record_model(data, env, binding):
|
|
1152
|
+
model = tail_model(transcript_for(data))
|
|
1153
|
+
if not model or model == binding.get("model") or not binding.get("name") or not binding.get("board"):
|
|
1154
|
+
return False
|
|
1155
|
+
board = open_board(binding["board"], {})
|
|
1156
|
+
name = binding["name"]
|
|
1157
|
+
record = board.agents().get(name)
|
|
1158
|
+
binding["model"] = model
|
|
1159
|
+
if not record or record.get("model") == model:
|
|
1160
|
+
return True
|
|
1161
|
+
caller = binding_caller(board, binding, subagent=is_subagent(data), env=worker_env(data, env))
|
|
1162
|
+
if getattr(caller, "scope_card", ""):
|
|
1163
|
+
return True
|
|
1164
|
+
board.join(
|
|
1165
|
+
caller,
|
|
1166
|
+
agent=name,
|
|
1167
|
+
role=record.get("role", "worker"),
|
|
1168
|
+
runtime=record.get("runtime") or "claude-code",
|
|
1169
|
+
provider=record.get("provider") or None,
|
|
1170
|
+
reported_model=model,
|
|
1171
|
+
)
|
|
1172
|
+
return True
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def heartbeat_due(env, data, binding):
|
|
1176
|
+
path = os.path.join(state_dir(env), "hb", _safe(data.get("session_id", "")) + "-" + _safe(agent_key(data)))
|
|
1177
|
+
try:
|
|
1178
|
+
if time.time() - os.path.getmtime(path) < HEARTBEAT_SECONDS:
|
|
1179
|
+
return False
|
|
1180
|
+
except OSError:
|
|
1181
|
+
pass
|
|
1182
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
1183
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1184
|
+
f.write(binding.get("card", ""))
|
|
1185
|
+
return True
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def post_tool(data, env):
|
|
1189
|
+
session, key = data.get("session_id", ""), agent_key(data)
|
|
1190
|
+
binding = load_binding(env, session, key)
|
|
1191
|
+
changed = False
|
|
1192
|
+
if data.get("tool_name") == "Bash":
|
|
1193
|
+
changed = bind_from_bash(data, env, binding)
|
|
1194
|
+
try:
|
|
1195
|
+
changed |= record_model(data, env, binding)
|
|
1196
|
+
except Exception as e:
|
|
1197
|
+
log_error(env, "model", e)
|
|
1198
|
+
if changed:
|
|
1199
|
+
save_binding(env, session, key, binding)
|
|
1200
|
+
if not is_subagent(data):
|
|
1201
|
+
try:
|
|
1202
|
+
from .limits import ingest_transcript
|
|
1203
|
+
|
|
1204
|
+
ingest_transcript(data)
|
|
1205
|
+
except Exception as e:
|
|
1206
|
+
log_error(env, "limits", e)
|
|
1207
|
+
if binding.get("card") and binding.get("fence") and binding.get("board") and heartbeat_due(env, data, binding):
|
|
1208
|
+
board = open_board(binding["board"], {})
|
|
1209
|
+
caller = binding_caller(board, binding, subagent=is_subagent(data), env=worker_env(data, env))
|
|
1210
|
+
board.heartbeat(binding["card"], caller)
|
|
1211
|
+
return None
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def subagent_stop(data, env):
|
|
1215
|
+
binding = load_binding(env, data.get("session_id", ""), agent_key(data))
|
|
1216
|
+
text = (data.get("last_assistant_message") or "").strip()
|
|
1217
|
+
if not binding.get("name") or not binding.get("board") or not text:
|
|
1218
|
+
return None
|
|
1219
|
+
from .core import transcript
|
|
1220
|
+
|
|
1221
|
+
board = open_board(binding["board"], {})
|
|
1222
|
+
binding_caller(board, binding, subagent=is_subagent(data), env=worker_env(data, env))
|
|
1223
|
+
record = board.agents().get(binding["name"], {})
|
|
1224
|
+
model = tail_model(transcript_for(data)) or record.get("model", "")
|
|
1225
|
+
sender = {"agent": binding["name"], "provider": "anthropic", "runtime": "claude-code", "model": model}
|
|
1226
|
+
card = binding.get("card") or binding.get("last_card", "")
|
|
1227
|
+
if len(text) > HANDBACK_CHARS:
|
|
1228
|
+
text = text[:HANDBACK_CHARS] + " [truncated]"
|
|
1229
|
+
board.transcript.append(
|
|
1230
|
+
transcript.make(
|
|
1231
|
+
"handback",
|
|
1232
|
+
sender,
|
|
1233
|
+
text=text,
|
|
1234
|
+
task=card,
|
|
1235
|
+
context=f"{board.name}/{card}" if card else board.name,
|
|
1236
|
+
src="claude-code",
|
|
1237
|
+
)
|
|
1238
|
+
)
|
|
1239
|
+
return None
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def session_start(data, env):
|
|
1243
|
+
board_dir = find_board(data.get("cwd"), env.get("RAIDLOCK_BOARD") or env.get("BOARD_DIR"))
|
|
1244
|
+
folder = os.path.join(state_dir(env), "sessions")
|
|
1245
|
+
os.makedirs(folder, exist_ok=True)
|
|
1246
|
+
info = {
|
|
1247
|
+
"session_id": data.get("session_id", ""),
|
|
1248
|
+
"cwd": data.get("cwd", ""),
|
|
1249
|
+
"board": board_dir or "",
|
|
1250
|
+
"source": data.get("source", ""),
|
|
1251
|
+
"started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
1252
|
+
}
|
|
1253
|
+
path = os.path.join(folder, _safe(data.get("session_id", "")) + ".json")
|
|
1254
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1255
|
+
json.dump(info, f, sort_keys=True)
|
|
1256
|
+
name = env.get("RAIDLOCK_AGENT_ID") or env.get("RAIDLOCK_AGENT")
|
|
1257
|
+
if board_dir and not is_subagent(data):
|
|
1258
|
+
out = native_join(data, env, board_dir)
|
|
1259
|
+
if out is not None:
|
|
1260
|
+
return out
|
|
1261
|
+
if board_dir and name:
|
|
1262
|
+
binding = load_binding(env, data.get("session_id", ""), "main")
|
|
1263
|
+
board_obj = open_board(board_dir, {})
|
|
1264
|
+
try:
|
|
1265
|
+
caller = board_obj.caller(agent=name, env=env)
|
|
1266
|
+
except Exception:
|
|
1267
|
+
return None
|
|
1268
|
+
if not caller.authenticated:
|
|
1269
|
+
return None
|
|
1270
|
+
binding.update({"name": caller.name, "board": board_dir})
|
|
1271
|
+
if caller.is_lead:
|
|
1272
|
+
binding.update(lead_session=caller.session, lead_fence=caller.lead_fence)
|
|
1273
|
+
if not caller.is_lead:
|
|
1274
|
+
binding["proof"] = hashlib.sha256(env.get("RAIDLOCK_TOKEN", "").encode()).hexdigest()
|
|
1275
|
+
if str(env.get("RAIDLOCK_FENCE", "")).isdigit():
|
|
1276
|
+
binding["fence"] = int(env["RAIDLOCK_FENCE"])
|
|
1277
|
+
save_binding(env, data.get("session_id", ""), "main", binding)
|
|
1278
|
+
return None
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def env_file_lines(env, role, name="", token="", board_dir=""):
|
|
1282
|
+
lines = []
|
|
1283
|
+
if role != "lead":
|
|
1284
|
+
from .core.lead_env import lead_material
|
|
1285
|
+
|
|
1286
|
+
stale = sorted(lead_material(env))
|
|
1287
|
+
if stale:
|
|
1288
|
+
lines.append("unset " + " ".join(stale))
|
|
1289
|
+
if name and token:
|
|
1290
|
+
lines.append(f"export RAIDLOCK_AGENT={shlex.quote(name)}")
|
|
1291
|
+
lines.append(f"export RAIDLOCK_TOKEN={shlex.quote(token)}")
|
|
1292
|
+
lines.append(f"export RAIDLOCK_BOARD={shlex.quote(os.path.dirname(board_dir))}")
|
|
1293
|
+
return lines
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def write_env_file(env, lines):
|
|
1297
|
+
path = env.get("CLAUDE_ENV_FILE", "")
|
|
1298
|
+
if not path or not lines:
|
|
1299
|
+
return False
|
|
1300
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
1301
|
+
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
1302
|
+
f.write("\n".join(lines) + "\n")
|
|
1303
|
+
return True
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def native_join(data, env, board_dir):
|
|
1307
|
+
"""A main session starting in a raidlock workspace: drop inherited lead material from a non-lead session and,
|
|
1308
|
+
when the lead configured an auto-join pool and the host offers CLAUDE_ENV_FILE, give an unbound session one
|
|
1309
|
+
pooled worker identity of its own."""
|
|
1310
|
+
from . import bridge
|
|
1311
|
+
|
|
1312
|
+
board = open_board(board_dir, {})
|
|
1313
|
+
with bridge.pool_lock(state_dir(env), board.uid):
|
|
1314
|
+
return _native_join(data, env, board_dir, board)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _native_join(data, env, board_dir, board):
|
|
1318
|
+
from . import bridge
|
|
1319
|
+
|
|
1320
|
+
session = data.get("session_id", "")
|
|
1321
|
+
binding = load_binding(env, session, "main")
|
|
1322
|
+
if binding.get("pooled"):
|
|
1323
|
+
reserved = bridge.allocated(state_dir(env), board.uid, session)
|
|
1324
|
+
if (
|
|
1325
|
+
reserved != binding.get("name")
|
|
1326
|
+
or reserved not in bridge.load(board)["pool"]
|
|
1327
|
+
or not env.get("CLAUDE_ENV_FILE")
|
|
1328
|
+
):
|
|
1329
|
+
bridge.free(state_dir(env), board.uid, session)
|
|
1330
|
+
binding = {}
|
|
1331
|
+
save_binding(env, session, "main", binding)
|
|
1332
|
+
role = session_role(data, env, board, binding)
|
|
1333
|
+
if role == "lead":
|
|
1334
|
+
return None
|
|
1335
|
+
name = token = ""
|
|
1336
|
+
if binding.get("pooled") and role == "worker":
|
|
1337
|
+
name = binding["name"]
|
|
1338
|
+
token = agent_token(board, name) or ""
|
|
1339
|
+
elif role == "unbound" and env.get("CLAUDE_ENV_FILE"):
|
|
1340
|
+
name = bridge.allocate(board, state_dir(env), session)
|
|
1341
|
+
token = agent_token(board, name) if name else ""
|
|
1342
|
+
if name and not token:
|
|
1343
|
+
bridge.free(state_dir(env), board.uid, session)
|
|
1344
|
+
name = ""
|
|
1345
|
+
write_env_file(env, env_file_lines(env, role, name, token, board_dir))
|
|
1346
|
+
if not name:
|
|
1347
|
+
return None
|
|
1348
|
+
binding.update(
|
|
1349
|
+
{"name": name, "board": board_dir, "proof": hashlib.sha256(token.encode()).hexdigest(), "pooled": True}
|
|
1350
|
+
)
|
|
1351
|
+
save_binding(env, session, "main", binding)
|
|
1352
|
+
text = (
|
|
1353
|
+
f"raidlock: this session joined workspace {board.name} as worker {name}. "
|
|
1354
|
+
"Edits are allowed only inside a lease you hold. Next: raidlock claimable, then raidlock claim <id>."
|
|
1355
|
+
)
|
|
1356
|
+
return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}}
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def session_end(data, env):
|
|
1360
|
+
from . import bridge
|
|
1361
|
+
|
|
1362
|
+
if is_subagent(data):
|
|
1363
|
+
return None
|
|
1364
|
+
binding = load_binding(env, data.get("session_id", ""), "main")
|
|
1365
|
+
board_dir = binding.get("board") or find_board(data.get("cwd"))
|
|
1366
|
+
if not board_dir:
|
|
1367
|
+
save_binding(env, data.get("session_id", ""), "main", {})
|
|
1368
|
+
return None
|
|
1369
|
+
board = open_board(board_dir, {})
|
|
1370
|
+
with bridge.pool_lock(state_dir(env), board.uid):
|
|
1371
|
+
bridge.free(state_dir(env), board.uid, data.get("session_id", ""))
|
|
1372
|
+
save_binding(env, data.get("session_id", ""), "main", {})
|
|
1373
|
+
return None
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def listing_line(*parts):
|
|
1377
|
+
path = os.path.join(PLUGIN_ROOT, *parts)
|
|
1378
|
+
try:
|
|
1379
|
+
with open(path, encoding="utf-8") as f:
|
|
1380
|
+
text = f.read()
|
|
1381
|
+
except OSError:
|
|
1382
|
+
return ""
|
|
1383
|
+
head = text.split("\n---", 1)[0]
|
|
1384
|
+
name = re.search(r"^name: (.*)$", head, re.M)
|
|
1385
|
+
match = re.search(r"^description: (.*)$", head, re.M)
|
|
1386
|
+
if not match or not name:
|
|
1387
|
+
return ""
|
|
1388
|
+
return name.group(1).strip() + ": " + match.group(1).strip()
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def skill_description():
|
|
1392
|
+
return listing_line("skills", "raidlock", "SKILL.md")
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _hooks_tokens(board):
|
|
1396
|
+
return {
|
|
1397
|
+
"tokens": 0,
|
|
1398
|
+
"detail": f"silent on allow; a deny costs about {DENY_BYTES // 4} tokens; urgent messages "
|
|
1399
|
+
f"only when pending, at most {URGENT_CHARS} chars (~{URGENT_CHARS // 4} tokens)",
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _skill_tokens(board):
|
|
1404
|
+
text = skill_description()
|
|
1405
|
+
if not text:
|
|
1406
|
+
return {
|
|
1407
|
+
"tokens": 0,
|
|
1408
|
+
"detail": "plugin files not found next to this install; see claude plugin details raidlock",
|
|
1409
|
+
}
|
|
1410
|
+
return {"tokens": (len(text) + 3) // 4, "detail": f"skill listing line, {len(text)} chars"}
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def _agent_tokens(board):
|
|
1414
|
+
text = listing_line("agents", "raid-worker.md")
|
|
1415
|
+
if not text:
|
|
1416
|
+
return {"tokens": 0, "detail": "plugin files not found next to this install"}
|
|
1417
|
+
return {"tokens": (len(text) + 3) // 4, "detail": f"raid-worker agent listing line, {len(text)} chars"}
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def register_cli(add_command):
|
|
1421
|
+
from .bridge import register_cli as bridge_commands
|
|
1422
|
+
from .commands_install import register_cli as install_commands
|
|
1423
|
+
|
|
1424
|
+
install_commands(add_command)
|
|
1425
|
+
bridge_commands(add_command)
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def register_tokens(register):
|
|
1429
|
+
register("claude-code hooks", _hooks_tokens)
|
|
1430
|
+
register("claude-code skill listing", _skill_tokens)
|
|
1431
|
+
register("claude-code agent listing", _agent_tokens)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
HANDLERS = {
|
|
1435
|
+
"pre-urgent": pre_urgent,
|
|
1436
|
+
"pre-read": pre_read,
|
|
1437
|
+
"pre-write": pre_write,
|
|
1438
|
+
"pre-bash": pre_bash,
|
|
1439
|
+
"post-tool": post_tool,
|
|
1440
|
+
"subagent-stop": subagent_stop,
|
|
1441
|
+
"session-start": session_start,
|
|
1442
|
+
"session-end": session_end,
|
|
1443
|
+
}
|
|
1444
|
+
GUARDS = ("pre-read", "pre-write", "pre-bash", "pre-urgent")
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def normalize_tool(data):
|
|
1448
|
+
from .core.errors import IdentityError
|
|
1449
|
+
|
|
1450
|
+
data["_host_cwd"] = data.get("cwd")
|
|
1451
|
+
name = data.get("tool_name")
|
|
1452
|
+
shell = name in ("Bash", "exec_command", "shell", "shell_command")
|
|
1453
|
+
patch = name == "apply_patch"
|
|
1454
|
+
tool = data.get("tool_input")
|
|
1455
|
+
if shell or patch:
|
|
1456
|
+
if patch and isinstance(tool, str):
|
|
1457
|
+
tool = {"command": tool}
|
|
1458
|
+
if not isinstance(tool, dict):
|
|
1459
|
+
raise IdentityError("missing or malformed command")
|
|
1460
|
+
fields = ("command", "cmd", "input")
|
|
1461
|
+
values = [tool[k] for k in fields if k in tool]
|
|
1462
|
+
if not values or any(not isinstance(v, str) or not v.strip() for v in values):
|
|
1463
|
+
raise IdentityError("missing or malformed command")
|
|
1464
|
+
if any(v != values[0] for v in values):
|
|
1465
|
+
raise IdentityError("conflicting command fields")
|
|
1466
|
+
tool = {**tool, "command": values[0]}
|
|
1467
|
+
if "workdir" in tool:
|
|
1468
|
+
if not isinstance(tool["workdir"], str) or not tool["workdir"]:
|
|
1469
|
+
raise IdentityError("malformed command workdir")
|
|
1470
|
+
data["cwd"] = os.path.abspath(os.path.join(data.get("cwd") or os.getcwd(), tool["workdir"]))
|
|
1471
|
+
data["tool_input"] = tool
|
|
1472
|
+
if shell:
|
|
1473
|
+
data["tool_name"] = "Bash"
|
|
1474
|
+
elif name in ("Write", "Edit", "NotebookEdit"):
|
|
1475
|
+
if not isinstance(tool, dict) or not any(
|
|
1476
|
+
isinstance(tool.get(k), str) and tool[k].strip() for k in ("file_path", "notebook_path")
|
|
1477
|
+
):
|
|
1478
|
+
raise IdentityError("missing or malformed write path")
|
|
1479
|
+
return data
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def run(event, raw, env):
|
|
1483
|
+
handler = HANDLERS.get(event)
|
|
1484
|
+
if handler is None:
|
|
1485
|
+
return None
|
|
1486
|
+
try:
|
|
1487
|
+
data = json.loads(raw or "{}")
|
|
1488
|
+
except ValueError:
|
|
1489
|
+
return None
|
|
1490
|
+
if not isinstance(data, dict):
|
|
1491
|
+
return None
|
|
1492
|
+
try:
|
|
1493
|
+
data = normalize_tool(data)
|
|
1494
|
+
if event in GUARDS and event != "pre-urgent":
|
|
1495
|
+
read_guard(data, env)
|
|
1496
|
+
return handler(data, env)
|
|
1497
|
+
except NoBoard:
|
|
1498
|
+
return None
|
|
1499
|
+
except Exception as e:
|
|
1500
|
+
log_error(env, event, e)
|
|
1501
|
+
if event in GUARDS:
|
|
1502
|
+
from .core import RaidlockError
|
|
1503
|
+
|
|
1504
|
+
if isinstance(e, RaidlockError):
|
|
1505
|
+
return deny(e.message)
|
|
1506
|
+
return deny(f"hook error ({type(e).__name__}); see {os.path.join(state_dir(env), 'errors.log')}")
|
|
1507
|
+
return None
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def main(argv=None):
|
|
1511
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1512
|
+
if not argv:
|
|
1513
|
+
print(
|
|
1514
|
+
"usage: raidlock hook pre-write|pre-read|pre-bash|pre-urgent|post-tool|subagent-stop|"
|
|
1515
|
+
"session-start|session-end",
|
|
1516
|
+
file=sys.stderr,
|
|
1517
|
+
)
|
|
1518
|
+
return 3
|
|
1519
|
+
out = run(argv[0], sys.stdin.read(), dict(os.environ))
|
|
1520
|
+
if out:
|
|
1521
|
+
sys.stdout.write(json.dumps(out, separators=(",", ":")))
|
|
1522
|
+
return 0
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
if __name__ == "__main__":
|
|
1526
|
+
sys.exit(main())
|