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/hints.py ADDED
@@ -0,0 +1,32 @@
1
+ """Copy-ready command lines for hints, the approvals inbox and the web board."""
2
+
3
+ import os
4
+ import shlex
5
+
6
+ from .identity import token_path
7
+
8
+
9
+ def short_path(path):
10
+ home = os.path.expanduser("~")
11
+ return "~" + path[len(home) :] if path and path.startswith(home + os.sep) else path
12
+
13
+
14
+ def quote_path(path):
15
+ text = short_path(str(path))
16
+ if text.startswith("~/"):
17
+ return "~/" + shlex.quote(text[2:])
18
+ return shlex.quote(text)
19
+
20
+
21
+ def lead_secret_src(board):
22
+ """What the lead passes to --lead-secret: `keychain`, or the shell-quoted secret file path."""
23
+ try:
24
+ where = board.leadlease().store.using
25
+ except Exception:
26
+ where = "file"
27
+ return "keychain" if where == "keychain" else quote_path(token_path(board.home, board.uid, board.lead))
28
+
29
+
30
+ def lead_cmd(board, rest, prog="raidlock"):
31
+ """A lead command line that works when pasted into the lead session."""
32
+ return f"{prog} --lead-secret {lead_secret_src(board)} {rest}"
@@ -0,0 +1,285 @@
1
+ import hashlib
2
+ import hmac
3
+ import os
4
+ import secrets
5
+ from dataclasses import dataclass, field
6
+
7
+ from . import model, safeio
8
+ from .errors import IdentityError
9
+
10
+ PROVIDERS = ("anthropic", "openai", "google", "local", "human")
11
+ RUNTIMES = ("claude-code", "codex-cli", "gemini-cli", "aider", "shell", "human")
12
+ RUNTIME_PROVIDER = {
13
+ "claude-code": "anthropic",
14
+ "codex-cli": "openai",
15
+ "gemini-cli": "google",
16
+ "aider": "local",
17
+ "shell": "local",
18
+ "human": "human",
19
+ }
20
+ RUNTIME_SHORT = {
21
+ "claude-code": "claude",
22
+ "codex-cli": "codex",
23
+ "gemini-cli": "gemini",
24
+ "aider": "aider",
25
+ "shell": "shell",
26
+ "human": "human",
27
+ }
28
+ RUNTIME_LONG = {short: long for long, short in RUNTIME_SHORT.items()}
29
+ TRUE = ("1", "true", "yes", "on")
30
+
31
+
32
+ def long_runtime(value):
33
+ """Runtime name as the board stores it: codex -> codex-cli, claude -> claude-code, gemini -> gemini-cli."""
34
+ text = (value or "").strip().lower()
35
+ return RUNTIME_LONG.get(text, text) if text else value
36
+
37
+
38
+ def short_runtime(value):
39
+ """Runtime name as runners and routing use it: codex-cli -> codex, claude-code -> claude."""
40
+ text = (value or "").strip().lower()
41
+ return RUNTIME_SHORT.get(text, text)
42
+
43
+
44
+ @dataclass
45
+ class Caller:
46
+ """Who is calling. `authenticated` means a matching agent token, card capability or lead secret was presented.
47
+ `is_lead` is true only for a verified lead secret; `lead_fence` is the lead fence it carries. `scope_card` is set
48
+ when the caller authenticated with a card-scoped capability and may act only on that card."""
49
+
50
+ name: str
51
+ authenticated: bool = False
52
+ is_lead: bool = False
53
+ subagent: bool = False
54
+ fence: int = 0
55
+ session: str = ""
56
+ record: dict = field(default_factory=dict)
57
+ lead_fence: int = 0
58
+ scope_card: str = ""
59
+ lead_secret: str = field(default="", repr=False)
60
+
61
+ @property
62
+ def model(self):
63
+ return self.record.get("model", "")
64
+
65
+
66
+ def token_path(home, board_uid, agent):
67
+ model.agent_name(agent)
68
+ if (
69
+ not isinstance(board_uid, str)
70
+ or not board_uid
71
+ or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" for c in board_uid)
72
+ ):
73
+ raise IdentityError("invalid board UID")
74
+ root = safeio.check(os.path.join(home, "tokens"))
75
+ path = safeio.check(root / board_uid / (agent + ".token"))
76
+ if not path.is_relative_to(root):
77
+ raise IdentityError("token path escapes token directory")
78
+ return str(path)
79
+
80
+
81
+ def issue_token(home, board_uid, agent):
82
+ token = "t" + secrets.token_urlsafe(24)
83
+ safeio.write_text(token_path(home, board_uid, agent), token + "\n")
84
+ return token
85
+
86
+
87
+ def has_token(home, board_uid, agent):
88
+ return os.path.exists(token_path(home, board_uid, agent))
89
+
90
+
91
+ def read_token(home, board_uid, agent):
92
+ try:
93
+ return safeio.read(token_path(home, board_uid, agent)).decode().strip()
94
+ except OSError:
95
+ return ""
96
+
97
+
98
+ def check_token(home, board_uid, agent, token):
99
+ if not token or not agent:
100
+ return False
101
+ stored = read_token(home, board_uid, agent)
102
+ return bool(stored) and hmac.compare_digest(stored.encode(), token.strip().encode())
103
+
104
+
105
+ def auth_required(settings, env):
106
+ return settings.get("auth") == "token" or env.get("RAIDLOCK_REQUIRE_TOKEN", "").lower() in TRUE
107
+
108
+
109
+ def make_cap(token, agent, card, fence):
110
+ """A card-scoped capability: valid for `agent` on `card` at `fence` only, derived from the agent token."""
111
+ mac = hmac.new(token.encode(), f"{agent}|{card}|{int(fence)}".encode(), hashlib.sha256).hexdigest()[:40]
112
+ return f"{card}:{int(fence)}:{mac}"
113
+
114
+
115
+ def check_cap(home, board_uid, agent, cap):
116
+ """(card, fence) when `cap` was issued to `agent`, else None."""
117
+ parts = (cap or "").strip().split(":")
118
+ if len(parts) != 3 or not parts[1].isdigit() or not agent:
119
+ return None
120
+ token = read_token(home, board_uid, agent)
121
+ if not token:
122
+ return None
123
+ want = make_cap(token, agent, parts[0], int(parts[1]))
124
+ return (parts[0], int(parts[1])) if hmac.compare_digest(want.encode(), cap.strip().encode()) else None
125
+
126
+
127
+ def _worker_token(home, board_uid, agents, lead_name, token):
128
+ return bool(token) and any(check_token(home, board_uid, n, token) for n in agents if n != lead_name)
129
+
130
+
131
+ def presented_secret(lead_secret, env):
132
+ """The lead secret this call presents: the --lead-secret value, RAIDLOCK_LEAD_SECRET, or the file named by
133
+ RAIDLOCK_LEAD_SECRET_FILE. Nothing is read from the secret store on the caller's behalf."""
134
+ if lead_secret:
135
+ return lead_secret.strip()
136
+ if env.get("RAIDLOCK_LEAD_SECRET", "").strip():
137
+ return env["RAIDLOCK_LEAD_SECRET"].strip()
138
+ if env.get("RAIDLOCK_LEAD_SECRET_FILE", "").strip():
139
+ from .lead import read_presented
140
+
141
+ return read_presented(env["RAIDLOCK_LEAD_SECRET_FILE"].strip(), None)
142
+ return ""
143
+
144
+
145
+ def resolve(
146
+ home,
147
+ board_uid,
148
+ settings,
149
+ agents,
150
+ lead_name,
151
+ agent=None,
152
+ token=None,
153
+ fence=None,
154
+ env=None,
155
+ lead_secret=None,
156
+ cap=None,
157
+ lead_check=None,
158
+ ):
159
+ """Resolve the caller from flags and environment.
160
+
161
+ Lead rights come only from presenting the lead secret, checked by `lead_check(secret) -> (fence, session)`;
162
+ the lead's name, RAIDLOCK_ROLE or the secret file merely existing grant nothing. A caller with worker
163
+ credentials (a pinned RAIDLOCK_AGENT_ID, a worker token or a card capability) that also holds the lead
164
+ secret is refused."""
165
+ env = os.environ if env is None else env
166
+ if env.get("RAIDLOCK_ROLE", "") == "lead":
167
+ raise IdentityError(
168
+ "RAIDLOCK_ROLE=lead is refused; lead rights come only from presenting the lead secret",
169
+ hint="unset RAIDLOCK_ROLE; the lead passes --lead-secret <file>",
170
+ )
171
+ pinned = env.get("RAIDLOCK_AGENT_ID", "")
172
+ if pinned and agent and agent != pinned:
173
+ raise IdentityError(f"--agent {agent} does not match RAIDLOCK_AGENT_ID={pinned}", agent=agent)
174
+ name = pinned or agent or env.get("RAIDLOCK_AGENT", "")
175
+ token = token or env.get("RAIDLOCK_TOKEN", "")
176
+ cap = cap or env.get("RAIDLOCK_CAP", "")
177
+ secret = presented_secret(lead_secret, env)
178
+ worker = bool(pinned) or bool(cap) or _worker_token(home, board_uid, agents, lead_name, token)
179
+ if secret and worker:
180
+ raise IdentityError(
181
+ "a worker must never hold the lead secret; refusing to run",
182
+ agent=name,
183
+ hint="scrub RAIDLOCK_LEAD_* from this environment",
184
+ )
185
+ fence_value = _int(fence if fence is not None else env.get("RAIDLOCK_FENCE", ""))
186
+ common = dict(subagent=bool(pinned), session=env.get("RAIDLOCK_SESSION", ""), record=dict(agents.get(name, {})))
187
+ if secret:
188
+ if lead_check is None:
189
+ raise IdentityError("no lead lease to check the secret against")
190
+ lead_fence, session = lead_check(secret)
191
+ name = lead_name
192
+ common.update(session=session, record=dict(agents.get(name, {})))
193
+ return Caller(
194
+ name=name,
195
+ authenticated=True,
196
+ is_lead=True,
197
+ fence=fence_value,
198
+ lead_fence=lead_fence,
199
+ lead_secret=secret,
200
+ **common,
201
+ )
202
+ scope = ""
203
+ if cap:
204
+ found = check_cap(home, board_uid, name, cap)
205
+ if not found:
206
+ raise IdentityError(f"card capability is not valid for {name or 'this caller'}", agent=name)
207
+ scope, cap_fence = found
208
+ if fence_value and fence_value != cap_fence:
209
+ raise IdentityError("card fence does not match the presented capability", agent=name)
210
+ fence_value = cap_fence
211
+ authenticated = True
212
+ else:
213
+ authenticated = bool(name) and name != lead_name and check_token(home, board_uid, name, token)
214
+ return Caller(name=name, authenticated=authenticated, fence=fence_value, scope_card=scope, **common)
215
+
216
+
217
+ def _int(value):
218
+ try:
219
+ return int(value)
220
+ except (TypeError, ValueError):
221
+ return 0
222
+
223
+
224
+ def require_name(caller):
225
+ if not caller.name:
226
+ raise IdentityError("no agent identity", hint="raidlock join (or pass --agent / set RAIDLOCK_AGENT)")
227
+
228
+
229
+ LEAD_HINT = "lead rights need the lead secret: raidlock --lead-secret <file> ... from the lead session"
230
+
231
+
232
+ def require_auth(caller, settings, env, lead_name, known):
233
+ """Refuse an unauthenticated caller when tokens are required; refuse the lead's name without the lead secret."""
234
+ require_name(caller)
235
+ if caller.name == lead_name and not caller.is_lead:
236
+ raise IdentityError(
237
+ f"{lead_name} is the lead's name and this caller presented no lead secret",
238
+ agent=caller.name,
239
+ hint=LEAD_HINT,
240
+ )
241
+ if auth_required(settings, env) and not caller.authenticated:
242
+ raise IdentityError(
243
+ f"{caller.name} gave no valid token",
244
+ agent=caller.name,
245
+ hint="export RAIDLOCK_TOKEN=<token printed by raidlock join>",
246
+ )
247
+ if caller.name not in known and not caller.is_lead:
248
+ raise IdentityError(
249
+ f"{caller.name} has not joined this board", agent=caller.name, hint=f"raidlock join --agent {caller.name}"
250
+ )
251
+
252
+
253
+ def require_lead(caller, action):
254
+ if not caller.is_lead:
255
+ raise IdentityError(f"only the lead can {action}", agent=caller.name, action=action, hint=LEAD_HINT)
256
+
257
+
258
+ def default_name(runtime, role, card, taken):
259
+ base = RUNTIME_SHORT.get(runtime, "agent") + "-" + role
260
+ if role == "lead" and base not in taken:
261
+ return base
262
+ if card:
263
+ candidate = (base + "-" + card)[:40].rstrip("-")
264
+ if candidate not in taken:
265
+ return candidate
266
+ n = 1
267
+ while f"{base}-{n}" in taken:
268
+ n += 1
269
+ return f"{base}-{n}"
270
+
271
+
272
+ def detect_runtime(env=None, interactive=False):
273
+ """Guess the calling runtime from its environment markers."""
274
+ env = os.environ if env is None else env
275
+ if env.get("RAIDLOCK_RUNTIME") in RUNTIMES:
276
+ return env["RAIDLOCK_RUNTIME"]
277
+ if env.get("CLAUDECODE") or env.get("CLAUDE_CODE_ENTRYPOINT"):
278
+ return "claude-code"
279
+ if any(k.startswith("CODEX_") and k != "CODEX_HOME" for k in env):
280
+ return "codex-cli"
281
+ if env.get("GEMINI_CLI"):
282
+ return "gemini-cli"
283
+ if env.get("AIDER_MODEL") or env.get("AIDER_CHAT_HISTORY_FILE"):
284
+ return "aider"
285
+ return "human" if interactive else "shell"