toolframe-core 0.6.2__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 (46) hide show
  1. toolframe_core/__init__.py +3 -0
  2. toolframe_core/_state_postgres.py +420 -0
  3. toolframe_core/_state_sqlite.py +339 -0
  4. toolframe_core/access.py +513 -0
  5. toolframe_core/api.py +700 -0
  6. toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  7. toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
  8. toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
  9. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
  10. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
  11. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
  12. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
  13. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
  14. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
  15. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
  16. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
  17. toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
  18. toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
  19. toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
  20. toolframe_core/cli.py +681 -0
  21. toolframe_core/config.py +267 -0
  22. toolframe_core/diagnostics.py +200 -0
  23. toolframe_core/graph.py +207 -0
  24. toolframe_core/host.py +2610 -0
  25. toolframe_core/maintenance.py +868 -0
  26. toolframe_core/mcp_access.py +259 -0
  27. toolframe_core/network.py +97 -0
  28. toolframe_core/oidc_access.py +187 -0
  29. toolframe_core/packages.py +728 -0
  30. toolframe_core/process.py +212 -0
  31. toolframe_core/redaction.py +120 -0
  32. toolframe_core/scaffold.py +142 -0
  33. toolframe_core/skills.py +210 -0
  34. toolframe_core/state.py +226 -0
  35. toolframe_core/state_admin.py +270 -0
  36. toolframe_core/stores.py +88 -0
  37. toolframe_core/templates.json +6 -0
  38. toolframe_core/templates.py +56 -0
  39. toolframe_core-0.6.2.dist-info/METADATA +465 -0
  40. toolframe_core-0.6.2.dist-info/RECORD +46 -0
  41. toolframe_core-0.6.2.dist-info/WHEEL +4 -0
  42. toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
  43. toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
  44. toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
  45. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  46. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
