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/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()
|