create-egregore 0.21.4 → 0.21.6
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.
- package/assets/egregore-launcher.js +88 -14
- package/bin/cli.js +6 -3
- package/lib/api.js +26 -22
- package/lib/connect-runtime.js +13 -8
- package/lib/hosted-api.js +70 -0
- package/lib/local.js +3 -17
- package/lib/setup.js +6 -1
- package/package.json +2 -2
- package/runtime/claude/.claude/context/telemetry.md +8 -18
- package/runtime/claude/.claude/skills/emissary/SKILL.md +1 -1
- package/runtime/claude/.claude/skills/telemetry/SKILL.md +3 -3
- package/runtime/claude/.claude/skills/update/SKILL.md +46 -32
- package/runtime/claude/bin/handoff-run.sh +1 -1
- package/runtime/claude/bin/hosting-ops.sh +5 -243
- package/runtime/claude/bin/lib/framework-update.sh +3 -1
- package/runtime/claude/bin/lib/git-sync.sh +2 -2
- package/runtime/claude/bin/notify.sh +1 -1
- package/runtime/claude/bin/post-update.sh +13 -0
- package/runtime/claude/bin/publish-artifact.sh +1 -1
- package/runtime/claude/bin/telemetry.sh +7 -10
- package/runtime/claude/egregore_runtime/contracts.py +38 -0
- package/runtime/claude/egregore_runtime/member_authority.py +405 -0
- package/runtime/claude/egregore_runtime/upgrade.py +41 -2
- package/runtime/claude/manifest.json +1 -1
- package/runtime/codex/bin/handoff-run.sh +1 -1
- package/runtime/codex/bin/hosting-ops.sh +5 -243
- package/runtime/codex/bin/lib/framework-update.sh +3 -1
- package/runtime/codex/bin/lib/git-sync.sh +2 -2
- package/runtime/codex/bin/notify.sh +1 -1
- package/runtime/codex/bin/post-update.sh +13 -0
- package/runtime/codex/bin/publish-artifact.sh +1 -1
- package/runtime/codex/bin/telemetry.sh +7 -10
- package/runtime/codex/egregore_runtime/contracts.py +38 -0
- package/runtime/codex/egregore_runtime/member_authority.py +405 -0
- package/runtime/codex/egregore_runtime/upgrade.py +41 -2
- package/runtime/codex/manifest.json +1 -1
- package/runtime/pi/bin/handoff-run.sh +1 -1
- package/runtime/pi/bin/hosting-ops.sh +5 -243
- package/runtime/pi/bin/lib/framework-update.sh +3 -1
- package/runtime/pi/bin/lib/git-sync.sh +2 -2
- package/runtime/pi/bin/notify.sh +1 -1
- package/runtime/pi/bin/post-update.sh +13 -0
- package/runtime/pi/bin/publish-artifact.sh +1 -1
- package/runtime/pi/bin/telemetry.sh +7 -10
- package/runtime/pi/egregore_runtime/contracts.py +38 -0
- package/runtime/pi/egregore_runtime/member_authority.py +405 -0
- package/runtime/pi/egregore_runtime/upgrade.py +41 -2
- package/runtime/pi/manifest.json +1 -1
- package/runtime/prime/bin/handoff-run.sh +1 -1
- package/runtime/prime/bin/hosting-ops.sh +5 -243
- package/runtime/prime/bin/lib/framework-update.sh +3 -1
- package/runtime/prime/bin/lib/git-sync.sh +2 -2
- package/runtime/prime/bin/notify.sh +1 -1
- package/runtime/prime/bin/post-update.sh +13 -0
- package/runtime/prime/bin/publish-artifact.sh +1 -1
- package/runtime/prime/bin/telemetry.sh +7 -10
- package/runtime/prime/egregore_runtime/contracts.py +38 -0
- package/runtime/prime/egregore_runtime/member_authority.py +405 -0
- package/runtime/prime/egregore_runtime/upgrade.py +41 -2
- package/runtime/prime/manifest.json +1 -1
- package/runtime/claude/bin/build-sealed-dataroom.mjs +0 -722
- package/runtime/codex/bin/build-sealed-dataroom.mjs +0 -722
- package/runtime/pi/bin/build-sealed-dataroom.mjs +0 -722
- package/runtime/prime/bin/build-sealed-dataroom.mjs +0 -722
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Request-local member authority above an injected trusted introspector.
|
|
2
|
+
|
|
3
|
+
This module implements parsing, identity resolution and policy intersection.
|
|
4
|
+
It does not issue credentials, call HTTP, read operator identity or provision
|
|
5
|
+
members. The host supplies an authenticated introspector; every resolution and
|
|
6
|
+
policy decision calls it again. Ownership, funding and actor parentage confer
|
|
7
|
+
no permissions here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass, fields, replace
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
|
|
19
|
+
from .contracts import (
|
|
20
|
+
AccountIdentity,
|
|
21
|
+
ActionAuthorizer,
|
|
22
|
+
ActorContext,
|
|
23
|
+
ActorIdentity,
|
|
24
|
+
ActorKind,
|
|
25
|
+
Capability,
|
|
26
|
+
EgregoreProfile,
|
|
27
|
+
Entitlements,
|
|
28
|
+
ExecutionAuthority,
|
|
29
|
+
OrgMembership,
|
|
30
|
+
Permission,
|
|
31
|
+
PolicyDecision,
|
|
32
|
+
RetrievalRequest,
|
|
33
|
+
)
|
|
34
|
+
from .errors import AuthorizationDenied
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SCHEMA = "egregore.member-authority/v1"
|
|
38
|
+
_PERMISSIONS = frozenset({Permission.DISCOVER, Permission.READ, Permission.WRITE})
|
|
39
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z0-9._:-]{1,160}$")
|
|
40
|
+
_PATH_PART = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MemberAuthorityError(AuthorizationDenied):
|
|
44
|
+
"""A bounded refusal that contains neither payload nor transport details."""
|
|
45
|
+
|
|
46
|
+
def __init__(self):
|
|
47
|
+
super().__init__("Authenticated member authority could not be verified.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _require(value: bool) -> None:
|
|
51
|
+
if not value:
|
|
52
|
+
raise MemberAuthorityError()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _identifier(value: Any) -> str:
|
|
56
|
+
_require(isinstance(value, str) and _IDENTIFIER.fullmatch(value) is not None)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _uuid(value: Any) -> str:
|
|
61
|
+
_require(isinstance(value, str))
|
|
62
|
+
try:
|
|
63
|
+
parsed = UUID(value)
|
|
64
|
+
except (ValueError, AttributeError):
|
|
65
|
+
raise MemberAuthorityError() from None
|
|
66
|
+
_require(parsed.int != 0 and str(parsed) == value)
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _text(value: Any, limit: int = 160) -> str:
|
|
71
|
+
_require(isinstance(value, str) and 0 < len(value) <= limit
|
|
72
|
+
and value == value.strip() and all(ord(c) >= 32 and ord(c) != 127 for c in value))
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _mapping(value: Any) -> Mapping:
|
|
77
|
+
_require(isinstance(value, Mapping))
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _strings(value: Any, *, limit: int = 32) -> tuple[str, ...]:
|
|
82
|
+
_require(isinstance(value, list) and len(value) <= limit)
|
|
83
|
+
result = tuple(_identifier(item) for item in value)
|
|
84
|
+
_require(len(set(result)) == len(result))
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _prefix(value: Any) -> str:
|
|
89
|
+
_require(isinstance(value, str) and len(value) <= 1024)
|
|
90
|
+
parts = value.split("/")
|
|
91
|
+
_require(parts[0] == "memory" and all(
|
|
92
|
+
part not in {"", ".", ".."} and _PATH_PART.fullmatch(part) for part in parts
|
|
93
|
+
))
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _prefixes(value: Any) -> tuple[str, ...]:
|
|
98
|
+
_require(isinstance(value, list) and len(value) <= 64)
|
|
99
|
+
result = tuple(_prefix(item) for item in value)
|
|
100
|
+
_require(len(set(result)) == len(result))
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _utc(value: Any) -> datetime:
|
|
105
|
+
_require(isinstance(value, datetime) and value.tzinfo is not None
|
|
106
|
+
and value.utcoffset() == timedelta(0))
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class MemberAuthorityBinding:
|
|
112
|
+
member_account_id: str
|
|
113
|
+
member_actor_id: str
|
|
114
|
+
membership_id: str
|
|
115
|
+
org_id: str
|
|
116
|
+
executor_actor_id: str
|
|
117
|
+
worker_id: str
|
|
118
|
+
binding_id: str
|
|
119
|
+
audience: str
|
|
120
|
+
grant_id: str
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
for field in fields(self):
|
|
124
|
+
value = getattr(self, field.name)
|
|
125
|
+
# Connected instances may retain their immutable Local organization
|
|
126
|
+
# reference (migration 046). It must still match the host-selected
|
|
127
|
+
# reference exactly; the database UUID is not an interchangeable alias.
|
|
128
|
+
if field.name == "org_id" and isinstance(value, str) and re.fullmatch(r"org_[0-9a-f]{32}", value):
|
|
129
|
+
continue
|
|
130
|
+
(_identifier if field.name in {"worker_id", "audience"} else _uuid)(value)
|
|
131
|
+
_require(self.executor_actor_id != self.member_actor_id)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class MemberIntrospector(Protocol):
|
|
135
|
+
def __call__(
|
|
136
|
+
self, *, expected: MemberAuthorityBinding, operation: str,
|
|
137
|
+
) -> Mapping[str, Any]: ...
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class VerifiedMemberAuthority:
|
|
142
|
+
expected: MemberAuthorityBinding
|
|
143
|
+
executor_kind: ActorKind
|
|
144
|
+
revision: str
|
|
145
|
+
expires_at: datetime
|
|
146
|
+
permissions: frozenset[Permission]
|
|
147
|
+
read_prefixes: tuple[str, ...]
|
|
148
|
+
write_prefixes: tuple[str, ...]
|
|
149
|
+
account_name: str
|
|
150
|
+
member_name: str
|
|
151
|
+
roles: tuple[str, ...]
|
|
152
|
+
teams: tuple[str, ...]
|
|
153
|
+
aliases: tuple[tuple[str, str], ...]
|
|
154
|
+
org_slug: str
|
|
155
|
+
org_name: str
|
|
156
|
+
org_revision: str
|
|
157
|
+
default_policy: str
|
|
158
|
+
default_context_budget: int
|
|
159
|
+
entitlement_plan: str
|
|
160
|
+
entitlement_capabilities: frozenset[Capability]
|
|
161
|
+
entitlement_source: str
|
|
162
|
+
entitlement_revision: str
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def execution(self) -> ExecutionAuthority:
|
|
166
|
+
return ExecutionAuthority(
|
|
167
|
+
**{field.name: getattr(self.expected, field.name) for field in fields(self.expected)},
|
|
168
|
+
executor_kind=self.executor_kind, revision=self.revision, expires_at=self.expires_at,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def actor_context(self, *, session_id: str, harness: str) -> ActorContext:
|
|
172
|
+
_text(session_id, 240)
|
|
173
|
+
_identifier(harness)
|
|
174
|
+
binding = self.expected
|
|
175
|
+
return ActorContext(
|
|
176
|
+
account=AccountIdentity(binding.member_account_id, self.account_name),
|
|
177
|
+
actor=ActorIdentity(
|
|
178
|
+
actor_id=binding.member_actor_id, kind=ActorKind.PERSON,
|
|
179
|
+
display_name=self.member_name, account_id=binding.member_account_id,
|
|
180
|
+
aliases=dict(self.aliases),
|
|
181
|
+
),
|
|
182
|
+
profile=EgregoreProfile(
|
|
183
|
+
schema_version="egregore-profile/v1", org_id=binding.org_id,
|
|
184
|
+
slug=self.org_slug, name=self.org_name, revision=self.org_revision,
|
|
185
|
+
default_policy=self.default_policy,
|
|
186
|
+
default_context_budget=self.default_context_budget,
|
|
187
|
+
),
|
|
188
|
+
membership=OrgMembership(
|
|
189
|
+
binding.membership_id, binding.org_id, binding.member_actor_id,
|
|
190
|
+
roles=self.roles, teams=self.teams, status="active",
|
|
191
|
+
),
|
|
192
|
+
entitlements=Entitlements(
|
|
193
|
+
self.entitlement_plan, self.entitlement_capabilities,
|
|
194
|
+
self.entitlement_source, self.entitlement_revision,
|
|
195
|
+
),
|
|
196
|
+
session_id=session_id, harness=harness, execution=self.execution,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_member_authority(
|
|
201
|
+
payload: Mapping[str, Any], *, expected: MemberAuthorityBinding, now: datetime,
|
|
202
|
+
) -> VerifiedMemberAuthority:
|
|
203
|
+
"""Validate a fresh authenticated response against a host-selected binding."""
|
|
204
|
+
try:
|
|
205
|
+
_require(isinstance(expected, MemberAuthorityBinding))
|
|
206
|
+
_utc(now)
|
|
207
|
+
payload = _mapping(payload)
|
|
208
|
+
_require(payload.get("schema") == SCHEMA and payload.get("active") is True)
|
|
209
|
+
actual = MemberAuthorityBinding(**{
|
|
210
|
+
field.name: payload[field.name] for field in fields(expected)
|
|
211
|
+
})
|
|
212
|
+
_require(actual == expected)
|
|
213
|
+
kind = ActorKind(payload["executor_kind"])
|
|
214
|
+
_require(kind in {ActorKind.AGENT, ActorKind.SERVICE})
|
|
215
|
+
expires = _text(payload["expires_at"], 40)
|
|
216
|
+
expires_at = _utc(datetime.fromisoformat(expires.replace("Z", "+00:00")))
|
|
217
|
+
_require(expires_at > now)
|
|
218
|
+
operations = _strings(payload["operations"], limit=3)
|
|
219
|
+
permissions = frozenset(Permission(item) for item in operations)
|
|
220
|
+
_require(bool(permissions) and permissions <= _PERMISSIONS)
|
|
221
|
+
reads, writes = _prefixes(payload["read_prefixes"]), _prefixes(payload["write_prefixes"])
|
|
222
|
+
member, account = _mapping(payload["member"]), _mapping(payload["account"])
|
|
223
|
+
aliases = _mapping(member["aliases"])
|
|
224
|
+
_require(len(aliases) <= 16)
|
|
225
|
+
org, entitlement = _mapping(payload["organization"]), _mapping(payload["entitlements"])
|
|
226
|
+
budget = org["default_context_budget"]
|
|
227
|
+
_require(type(budget) is int and 1 <= budget <= 1_000_000)
|
|
228
|
+
return VerifiedMemberAuthority(
|
|
229
|
+
expected=actual, executor_kind=kind, revision=_identifier(payload["revision"]),
|
|
230
|
+
expires_at=expires_at, permissions=permissions,
|
|
231
|
+
read_prefixes=reads, write_prefixes=writes,
|
|
232
|
+
account_name=_text(account["display_name"]), member_name=_text(member["display_name"]),
|
|
233
|
+
roles=_strings(member["roles"]), teams=_strings(member["teams"]),
|
|
234
|
+
aliases=tuple(sorted((_identifier(key), _text(value)) for key, value in aliases.items())),
|
|
235
|
+
org_slug=_identifier(org["slug"]), org_name=_text(org["name"]),
|
|
236
|
+
org_revision=_text(org["revision"]), default_policy=_identifier(org["default_policy"]),
|
|
237
|
+
default_context_budget=budget, entitlement_plan=_identifier(entitlement["plan"]),
|
|
238
|
+
entitlement_capabilities=frozenset(Capability(item) for item in
|
|
239
|
+
_strings(entitlement["capabilities"])),
|
|
240
|
+
entitlement_source=_text(entitlement["source"]),
|
|
241
|
+
entitlement_revision=_text(entitlement["revision"]),
|
|
242
|
+
)
|
|
243
|
+
except MemberAuthorityError:
|
|
244
|
+
raise
|
|
245
|
+
except Exception:
|
|
246
|
+
raise MemberAuthorityError() from None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _now() -> datetime:
|
|
250
|
+
return datetime.now(UTC)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True, slots=True)
|
|
254
|
+
class AuthenticatedMemberResolver:
|
|
255
|
+
expected: MemberAuthorityBinding
|
|
256
|
+
introspect: MemberIntrospector
|
|
257
|
+
clock: Callable[[], datetime] = _now
|
|
258
|
+
|
|
259
|
+
def __post_init__(self) -> None:
|
|
260
|
+
_require(isinstance(self.expected, MemberAuthorityBinding)
|
|
261
|
+
and callable(self.introspect) and callable(self.clock))
|
|
262
|
+
|
|
263
|
+
def _fresh(self, operation: str) -> VerifiedMemberAuthority:
|
|
264
|
+
try:
|
|
265
|
+
_require(operation in {"resolve", "discover", "read", "write"})
|
|
266
|
+
payload = self.introspect(expected=self.expected, operation=operation)
|
|
267
|
+
# Validate time after introspection, not before a potentially slow call.
|
|
268
|
+
return parse_member_authority(payload, expected=self.expected, now=self.clock())
|
|
269
|
+
except MemberAuthorityError:
|
|
270
|
+
raise
|
|
271
|
+
except Exception:
|
|
272
|
+
raise MemberAuthorityError() from None
|
|
273
|
+
|
|
274
|
+
def resolve(self, *, session_id: str, harness: str) -> ActorContext:
|
|
275
|
+
return self._fresh("resolve").actor_context(session_id=session_id, harness=harness)
|
|
276
|
+
|
|
277
|
+
def verify(self, actor: ActorContext, operation: str) -> VerifiedMemberAuthority:
|
|
278
|
+
"""Verify current authority without trusting mutable presentation fields."""
|
|
279
|
+
try:
|
|
280
|
+
binding = self.expected
|
|
281
|
+
_require(isinstance(actor, ActorContext) and actor.account is not None
|
|
282
|
+
and actor.membership is not None and isinstance(actor.execution, ExecutionAuthority))
|
|
283
|
+
_require(actor.account.account_id == actor.actor.account_id == binding.member_account_id
|
|
284
|
+
and actor.actor.actor_id == actor.membership.actor_id == binding.member_actor_id
|
|
285
|
+
and actor.profile.org_id == actor.membership.org_id == binding.org_id
|
|
286
|
+
and actor.membership.membership_id == binding.membership_id
|
|
287
|
+
and actor.membership.status == "active" and actor.actor.kind == ActorKind.PERSON)
|
|
288
|
+
_require(all(getattr(actor.execution, field.name) == getattr(binding, field.name)
|
|
289
|
+
for field in fields(binding)))
|
|
290
|
+
verified = self._fresh(operation)
|
|
291
|
+
_require(actor.execution.executor_kind == verified.executor_kind)
|
|
292
|
+
return verified
|
|
293
|
+
except MemberAuthorityError:
|
|
294
|
+
raise
|
|
295
|
+
except Exception:
|
|
296
|
+
raise MemberAuthorityError() from None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _intersect(left: Sequence[str], right: Sequence[str]) -> tuple[str, ...]:
|
|
300
|
+
"""Intersect segment prefixes; never expand an append grant into a write."""
|
|
301
|
+
result = set()
|
|
302
|
+
for first in left:
|
|
303
|
+
_prefix(first)
|
|
304
|
+
for second in right:
|
|
305
|
+
_prefix(second)
|
|
306
|
+
# Both sides are canonical memory prefixes, validated above.
|
|
307
|
+
# This is lexical segment containment, not filesystem traversal;
|
|
308
|
+
# keeping it here lets the hosted parser run without Local policy.
|
|
309
|
+
if first == second or first.startswith(second + "/"):
|
|
310
|
+
result.add(first)
|
|
311
|
+
elif second.startswith(first + "/"):
|
|
312
|
+
result.add(second)
|
|
313
|
+
return tuple(sorted(result))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass(frozen=True, slots=True)
|
|
317
|
+
class MemberAuthorityPolicy:
|
|
318
|
+
resolver: AuthenticatedMemberResolver
|
|
319
|
+
base_policy_factory: Callable[[ActorContext], ActionAuthorizer]
|
|
320
|
+
|
|
321
|
+
def _denied(self, permission: Permission, reason: str, base=None) -> PolicyDecision:
|
|
322
|
+
if base is not None:
|
|
323
|
+
return replace(base, allowed=False, reasons=(reason,), scopes=(), resource_ids=(),
|
|
324
|
+
read_prefixes=(), write_prefixes=(), append_prefixes=())
|
|
325
|
+
expected = self.resolver.expected
|
|
326
|
+
return PolicyDecision(False, expected.member_actor_id, expected.org_id, permission,
|
|
327
|
+
"member-authority/v1", reasons=(reason,))
|
|
328
|
+
|
|
329
|
+
def _intersect_decision(self, base, verified, permission) -> PolicyDecision:
|
|
330
|
+
expected = self.resolver.expected
|
|
331
|
+
_require(isinstance(base, PolicyDecision) and base.actor_id == expected.member_actor_id
|
|
332
|
+
and base.org_id == expected.org_id and base.permission == permission)
|
|
333
|
+
if not base.allowed:
|
|
334
|
+
return self._denied(permission, "Local policy denied this operation.", base)
|
|
335
|
+
# policy.check consumes prefix fields, not decision.permission. Return
|
|
336
|
+
# only the operation the base policy actually authorized, so a read
|
|
337
|
+
# decision cannot become a write or a discover-only grant expose bytes.
|
|
338
|
+
reads = verified.read_prefixes if (
|
|
339
|
+
permission in {Permission.DISCOVER, Permission.READ}
|
|
340
|
+
and Permission.READ in verified.permissions
|
|
341
|
+
) else ()
|
|
342
|
+
writes = verified.write_prefixes if (
|
|
343
|
+
permission == Permission.WRITE and Permission.WRITE in verified.permissions
|
|
344
|
+
) else ()
|
|
345
|
+
read = _intersect(base.read_prefixes, reads)
|
|
346
|
+
write = _intersect(base.write_prefixes, writes)
|
|
347
|
+
append = _intersect(base.append_prefixes, writes)
|
|
348
|
+
selected = read if permission in {Permission.READ, Permission.DISCOVER} else (*write, *append)
|
|
349
|
+
if not selected:
|
|
350
|
+
return self._denied(permission, "The member grant and local policy have no common scope.", base)
|
|
351
|
+
return replace(base, read_prefixes=read, write_prefixes=write, append_prefixes=append,
|
|
352
|
+
scopes=_intersect(base.scopes, reads if permission != Permission.WRITE else writes))
|
|
353
|
+
|
|
354
|
+
def authorize_observe(self, actor: ActorContext, request: RetrievalRequest) -> PolicyDecision:
|
|
355
|
+
try:
|
|
356
|
+
verified = self.resolver.verify(actor, "read")
|
|
357
|
+
if not {Permission.DISCOVER, Permission.READ} <= verified.permissions:
|
|
358
|
+
return self._denied(Permission.READ, "The member grant does not permit discovery and reading.")
|
|
359
|
+
expected = self.resolver.expected
|
|
360
|
+
_require(request.org_id == expected.org_id and request.actor_id == expected.member_actor_id)
|
|
361
|
+
current = verified.actor_context(session_id=actor.session_id, harness=actor.harness)
|
|
362
|
+
base = self.base_policy_factory(current).authorize_observe(current, request)
|
|
363
|
+
return self._intersect_decision(base, verified, Permission.READ)
|
|
364
|
+
except Exception:
|
|
365
|
+
return self._denied(Permission.READ, "Authenticated member authority is unavailable.")
|
|
366
|
+
|
|
367
|
+
def authorize_action(
|
|
368
|
+
self, actor: ActorContext, permission: Permission, resource_ids: Sequence[str] = (),
|
|
369
|
+
) -> PolicyDecision:
|
|
370
|
+
try:
|
|
371
|
+
_require(isinstance(permission, Permission) and permission in _PERMISSIONS)
|
|
372
|
+
verified = self.resolver.verify(actor, permission.value)
|
|
373
|
+
if permission not in verified.permissions or (
|
|
374
|
+
permission == Permission.DISCOVER and Permission.READ not in verified.permissions
|
|
375
|
+
):
|
|
376
|
+
return self._denied(permission, "The member grant does not permit this operation.")
|
|
377
|
+
current = verified.actor_context(session_id=actor.session_id, harness=actor.harness)
|
|
378
|
+
authorizer = self.base_policy_factory(current)
|
|
379
|
+
base = authorizer.authorize_action(current, permission, resource_ids)
|
|
380
|
+
if permission == Permission.DISCOVER:
|
|
381
|
+
# Discovery exposes readable source metadata. Require the
|
|
382
|
+
# underlying policy's read decision as well as its discover
|
|
383
|
+
# decision, and retain the read decision's hold restrictions.
|
|
384
|
+
readable = authorizer.authorize_action(current, Permission.READ, resource_ids)
|
|
385
|
+
expected = self.resolver.expected
|
|
386
|
+
_require(isinstance(base, PolicyDecision) and isinstance(readable, PolicyDecision)
|
|
387
|
+
and base.permission == Permission.DISCOVER and readable.permission == Permission.READ
|
|
388
|
+
and base.actor_id == readable.actor_id == expected.member_actor_id
|
|
389
|
+
and base.org_id == readable.org_id == expected.org_id)
|
|
390
|
+
if not base.allowed or not readable.allowed:
|
|
391
|
+
return self._denied(permission, "Local policy denied discovery or reading.")
|
|
392
|
+
_require(base.policy_epoch == readable.policy_epoch and base.scope == readable.scope
|
|
393
|
+
and base.memory_revision == readable.memory_revision)
|
|
394
|
+
resources = base.resource_ids or readable.resource_ids
|
|
395
|
+
if base.resource_ids and readable.resource_ids:
|
|
396
|
+
resources = tuple(item for item in base.resource_ids if item in readable.resource_ids)
|
|
397
|
+
if not resources:
|
|
398
|
+
return self._denied(permission, "Local discovery and reading scopes do not overlap.")
|
|
399
|
+
base = replace(readable, permission=Permission.DISCOVER,
|
|
400
|
+
resource_ids=resources,
|
|
401
|
+
read_prefixes=_intersect(base.read_prefixes, readable.read_prefixes),
|
|
402
|
+
scopes=_intersect(base.scopes, readable.scopes))
|
|
403
|
+
return self._intersect_decision(base, verified, permission)
|
|
404
|
+
except Exception:
|
|
405
|
+
return self._denied(permission, "Authenticated member authority is unavailable.")
|
|
@@ -928,11 +928,27 @@ class UpgradeEngine:
|
|
|
928
928
|
return all(isinstance(digest, str) and re.fullmatch(r"[a-f0-9]{64}", digest)
|
|
929
929
|
for digest in value["files"].values())
|
|
930
930
|
|
|
931
|
+
@staticmethod
|
|
932
|
+
def _committed_unchanged(instance: Path, relative: str) -> bool:
|
|
933
|
+
"""Git tracks the path and nothing under it differs from HEAD."""
|
|
934
|
+
environment = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
|
935
|
+
tracked = subprocess.run(["git", "-C", str(instance), "ls-files", "-z", "--", relative],
|
|
936
|
+
capture_output=True, text=True, timeout=5, env=environment, check=False)
|
|
937
|
+
status = subprocess.run(["git", "-C", str(instance), "status", "--porcelain", "-z",
|
|
938
|
+
"--untracked-files=all", "--", relative],
|
|
939
|
+
capture_output=True, text=True, timeout=5, env=environment, check=False)
|
|
940
|
+
if tracked.returncode != 0 or status.returncode != 0 or not tracked.stdout.strip("\0"):
|
|
941
|
+
return False
|
|
942
|
+
return all(entry[3:].endswith(".pyc") and "/__pycache__/" in f"/{entry[3:]}"
|
|
943
|
+
for entry in status.stdout.split("\0") if entry)
|
|
944
|
+
|
|
931
945
|
def _legacy_template_baseline(self, instance: Path, *, previously_restored: bool) -> dict:
|
|
932
946
|
"""Recognize an unversioned template without claiming installed Runtime.
|
|
933
947
|
|
|
934
948
|
Git identifies provenance only. Dirty bytes are captured by the normal
|
|
935
949
|
rollback archive, never reconstructed by checking out a Git revision.
|
|
950
|
+
Runtime files a framework update committed are template content; an
|
|
951
|
+
untracked or edited Runtime file is unreceipted installation evidence.
|
|
936
952
|
"""
|
|
937
953
|
try:
|
|
938
954
|
for receipt in _runtime_install_receipt_paths(instance):
|
|
@@ -940,10 +956,13 @@ class UpgradeEngine:
|
|
|
940
956
|
raise ValueError("installer evidence needs repair")
|
|
941
957
|
for relative in ("bin/observe-context.sh", "bin/runtime-identity.sh", "bin/runtime-upgrade.sh"):
|
|
942
958
|
marker = instance / relative
|
|
943
|
-
if marker.
|
|
959
|
+
if marker.is_symlink() or (marker.exists() and not self._committed_unchanged(instance, relative)):
|
|
944
960
|
raise ValueError("Runtime adapter evidence needs repair")
|
|
945
961
|
core = instance / "egregore_runtime"
|
|
946
|
-
|
|
962
|
+
delivered = (not core.is_symlink() and core.is_dir()
|
|
963
|
+
and not any(path.is_symlink() for path in core.rglob("*"))
|
|
964
|
+
and self._committed_unchanged(instance, "egregore_runtime"))
|
|
965
|
+
if (core.exists() or core.is_symlink()) and not delivered:
|
|
947
966
|
# A completed legacy rollback removes added files, not their
|
|
948
967
|
# parent directories or Python's generated caches. Only a
|
|
949
968
|
# validated prior baseline permits these harmless remnants.
|
|
@@ -1522,6 +1541,26 @@ class UpgradeEngine:
|
|
|
1522
1541
|
for relative in (CODEX_SETTINGS_ADAPTER, *NATIVE_SETTINGS_ADAPTERS)
|
|
1523
1542
|
if relative in selected
|
|
1524
1543
|
}
|
|
1544
|
+
# Git cleanliness does not make a linked destination safe. Check every
|
|
1545
|
+
# file and parent before opening the journal or backing up any bytes;
|
|
1546
|
+
# copy2 follows destination links, including links inside a tracked tree.
|
|
1547
|
+
# Preserved org-owned paths are not selected and remain untouched.
|
|
1548
|
+
for relative in selected:
|
|
1549
|
+
destination = instance / relative
|
|
1550
|
+
# copy2 treats a directory destination as a container and appends
|
|
1551
|
+
# the source basename, which could select an unchecked child link.
|
|
1552
|
+
if destination.exists() and not destination.is_file():
|
|
1553
|
+
raise UpgradeError(
|
|
1554
|
+
f"Runtime destination is not a regular file: {relative}; "
|
|
1555
|
+
"repair the destination before upgrading"
|
|
1556
|
+
)
|
|
1557
|
+
while destination != instance:
|
|
1558
|
+
if destination.is_symlink():
|
|
1559
|
+
raise UpgradeError(
|
|
1560
|
+
f"Runtime destination contains a symbolic link: {destination.relative_to(instance)}; "
|
|
1561
|
+
"replace it with an instance-local file or directory before upgrading"
|
|
1562
|
+
)
|
|
1563
|
+
destination = destination.parent
|
|
1525
1564
|
# Journal opens the transaction BEFORE the first byte moves. From here
|
|
1526
1565
|
# until the caller clears it, a crash is recoverable from the backup.
|
|
1527
1566
|
journal = {
|
package/runtime/pi/manifest.json
CHANGED
|
@@ -132,7 +132,6 @@
|
|
|
132
132
|
"bin/autosave.sh",
|
|
133
133
|
"bin/base-branch.sh",
|
|
134
134
|
"bin/branch-guard.cjs",
|
|
135
|
-
"bin/build-sealed-dataroom.mjs",
|
|
136
135
|
"bin/capture-reconcile.sh",
|
|
137
136
|
"bin/capture-run.sh",
|
|
138
137
|
"bin/codex-install-skills.sh",
|
|
@@ -308,6 +307,7 @@
|
|
|
308
307
|
"egregore_runtime/knowledge_cli.py",
|
|
309
308
|
"egregore_runtime/lifecycle.py",
|
|
310
309
|
"egregore_runtime/lifecycle_cli.py",
|
|
310
|
+
"egregore_runtime/member_authority.py",
|
|
311
311
|
"egregore_runtime/notification_cli.py",
|
|
312
312
|
"egregore_runtime/notifications.py",
|
|
313
313
|
"egregore_runtime/observe.py",
|
|
@@ -402,7 +402,7 @@ PID_MEMORY=$!
|
|
|
402
402
|
if [ -f "$EMISSARY_CONFIG" ]; then
|
|
403
403
|
EMISSARY_TOKEN=$(jq -r '.auth_token // empty' "$EMISSARY_CONFIG" 2>/dev/null)
|
|
404
404
|
fi
|
|
405
|
-
EMISSARY_API_URL="${EMISSARY_API_URL:-https://egregore
|
|
405
|
+
EMISSARY_API_URL="${EMISSARY_API_URL:-https://api.egregore.xyz/api/v1/emissary}"
|
|
406
406
|
|
|
407
407
|
# /handoff is an internal team primitive, not an implicit public-emissary
|
|
408
408
|
# composer. Use the emissary route only for an explicit recipient, and make
|