@passioncode-ai/fabric-agent-adapter 0.32.1 → 0.34.0
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/CHANGELOG.md +90 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/profile-selection.md +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +35 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +26 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +129 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +77 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +192 -50
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +126 -25
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +8 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py
CHANGED
|
@@ -151,6 +151,84 @@ def _list(value: Any) -> List[Dict[str, Any]]:
|
|
|
151
151
|
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
def _indexed(value: Any) -> List[Tuple[Dict[str, Any], int]]:
|
|
155
|
+
"""The object items of an array with their index in the array as written, so an instancePath names the item it
|
|
156
|
+
flags even when a non-object precedes it (the contract's ``indexed``)."""
|
|
157
|
+
return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _Unshowable(Exception):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _strict_string(value: Any) -> str:
|
|
165
|
+
"""String(value), raising where JavaScript's String() throws: an object whose own ``toString`` is not a function
|
|
166
|
+
(every own member of parsed JSON is data), directly or inside an array it joins."""
|
|
167
|
+
if isinstance(value, dict):
|
|
168
|
+
if "toString" in value:
|
|
169
|
+
raise _Unshowable()
|
|
170
|
+
return "[object Object]"
|
|
171
|
+
if isinstance(value, list):
|
|
172
|
+
return ",".join("" if item is None else _strict_string(item) for item in value)
|
|
173
|
+
return _js_string(value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _json_stringify(value: Any) -> str:
|
|
177
|
+
"""JSON.stringify for a parsed JSON value: members in their order, numbers as JavaScript writes them."""
|
|
178
|
+
if isinstance(value, dict):
|
|
179
|
+
return "{" + ",".join(_delegation._string(k) + ":" + _json_stringify(v) for k, v in value.items()) + "}"
|
|
180
|
+
if isinstance(value, list):
|
|
181
|
+
return "[" + ",".join(_json_stringify(v) for v in value) + "]"
|
|
182
|
+
if value is None:
|
|
183
|
+
return "null"
|
|
184
|
+
if isinstance(value, bool):
|
|
185
|
+
return "true" if value else "false"
|
|
186
|
+
if _number(value):
|
|
187
|
+
text = _js_string(value)
|
|
188
|
+
return "null" if text in ("NaN", "Infinity", "-Infinity") else text
|
|
189
|
+
return _delegation._string(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _show(value: Any) -> str:
|
|
193
|
+
"""The contract's ``show``: String(value), or the value's JSON where String() would throw."""
|
|
194
|
+
try:
|
|
195
|
+
return _strict_string(value)
|
|
196
|
+
except _Unshowable:
|
|
197
|
+
return _json_stringify(value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _key_of(*parts: Any) -> str:
|
|
201
|
+
"""The contract's ``keyOf``: a collision-free key over JSON values, JSON.stringify of the parts (an absent value
|
|
202
|
+
is null inside an array, as JavaScript writes it)."""
|
|
203
|
+
return _json_stringify([None if part is _MISSING else part for part in parts])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
#: DEC-0082: the personal context, and an organization's context ``org:<orgId>`` (the contract's ``context-route.ts``).
|
|
207
|
+
PERSONAL_CONTEXT = "personal"
|
|
208
|
+
ORG_CONTEXT_PREFIX = "org:"
|
|
209
|
+
_ORG_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_context(value: Any) -> Optional[Dict[str, str]]:
|
|
213
|
+
"""The context ``value`` names, or None when it names none: ``personal`` exactly, or ``org:`` and an organization id
|
|
214
|
+
(``[A-Za-z0-9][A-Za-z0-9_-]{0,127}``). Compared as written: ``Personal``, ``ORG:x`` or a trailing newline name no
|
|
215
|
+
context. Never raises."""
|
|
216
|
+
if not isinstance(value, str):
|
|
217
|
+
return None
|
|
218
|
+
if value == PERSONAL_CONTEXT:
|
|
219
|
+
return {"kind": "personal"}
|
|
220
|
+
if not value.startswith(ORG_CONTEXT_PREFIX):
|
|
221
|
+
return None
|
|
222
|
+
org = value[len(ORG_CONTEXT_PREFIX):]
|
|
223
|
+
return {"kind": "org", "org": org} if _ORG_ID.fullmatch(org) else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _context_of(entry: Any) -> Any:
|
|
227
|
+
"""A placement's or a secret placement's context: its ``context``, ``personal`` when absent (DEC-0082)."""
|
|
228
|
+
context = _get(entry, "context")
|
|
229
|
+
return PERSONAL_CONTEXT if context is _MISSING else context
|
|
230
|
+
|
|
231
|
+
|
|
154
232
|
def _b64url_bytes(text: str) -> bytes:
|
|
155
233
|
"""Buffer.from(text, "base64url") for text of the base64url alphabet: a dangling sixth bit group is ignored."""
|
|
156
234
|
return _delegation._b64decode(text)
|
|
@@ -514,7 +592,7 @@ def _signing_credential(credential: Any, path: str, flag) -> None:
|
|
|
514
592
|
|
|
515
593
|
|
|
516
594
|
def _revocation_key(entry: Dict[str, Any]) -> str:
|
|
517
|
-
return "%s %s" % (
|
|
595
|
+
return "%s %s" % (_show(_get(entry, "kind")), _show(_get(entry, "ref")))
|
|
518
596
|
|
|
519
597
|
|
|
520
598
|
def _lt(a: float, b: float) -> bool:
|
|
@@ -610,11 +688,13 @@ def verify_estate_record(record: Any, *, at: Any, parent: Any = _MISSING, author
|
|
|
610
688
|
return {"ok": True, "digest": digest, "revision": record["revision"], "signedBy": credential["credentialId"], "signCount": assertion["signCount"]}
|
|
611
689
|
|
|
612
690
|
|
|
613
|
-
def placement_trust(record: Dict[str, Any], machine_id: str, agent_ref: str) -> Optional[Dict[str, Any]]:
|
|
614
|
-
"""What a package verifier (DEC-0051) trusts for a package placed on a machine
|
|
615
|
-
|
|
691
|
+
def placement_trust(record: Dict[str, Any], machine_id: str, agent_ref: str, context: str = PERSONAL_CONTEXT) -> Optional[Dict[str, Any]]:
|
|
692
|
+
"""What a package verifier (DEC-0051) trusts for a package placed on a machine in a context (DEC-0082; ``personal``
|
|
693
|
+
when not given, so a caller written before contexts is unchanged): the publisher keys the placement names, from the
|
|
694
|
+
record's `publisherKeys`. None when the record places no such agent on that machine in that context."""
|
|
616
695
|
machine = next((m for m in _list(_get(record, "machines")) if _same(_get(m, "id"), machine_id)), None)
|
|
617
|
-
placement = next((p for p in _list(_get(machine, "packages"))
|
|
696
|
+
placement = next((p for p in _list(_get(machine, "packages"))
|
|
697
|
+
if _same(_get(p, "agentRef"), agent_ref) and _same(_context_of(p), context)), None)
|
|
618
698
|
if placement is None:
|
|
619
699
|
return None
|
|
620
700
|
return {"publisher": [k for k in _list(_get(record, "publisherKeys")) if _same(_get(k, "kid"), _get(placement, "publisherKid"))]}
|
|
@@ -720,7 +800,7 @@ def _revision_chain(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
720
800
|
flag("/next/estate", "a revision continues its parent's estate")
|
|
721
801
|
expected = _js_to_number(_get(parent, "revision")) + 1
|
|
722
802
|
if not _same(_get(nxt, "revision"), expected):
|
|
723
|
-
flag("/next/revision", "the revision after %s is %s: a revision never skips or repeats one" % (
|
|
803
|
+
flag("/next/revision", "the revision after %s is %s: a revision never skips or repeats one" % (_show(_get(parent, "revision")), _js_string(expected)))
|
|
724
804
|
try:
|
|
725
805
|
parent_digest: Any = estate_record_digest(parent)
|
|
726
806
|
except TypeError:
|
|
@@ -730,15 +810,15 @@ def _revision_chain(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
730
810
|
if _lt(estate_time(_get(nxt, "issuedAt")), estate_time(_get(parent, "issuedAt"))):
|
|
731
811
|
flag("/next/issuedAt", "a revision is never issued before its parent")
|
|
732
812
|
kept = {_revocation_key(e) for e in _list(_get(nxt, "revocations"))}
|
|
733
|
-
for
|
|
813
|
+
for entry, index in _indexed(_get(parent, "revocations")):
|
|
734
814
|
if _revocation_key(entry) not in kept:
|
|
735
|
-
flag("/parent/revocations/%d" % index, "the %s revocation of %s is dropped: a revision keeps every revocation before it" % (
|
|
815
|
+
flag("/parent/revocations/%d" % index, "the %s revocation of %s is dropped: a revision keeps every revocation before it" % (_show(_get(entry, "kind")), _show(_get(entry, "ref"))))
|
|
736
816
|
return findings
|
|
737
817
|
|
|
738
818
|
|
|
739
819
|
def _valid_device_key(key: Dict[str, Any]) -> bool:
|
|
740
820
|
public = _get(key, "publicKey")
|
|
741
|
-
raw = _node_base64("" if public is _MISSING or public is None else
|
|
821
|
+
raw = _node_base64("" if public is _MISSING or public is None else _show(public))
|
|
742
822
|
if _get(key, "alg") == "ed25519":
|
|
743
823
|
return len(raw) == 32 and not _delegation.is_small_order_key(raw)
|
|
744
824
|
return is_valid_p256_key(raw)
|
|
@@ -748,10 +828,10 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
748
828
|
ids: List[Any] = []
|
|
749
829
|
hub_kids: List[Any] = []
|
|
750
830
|
hub_keys: List[Any] = []
|
|
751
|
-
for
|
|
831
|
+
for machine, m in _indexed(_get(value, "machines")):
|
|
752
832
|
at = "/machines/%d" % m
|
|
753
833
|
if _contains(ids, _get(machine, "id")):
|
|
754
|
-
flag("%s/id" % at, "machine %s is listed twice" %
|
|
834
|
+
flag("%s/id" % at, "machine %s is listed twice" % _show(_get(machine, "id")))
|
|
755
835
|
ids.append(_get(machine, "id"))
|
|
756
836
|
device_keys = _list(_get(machine, "deviceKeys"))
|
|
757
837
|
if len([k for k in device_keys if _get(k, "use") == "sig"]) != 1:
|
|
@@ -759,8 +839,8 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
759
839
|
if len([k for k in device_keys if _get(k, "use") == "kem"]) > 1:
|
|
760
840
|
flag("%s/deviceKeys" % at, "a machine has at most one key-agreement device key, <id>#kem")
|
|
761
841
|
seen: List[Any] = []
|
|
762
|
-
for
|
|
763
|
-
if not _same(_get(key, "kid"), "%s#%s" % (
|
|
842
|
+
for key, k in _indexed(_get(machine, "deviceKeys")):
|
|
843
|
+
if not _same(_get(key, "kid"), "%s#%s" % (_show(_get(machine, "id")), _show(_get(key, "use")))):
|
|
764
844
|
flag("%s/deviceKeys/%d/kid" % (at, k), "a device key is listed as <machine id>#sig or <machine id>#kem, by its role")
|
|
765
845
|
if not _valid_device_key(key):
|
|
766
846
|
flag("%s/deviceKeys/%d/publicKey" % (at, k), "an Ed25519 key is never of small order (DEC-0045) and a P-256 key is a valid point (SEC 1 §3.2.2)")
|
|
@@ -770,7 +850,7 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
770
850
|
hub = _get(machine, "hubKey")
|
|
771
851
|
if isinstance(hub, dict):
|
|
772
852
|
if _contains(hub_kids, _get(hub, "kid")):
|
|
773
|
-
flag("%s/hubKey/kid" % at, "hub key %s is certified for two machines: one kid per machine" %
|
|
853
|
+
flag("%s/hubKey/kid" % at, "hub key %s is certified for two machines: one kid per machine" % _show(_get(hub, "kid")))
|
|
774
854
|
hub_kids.append(_get(hub, "kid"))
|
|
775
855
|
if _contains(hub_keys, _get(hub, "x")):
|
|
776
856
|
flag("%s/hubKey/x" % at, "one hub public key is certified for two machines under two kids: one key per machine")
|
|
@@ -780,49 +860,55 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
780
860
|
if _delegation.is_small_order_key(raw):
|
|
781
861
|
flag("%s/hubKey/x" % at, "a hub key of small order verifies forged tokens and is never certified (DEC-0045)")
|
|
782
862
|
credentials: List[Any] = []
|
|
783
|
-
for
|
|
863
|
+
for authority, index in _indexed(_get(value, "authorities")):
|
|
784
864
|
if _contains(credentials, _get(authority, "credentialId")):
|
|
785
865
|
flag("/authorities/%d/credentialId" % index, "a passkey is listed once")
|
|
786
866
|
credentials.append(_get(authority, "credentialId"))
|
|
787
|
-
if "problem" in credential_key(_get(authority, "publicKey"),
|
|
867
|
+
if "problem" in credential_key(_get(authority, "publicKey"), _show(_get(authority, "alg"))):
|
|
788
868
|
flag("/authorities/%d/publicKey" % index, "a passkey's public key is a valid P-256 point for ES256 or an Ed25519 key of full order for EdDSA")
|
|
789
869
|
|
|
790
870
|
|
|
791
871
|
def _placements(value: Dict[str, Any], flag) -> None:
|
|
792
872
|
kids: List[Any] = []
|
|
793
|
-
for
|
|
873
|
+
for key, index in _indexed(_get(value, "publisherKeys")):
|
|
794
874
|
if _contains(kids, _get(key, "kid")):
|
|
795
|
-
flag("/publisherKeys/%d/kid" % index, "publisher key %s is pinned twice" %
|
|
875
|
+
flag("/publisherKeys/%d/kid" % index, "publisher key %s is pinned twice" % _show(_get(key, "kid")))
|
|
796
876
|
kids.append(_get(key, "kid"))
|
|
797
|
-
for
|
|
798
|
-
agents: List[
|
|
799
|
-
for
|
|
877
|
+
for machine, m in _indexed(_get(value, "machines")):
|
|
878
|
+
agents: List[str] = []
|
|
879
|
+
for placement, p in _indexed(_get(machine, "packages")):
|
|
800
880
|
at = "/machines/%d/packages/%d" % (m, p)
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
881
|
+
key = _key_of(_get(placement, "agentRef"), _context_of(placement))
|
|
882
|
+
if key in agents:
|
|
883
|
+
flag("%s/agentRef" % at, "%s is placed twice on one machine in the context %s"
|
|
884
|
+
% (_show(_get(placement, "agentRef")), _show(_context_of(placement))))
|
|
885
|
+
agents.append(key)
|
|
804
886
|
if not _contains(kids, _get(placement, "publisherKid")):
|
|
805
|
-
flag("%s/publisherKid" % at, "publisher key %s is not in publisherKeys, so the machine pins no key to verify the package under" %
|
|
887
|
+
flag("%s/publisherKid" % at, "publisher key %s is not in publisherKeys, so the machine pins no key to verify the package under" % _show(_get(placement, "publisherKid")))
|
|
806
888
|
|
|
807
889
|
|
|
808
890
|
def _secret_references(value: Dict[str, Any], flag) -> None:
|
|
809
891
|
machine_ids = [_get(m, "id") for m in _list(_get(value, "machines"))]
|
|
810
|
-
for
|
|
811
|
-
placed = [_get(p, "agentRef") for p in _list(_get(machine, "packages"))]
|
|
892
|
+
for machine, m in _indexed(_get(value, "machines")):
|
|
893
|
+
placed = [_key_of(_get(p, "agentRef"), _context_of(p)) for p in _list(_get(machine, "packages"))]
|
|
812
894
|
slots: List[str] = []
|
|
813
|
-
for
|
|
895
|
+
for secret, s in _indexed(_get(machine, "secrets")):
|
|
814
896
|
at = "/machines/%d/secrets/%d" % (m, s)
|
|
815
|
-
|
|
897
|
+
# The message never repeats the value, and no message of this rule repeats a secret placement's context.
|
|
898
|
+
for field in ("slot", "from", "agentRef", "context"):
|
|
816
899
|
text = _get(secret, field)
|
|
817
900
|
if isinstance(text, str) and _secrets.secret_value(text):
|
|
818
901
|
flag("%s/%s" % (at, field), "a secret placement names a slot, never a value: this looks like a credential")
|
|
819
|
-
if
|
|
820
|
-
flag("%s/agentRef" % at, "a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine")
|
|
902
|
+
if _key_of(_get(secret, "agentRef"), _context_of(secret)) not in placed:
|
|
903
|
+
flag("%s/agentRef" % at, "a secret goes only where a placed package declares it (v4 Path 2): this agent is not placed on this machine in the secret's context")
|
|
821
904
|
if not _contains(machine_ids, _get(secret, "from")):
|
|
822
905
|
flag("%s/from" % at, "the slot is filled from a machine of this estate")
|
|
823
|
-
|
|
906
|
+
elif not _same(_context_of(secret), PERSONAL_CONTEXT) and not _same(_get(secret, "from"), _get(machine, "id")):
|
|
907
|
+
# An envelope names no context (DEC-0082): an organization's instance fills its slot on its own machine.
|
|
908
|
+
flag("%s/from" % at, "an organization's instance fills its slot from its own machine's vault, never by an envelope from another machine")
|
|
909
|
+
key = _key_of(_get(secret, "agentRef"), _get(secret, "slot"), _context_of(secret))
|
|
824
910
|
if key in slots:
|
|
825
|
-
flag("%s/slot" % at, "slot %s is placed twice for one agent on one machine" %
|
|
911
|
+
flag("%s/slot" % at, "slot %s is placed twice for one agent in one context on one machine" % _show(_get(secret, "slot")))
|
|
826
912
|
slots.append(key)
|
|
827
913
|
|
|
828
914
|
|
|
@@ -834,12 +920,12 @@ def _grant_key(ident: Any) -> Any:
|
|
|
834
920
|
|
|
835
921
|
def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
836
922
|
grant_ids: List[Any] = []
|
|
837
|
-
for
|
|
923
|
+
for grant, index in _indexed(_get(value, "shareGrants")):
|
|
838
924
|
if _get(grant, "principalKind") != "external":
|
|
839
925
|
flag("/shareGrants/%d/principalKind" % index, "a share grant is to a principal outside the estate: principalKind external")
|
|
840
926
|
ident = _get(_get(grant, "meta"), "id") if isinstance(_get(grant, "meta"), dict) else _MISSING
|
|
841
927
|
if _contains(grant_ids, _grant_key(ident)):
|
|
842
|
-
flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" %
|
|
928
|
+
flag("/shareGrants/%d/meta/id" % index, "grant %s is listed twice" % _show(ident))
|
|
843
929
|
grant_ids.append(_grant_key(ident))
|
|
844
930
|
machines = _list(_get(value, "machines"))
|
|
845
931
|
listed = {
|
|
@@ -852,14 +938,60 @@ def _grants_and_revocations(value: Dict[str, Any], flag) -> None:
|
|
|
852
938
|
"grant": grant_ids,
|
|
853
939
|
}
|
|
854
940
|
seen: List[str] = []
|
|
855
|
-
for
|
|
941
|
+
for revocation, index in _indexed(_get(value, "revocations")):
|
|
856
942
|
ref = _grant_key(_get(revocation, "ref")) if _get(revocation, "kind") == "grant" else _get(revocation, "ref")
|
|
857
|
-
|
|
943
|
+
kind = _show(_get(revocation, "kind"))
|
|
944
|
+
key = "%s %s" % (kind, _show(ref))
|
|
858
945
|
if key in seen:
|
|
859
|
-
flag("/revocations/%d" % index, "the %s %s is revoked twice" % (
|
|
946
|
+
flag("/revocations/%d" % index, "the %s %s is revoked twice" % (kind, _show(_get(revocation, "ref"))))
|
|
860
947
|
seen.append(key)
|
|
861
|
-
if _contains(listed.get(
|
|
862
|
-
flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" %
|
|
948
|
+
if _contains(listed.get(kind, []), ref):
|
|
949
|
+
flag("/revocations/%d/ref" % index, "the record still lists the %s it revokes: revocation outranks addition" % kind)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def grant_context_findings(grant: Any, at: str) -> List[Dict[str, str]]:
|
|
953
|
+
"""FAC-SEM-110 (DEC-0082): a grant to a principal outside the estate (``principalKind: external``, a share grant) is
|
|
954
|
+
used in that principal's organization, so its ``context`` is never ``personal``; absent means not stated."""
|
|
955
|
+
if not isinstance(grant, dict) or _get(grant, "principalKind") != "external" or _get(grant, "context") != PERSONAL_CONTEXT:
|
|
956
|
+
return []
|
|
957
|
+
return [{"code": "FAC-SEM-110", "instancePath": "%s/context" % at,
|
|
958
|
+
"message": "a grant to a principal outside the estate is used in that principal's organization, never in the personal context: "
|
|
959
|
+
"name the organization's org:<orgId>, or leave context out"}]
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _placement_grant_problems(record: Dict[str, Any], placement: Dict[str, Any]) -> List[Tuple[str, str]]:
|
|
963
|
+
"""FAC-SEM-111 (DEC-0082) for one placement: an instance in an organization's context names, in ``grant``, a share
|
|
964
|
+
grant of the same record (matched by the normal form of its id, DEC-0076) whose ``context`` is the placement's, as
|
|
965
|
+
written, and that the record does not revoke; a personal instance names none. Any other context needs a grant, and
|
|
966
|
+
only an ``org:<orgId>`` context can match one, so a malformed context fails closed."""
|
|
967
|
+
import fabric_identifiers # noqa: E402 - beside this file
|
|
968
|
+
context = _context_of(placement)
|
|
969
|
+
grant = _get(placement, "grant")
|
|
970
|
+
if _same(context, PERSONAL_CONTEXT):
|
|
971
|
+
return [] if grant is _MISSING else [("/grant", "a personal instance runs under no share grant: only a placement in an organization's context names one")]
|
|
972
|
+
if not isinstance(grant, str):
|
|
973
|
+
return [("/grant", "an instance placed in the context %s names the share grant it runs under, by its id" % _show(context))]
|
|
974
|
+
ident = fabric_identifiers.normal_identifier(grant)
|
|
975
|
+
grants = [g for g in _list(_get(record, "shareGrants")) if isinstance(_get(g, "meta"), dict)
|
|
976
|
+
and isinstance(_get(g["meta"], "id"), str) and fabric_identifiers.normal_identifier(g["meta"]["id"]) == ident]
|
|
977
|
+
problems: List[Tuple[str, str]] = []
|
|
978
|
+
if not grants:
|
|
979
|
+
problems.append(("/grant", "the record has no share grant %s" % grant))
|
|
980
|
+
elif (parse_context(context) or {}).get("kind") != "org":
|
|
981
|
+
problems.append(("/context", "%s is not an organization's context id (org:<orgId>), so no share grant matches it" % _show(context)))
|
|
982
|
+
elif not any(_same(_get(g, "context"), context) for g in grants):
|
|
983
|
+
problems.append(("/grant", "the share grant %s is not for the context %s: its context must be the placement's, as written" % (grant, _show(context))))
|
|
984
|
+
if any(_get(e, "kind") == "grant" and isinstance(_get(e, "ref"), str) and fabric_identifiers.normal_identifier(e["ref"]) == ident
|
|
985
|
+
for e in _list(_get(record, "revocations"))):
|
|
986
|
+
problems.append(("/grant", "the record revokes the share grant %s, so no instance runs under it" % grant))
|
|
987
|
+
return problems
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _placement_grants(value: Dict[str, Any], flag) -> None:
|
|
991
|
+
for machine, m in _indexed(_get(value, "machines")):
|
|
992
|
+
for placement, p in _indexed(_get(machine, "packages")):
|
|
993
|
+
for suffix, message in _placement_grant_problems(value, placement):
|
|
994
|
+
flag("/machines/%d/packages/%d%s" % (m, p, suffix), message)
|
|
863
995
|
|
|
864
996
|
|
|
865
997
|
def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
@@ -869,7 +1001,10 @@ def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
869
1001
|
_placements(value, _finding(findings, "FAC-SEM-074"))
|
|
870
1002
|
_secret_references(value, _finding(findings, "FAC-SEM-075"))
|
|
871
1003
|
_grants_and_revocations(value, _finding(findings, "FAC-SEM-076"))
|
|
872
|
-
for
|
|
1004
|
+
for grant, index in _indexed(_get(value, "shareGrants")): # FAC-SEM-110 (DEC-0082)
|
|
1005
|
+
findings.extend(grant_context_findings(grant, "/shareGrants/%d" % index))
|
|
1006
|
+
_placement_grants(value, _finding(findings, "FAC-SEM-111"))
|
|
1007
|
+
for authority, index in _indexed(_get(value, "authorities")): # FAC-SEM-092 (DEC-0063)
|
|
873
1008
|
_signing_credential(authority, "/authorities/%d" % index, _finding(findings, "FAC-SEM-092"))
|
|
874
1009
|
return findings
|
|
875
1010
|
|
|
@@ -884,29 +1019,36 @@ def _installation(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
884
1019
|
record = value["record"] if isinstance(_get(value, "record"), dict) else {}
|
|
885
1020
|
pkg = value["package"] if isinstance(_get(value, "package"), dict) else {}
|
|
886
1021
|
machines = _list(_get(record, "machines"))
|
|
887
|
-
machine_index = next((i for
|
|
1022
|
+
machine_index = next((i for m, i in _indexed(_get(record, "machines")) if _same(_get(m, "id"), _get(value, "machine"))), -1)
|
|
888
1023
|
flag074 = _finding(findings, "FAC-SEM-074")
|
|
889
1024
|
if machine_index < 0:
|
|
890
|
-
flag074("/machine", "machine %s is not in the estate record" %
|
|
1025
|
+
flag074("/machine", "machine %s is not in the estate record" % _show(_get(value, "machine")))
|
|
891
1026
|
return findings
|
|
892
|
-
machine = machines[machine_index]
|
|
1027
|
+
machine = record["machines"][machine_index]
|
|
893
1028
|
try:
|
|
894
1029
|
digest: Any = _package_digest(pkg)
|
|
895
1030
|
except TypeError:
|
|
896
1031
|
digest = _MISSING
|
|
897
1032
|
if digest is _MISSING or not _same(digest, _get(pkg, "digest")):
|
|
898
1033
|
flag074("/package/digest", "the package is not the one its digest names: it changed after it was packed")
|
|
899
|
-
|
|
1034
|
+
context = _context_of(value)
|
|
1035
|
+
placement, placement_index = next(((p, i) for p, i in _indexed(_get(machine, "packages"))
|
|
1036
|
+
if _same(_get(p, "agentRef"), _get(pkg, "agentRef")) and _same(_context_of(p), context)), (None, -1))
|
|
900
1037
|
if placement is None:
|
|
901
|
-
flag074("/package/agentRef", "the record places no %s on machine %s
|
|
1038
|
+
flag074("/package/agentRef", "the record places no %s on machine %s in the context %s"
|
|
1039
|
+
% (_show(_get(pkg, "agentRef")), _show(_get(value, "machine")), _show(context)))
|
|
902
1040
|
elif not _same(_get(placement, "digest"), digest):
|
|
903
|
-
flag074("/package", "the record places %s of this agent on this machine, not this package" %
|
|
1041
|
+
flag074("/package", "the record places %s of this agent on this machine in this context, not this package" % _show(_get(placement, "digest")))
|
|
904
1042
|
declared = [_get(slot, "name") for slot in _list(_get(pkg, "secrets"))]
|
|
905
1043
|
flag075 = _finding(findings, "FAC-SEM-075")
|
|
906
|
-
for
|
|
907
|
-
if _same(_get(secret, "agentRef"), _get(pkg, "agentRef")) and not _contains(declared, _get(secret, "slot")):
|
|
1044
|
+
for secret, index in _indexed(_get(machine, "secrets")):
|
|
1045
|
+
if _same(_get(secret, "agentRef"), _get(pkg, "agentRef")) and _same(_context_of(secret), context) and not _contains(declared, _get(secret, "slot")):
|
|
908
1046
|
flag075("/record/machines/%d/secrets/%d/slot" % (machine_index, index),
|
|
909
|
-
"the package declares no slot %s: a secret goes only where a placed package declares it" %
|
|
1047
|
+
"the package declares no slot %s: a secret goes only where a placed package declares it" % _show(_get(secret, "slot")))
|
|
1048
|
+
if placement is not None:
|
|
1049
|
+
flag111 = _finding(findings, "FAC-SEM-111")
|
|
1050
|
+
for suffix, message in _placement_grant_problems(record, placement):
|
|
1051
|
+
flag111("/record/machines/%d/packages/%d%s" % (machine_index, placement_index, suffix), message)
|
|
910
1052
|
return findings
|
|
911
1053
|
|
|
912
1054
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -19,6 +19,7 @@ import shutil
|
|
|
19
19
|
import subprocess
|
|
20
20
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
21
21
|
|
|
22
|
+
import fabric_estate as _estate
|
|
22
23
|
import fabric_identifiers as _ids
|
|
23
24
|
import fabric_secrets as _secrets
|
|
24
25
|
import fabric_service as _fs
|
|
@@ -27,6 +28,10 @@ RESOLUTION_ORDER = ("request", "agent", "binding", "estate")
|
|
|
27
28
|
# The vendor login each subscription is, and the one runner kind it runs in (DEC-0046, ADR-0147 decision 2).
|
|
28
29
|
SUBSCRIPTION_RUNNER = {"claude": "claude-code", "kimi": "kimi-code", "chatgpt": "codex"}
|
|
29
30
|
MAX_PROBE_MS = 5000
|
|
31
|
+
#: DEC-0083: the model class of a model whose vendor publishes its weights openly — descriptive: a policy admits and
|
|
32
|
+
#: excludes nothing by it — and what a lane reads when it reads what a member, a client or the public sends.
|
|
33
|
+
OPEN_VENDOR = "open-vendor"
|
|
34
|
+
USER_CONTENT = "user-content"
|
|
30
35
|
_BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
31
36
|
|
|
32
37
|
|
|
@@ -38,6 +43,35 @@ def _list(value: Any) -> List[Dict[str, Any]]:
|
|
|
38
43
|
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
39
44
|
|
|
40
45
|
|
|
46
|
+
def _indexed(value: Any) -> List[tuple]:
|
|
47
|
+
"""The object items of an array with their index as written, so an instancePath names the item even after a
|
|
48
|
+
non-object (the contract's ``indexed``)."""
|
|
49
|
+
return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _set_key(value: Any) -> Any:
|
|
53
|
+
"""A key with JavaScript's Set semantics for a JSON value: 1 and true, null and absent, are different; two objects
|
|
54
|
+
are never the same member however equal."""
|
|
55
|
+
if value is _MISSING_ID:
|
|
56
|
+
return ("undefined",)
|
|
57
|
+
if value is None:
|
|
58
|
+
return ("null",)
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return ("boolean", value)
|
|
61
|
+
if isinstance(value, (int, float)):
|
|
62
|
+
return ("number", float(value))
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
return ("string", value)
|
|
65
|
+
return ("object", id(value))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_MISSING_ID = object()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _id_of(item: Dict[str, Any]) -> Any:
|
|
72
|
+
return item.get("id", _MISSING_ID)
|
|
73
|
+
|
|
74
|
+
|
|
41
75
|
def _strings(value: Any) -> List[str]:
|
|
42
76
|
return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
|
|
43
77
|
|
|
@@ -75,10 +109,11 @@ def _js_number(value: Any) -> Optional[float]:
|
|
|
75
109
|
return None
|
|
76
110
|
|
|
77
111
|
|
|
78
|
-
def resolve_model_profile(*, catalogue: Any, profiles: Any, choices: Any, policy: Any = None) -> Dict[str, Any]:
|
|
112
|
+
def resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
|
|
79
113
|
"""FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default.
|
|
80
114
|
When the project's policy names `modelClasses`, a model stays only if every class the catalogue gives it is
|
|
81
|
-
allowed;
|
|
115
|
+
allowed; the descriptive class `open-vendor` is not checked (DEC-0083), and a model with no other class does not stay.
|
|
116
|
+
An empty result refuses the launch, naming the policy."""
|
|
82
117
|
choices = choices if isinstance(choices, dict) else {}
|
|
83
118
|
level = next((name for name in RESOLUTION_ORDER if isinstance(choices.get(name), str)), None)
|
|
84
119
|
if level is None:
|
|
@@ -94,7 +129,11 @@ def resolve_model_profile(*, catalogue: Any, profiles: Any, choices: Any, policy
|
|
|
94
129
|
rows = {}
|
|
95
130
|
for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None):
|
|
96
131
|
rows[json.dumps(row.get("id"))] = row
|
|
97
|
-
|
|
132
|
+
def admitted(model: str) -> bool:
|
|
133
|
+
row = rows.get(json.dumps(model))
|
|
134
|
+
admission = [c for c in _strings(row.get("classes")) if c != OPEN_VENDOR] if row is not None else []
|
|
135
|
+
return bool(admission) and all(c in allowed for c in admission)
|
|
136
|
+
within = [m for m in models if admitted(m)]
|
|
98
137
|
if within:
|
|
99
138
|
return {"from": level, "profile": profile_id, "models": within}
|
|
100
139
|
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
@@ -106,25 +145,25 @@ def _finding(code: str, path: str, message: str) -> Dict[str, str]:
|
|
|
106
145
|
|
|
107
146
|
|
|
108
147
|
def _model_catalogue(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
109
|
-
findings, seen = [],
|
|
110
|
-
for
|
|
111
|
-
if row
|
|
112
|
-
findings.append(_finding("FAC-SEM-058", "/models/%d/id" % index, "model %s is catalogued more than once" %
|
|
113
|
-
seen.
|
|
148
|
+
findings, seen = [], set()
|
|
149
|
+
for row, index in _indexed(value.get("models")):
|
|
150
|
+
if _set_key(_id_of(row)) in seen:
|
|
151
|
+
findings.append(_finding("FAC-SEM-058", "/models/%d/id" % index, "model %s is catalogued more than once" % _show_id(row)))
|
|
152
|
+
seen.add(_set_key(_id_of(row)))
|
|
114
153
|
routing = row.get("provider") if isinstance(row.get("provider"), dict) else {}
|
|
115
154
|
ignored = set(_strings(routing.get("ignore")))
|
|
116
155
|
for slug in _strings(routing.get("order")):
|
|
117
156
|
if slug in ignored or slug.split("/")[0] in ignored:
|
|
118
|
-
findings.append(_finding("FAC-SEM-058", "/models/%d/provider" % index, "model %s both orders and ignores provider %s" % (
|
|
157
|
+
findings.append(_finding("FAC-SEM-058", "/models/%d/provider" % index, "model %s both orders and ignores provider %s" % (_show_id(row), slug)))
|
|
119
158
|
return findings
|
|
120
159
|
|
|
121
160
|
|
|
122
161
|
def _model_profiles(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
123
|
-
findings, seen = [],
|
|
124
|
-
for
|
|
125
|
-
if profile
|
|
126
|
-
findings.append(_finding("FAC-SEM-058", "/profiles/%d/id" % index, "model profile %s is declared more than once" %
|
|
127
|
-
seen.
|
|
162
|
+
findings, seen = [], set()
|
|
163
|
+
for profile, index in _indexed(value.get("profiles")):
|
|
164
|
+
if _set_key(_id_of(profile)) in seen:
|
|
165
|
+
findings.append(_finding("FAC-SEM-058", "/profiles/%d/id" % index, "model profile %s is declared more than once" % _show_id(profile)))
|
|
166
|
+
seen.add(_set_key(_id_of(profile)))
|
|
128
167
|
return findings
|
|
129
168
|
|
|
130
169
|
|
|
@@ -154,14 +193,15 @@ def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
154
193
|
findings = []
|
|
155
194
|
catalogue = value.get("catalogue")
|
|
156
195
|
catalogued = [row.get("id") for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)]
|
|
157
|
-
ids
|
|
158
|
-
for
|
|
159
|
-
if profile
|
|
160
|
-
findings.append(_finding("FAC-SEM-060", "/profiles/%d/id" % i, "profile id %s is offered more than once to one resolution" %
|
|
161
|
-
ids.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
196
|
+
ids = set()
|
|
197
|
+
for profile, i in _indexed(value.get("profiles")):
|
|
198
|
+
if _set_key(_id_of(profile)) in ids:
|
|
199
|
+
findings.append(_finding("FAC-SEM-060", "/profiles/%d/id" % i, "profile id %s is offered more than once to one resolution" % _show_id(profile)))
|
|
200
|
+
ids.add(_set_key(_id_of(profile)))
|
|
201
|
+
# Every model by its index as written; only a string can name a catalogued model.
|
|
202
|
+
for j, model in enumerate(profile.get("models") if isinstance(profile.get("models"), list) else []):
|
|
203
|
+
if isinstance(model, str) and model not in catalogued:
|
|
204
|
+
findings.append(_finding("FAC-SEM-060", "/profiles/%d/models/%d" % (i, j), "profile %s names %s, which the catalogue does not hold" % (_show_id(profile), model)))
|
|
165
205
|
choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
|
|
166
206
|
expected = resolve_model_profile(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
|
|
167
207
|
if "resolved" not in value or value["resolved"] != expected:
|
|
@@ -169,11 +209,69 @@ def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
169
209
|
return findings
|
|
170
210
|
|
|
171
211
|
|
|
212
|
+
def closed_user_content_route(profile: Any, catalogue: Any) -> Optional[List[str]]:
|
|
213
|
+
"""FAC-SEM-112 (DEC-0083) for one profile: the models that run when the lane reads user content and none of them is
|
|
214
|
+
an open-vendor model, or None when the profile does not read user content or its route has one. Under
|
|
215
|
+
``between-turns`` every model may run; under ``none``, or any other value (failing closed), only the first. A model
|
|
216
|
+
the catalogue does not hold, or whose row is not ``open-vendor``, counts as closed. Never raises."""
|
|
217
|
+
if not isinstance(profile, dict) or USER_CONTENT not in _strings(profile.get("reads")):
|
|
218
|
+
return None
|
|
219
|
+
models = profile.get("models") if isinstance(profile.get("models"), list) else []
|
|
220
|
+
route = models if profile.get("fallback") == "between-turns" else models[:1]
|
|
221
|
+
open_ids = {row["id"] for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)
|
|
222
|
+
if isinstance(row.get("id"), str) and OPEN_VENDOR in _strings(row.get("classes"))}
|
|
223
|
+
if any(isinstance(model, str) and model in open_ids for model in route):
|
|
224
|
+
return None
|
|
225
|
+
return [_estate._show(model) for model in route]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _closed_route(fallback: Any, closed: List[str]) -> str:
|
|
229
|
+
"""How a closed route is named in a finding: under ``between-turns`` all of it, otherwise its first model."""
|
|
230
|
+
return "none of its models is" if fallback == "between-turns" else "its first model, %s, is not" % (closed[0] if closed else "none")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _open_vendor_routes(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
234
|
+
"""FAC-SEM-112: a profile that reads user content has an open-vendor route, so the lane never runs on closed vendors
|
|
235
|
+
alone, which refuse the content it exists to handle. The resolution's outcome is held to the same test: when the
|
|
236
|
+
resolved profile reads user content and its own route is open, the models the policy leaves must still give one,
|
|
237
|
+
or the policy is named as what leaves the lane closed (one cause, one finding)."""
|
|
238
|
+
findings = []
|
|
239
|
+
profiles = _list(value.get("profiles"))
|
|
240
|
+
for profile, i in _indexed(value.get("profiles")):
|
|
241
|
+
closed = closed_user_content_route(profile, value.get("catalogue"))
|
|
242
|
+
if closed is None:
|
|
243
|
+
continue
|
|
244
|
+
findings.append(_finding("FAC-SEM-112", "/profiles/%d/models" % i, "profile %s reads user content, but %s open-vendor in "
|
|
245
|
+
"the catalogue: the lane would run on closed vendors alone" % (_show_id(profile), _closed_route(profile.get("fallback"), closed))))
|
|
246
|
+
choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
|
|
247
|
+
outcome = resolve_model_profile(catalogue=value.get("catalogue"), profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
|
|
248
|
+
if "models" not in outcome:
|
|
249
|
+
return findings
|
|
250
|
+
profile = next((item for item in profiles if item.get("id") == outcome["profile"]), None) # a string: == is ===
|
|
251
|
+
if profile is None or closed_user_content_route(profile, value.get("catalogue")) is not None:
|
|
252
|
+
return findings
|
|
253
|
+
closed = closed_user_content_route(dict(profile, models=outcome["models"]), value.get("catalogue"))
|
|
254
|
+
if closed is None:
|
|
255
|
+
return findings
|
|
256
|
+
policy = value.get("policy")
|
|
257
|
+
meta = policy.get("meta") if isinstance(policy, dict) and isinstance(policy.get("meta"), dict) else {}
|
|
258
|
+
findings.append(_finding("FAC-SEM-112", "/resolved/models", "the policy %s leaves profile %s's lane on closed vendors alone: of the "
|
|
259
|
+
"models it admits, %s open-vendor in the catalogue" % (_estate._show(meta["id"]) if "id" in meta else "undefined",
|
|
260
|
+
_show_id(profile), _closed_route(profile.get("fallback"), closed))))
|
|
261
|
+
return findings
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _show_id(profile: Dict[str, Any]) -> str:
|
|
266
|
+
"""show(profile.id), where an absent id is JavaScript's undefined."""
|
|
267
|
+
return _estate._show(profile["id"]) if "id" in profile else "undefined"
|
|
268
|
+
|
|
269
|
+
|
|
172
270
|
def profile_problems(kind: str, value: Dict[str, Any]) -> Optional[List[Dict[str, str]]]:
|
|
173
271
|
"""The contract's profileRules: findings for a `model-catalogue`, `model-profiles`, `runner-profile` or
|
|
174
272
|
`profile-resolution` document; None for another kind."""
|
|
175
273
|
return {"model-catalogue": _model_catalogue, "model-profiles": _model_profiles, "runner-profile": _runner_profile,
|
|
176
|
-
"profile-resolution": _profile_resolution}.get(kind, lambda v: None)(value)
|
|
274
|
+
"profile-resolution": lambda v: _profile_resolution(v) + _open_vendor_routes(v)}.get(kind, lambda v: None)(value)
|
|
177
275
|
|
|
178
276
|
|
|
179
277
|
# --- the doctor's checks ----------------------------------------------------------------------------------------
|
|
@@ -253,9 +351,12 @@ def model_usable(model: str, catalogue: Any, policy: Any = None) -> List[str]:
|
|
|
253
351
|
return ["model %s is not in the model catalogue" % model]
|
|
254
352
|
if isinstance(policy, dict) and isinstance(policy.get("modelClasses"), list):
|
|
255
353
|
allowed = set(_strings(policy["modelClasses"]))
|
|
256
|
-
|
|
354
|
+
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
355
|
+
admission = [c for c in _strings(rows[model].get("classes")) if c != OPEN_VENDOR] # DEC-0083: descriptive
|
|
356
|
+
if not admission:
|
|
357
|
+
return ["model %s has no class but %s, which admits nothing, so policy %s does not allow it" % (model, OPEN_VENDOR, _js_string(meta.get("id")))]
|
|
358
|
+
outside = [c for c in admission if c not in allowed]
|
|
257
359
|
if outside:
|
|
258
|
-
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
259
360
|
return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside), _js_string(meta.get("id")))]
|
|
260
361
|
return []
|
|
261
362
|
|