pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / grants.py

11,133 bytes · last changed 2026-10-28 in Agents beside the swarm: open-weights models that probe the trees, suggest, chat, remember and follow skills; release 0.15.0

1"""Grants: which identities may do what on this hub.
2
3An identity grants nothing by itself (``_meta/identity.md`` §1): a hub
4keeps its own list, from ``--allow`` and from its operator at ``/allow``,
5of roots and what each may do here: ``ask`` (the routed paths behind a
6token), ``chat`` (a seat in a lobby behind a token), ``run`` (follow as a
7member and be handed trees) and ``operate`` (everything the token allows).
8A device may do what its root's grant says, intersected with its note's
9``may``.
10
11A grant made by name (``alice@pyrtan.com``) resolves the name once and is
12held against the identifier from then on: the name is the pin, and is
13never resolved again, so a domain that later points it elsewhere moves
14nothing here. Each grant keeps
15the newest identity document it has seen, from a push to ``/identity``,
16from the name's domain or from the document's own routes, re-read at
17least hourly and on a request from a device not seen in a day; a device
18the document revokes is refused. The list lives in a JSON file beside
19the hub's state, or in memory without one.
20"""
21
22from __future__ import annotations
23
24import json
25import threading
26import time
27from dataclasses import dataclass, field
28from pathlib import Path
29from typing import Any, Callable
30
31from . import identity, names
32from .identity import ABILITIES, IdentityError, Signer
33
34REFRESH_EVERY = 3600.0 # seconds between reads of a grant's identity document
35DEVICE_UNSEEN = 86400.0 # a device not seen for this long makes its root's document be re-read
36
37# Reached with a key whatever its grants: following as a member is open, and trust is decided there.
38OPEN = frozenset({"/inbox"})
39
40
41def ability_for(method: str, path: str) -> str | None:
42 """The ability a key-signed request needs for ``path`` on a hub with a token; None for
43 the paths anyone may reach (the public ones, the inbox, a pushed identity document)."""
44 from .service import public_path
45 if public_path(path) or path in OPEN:
46 return None
47 if path in ("/intent", "/run", "/resume") or (method == "GET" and path == "/capabilities") or (method == "POST" and path == "/suggestions"):
48 return "ask" # an agent's report is as much as an ask: its asks record gaps already
49 if path == "/chat" or path.startswith("/chat/"):
50 return "chat"
51 return "operate"
52
53
54def parse_may(text: Any) -> list[str]:
55 """``"ask,run"`` (or a list) as abilities, each once, in the canonical order."""
56 items = text if isinstance(text, list) else str(text or "").replace(" ", "").split(",")
57 items = [a for a in items if a]
58 unknown = [a for a in items if a not in ABILITIES]
59 if unknown or not items:
60 raise IdentityError(f"abilities are some of {', '.join(ABILITIES)}" + (f"; not {', '.join(unknown)}" if unknown else ""))
61 return [a for a in ABILITIES if a in items]
62
63
64def parse_spec(spec: str) -> tuple[str, list[str]]:
65 """``WHO=ask,run`` as ``(who, abilities)``; WHO is ``pyr:…`` or ``name@domain``."""
66 who, eq, may = spec.rpartition("=")
67 if not eq or not who:
68 raise IdentityError(f"{spec!r}: write WHO=ABILITIES, e.g. alice@pyrtan.com=ask,chat")
69 identity.parse(who)
70 return who, parse_may(may)
71
72
73@dataclass
74class Grant:
75 id: str
76 may: list[str]
77 name: str | None = None
78 doc: dict[str, Any] | None = None
79 fetched: float = 0.0
80 granted: float = field(default_factory=time.time)
81
82 def view(self) -> dict[str, Any]:
83 return {"id": self.id, "name": self.name, "may": self.may, "granted": self.granted,
84 "document": {"updated": self.doc["updated"], "routes": self.doc["routes"], "devices": len(self.doc["devices"]), "revoked": len(self.doc["revoked"])} if self.doc else None}
85
86 def to_state(self) -> dict[str, Any]:
87 return {"id": self.id, "may": self.may, "name": self.name, "doc": self.doc, "fetched": self.fetched, "granted": self.granted}
88
89
90class Grants:
91 def __init__(self, path: str | Path | None = None, *, fetch: names.Fetch | None = None,
92 on_change: Callable[[str], None] | None = None) -> None:
93 self.path = Path(path) if path else None
94 self.fetch = fetch
95 self.on_change = on_change # called with a root whose grant or document changed
96 self.grants: dict[str, Grant] = {}
97 self.seen: dict[str, float] = {} # device -> when it last signed here
98 self._lock = threading.RLock()
99 if self.path and self.path.exists():
100 self._load()
101
102 # ------------------------------------------------------------ the list
103
104 def __len__(self) -> int:
105 return len(self.grants)
106
107 def by_name(self, name: str) -> Grant | None:
108 key = str(identity.parse(name))
109 return next((g for g in self.grants.values() if g.name == key), None)
110
111 def allow(self, who: str, may: list[str]) -> Grant:
112 """Grant ``who`` (``pyr:…`` or a name, resolved and pinned now) the abilities ``may``."""
113 may = parse_may(may)
114 parsed = identity.parse(who)
115 if parsed.form == "name":
116 held = self.by_name(who)
117 claim = names.resolve(who, fetch=self.fetch) if held is None else None
118 ident = held.id if held else claim["id"]
119 name = str(parsed)
120 else:
121 ident, name, claim = parsed.id, None, None
122 with self._lock:
123 grant = self.grants.get(ident)
124 if grant is None:
125 grant = self.grants[ident] = Grant(ident, may, name)
126 else:
127 grant.may = may
128 grant.name = grant.name or name
129 self._save()
130 if claim is not None and grant.doc is None:
131 self.refresh_one(ident)
132 self._changed(ident)
133 return grant
134
135 def disallow(self, who: str) -> bool:
136 parsed = identity.parse(who)
137 grant = self.by_name(who) if parsed.form == "name" else self.grants.get(parsed.id)
138 if grant is None:
139 return False
140 with self._lock:
141 self.grants.pop(grant.id, None)
142 self._save()
143 self._changed(grant.id)
144 return True
145
146 def view(self) -> list[dict[str, Any]]:
147 with self._lock:
148 return [g.view() for g in sorted(self.grants.values(), key=lambda g: (g.name or "~", g.id))]
149
150 # ------------------------------------------------------------ callers
151
152 def may(self, signer: Signer, *, now: float | None = None) -> set[str]:
153 """What ``signer`` may do here: its root's grant, capped by the device note, and
154 nothing for a device its root's document revokes. Notes a device's visit."""
155 now = time.time() if now is None else now
156 grant = self.grants.get(signer.root)
157 if grant is None:
158 return set()
159 if signer.note is None:
160 return set(grant.may)
161 if grant.doc is not None and identity.device_status(grant.doc, signer.key, now=now) == "revoked":
162 return set()
163 last = self.seen.get(signer.key)
164 self.seen[signer.key] = now
165 if last is None or now - last > DEVICE_UNSEEN:
166 threading.Thread(target=self.refresh_one, args=(signer.root,), daemon=True).start()
167 return set(grant.may) & set(signer.note["may"])
168
169 def still(self, root: str, device: str, note: dict[str, Any] | None, ability: str, *, now: float | None = None) -> bool:
170 """Whether a member that followed as ``device`` for ``root`` may still do ``ability``."""
171 if note is not None and identity.check_device(note, root=root, now=now) is not None:
172 return False
173 return ability in self.may(Signer(device, root, note), now=now)
174
175 # ------------------------------------------------------------ documents
176
177 def document(self, ident: str) -> dict[str, Any] | None:
178 grant = self.grants.get(ident.lower())
179 return grant.doc if grant else None
180
181 def take_document(self, doc: Any, *, now: float | None = None) -> str | None:
182 """Keep ``doc`` if it is a granted root's and newer than ours; None, or why not."""
183 if not isinstance(doc, dict) or not isinstance(doc.get("id"), str):
184 return "not an identity document"
185 grant = self.grants.get(doc["id"].lower())
186 if grant is None:
187 return "this hub grants that identity nothing, so keeps no document for it"
188 problem = identity.check_identity(doc, ident=grant.id, now=now)
189 if problem:
190 return problem
191 with self._lock:
192 if grant.doc is not None and doc["updated"] <= grant.doc["updated"]:
193 return None
194 grant.doc = doc
195 self._save()
196 self._changed(grant.id)
197 return None
198
199 def refresh_one(self, ident: str) -> None:
200 """Look for a newer document where the grant's name and routes say it lives."""
201 grant = self.grants.get(ident)
202 if grant is None:
203 return
204 hosts = list(grant.doc["routes"]) if grant.doc else []
205 if grant.name:
206 hosts.insert(0, identity.parse(grant.name).domain)
207 grant.fetched = time.time()
208 for host in dict.fromkeys(hosts):
209 try:
210 doc = names.fetch_identity(ident, host, fetch=self.fetch)
211 except IdentityError:
212 continue # an onion route, for now
213 if doc is not None:
214 self.take_document(doc)
215
216 def refresh(self, *, now: float | None = None) -> None:
217 now = time.time() if now is None else now
218 for ident in [g.id for g in list(self.grants.values()) if now - g.fetched >= REFRESH_EVERY]:
219 self.refresh_one(ident)
220
221 # ------------------------------------------------------------ state
222
223 def _changed(self, ident: str) -> None:
224 if self.on_change:
225 self.on_change(ident)
226
227 def _save(self) -> None:
228 if not self.path:
229 return
230 with self._lock:
231 data = {"pyrtan-grants": 1, "grants": [g.to_state() for g in self.grants.values()]}
232 self.path.parent.mkdir(parents=True, exist_ok=True)
233 temp = self.path.with_suffix(self.path.suffix + ".tmp")
234 temp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
235 temp.replace(self.path)
236
237 def _load(self) -> None:
238 assert self.path
239 try:
240 data = json.loads(self.path.read_text(encoding="utf-8"))
241 except (OSError, json.JSONDecodeError) as e:
242 raise IdentityError(f"{self.path}: {e}") from None
243 if not isinstance(data, dict) or data.get("pyrtan-grants") != 1:
244 raise IdentityError(f"{self.path} is not a pyrtan grants file")
245 for d in data.get("grants", []):
246 try:
247 ident = identity.normal(d["id"])
248 doc = d.get("doc") if identity.check_identity(d.get("doc"), ident=ident) is None else None
249 self.grants[ident] = Grant(ident, parse_may(d.get("may")), d.get("name"), doc, float(d.get("fetched", 0)), float(d.get("granted", 0)))
250 except (IdentityError, KeyError, TypeError, ValueError):
251 continue