raidlock 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/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())