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/core/lead.py ADDED
@@ -0,0 +1,758 @@
1
+ """The lead lease: one lead capability per board, bound to a session, fenced, renewed by use.
2
+
3
+ The lead secret has the form `<fence>.<random>`. Presenting it is the only way to get lead rights. Its SHA-256 and
4
+ the lease (session, process anchor, fence, expiry, history of retired secrets) live in `.board/lead.json`; the secret
5
+ itself lives in the secret store (a 0600 file, or the OS keychain when the board opts in)."""
6
+
7
+ import contextlib
8
+ import hashlib
9
+ import hmac
10
+ import os
11
+ import re
12
+ import secrets
13
+ import shutil
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ import time
18
+
19
+ from . import clock, store
20
+ from .errors import Denied, IdentityError, Precondition, StaleLead
21
+
22
+ SESSION_VARS = (
23
+ "RAIDLOCK_SESSION",
24
+ "CLAUDE_CODE_SESSION_ID",
25
+ "CLAUDE_SESSION_ID",
26
+ "CODEX_SESSION_ID",
27
+ "CODEX_THREAD_ID",
28
+ "GEMINI_SESSION_ID",
29
+ )
30
+ PID_VARS = ("RAIDLOCK_LEAD_PID", "CLAUDE_PID")
31
+ WRAPPERS = (
32
+ "python",
33
+ "python3",
34
+ "pytest",
35
+ "py.test",
36
+ "uv",
37
+ "uvx",
38
+ "env",
39
+ "timeout",
40
+ "nohup",
41
+ "raid",
42
+ "raidlock",
43
+ "sudo",
44
+ "xargs",
45
+ "time",
46
+ "pipx",
47
+ )
48
+ SHELLS = ("sh", "bash", "zsh", "dash", "fish", "ksh", "tcsh", "csh", "pwsh", "powershell")
49
+ AGENT_HOSTS = ("claude", "node", "codex", "gemini", "aider", "bun", "deno")
50
+ HISTORY = 20
51
+ SECRET = re.compile(r"^(\d+)\.([A-Za-z0-9_-]{16,})$")
52
+ STORES = ("file", "keychain")
53
+ SERVICE = "raidlock-lead"
54
+
55
+
56
+ def digest(secret):
57
+ return hashlib.sha256((secret or "").strip().encode()).hexdigest()
58
+
59
+
60
+ def mint(fence):
61
+ return f"{int(fence)}.{secrets.token_urlsafe(32)}"
62
+
63
+
64
+ def fence_of(secret):
65
+ m = SECRET.match((secret or "").strip())
66
+ return int(m.group(1)) if m else 0
67
+
68
+
69
+ def _base(comm):
70
+ name = os.path.basename((comm or "").strip()).lower().lstrip("-")
71
+ name = re.sub(r"\.exe$", "", name)
72
+ return re.sub(r"[\d.]+$", "", name) or name
73
+
74
+
75
+ KINFO_SIZE = 648
76
+ KINFO_PPID = 560
77
+ KINFO_COMM = 243
78
+ CTL_KERN, KERN_PROC, KERN_PROC_PID = 1, 14, 1
79
+ DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
80
+ MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
81
+
82
+
83
+ def lstart(epoch):
84
+ """A start time in the form `ps -o lstart=` prints, so leases written by either source compare equal."""
85
+ t = time.localtime(epoch)
86
+ return (
87
+ f"{DAYS[t.tm_wday]} {MONTHS[t.tm_mon - 1]} {t.tm_mday} "
88
+ f"{t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d} {t.tm_year}"
89
+ )
90
+
91
+
92
+ class LiveTable:
93
+ """{pid: (ppid, start, command)} looked up one pid at a time from the kernel (sysctl kern.proc on macOS, /proc
94
+ on Linux, Toolhelp on Windows), with `ps -p` only when no kernel source works. Rows are memoized per table."""
95
+
96
+ def __init__(self, lookup=None):
97
+ self._lookup = lookup or row_lookup()
98
+ self._rows = {}
99
+
100
+ def get(self, pid, default=None):
101
+ pid = int(pid)
102
+ if pid not in self._rows:
103
+ try:
104
+ self._rows[pid] = self._lookup(pid)
105
+ except (OSError, ValueError):
106
+ self._rows[pid] = None
107
+ row = self._rows[pid]
108
+ return default if row is None else row
109
+
110
+ def __contains__(self, pid):
111
+ return self.get(pid) is not None
112
+
113
+ def __getitem__(self, pid):
114
+ row = self.get(pid)
115
+ if row is None:
116
+ raise KeyError(pid)
117
+ return row
118
+
119
+ def __bool__(self):
120
+ return self.get(os.getpid()) is not None
121
+
122
+
123
+ def process_table():
124
+ """The caller's view of the process table as a lazy {pid: (ppid, start, command)} mapping; empty when no
125
+ source can describe this process."""
126
+ return LiveTable()
127
+
128
+
129
+ _NATIVE = {}
130
+
131
+
132
+ def row_lookup():
133
+ """The per-pid lookup for this host: a kernel source when it can describe this process, else `ps -p`."""
134
+ if "lookup" not in _NATIVE:
135
+ _NATIVE["lookup"] = None
136
+ for source in (_proc_row, _sysctl_row, _toolhelp_row):
137
+ try:
138
+ ok = source(os.getpid()) is not None
139
+ except (OSError, ValueError, AttributeError):
140
+ ok = False
141
+ if ok:
142
+ _NATIVE["lookup"] = source
143
+ break
144
+ return _NATIVE["lookup"] or _ps_row
145
+
146
+
147
+ def _proc_row(pid, root="/proc"):
148
+ if not os.path.isdir(os.path.join(root, "self")):
149
+ return None
150
+ try:
151
+ with open(os.path.join(root, str(int(pid)), "stat"), encoding="utf-8", errors="replace") as f:
152
+ text = f.read()
153
+ except OSError:
154
+ return None
155
+ head, _, tail = text.rpartition(")")
156
+ comm = head.partition("(")[2]
157
+ fields = tail.split()
158
+ if len(fields) <= 19:
159
+ return None
160
+ return int(fields[1]), fields[19], comm
161
+
162
+
163
+ def _libc():
164
+ import ctypes
165
+
166
+ if "libc" not in _NATIVE:
167
+ libc = ctypes.CDLL(None, use_errno=True)
168
+ libc.sysctl.argtypes = (
169
+ ctypes.POINTER(ctypes.c_int),
170
+ ctypes.c_uint,
171
+ ctypes.c_void_p,
172
+ ctypes.POINTER(ctypes.c_size_t),
173
+ ctypes.c_void_p,
174
+ ctypes.c_size_t,
175
+ )
176
+ libc.sysctl.restype = ctypes.c_int
177
+ _NATIVE["libc"] = libc
178
+ return _NATIVE["libc"]
179
+
180
+
181
+ def _sysctl_row(pid):
182
+ if sys.platform != "darwin":
183
+ return None
184
+ import ctypes
185
+
186
+ mib = (ctypes.c_int * 4)(CTL_KERN, KERN_PROC, KERN_PROC_PID, int(pid))
187
+ buf = ctypes.create_string_buffer(KINFO_SIZE)
188
+ size = ctypes.c_size_t(KINFO_SIZE)
189
+ if _libc().sysctl(mib, 4, buf, ctypes.byref(size), None, 0) != 0:
190
+ raise OSError(ctypes.get_errno(), "sysctl kern.proc failed")
191
+ if size.value < KINFO_SIZE:
192
+ return None
193
+ raw = buf.raw
194
+ seconds = int.from_bytes(raw[0:8], sys.byteorder, signed=True)
195
+ ppid = int.from_bytes(raw[KINFO_PPID : KINFO_PPID + 4], sys.byteorder, signed=True)
196
+ comm = raw[KINFO_COMM : KINFO_COMM + 17].split(b"\0", 1)[0].decode("utf-8", "replace")
197
+ microseconds = int.from_bytes(raw[8:16], sys.byteorder, signed=True)
198
+ return ppid, f"darwin:{seconds}.{microseconds:06d}", comm
199
+
200
+
201
+ def _toolhelp_row(pid):
202
+ if sys.platform != "win32":
203
+ return None
204
+ import ctypes
205
+ from ctypes import wintypes
206
+
207
+ class Entry(ctypes.Structure):
208
+ _fields_ = (
209
+ ("dwSize", wintypes.DWORD),
210
+ ("cntUsage", wintypes.DWORD),
211
+ ("th32ProcessID", wintypes.DWORD),
212
+ ("th32DefaultHeapID", ctypes.c_size_t),
213
+ ("th32ModuleID", wintypes.DWORD),
214
+ ("cntThreads", wintypes.DWORD),
215
+ ("th32ParentProcessID", wintypes.DWORD),
216
+ ("pcPriClassBase", ctypes.c_long),
217
+ ("dwFlags", wintypes.DWORD),
218
+ ("szExeFile", ctypes.c_wchar * 260),
219
+ )
220
+
221
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
222
+ entry_ref = ctypes.POINTER(Entry)
223
+ kernel.CreateToolhelp32Snapshot.argtypes = (wintypes.DWORD, wintypes.DWORD)
224
+ kernel.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
225
+ kernel.Process32FirstW.argtypes = (wintypes.HANDLE, entry_ref)
226
+ kernel.Process32FirstW.restype = wintypes.BOOL
227
+ kernel.Process32NextW.argtypes = (wintypes.HANDLE, entry_ref)
228
+ kernel.Process32NextW.restype = wintypes.BOOL
229
+ kernel.CloseHandle.argtypes = (wintypes.HANDLE,)
230
+ kernel.CloseHandle.restype = wintypes.BOOL
231
+ kernel.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
232
+ kernel.OpenProcess.restype = wintypes.HANDLE
233
+ kernel.GetProcessTimes.argtypes = (wintypes.HANDLE, *(ctypes.POINTER(wintypes.FILETIME),) * 4)
234
+ kernel.GetProcessTimes.restype = wintypes.BOOL
235
+ snap = kernel.CreateToolhelp32Snapshot(0x2, 0)
236
+ if not snap or snap == wintypes.HANDLE(-1).value:
237
+ raise OSError("CreateToolhelp32Snapshot failed")
238
+ found = None
239
+ try:
240
+ entry = Entry()
241
+ entry.dwSize = ctypes.sizeof(Entry)
242
+ more = kernel.Process32FirstW(snap, ctypes.byref(entry))
243
+ while more:
244
+ if entry.th32ProcessID == int(pid):
245
+ found = (int(entry.th32ParentProcessID), entry.szExeFile)
246
+ break
247
+ more = kernel.Process32NextW(snap, ctypes.byref(entry))
248
+ finally:
249
+ kernel.CloseHandle(snap)
250
+ if found is None:
251
+ return None
252
+ handle = kernel.OpenProcess(0x1000, False, int(pid))
253
+ if not handle:
254
+ return None
255
+ try:
256
+ times = [wintypes.FILETIME() for _ in range(4)]
257
+ if not kernel.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)):
258
+ return None
259
+ created = (times[0].dwHighDateTime << 32) | times[0].dwLowDateTime
260
+ finally:
261
+ kernel.CloseHandle(handle)
262
+ return found[0], str(created), found[1]
263
+
264
+
265
+ def _ps_row(pid):
266
+ if sys.platform == "darwin":
267
+ return None
268
+ ps = shutil.which("ps") if os.name == "posix" else None
269
+ if not ps:
270
+ return None
271
+ for attempt in range(2):
272
+ try:
273
+ proc = subprocess.run(
274
+ [ps, "-o", "ppid=,lstart=,comm=", "-p", str(int(pid))],
275
+ capture_output=True,
276
+ text=True,
277
+ timeout=5,
278
+ env={"LC_ALL": "C", "PATH": os.environ.get("PATH", "/bin:/usr/bin")},
279
+ )
280
+ break
281
+ except subprocess.TimeoutExpired:
282
+ if attempt:
283
+ return None
284
+ except (OSError, subprocess.SubprocessError):
285
+ return None
286
+ if proc.returncode != 0:
287
+ return None
288
+ parts = proc.stdout.strip().split(None, 6)
289
+ if len(parts) < 7 or not parts[0].isdigit():
290
+ return None
291
+ return int(parts[0]), " ".join(parts[1:6]), parts[6]
292
+
293
+
294
+ def ancestors(table, pid=None):
295
+ """[(pid, start, command)] from the parent of `pid` (default: this process) upward."""
296
+ pid = os.getpid() if pid is None else pid
297
+ out, seen = [], set()
298
+ cur = table.get(pid, (0,))[0]
299
+ while cur and cur not in seen and cur in table:
300
+ seen.add(cur)
301
+ ppid, start, comm = table[cur]
302
+ out.append((cur, start, comm))
303
+ cur = ppid
304
+ return out
305
+
306
+
307
+ def anchor(env, table=None):
308
+ """The long-lived process this call belongs to: an explicit ancestor (RAIDLOCK_LEAD_PID, CLAUDE_PID), else the
309
+ agent host above a per-call shell, else the first interactive shell or other non-wrapper ancestor."""
310
+ table = process_table() if table is None else table
311
+ chain = ancestors(table)
312
+ if not chain:
313
+ return None
314
+ for var in PID_VARS:
315
+ want = str(env.get(var, "")).strip()
316
+ if want.isdigit():
317
+ hit = next((p for p in chain if p[0] == int(want)), None)
318
+ if hit:
319
+ return hit[0], hit[1]
320
+ host = next((p for p in chain if _base(p[2]) in AGENT_HOSTS), None)
321
+ if host:
322
+ return host[0], host[1]
323
+ shell = None
324
+ for pid, start, comm in chain:
325
+ base = _base(comm)
326
+ if base in WRAPPERS:
327
+ continue
328
+ if base in SHELLS:
329
+ shell = (pid, start)
330
+ continue
331
+ return shell or (pid, start)
332
+ if shell:
333
+ return shell
334
+ return None
335
+
336
+
337
+ def session_of(env, explicit=None, table=None):
338
+ """(session id, pid, pid start) of the caller. Session ids come from the flag or the runtime's environment;
339
+ without one, the session is the process anchor."""
340
+ found = anchor(env, table)
341
+ pid, start = found if found else (None, None)
342
+ sid = (explicit or "").strip() or next(
343
+ (str(env[v]).strip() for v in SESSION_VARS if str(env.get(v, "")).strip()), ""
344
+ )
345
+ if not sid and pid:
346
+ sid = f"pid:{pid}:{start.replace(' ', '_')}"
347
+ return sid, pid, start
348
+
349
+
350
+ def legacy_anchor(start):
351
+ return sys.platform == "darwin" and bool(re.match(r"^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ", str(start)))
352
+
353
+
354
+ def alive(pid, start, table=None):
355
+ if legacy_anchor(start):
356
+ return False
357
+ if not pid:
358
+ return True
359
+ table = process_table() if table is None else table
360
+ if not table:
361
+ return True
362
+ row = table.get(int(pid))
363
+ return bool(row) and row[1] == start
364
+
365
+
366
+ class FileStore:
367
+ kind = "file"
368
+
369
+ def __init__(self, path):
370
+ self.path = path
371
+
372
+ def get(self):
373
+ try:
374
+ st = os.stat(self.path)
375
+ except OSError:
376
+ return None
377
+ if os.name == "posix" and (stat.S_IMODE(st.st_mode) & 0o077 or st.st_uid != os.getuid()):
378
+ raise IdentityError(
379
+ f"lead secret file {self.path} must be mode 0600 and owned by you", hint=f"chmod 600 {self.path}"
380
+ )
381
+ try:
382
+ with open(self.path, encoding="utf-8") as f:
383
+ return f.read().strip() or None
384
+ except OSError:
385
+ return None
386
+
387
+ def put(self, secret):
388
+ os.makedirs(os.path.dirname(self.path), mode=0o700, exist_ok=True)
389
+ store.atomic_write_text(self.path, secret + "\n")
390
+ os.chmod(self.path, 0o600)
391
+ return True
392
+
393
+ def delete(self):
394
+ with contextlib.suppress(OSError):
395
+ os.remove(self.path)
396
+
397
+
398
+ class KeychainStore:
399
+ """macOS `security` or Linux `secret-tool`, called with subprocess only. `available()` is False elsewhere."""
400
+
401
+ kind = "keychain"
402
+
403
+ def __init__(self, account, run=None, which=None, platform=None):
404
+ self.account = account
405
+ self.run = run or subprocess.run
406
+ self.which = which or shutil.which
407
+ self.platform = platform or sys.platform
408
+
409
+ def tool(self):
410
+ if self.platform == "darwin" and self.which("security"):
411
+ return "security"
412
+ if self.platform.startswith("linux") and self.which("secret-tool"):
413
+ return "secret-tool"
414
+ return None
415
+
416
+ def available(self):
417
+ return self.tool() is not None
418
+
419
+ def _call(self, argv, stdin=None):
420
+ try:
421
+ proc = self.run(argv, input=stdin, capture_output=True, text=True, timeout=20)
422
+ except (OSError, subprocess.SubprocessError):
423
+ return None
424
+ return proc.stdout if proc.returncode == 0 else None
425
+
426
+ def get(self):
427
+ tool = self.tool()
428
+ if tool == "security":
429
+ out = self._call(["security", "find-generic-password", "-a", self.account, "-s", SERVICE, "-w"])
430
+ elif tool == "secret-tool":
431
+ out = self._call(["secret-tool", "lookup", "service", SERVICE, "account", self.account])
432
+ else:
433
+ return None
434
+ return (out or "").strip() or None
435
+
436
+ def put(self, secret):
437
+ tool = self.tool()
438
+ if tool == "security":
439
+ return (
440
+ self._call(
441
+ [
442
+ "security",
443
+ "add-generic-password",
444
+ "-U",
445
+ "-a",
446
+ self.account,
447
+ "-s",
448
+ SERVICE,
449
+ "-l",
450
+ "raidlock lead secret",
451
+ "-w",
452
+ secret,
453
+ ]
454
+ )
455
+ is not None
456
+ )
457
+ if tool == "secret-tool":
458
+ return (
459
+ self._call(
460
+ [
461
+ "secret-tool",
462
+ "store",
463
+ "--label=raidlock lead secret",
464
+ "service",
465
+ SERVICE,
466
+ "account",
467
+ self.account,
468
+ ],
469
+ stdin=secret,
470
+ )
471
+ is not None
472
+ )
473
+ return False
474
+
475
+ def delete(self):
476
+ tool = self.tool()
477
+ if tool == "security":
478
+ self._call(["security", "delete-generic-password", "-a", self.account, "-s", SERVICE])
479
+ elif tool == "secret-tool":
480
+ self._call(["secret-tool", "clear", "service", SERVICE, "account", self.account])
481
+
482
+
483
+ class SecretStore:
484
+ """The keychain when chosen and working, with the 0600 file as fallback."""
485
+
486
+ def __init__(self, file_path, account, kind="file", keychain=None):
487
+ self.file = FileStore(file_path)
488
+ self.keychain = keychain or KeychainStore(account)
489
+ self.kind = kind if kind in STORES else "file"
490
+
491
+ @property
492
+ def using(self):
493
+ return (
494
+ "keychain"
495
+ if self.kind == "keychain" and self.keychain.available() and not os.path.exists(self.file.path)
496
+ else "file"
497
+ )
498
+
499
+ def get(self):
500
+ if self.using == "keychain":
501
+ value = self.keychain.get()
502
+ if value:
503
+ return value
504
+ return self.file.get()
505
+
506
+ def put(self, secret):
507
+ if self.kind == "keychain" and self.keychain.available() and self.keychain.put(secret):
508
+ self.file.delete()
509
+ return "keychain"
510
+ self.file.put(secret)
511
+ return "file"
512
+
513
+ def delete(self):
514
+ if self.kind == "keychain":
515
+ self.keychain.delete()
516
+ self.file.delete()
517
+
518
+
519
+ class ForeignLead(IdentityError):
520
+ """A valid lead secret presented from a session that does not hold the lead lease."""
521
+
522
+
523
+ class Lead:
524
+ """Lead lease operations for one board. Callers hold the board lock around every write."""
525
+
526
+ def __init__(self, board_dir, secret_path, account, ttl_s, store_kind="file", keychain=None, table=None):
527
+ self.path = os.path.join(board_dir, "lead.json")
528
+ self.ttl_s = float(ttl_s)
529
+ self.store = SecretStore(secret_path, account, store_kind, keychain)
530
+ self._table = table
531
+
532
+ def table(self):
533
+ if self._table is None:
534
+ self._table = process_table()
535
+ return self._table
536
+
537
+ def lease(self):
538
+ return store.read_json(self.path) or {}
539
+
540
+ def _save(self, lease):
541
+ store.write_json(self.path, lease)
542
+
543
+ def expired(self, lease=None, now=None):
544
+ lease = self.lease() if lease is None else lease
545
+ if not lease:
546
+ return True
547
+ exp = clock.parse(lease.get("expires", ""))
548
+ if exp is None or exp <= (now or clock.now()):
549
+ return True
550
+ return not alive(lease.get("pid"), lease.get("pid_start"), self.table())
551
+
552
+ def summary(self, lease=None):
553
+ lease = self.lease() if lease is None else lease
554
+ if not lease:
555
+ return {"held": False, "fence": 0, "session": "", "expires": "", "live": False, "store": self.store.using}
556
+ return {
557
+ "held": True,
558
+ "fence": lease.get("fence", 0),
559
+ "session": lease.get("session", ""),
560
+ "pid": lease.get("pid"),
561
+ "expires": lease.get("expires", ""),
562
+ "since": lease.get("since", ""),
563
+ "live": not self.expired(lease),
564
+ "store": self.store.using,
565
+ "pending": not lease.get("pid") and not str(lease.get("session", "")).startswith("pid:"),
566
+ }
567
+
568
+ def _bind(self, lease, session, pid, start, fence, secret):
569
+ now = clock.iso()
570
+ lease.update(
571
+ fence=fence,
572
+ session=session,
573
+ pid=pid,
574
+ pid_start=start,
575
+ hash=digest(secret),
576
+ renewed=now,
577
+ expires=clock.later(self.ttl_s),
578
+ since=now,
579
+ )
580
+ return lease
581
+
582
+ def _retire(self, lease):
583
+ if lease.get("hash"):
584
+ hist = lease.setdefault("history", [])
585
+ hist.append(
586
+ {
587
+ "fence": lease.get("fence", 0),
588
+ "hash": lease["hash"],
589
+ "session": lease.get("session", ""),
590
+ "until": clock.iso(),
591
+ }
592
+ )
593
+ del hist[:-HISTORY]
594
+
595
+ def establish(self, session, pid, start):
596
+ """Create the lease at fence 1 (board init). Returns the secret; the caller shows its location, never it."""
597
+ if not session or not pid or not start:
598
+ raise IdentityError("cannot establish lead lease without a session process anchor")
599
+ lease = self.lease()
600
+ fence = int(lease.get("fence", 0)) + 1
601
+ secret = mint(fence)
602
+ self._retire(lease)
603
+ self._save(self._bind(lease, session, pid, start, fence, secret))
604
+ self.store.put(secret)
605
+ return secret
606
+
607
+ def check(self, secret, session, any_session=False, pid=None, start=None):
608
+ """Verify a presented secret. Returns the lease fence. Raises StaleLead (exit 4) for a retired or older
609
+ secret, ForeignLead when the lease is bound to another session, IdentityError for anything else."""
610
+ secret = (secret or "").strip()
611
+ if not any_session and (not session or not pid or not start):
612
+ raise IdentityError("cannot authenticate the lead without a session process anchor")
613
+ lease = self.lease()
614
+ if not lease:
615
+ stored = self.store.get()
616
+ if stored and hmac.compare_digest(digest(stored), digest(secret)):
617
+ return 0
618
+ raise IdentityError("the presented lead secret is not valid for this board")
619
+ h = digest(secret)
620
+ if hmac.compare_digest(h, lease.get("hash", "")):
621
+ if not any_session and legacy_anchor(lease.get("pid_start")):
622
+ raise StaleLead(
623
+ "the macOS lead anchor lacks microseconds; re-establish the lease with explicit lead takeover"
624
+ )
625
+ descendant = bool(
626
+ pid
627
+ and self.table().get(pid, (None, None))[1] == start
628
+ and any(
629
+ parent == lease.get("pid") and born == lease.get("pid_start")
630
+ for parent, born, _ in ancestors(self.table(), pid)
631
+ )
632
+ )
633
+ process_session = descendant and session.startswith("pid:") and lease.get("session", "").startswith("pid:")
634
+ if lease.get("session") and session != lease["session"] and not any_session and not process_session:
635
+ raise ForeignLead(
636
+ f"the lead lease is held by session {lease['session']}, not {session or 'this one'}",
637
+ lead_session=lease["session"],
638
+ session=session,
639
+ fence=lease.get("fence", 0),
640
+ hint="from the lead session: raidlock lead handover --to <this session>; "
641
+ "or, once its lease expires: raidlock lead takeover",
642
+ )
643
+ if not any_session:
644
+ expires = clock.parse(lease.get("expires", ""))
645
+ if (
646
+ not expires
647
+ or clock.age_seconds(lease["expires"]) > 7 * 86400
648
+ or not alive(lease.get("pid"), lease.get("pid_start"), self.table())
649
+ ):
650
+ raise StaleLead("the lead lease expired; use explicit lead takeover", fence=lease.get("fence", 0))
651
+ if lease.get("pid") and (pid != lease["pid"] or start != lease.get("pid_start")) and not descendant:
652
+ raise ForeignLead(
653
+ "the lead session process does not match its lease",
654
+ session=session,
655
+ lead_session=lease.get("session", ""),
656
+ fence=lease.get("fence", 0),
657
+ hint="run lead commands from the lead session's own shell; a background job of that shell "
658
+ "works, but nohup, setsid or a shell that has exited detaches the process from the lead",
659
+ )
660
+ return int(lease.get("fence", 0))
661
+ old = next((r for r in lease.get("history", []) if hmac.compare_digest(h, r.get("hash", ""))), None)
662
+ presented = old["fence"] if old else fence_of(secret)
663
+ if old or 0 < presented < int(lease.get("fence", 0)):
664
+ raise StaleLead(
665
+ f"lead fence {presented} is stale; the lead is now at fence {lease.get('fence', 0)} "
666
+ f"held by session {lease.get('session', '')}",
667
+ fence=presented,
668
+ current=lease.get("fence", 0),
669
+ lead_session=lease.get("session", ""),
670
+ hint="the lead was handed over or taken over; ask the current lead",
671
+ )
672
+ raise IdentityError("the presented lead secret is not valid for this board")
673
+
674
+ def touch(self, secret, session, pid, start):
675
+ """A verified lead write: adopt a legacy secret, bind a pending handover, renew the TTL.
676
+ Returns (fence, replacement secret or None)."""
677
+ lease = self.lease()
678
+ if not lease:
679
+ fence = max(1, fence_of(secret) + 1)
680
+ new = mint(fence)
681
+ self._save(
682
+ self._bind(
683
+ {
684
+ "history": [
685
+ {"fence": fence_of(secret), "hash": digest(secret), "session": "", "until": clock.iso()}
686
+ ]
687
+ },
688
+ session,
689
+ pid,
690
+ start,
691
+ fence,
692
+ new,
693
+ )
694
+ )
695
+ self.store.put(new)
696
+ return fence, new
697
+ if not lease.get("pid") and pid and session == lease.get("session"):
698
+ lease.update(pid=pid, pid_start=start)
699
+ lease.update(renewed=clock.iso(), expires=clock.later(self.ttl_s))
700
+ self._save(lease)
701
+ return int(lease.get("fence", 0)), None
702
+
703
+ def handover(self, to_session):
704
+ """Rotate the secret and bump the fence; bind the lease to `to_session` (its process binds on first use)."""
705
+ to_session = (to_session or "").strip()
706
+ if not to_session:
707
+ raise Precondition("handover needs --to <session>")
708
+ lease = self.lease()
709
+ fence = int(lease.get("fence", 0)) + 1
710
+ secret = mint(fence)
711
+ prev = lease.get("session", "")
712
+ self._retire(lease)
713
+ self._save(self._bind(lease, to_session, None, None, fence, secret))
714
+ where = self.store.put(secret)
715
+ return {"fence": fence, "from": prev, "to": to_session, "store": where}
716
+
717
+ def takeover(self, session, pid, start):
718
+ """Only after the lease expired (TTL passed or the holder process is gone)."""
719
+ if not session or not pid or not start:
720
+ raise IdentityError("cannot take over the lead without a session process anchor")
721
+ lease = self.lease()
722
+ if lease and not self.expired(lease):
723
+ raise Denied(
724
+ f"the lead lease of session {lease.get('session', '')} is live until {lease.get('expires')}",
725
+ lead_session=lease.get("session", ""),
726
+ expires=lease.get("expires"),
727
+ hint="ask the lead to run: raidlock lead handover --to <your session>",
728
+ )
729
+ fence = int(lease.get("fence", 0)) + 1
730
+ secret = mint(fence)
731
+ prev = {"session": lease.get("session", ""), "fence": lease.get("fence", 0), "expires": lease.get("expires")}
732
+ self._retire(lease)
733
+ self._save(self._bind(lease, session, pid, start, fence, secret))
734
+ where = self.store.put(secret)
735
+ return {"fence": fence, "previous": prev, "session": session, "store": where}
736
+
737
+ def current_secret(self):
738
+ return self.store.get()
739
+
740
+
741
+ def read_presented(src, store_obj, stdin=None):
742
+ """Resolve `--lead-secret SRC`: '-' reads stdin, 'keychain' asks the secret store, anything else is a file path."""
743
+ if not src:
744
+ return ""
745
+ if src == "-":
746
+ stream = stdin or sys.stdin
747
+ return (stream.readline() or "").strip()
748
+ if src == "keychain":
749
+ return (store_obj.get() or "").strip()
750
+ path = os.path.expanduser(src)
751
+ try:
752
+ st = os.stat(path)
753
+ except OSError:
754
+ raise Precondition(f"cannot read lead secret file {src}") from None
755
+ if os.name == "posix" and (stat.S_IMODE(st.st_mode) & 0o077 or st.st_uid != os.getuid()):
756
+ raise IdentityError(f"lead secret file {path} must be mode 0600 and owned by you", hint=f"chmod 600 {path}")
757
+ with open(path, encoding="utf-8") as f:
758
+ return f.read().strip()