@@ -0,0 +1,513 @@
1
+ """Host-owned identity admission; plugin-owned credentials and session persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import secrets
7
+ import time
8
+ from collections import OrderedDict
9
+ from dataclasses import dataclass
10
+ from urllib.parse import urlsplit
11
+
12
+ from toolframe_sdk import SYSTEM_SCOPES, PluginError, Principal
13
+
14
+ from .config import atomic_write
15
+ from .network import loopback
16
+
17
+ OPERATOR_COOKIE = "toolframe_session"
18
+ PASSWORD_COOKIE = "toolframe_access"
19
+ CONTEXT_HEADER = "x-toolframe-context"
20
+ DENIALS = {
21
+ "invalid_credentials",
22
+ "session_invalid",
23
+ "password_conflict",
24
+ "credential_missing",
25
+ "credential_revoked",
26
+ "invalid_request",
27
+ "forbidden",
28
+ "rate_limited",
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Identity:
34
+ local: bool = False
35
+ operator: bool = False
36
+ session: str = ""
37
+
38
+
39
+ def operation_scopes(action, params=None):
40
+ params = params or {}
41
+ if action == "plan":
42
+ return operation_scopes(params.get("action", "apply"))
43
+ if action in {
44
+ "status",
45
+ "operation",
46
+ "operations",
47
+ "logs",
48
+ "composition",
49
+ "readiness",
50
+ }:
51
+ return ("system.plugins.read",)
52
+ if action in {"migrate-plan", "migrate-apply"}:
53
+ return ("system.migrations.manage",)
54
+ return ("system.plugins.manage",)
55
+
56
+
57
+ class Access:
58
+ def __init__(self, host):
59
+ self.host = host
60
+ self.contexts: dict[str, Identity] = {}
61
+ self.failures = {}
62
+ self.login_attempts = OrderedDict()
63
+ self.login_slots = 0
64
+
65
+ def catalog(self):
66
+ catalog = {
67
+ key: {"title": title, "category": "system", "owner": "system"}
68
+ for key, title in SYSTEM_SCOPES.items()
69
+ }
70
+ for owner, definition in self.host.graph.definitions.items():
71
+ for name, scope in definition.authorization.scopes.items():
72
+ catalog[name] = {**scope.model_dump(), "owner": owner}
73
+ return catalog
74
+
75
+ def identity(self, connection):
76
+ authorization = connection.headers.get("authorization", "")
77
+ token = (
78
+ authorization.removeprefix("Bearer ")
79
+ if authorization.startswith("Bearer ")
80
+ else connection.cookies.get(OPERATOR_COOKIE, "")
81
+ )
82
+ try:
83
+ local = bool(connection.client and loopback(connection.client.host))
84
+ except ValueError:
85
+ local = False
86
+ # A public virtual host is remote even if its proxy omits forwarded headers.
87
+ public_url = self.host.composition.application.public_url
88
+ if (
89
+ public_url
90
+ and connection.headers.get("host", "").lower()
91
+ == urlsplit(public_url).netloc.lower()
92
+ ):
93
+ try:
94
+ local = local and loopback(urlsplit(public_url).hostname)
95
+ except ValueError:
96
+ local = False
97
+ # A reverse proxy's loopback socket is not a local human operator.
98
+ if any(
99
+ name in connection.headers
100
+ for name in (
101
+ "forwarded",
102
+ "x-forwarded-for",
103
+ "x-forwarded-host",
104
+ "x-forwarded-proto",
105
+ )
106
+ ):
107
+ local = False
108
+ return Identity(
109
+ local=local and self.host.composition.access.local_control,
110
+ operator=hmac.compare_digest(
111
+ token.encode("utf-8"), self.host.token.encode("utf-8")
112
+ ),
113
+ session=connection.cookies.get(PASSWORD_COOKIE, ""),
114
+ )
115
+
116
+ def register(self, identity):
117
+ if len(self.contexts) >= 4096:
118
+ raise PluginError("rate_limited", "Too many Dashboard connections")
119
+ reference = secrets.token_urlsafe(32)
120
+ self.contexts[reference] = identity
121
+ return reference
122
+
123
+ def context(self, reference):
124
+ identity = self.contexts.get(reference)
125
+ if identity is None:
126
+ raise PluginError(
127
+ "authentication_required", "Dashboard connection expired; reconnect"
128
+ )
129
+ return identity
130
+
131
+ def chain(self):
132
+ policy = self.host.composition.access
133
+ return (
134
+ (policy.auth_provider, "auth"),
135
+ (policy.dashboard_auth, "dashboard-auth"),
136
+ )
137
+
138
+ async def mode(self):
139
+ host, policy = self.host, self.host.composition.access
140
+ if policy.mode == "public":
141
+ return {
142
+ "mode": "public",
143
+ "reason": "configured_public",
144
+ "configured": "public",
145
+ }
146
+ problems, transitioning = [], False
147
+ for name, kind in self.chain():
148
+ desired = host.composition.plugins.get(name)
149
+ runtime = host.runtimes.get(name)
150
+ if (
151
+ not desired
152
+ or not desired.enabled
153
+ or (await host.state.plugin(name))["stopped"]
154
+ ):
155
+ problems.append(f"{name}: disabled or not installed")
156
+ continue
157
+ if runtime and self.failures.get(name) == runtime.generation:
158
+ problems.append(f"{name}: auth decision failed")
159
+ continue
160
+ if (
161
+ host.healthy_runtime(name)
162
+ and runtime.candidate["definition"].get("provider_kind") == kind
163
+ ):
164
+ continue
165
+ if host.starting or name in host.transitioning:
166
+ transitioning = True
167
+ else:
168
+ problems.append(f"{name}: unavailable")
169
+ if problems:
170
+ return {
171
+ "mode": "waiting" if policy.failure_mode == "closed" else "public",
172
+ "reason": "auth_unavailable",
173
+ "configured": "protected",
174
+ "detail": "; ".join(problems),
175
+ }
176
+ return {
177
+ "mode": "waiting" if transitioning else "protected",
178
+ "reason": "auth_transition" if transitioning else "configured_protected",
179
+ "configured": "protected",
180
+ }
181
+
182
+ async def mark_failed(self, name):
183
+ runtime = self.host.runtimes.get(name)
184
+ if not runtime:
185
+ return
186
+ self.failures[name] = runtime.generation
187
+ runtime.health = {"runtime": "failed", "error": "Auth decision service failed"}
188
+ for member in self.host.graph.dependents({name}):
189
+ if active := self.host.runtimes.get(member):
190
+ active.admitted = False
191
+ self.host.gated.add(member)
192
+ (
193
+ await self.host.record(
194
+ active, "failed" if member == name else "blocked"
195
+ )
196
+ )
197
+ self.host.health_publication_pending = True
198
+
199
+ async def auth(self, method, params, *, principal=None):
200
+ name = self.host.composition.access.auth_provider
201
+ if not self.host.healthy_runtime(name):
202
+ raise PluginError(
203
+ "auth_unavailable", "Authentication provider is unavailable"
204
+ )
205
+ try:
206
+ result = await self.host.call_service(
207
+ name, "toolframe.auth", method, params, principal=principal
208
+ )
209
+ if not isinstance(result, dict):
210
+ raise TypeError("Invalid auth response")
211
+ if method in {"resolve", "login"}:
212
+ actor = Principal.model_validate(result["principal"])
213
+ if (
214
+ actor.kind not in {"password", "user"}
215
+ or not actor.credential_id
216
+ or (actor.kind == "user" and not actor.user_id)
217
+ ):
218
+ raise ValueError("Invalid credential decision")
219
+ if method == "login" and (
220
+ not isinstance(result.get("token"), str)
221
+ or len(result["token"]) < 32
222
+ or not isinstance(result.get("expires_at"), (int, float))
223
+ ):
224
+ raise ValueError("Invalid session decision")
225
+ return result
226
+ except PluginError as error:
227
+ if error.code in DENIALS or (
228
+ error.code in {"plugin_reloading", "dependency_unavailable"}
229
+ and (self.host.starting or name in self.host.transitioning)
230
+ ):
231
+ raise
232
+ (await self.mark_failed(name))
233
+ raise PluginError(
234
+ "auth_unavailable", "Authentication provider failed"
235
+ ) from None
236
+ except Exception: # noqa: BLE001 - verifier faults must obey the configured failure policy
237
+ (await self.mark_failed(name))
238
+ raise PluginError(
239
+ "auth_unavailable", "Authentication provider failed"
240
+ ) from None
241
+
242
+ async def resolve(self, identity, *, effective=True):
243
+ if identity.local and self.host.composition.access.local_control:
244
+ return Principal(kind="local")
245
+ if identity.operator:
246
+ return Principal(kind="operator")
247
+ actor = Principal(kind="anonymous")
248
+ if identity.session:
249
+ try:
250
+ actor = Principal.model_validate(
251
+ (await self.auth("resolve", {"token": identity.session}))[
252
+ "principal"
253
+ ]
254
+ )
255
+ except PluginError as error:
256
+ if error.code not in {
257
+ "session_invalid",
258
+ "auth_unavailable",
259
+ "plugin_reloading",
260
+ "dependency_unavailable",
261
+ }:
262
+ raise
263
+ if not effective:
264
+ return actor
265
+ mode = await self.mode()
266
+ if mode["mode"] == "waiting":
267
+ raise PluginError("auth_transition", "Access is preparing; retry shortly")
268
+ if mode["mode"] == "public":
269
+ return actor.model_copy(update={"all_business": True})
270
+ if actor.kind == "anonymous":
271
+ raise PluginError(
272
+ "authentication_required", "Sign in with an access password"
273
+ )
274
+ return actor
275
+
276
+ async def admit(self, identity, scopes=(), *, activity=False, effective=True):
277
+ actor = await self.resolve(identity, effective=effective)
278
+ if not actor.allows(scopes, self.catalog()):
279
+ if actor.kind == "anonymous":
280
+ raise PluginError(
281
+ "authentication_required",
282
+ "Operator or scoped password sign-in required",
283
+ )
284
+ raise PluginError(
285
+ "forbidden", "Your account does not grant the required scope"
286
+ )
287
+ if activity and actor.kind in {"password", "user"}:
288
+ # Check/touch only after the requested operation's scopes were granted.
289
+ # Public business access continues even if the optional session expires.
290
+ try:
291
+ await self.auth(
292
+ "resolve", {"token": identity.session, "activity": True}
293
+ )
294
+ except PluginError:
295
+ if (await self.mode())["mode"] != "public" or any(
296
+ self.catalog().get(s, {}).get("category") == "system"
297
+ for s in scopes
298
+ ):
299
+ raise
300
+ return actor
301
+
302
+ async def presentation(self, identity):
303
+ try:
304
+ actor = await self.resolve(identity)
305
+ except PluginError as error:
306
+ if error.code not in {"authentication_required", "auth_transition"}:
307
+ raise
308
+ actor = Principal(kind="anonymous")
309
+ catalog = self.catalog()
310
+ return {
311
+ **(await self.mode()),
312
+ "local_control": actor.kind == "local",
313
+ "actor": actor.kind,
314
+ "user_id": actor.user_id,
315
+ "allowed_scopes": [s for s in catalog if actor.allows([s], catalog)],
316
+ "login_url": "/_toolframe/auth/login",
317
+ "logout_url": "/_toolframe/auth/logout",
318
+ }
319
+
320
+ async def login(self, password, peer):
321
+ now = time.monotonic()
322
+ # Bounded peer buckets + global concurrency. Rejecting load is not an outage.
323
+ for key in list(self.login_attempts):
324
+ if now - self.login_attempts[key][0] >= 60:
325
+ del self.login_attempts[key]
326
+ start, count = self.login_attempts.get(peer, (now, 0))
327
+ if (
328
+ count >= 10
329
+ or self.login_slots >= 2
330
+ or (peer not in self.login_attempts and len(self.login_attempts) >= 4096)
331
+ ):
332
+ raise PluginError("rate_limited", "Too many login attempts; retry later")
333
+ self.login_attempts[peer] = (start, count + 1)
334
+ if (await self.mode())["mode"] == "waiting":
335
+ raise PluginError("auth_transition", "Access is preparing; retry shortly")
336
+ self.login_slots += 1
337
+ try:
338
+ return await self.auth("login", {"password": password})
339
+ finally:
340
+ self.login_slots -= 1
341
+
342
+ async def manage(self, identity, action, params):
343
+ if action.startswith("mcp."):
344
+ return await self.host.mcp_access.manage(identity, action[4:], params)
345
+ if action == "presentation":
346
+ return await self.presentation(identity)
347
+ if action == "user_catalog":
348
+ actor = await self.admit(identity, ["system.auth.users"], effective=False)
349
+ catalog = self.catalog()
350
+ return {
351
+ "scopes": {
352
+ name: spec
353
+ for name, spec in catalog.items()
354
+ if actor.allows([name], catalog)
355
+ }
356
+ }
357
+ if action in {"users", "user_update", "grants", "revoke_grant"}:
358
+ scope = (
359
+ "system.auth.users"
360
+ if action in {"users", "user_update"}
361
+ else "system.auth.self"
362
+ )
363
+ actor = await self.admit(
364
+ identity,
365
+ [scope],
366
+ effective=False,
367
+ activity=action not in {"users", "grants"},
368
+ )
369
+ return await self.auth(action, params, principal=actor)
370
+ if action == "catalog":
371
+ actor = await self.admit(
372
+ identity, ["system.auth.passwords"], effective=False
373
+ )
374
+ return {
375
+ "scopes": self.catalog(),
376
+ "policy": self.host.composition.access.model_dump(),
377
+ "policy_options": {"failure_modes": ["closed", "public"]},
378
+ "entrances": {
379
+ "dashboard": await self.mode(),
380
+ "mcp": self.host.mcp_access.status(),
381
+ "actor": actor.kind,
382
+ "declared_public_api": sorted(
383
+ f"/api/{owner}/{service_name}/{method_name}"
384
+ for owner, definition in self.host.graph.definitions.items()
385
+ for service_name, service in definition.services.items()
386
+ for method_name, method in service.methods.items()
387
+ if method.audience == "business" and method.public_api
388
+ ),
389
+ },
390
+ "revision": self.host.config.revision,
391
+ }
392
+ if action == "policy":
393
+ await self.admit(
394
+ identity, ["system.settings.manage"], activity=True, effective=False
395
+ )
396
+ if (
397
+ not isinstance(params.get("mode"), str)
398
+ or params["mode"] not in {"public", "protected"}
399
+ or not isinstance(params.get("local_control", True), bool)
400
+ or (
401
+ "failure_mode" in params
402
+ and (
403
+ not isinstance(params["failure_mode"], str)
404
+ or params["failure_mode"] not in {"closed", "public"}
405
+ )
406
+ )
407
+ ):
408
+ raise PluginError(
409
+ "invalid_request",
410
+ "Supply public/protected mode, boolean local_control and optional closed/public failure_mode",
411
+ )
412
+ async with self.host.mutation:
413
+ await self.admit(identity, ["system.settings.manage"], effective=False)
414
+
415
+ def edit(document):
416
+ access = document.setdefault("access", {})
417
+ if "failure_mode" in params:
418
+ access["failure_mode"] = params["failure_mode"]
419
+ elif (
420
+ params["mode"] == "protected"
421
+ and access.get("mode") != "protected"
422
+ ):
423
+ access.setdefault("failure_mode", "closed")
424
+ access["mode"] = params["mode"]
425
+ access["local_control"] = params.get("local_control", True)
426
+
427
+ self.host.composition, self.host.revision = self.host.config.edit(
428
+ params.get("expected_revision"), edit
429
+ )
430
+ (await self.host.write_lock())
431
+ return await self.mode()
432
+ if action not in {"create", "list", "update", "revoke"}:
433
+ raise PluginError("invalid_request", "Unknown password operation")
434
+ actor = await self.admit(
435
+ identity,
436
+ ["system.auth.passwords"],
437
+ activity=action != "list",
438
+ effective=False,
439
+ )
440
+ if action != "create":
441
+ return await self.auth(action, params, principal=actor)
442
+ # Serialize with composition operations; no password ever enters their journal.
443
+ async with self.host.mutation:
444
+ actor = await self.admit(
445
+ identity, ["system.auth.passwords"], effective=False
446
+ )
447
+ revision = self.host.config.revision
448
+ if params.get("expected_revision") != revision:
449
+ raise PluginError(
450
+ "stale_revision",
451
+ "Refresh access settings before creating a password",
452
+ )
453
+ result = await self.auth(action, params, principal=actor)
454
+ if result["first_password"]:
455
+ try:
456
+
457
+ def protect(document):
458
+ access = document.setdefault("access", {})
459
+ access.setdefault("failure_mode", "closed")
460
+ access["mode"] = "protected"
461
+
462
+ self.host.composition, self.host.revision = self.host.config.edit(
463
+ revision, protect
464
+ )
465
+ (await self.host.write_lock())
466
+ except PluginError, OSError, ValueError:
467
+ # Record was committed, so return its ID/one-time secret with an
468
+ # explicit recovery instruction instead of suggesting a blind retry.
469
+ result["policy_error"] = (
470
+ "Password created, but the policy update could not be confirmed. Refresh and explicitly confirm protected mode."
471
+ )
472
+ result["access"] = await self.mode()
473
+ return result
474
+
475
+ def private_secrets(self, definition, *, store_exists=None):
476
+ result = {}
477
+ for name in definition.private_secrets:
478
+ path = self.host.home / ".toolframe/secrets" / definition.id / f"{name}.key"
479
+ if path.is_symlink():
480
+ raise PluginError(
481
+ "secret_invalid",
482
+ "Private key must be an owner-controlled regular file",
483
+ )
484
+ if not path.exists():
485
+ if store_exists or (
486
+ store_exists is None
487
+ and (
488
+ self.host.home / "data/plugins" / definition.id / "data.sqlite"
489
+ ).exists()
490
+ ):
491
+ raise PluginError(
492
+ "secret_missing",
493
+ "Restore the plugin private key from the application backup",
494
+ )
495
+ atomic_write(path, secrets.token_hex(32))
496
+ path.parent.chmod(0o700)
497
+ if path.stat().st_mode & 0o077:
498
+ raise PluginError(
499
+ "secret_permissions", "Private key must have mode 0600"
500
+ )
501
+ value = path.read_text().strip()
502
+ if len(value) != 64:
503
+ raise PluginError(
504
+ "secret_invalid", "Private key must contain 32 hex-encoded bytes"
505
+ )
506
+ try:
507
+ bytes.fromhex(value)
508
+ except ValueError:
509
+ raise PluginError(
510
+ "secret_invalid", "Invalid private key encoding"
511
+ ) from None
512
+ result[name] = value
513
+ return result