@passioncode-ai/fabric-agent-adapter 0.30.0 → 0.31.1
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 +52 -0
- package/README.md +12 -0
- package/bin/fabric-agent-adapter.js +16 -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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +590 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.schema.json +631 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +824 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- 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_package.py
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""``fabric-package/1`` (contract DEC-0050, DEC-0051, DEC-0071, DEC-0074; FAA-27a): pack an agent or a skill set as one
|
|
3
|
+
folder with ``fabric.package.json``, sign it, and verify it. The twin of ``fabric-package.mjs``.
|
|
4
|
+
|
|
5
|
+
python3 fabric_package.py pack <folder> [--apply]
|
|
6
|
+
python3 fabric_package.py sign <folder> --key <private key file> [--kid <kid>] [--role publisher|organization] [--apply]
|
|
7
|
+
python3 fabric_package.py verify <folder> --trust <trust.json> [--countersigned]
|
|
8
|
+
|
|
9
|
+
``pack`` reads the author's ``fabric.package.json`` (the descriptive fields), lists every other file of the folder with
|
|
10
|
+
its SHA-256, derives ``agentRef``, computes the digest and checks the result against the contract's schema, its rules
|
|
11
|
+
(FAC-SEM-064…066, FAC-SEM-102) and the folder check. ``sign`` adds an Ed25519 signature over the digest (``EdDSA``,
|
|
12
|
+
the one algorithm DEC-0051 admits) from a key file the caller names; it never makes or keeps a key. ``verify`` is the
|
|
13
|
+
contract's ``verifyPackage`` (PK-1…PK-10) plus the folder check. Without ``--apply`` nothing is written. Standard
|
|
14
|
+
library only; the answer is JSON on stdout, exit 0 when it holds, 1 when it does not, 2 on a usage error.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
|
+
import fabric_delegation as fd # noqa: E402
|
|
30
|
+
from fabric_secrets import secret_argument, secret_value # noqa: E402
|
|
31
|
+
|
|
32
|
+
PACKAGE_FILE = "fabric.package.json"
|
|
33
|
+
PLUGIN_MANIFEST = ".claude-plugin/plugin.json"
|
|
34
|
+
SIGNATURE_STATEMENT = "fabric-package-signature/1"
|
|
35
|
+
# DEC-0051: ES256 joins when Fabric records its decision D2; until then a verifier refuses it as unknown-algorithm.
|
|
36
|
+
PACKAGE_ALGORITHMS = ("EdDSA",)
|
|
37
|
+
SCHEMA_PATH = Path(__file__).resolve().parent / "fabric-package.schema.json"
|
|
38
|
+
# What pack refuses to list: version-control and cache litter, and files that are credentials by their name.
|
|
39
|
+
JUNK = re.compile(r"^(\.git|__pycache__|\.DS_Store|.*\.pyc)$")
|
|
40
|
+
SECRET_FILE = re.compile(r"^(\.env(\..+)?|.*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.(npmrc|netrc|pypirc))$")
|
|
41
|
+
SECRET_FILE_OK = re.compile(r"^\.env\.(example|sample|template)$")
|
|
42
|
+
_B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
43
|
+
_PKCS8_ED25519 = bytes.fromhex("302e020100300506032b657004220420")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PackageError(Exception):
|
|
47
|
+
"""A refusal a person can act on; the message names what to change."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- digest, statement, identity ------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def agent_ref_of(publisher: str, id_: str) -> str:
|
|
53
|
+
"""``urn:fabric:agent:<publisher>:<id>``: one agent across its versions, derived and never chosen (FAC-SEM-066)."""
|
|
54
|
+
return "urn:fabric:agent:%s:%s" % (publisher, id_)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sha256_of(data: Any) -> str:
|
|
58
|
+
"""``sha256:`` and the hex SHA-256 of some bytes (a str is taken as UTF-8)."""
|
|
59
|
+
if isinstance(data, str):
|
|
60
|
+
data = data.encode("utf-8")
|
|
61
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def package_digest(pkg: Dict[str, Any]) -> str:
|
|
65
|
+
"""SHA-256 over the UTF-8 RFC 8785 form of the package without ``digest`` and ``signatures`` (DEC-0050).
|
|
66
|
+
Raises TypeError for a value with no RFC 8785 form."""
|
|
67
|
+
covered = {k: v for k, v in pkg.items() if k not in ("digest", "signatures")}
|
|
68
|
+
return sha256_of(fd.jcs(covered))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def package_signing_input(digest: str, role: str) -> bytes:
|
|
72
|
+
"""The bytes a package signature covers (DEC-0051); the role is inside, so no signature stands for another role."""
|
|
73
|
+
return fd.jcs({"type": SIGNATURE_STATEMENT, "role": role, "digest": digest}).encode("utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- Ed25519 signing (RFC 8032 §5.1.6) ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _secret_scalar(seed: bytes) -> Tuple[int, bytes]:
|
|
79
|
+
h = hashlib.sha512(seed).digest()
|
|
80
|
+
a = int.from_bytes(h[:32], "little")
|
|
81
|
+
a &= (1 << 254) - 8
|
|
82
|
+
a |= 1 << 254
|
|
83
|
+
return a, h[32:]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ed25519_public_key(seed: bytes) -> bytes:
|
|
87
|
+
"""The 32-byte public key of a 32-byte Ed25519 seed."""
|
|
88
|
+
if len(seed) != 32:
|
|
89
|
+
raise PackageError("An Ed25519 private key is 32 bytes.")
|
|
90
|
+
a, _ = _secret_scalar(seed)
|
|
91
|
+
return fd._encode_point(fd._mul(a, fd._G))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ed25519_sign(seed: bytes, message: bytes) -> bytes:
|
|
95
|
+
"""A deterministic Ed25519 signature; the same seed and message give the bytes Node's crypto.sign gives."""
|
|
96
|
+
a, prefix = _secret_scalar(seed)
|
|
97
|
+
public = fd._encode_point(fd._mul(a, fd._G))
|
|
98
|
+
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % fd._L
|
|
99
|
+
big_r = fd._encode_point(fd._mul(r, fd._G))
|
|
100
|
+
k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % fd._L
|
|
101
|
+
return big_r + ((r + k * a) % fd._L).to_bytes(32, "little")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _b64url(data: bytes) -> str:
|
|
105
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def public_jwk(seed: bytes, kid: str) -> Dict[str, str]:
|
|
109
|
+
"""The public JWK an installing machine pins for this key (the package names a publisher, never a key)."""
|
|
110
|
+
return {"kty": "OKP", "crv": "Ed25519", "x": _b64url(ed25519_public_key(seed)), "kid": kid, "alg": "EdDSA", "use": "sig"}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
|
|
114
|
+
"""``(seed, kid)`` from a private key file: a JWK (``kty`` OKP, ``crv`` Ed25519, ``d``; ``kid`` and ``alg``
|
|
115
|
+
EdDSA if named) or a PKCS#8 PEM (``openssl genpkey -algorithm ed25519``, then ``--kid``). The file must be readable
|
|
116
|
+
by its owner only; its bytes are never echoed."""
|
|
117
|
+
path = Path(path)
|
|
118
|
+
try:
|
|
119
|
+
stat = path.stat()
|
|
120
|
+
raw = path.read_bytes()
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
raise PackageError("Cannot read the key file %s: %s." % (path, exc.strerror)) from None
|
|
123
|
+
if os.name == "posix" and stat.st_mode & 0o077:
|
|
124
|
+
raise PackageError("The key file %s is readable by others (mode %o); chmod 600 it first." % (path, stat.st_mode & 0o777))
|
|
125
|
+
text = raw.decode("utf-8", "replace").strip()
|
|
126
|
+
if text.startswith("{"):
|
|
127
|
+
try:
|
|
128
|
+
jwk = json.loads(text)
|
|
129
|
+
except ValueError:
|
|
130
|
+
raise PackageError("The key file is neither a JWK nor a PEM private key.") from None
|
|
131
|
+
if not isinstance(jwk, dict) or jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(jwk.get("d"), str):
|
|
132
|
+
raise PackageError("The key file must be an Ed25519 private JWK (kty OKP, crv Ed25519, with d).")
|
|
133
|
+
if jwk.get("alg", "EdDSA") != "EdDSA":
|
|
134
|
+
raise PackageError("The key names alg %r; a package signature is EdDSA (DEC-0051)." % jwk.get("alg"))
|
|
135
|
+
seed = fd._b64decode(jwk["d"]) if _B64URL.match(jwk["d"]) else b""
|
|
136
|
+
if len(seed) != 32:
|
|
137
|
+
raise PackageError("The key's d is not a 32-byte Ed25519 private key.")
|
|
138
|
+
if isinstance(jwk.get("x"), str) and jwk["x"] != _b64url(ed25519_public_key(seed)):
|
|
139
|
+
raise PackageError("The key's x is not the public key of its d.")
|
|
140
|
+
named = jwk.get("kid")
|
|
141
|
+
if kid is not None and named is not None and kid != named:
|
|
142
|
+
raise PackageError("--kid %s disagrees with the key's own kid %s." % (kid, named))
|
|
143
|
+
kid = kid if kid is not None else named
|
|
144
|
+
elif "-----BEGIN " + "PRIVATE KEY-----" in text: # split, so a secret scanner never reads this line as a key
|
|
145
|
+
body = "".join(line for line in text.splitlines() if line and not line.startswith("-----"))
|
|
146
|
+
try:
|
|
147
|
+
der = base64.b64decode(body, validate=True)
|
|
148
|
+
except ValueError:
|
|
149
|
+
raise PackageError("The PEM key is not base64.") from None
|
|
150
|
+
if len(der) != 48 or not der.startswith(_PKCS8_ED25519):
|
|
151
|
+
raise PackageError("The PEM key is not an Ed25519 private key (PKCS#8).")
|
|
152
|
+
seed = der[16:]
|
|
153
|
+
else:
|
|
154
|
+
raise PackageError("The key file is neither a JWK nor a PEM private key.")
|
|
155
|
+
if not isinstance(kid, str) or not re.match(r"^[A-Za-z0-9._:-]{1,128}$", kid):
|
|
156
|
+
raise PackageError("Name the key with --kid (1-128 of A-Z a-z 0-9 . _ : -): the installing machine pins it by that name.")
|
|
157
|
+
return seed, kid
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- the schema (bundled from the contract at the pin) ------------------------------------------------
|
|
161
|
+
|
|
162
|
+
_SCHEMA_CACHE: Dict[str, Any] = {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _schema() -> Dict[str, Any]:
|
|
166
|
+
if "s" not in _SCHEMA_CACHE:
|
|
167
|
+
_SCHEMA_CACHE["s"] = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
168
|
+
return _SCHEMA_CACHE["s"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _json_equal(a: Any, b: Any) -> bool:
|
|
172
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
173
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
174
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
175
|
+
return a == b
|
|
176
|
+
if isinstance(a, list) and isinstance(b, list):
|
|
177
|
+
return len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
|
|
178
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
179
|
+
return a.keys() == b.keys() and all(_json_equal(a[k], b[k]) for k in a)
|
|
180
|
+
return type(a) is type(b) and a == b
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _type_of(value: Any) -> str:
|
|
184
|
+
if value is None:
|
|
185
|
+
return "null"
|
|
186
|
+
if isinstance(value, bool):
|
|
187
|
+
return "boolean"
|
|
188
|
+
if isinstance(value, int) or (isinstance(value, float) and value.is_integer()):
|
|
189
|
+
return "integer"
|
|
190
|
+
if isinstance(value, float):
|
|
191
|
+
return "number"
|
|
192
|
+
if isinstance(value, str):
|
|
193
|
+
return "string"
|
|
194
|
+
if isinstance(value, list):
|
|
195
|
+
return "array"
|
|
196
|
+
return "object"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_DATE_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$")
|
|
200
|
+
_URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[^\s]*$")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _format_ok(fmt: str, value: Any) -> bool:
|
|
204
|
+
if not isinstance(value, str):
|
|
205
|
+
return True
|
|
206
|
+
if fmt == "date-time":
|
|
207
|
+
if not _DATE_TIME.match(value):
|
|
208
|
+
return False
|
|
209
|
+
try:
|
|
210
|
+
datetime.strptime(value[:19].replace("t", "T"), "%Y-%m-%dT%H:%M:%S")
|
|
211
|
+
except ValueError:
|
|
212
|
+
return False
|
|
213
|
+
offset = value[19:].lstrip(".0123456789")
|
|
214
|
+
return offset in ("Z", "z") or (int(offset[1:3]) < 24 and int(offset[4:6]) < 60)
|
|
215
|
+
if fmt == "uri":
|
|
216
|
+
return bool(_URI.match(value))
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _pointer(token: Any) -> str:
|
|
221
|
+
return str(token).replace("~", "~0").replace("/", "~1")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _validate(schema: Any, value: Any, at: str, out: List[Dict[str, str]]) -> None:
|
|
225
|
+
"""The JSON Schema 2020-12 keywords the package schema uses, nothing more."""
|
|
226
|
+
if schema is True:
|
|
227
|
+
return
|
|
228
|
+
if schema is False:
|
|
229
|
+
out.append({"instancePath": at, "message": "is not allowed here"})
|
|
230
|
+
return
|
|
231
|
+
if "$ref" in schema:
|
|
232
|
+
target = _schema()
|
|
233
|
+
for token in schema["$ref"].lstrip("#/").split("/"):
|
|
234
|
+
target = target[token.replace("~1", "/").replace("~0", "~")]
|
|
235
|
+
_validate(target, value, at, out)
|
|
236
|
+
kind = _type_of(value)
|
|
237
|
+
if "type" in schema:
|
|
238
|
+
allowed = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
|
|
239
|
+
if not (kind in allowed or (kind == "integer" and "number" in allowed)):
|
|
240
|
+
out.append({"instancePath": at, "message": "must be %s" % " or ".join(allowed)})
|
|
241
|
+
return
|
|
242
|
+
if "const" in schema and not _json_equal(schema["const"], value):
|
|
243
|
+
out.append({"instancePath": at, "message": "must be %s" % json.dumps(schema["const"])})
|
|
244
|
+
if "enum" in schema and not any(_json_equal(item, value) for item in schema["enum"]):
|
|
245
|
+
out.append({"instancePath": at, "message": "must be one of %s" % ", ".join(json.dumps(i) for i in schema["enum"])})
|
|
246
|
+
if kind == "string":
|
|
247
|
+
length = len(value) # code points, as JSON Schema counts
|
|
248
|
+
if length < schema.get("minLength", 0):
|
|
249
|
+
out.append({"instancePath": at, "message": "is shorter than %d" % schema["minLength"]})
|
|
250
|
+
if "maxLength" in schema and length > schema["maxLength"]:
|
|
251
|
+
out.append({"instancePath": at, "message": "is longer than %d" % schema["maxLength"]})
|
|
252
|
+
if "pattern" in schema and not re.search(schema["pattern"], value):
|
|
253
|
+
out.append({"instancePath": at, "message": "does not match %s" % schema["pattern"]})
|
|
254
|
+
if "format" in schema and not _format_ok(schema["format"], value):
|
|
255
|
+
out.append({"instancePath": at, "message": "is not a %s" % schema["format"]})
|
|
256
|
+
if kind in ("integer", "number") and "minimum" in schema and value < schema["minimum"]:
|
|
257
|
+
out.append({"instancePath": at, "message": "is below %s" % schema["minimum"]})
|
|
258
|
+
if kind == "array":
|
|
259
|
+
if len(value) < schema.get("minItems", 0):
|
|
260
|
+
out.append({"instancePath": at, "message": "has fewer than %d items" % schema["minItems"]})
|
|
261
|
+
if "maxItems" in schema and len(value) > schema["maxItems"]:
|
|
262
|
+
out.append({"instancePath": at, "message": "has more than %d items" % schema["maxItems"]})
|
|
263
|
+
if schema.get("uniqueItems") and any(_json_equal(value[i], value[j]) for i in range(len(value)) for j in range(i)):
|
|
264
|
+
out.append({"instancePath": at, "message": "has duplicate items"})
|
|
265
|
+
if "items" in schema:
|
|
266
|
+
for index, item in enumerate(value):
|
|
267
|
+
_validate(schema["items"], item, "%s/%d" % (at, index), out)
|
|
268
|
+
if "contains" in schema:
|
|
269
|
+
matches = sum(1 for item in value if not _problems(schema["contains"], item))
|
|
270
|
+
if matches < schema.get("minContains", 1):
|
|
271
|
+
out.append({"instancePath": at, "message": "has fewer than %d matching items" % schema.get("minContains", 1)})
|
|
272
|
+
if "maxContains" in schema and matches > schema["maxContains"]:
|
|
273
|
+
out.append({"instancePath": at, "message": "has more than %d matching items" % schema["maxContains"]})
|
|
274
|
+
if kind == "object":
|
|
275
|
+
for name in schema.get("required", []):
|
|
276
|
+
if name not in value:
|
|
277
|
+
out.append({"instancePath": at, "message": "must have %s" % name})
|
|
278
|
+
properties = schema.get("properties", {})
|
|
279
|
+
for name, member in value.items():
|
|
280
|
+
if name in properties:
|
|
281
|
+
_validate(properties[name], member, "%s/%s" % (at, _pointer(name)), out)
|
|
282
|
+
elif "additionalProperties" in schema:
|
|
283
|
+
if schema["additionalProperties"] is False:
|
|
284
|
+
out.append({"instancePath": "%s/%s" % (at, _pointer(name)), "message": "is not a member of this object"})
|
|
285
|
+
else:
|
|
286
|
+
_validate(schema["additionalProperties"], member, "%s/%s" % (at, _pointer(name)), out)
|
|
287
|
+
if "propertyNames" in schema:
|
|
288
|
+
_validate(schema["propertyNames"], name, "%s/%s" % (at, _pointer(name)), out)
|
|
289
|
+
for sub in schema.get("allOf", []):
|
|
290
|
+
_validate(sub, value, at, out)
|
|
291
|
+
if "not" in schema and not _problems(schema["not"], value):
|
|
292
|
+
out.append({"instancePath": at, "message": "must not match"})
|
|
293
|
+
if "if" in schema:
|
|
294
|
+
branch = schema.get("then") if not _problems(schema["if"], value) else schema.get("else")
|
|
295
|
+
if branch is not None:
|
|
296
|
+
_validate(branch, value, at, out)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _problems(schema: Any, value: Any) -> List[Dict[str, str]]:
|
|
300
|
+
out: List[Dict[str, str]] = []
|
|
301
|
+
_validate(schema, value, "", out)
|
|
302
|
+
return out
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def schema_problems(pkg: Any) -> List[Dict[str, str]]:
|
|
306
|
+
"""Where the package breaks ``fabric-package.schema.json`` (the contract's, bundled at the pin)."""
|
|
307
|
+
return _problems(_schema(), pkg)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# --- the contract's rules (FAC-SEM-064…066, FAC-SEM-102) ------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def _objects(value: Any) -> List[Dict[str, Any]]:
|
|
313
|
+
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _mcp(value: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
317
|
+
requires = value.get("requires")
|
|
318
|
+
return _objects(requires.get("mcp")) if isinstance(requires, dict) else []
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _whole(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
322
|
+
out: List[Dict[str, str]] = []
|
|
323
|
+
|
|
324
|
+
def flag(at: str, message: str) -> None:
|
|
325
|
+
out.append({"code": "FAC-SEM-064", "instancePath": at, "message": message})
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
digest = package_digest(value)
|
|
329
|
+
if digest != value.get("digest"):
|
|
330
|
+
flag("/digest", "the digest is %s: the package changed after it was packed, or was packed over another form than RFC 8785" % digest)
|
|
331
|
+
except TypeError:
|
|
332
|
+
flag("/digest", "the package has no RFC 8785 form, so it has no digest")
|
|
333
|
+
sha: Dict[str, Any] = {}
|
|
334
|
+
for index, entry in enumerate(_objects(value.get("files"))):
|
|
335
|
+
path = _js_string(entry.get("path", _UNDEFINED))
|
|
336
|
+
if path in sha:
|
|
337
|
+
flag("/files/%d/path" % index, "%s is listed twice" % path)
|
|
338
|
+
if path == PACKAGE_FILE:
|
|
339
|
+
flag("/files/%d/path" % index, "%s carries the digest and is never listed in files[]" % PACKAGE_FILE)
|
|
340
|
+
sha[path] = entry.get("sha256", _UNDEFINED)
|
|
341
|
+
|
|
342
|
+
def named(at: str, path: Any, what: str) -> None:
|
|
343
|
+
if isinstance(path, str) and path not in sha:
|
|
344
|
+
flag(at, "%s %s is not listed in files[], so the digest does not cover it" % (what, path))
|
|
345
|
+
|
|
346
|
+
named("/provider", value.get("provider"), "the provider manifest")
|
|
347
|
+
service = value.get("service")
|
|
348
|
+
if isinstance(service, dict) and isinstance(service.get("entry"), list) and service["entry"]:
|
|
349
|
+
named("/service/entry/0", service["entry"][0], "the service's entry")
|
|
350
|
+
evals = value.get("evals")
|
|
351
|
+
if isinstance(evals, dict):
|
|
352
|
+
named("/evals/suite", evals.get("suite"), "the evals suite")
|
|
353
|
+
receipt = evals.get("receipt") if isinstance(evals.get("receipt"), dict) else None
|
|
354
|
+
suite = evals.get("suite")
|
|
355
|
+
if receipt is not None and isinstance(suite, str) and suite in sha and not _same_member(receipt, "suite", sha[suite]):
|
|
356
|
+
flag("/evals/receipt/suite", "the receipt was written for another suite than the one packed")
|
|
357
|
+
for index, dependency in enumerate(_objects(value.get("dependencies"))):
|
|
358
|
+
path = dependency.get("path")
|
|
359
|
+
if not isinstance(path, str):
|
|
360
|
+
continue
|
|
361
|
+
named("/dependencies/%d/path" % index, path, "the vendored dependency")
|
|
362
|
+
if path in sha and not _same_member(dependency, "sha256", sha[path]):
|
|
363
|
+
flag("/dependencies/%d/sha256" % index, "the vendored %s is not the file packed at %s" % (_js_string(dependency.get("name")), path))
|
|
364
|
+
seen: List[Any] = []
|
|
365
|
+
for index, entry in enumerate(_objects(value.get("state"))):
|
|
366
|
+
if any(_same_or_absent(entry.get("path", _UNDEFINED), other) for other in seen):
|
|
367
|
+
flag("/state/%d/path" % index, "state %s is declared twice" % _js_string(entry.get("path", _UNDEFINED)))
|
|
368
|
+
seen.append(entry.get("path", _UNDEFINED))
|
|
369
|
+
if value.get("kind") == "skill-set" and PLUGIN_MANIFEST not in sha:
|
|
370
|
+
flag("/files", "a skill set is one Claude Code plugin folder and lists %s" % PLUGIN_MANIFEST)
|
|
371
|
+
if value.get("kind") == "agent" and "provider" not in value and _mcp(value) and PLUGIN_MANIFEST not in sha:
|
|
372
|
+
flag("/files", "a consumer agent is one Claude Code plugin folder and lists %s" % PLUGIN_MANIFEST)
|
|
373
|
+
return out
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
_UNDEFINED = object()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _same_member(obj: Dict[str, Any], name: str, other: Any) -> bool:
|
|
380
|
+
"""JavaScript's ``obj[name] === other`` for JSON values: an absent member equals only an absent one."""
|
|
381
|
+
mine = obj.get(name, _UNDEFINED)
|
|
382
|
+
if mine is _UNDEFINED or other is _UNDEFINED:
|
|
383
|
+
return mine is other
|
|
384
|
+
if isinstance(mine, (dict, list)) or isinstance(other, (dict, list)):
|
|
385
|
+
return mine is other
|
|
386
|
+
return _json_equal(mine, other)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _js_string(value: Any) -> str:
|
|
390
|
+
"""JavaScript's ``String(value)`` for a JSON value, as the contract's messages print it."""
|
|
391
|
+
if value is _UNDEFINED:
|
|
392
|
+
return "undefined"
|
|
393
|
+
if value is None:
|
|
394
|
+
return "null"
|
|
395
|
+
if isinstance(value, bool):
|
|
396
|
+
return "true" if value else "false"
|
|
397
|
+
if isinstance(value, (int, float)):
|
|
398
|
+
return fd.jcs(value)
|
|
399
|
+
if isinstance(value, list):
|
|
400
|
+
return ",".join("" if item is None else _js_string(item) for item in value)
|
|
401
|
+
if isinstance(value, dict):
|
|
402
|
+
return "[object Object]"
|
|
403
|
+
return value
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _no_secret(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
407
|
+
out: List[Dict[str, str]] = []
|
|
408
|
+
|
|
409
|
+
def flag(at: str, message: str) -> None:
|
|
410
|
+
out.append({"code": "FAC-SEM-065", "instancePath": at, "message": message})
|
|
411
|
+
|
|
412
|
+
seen: List[Any] = []
|
|
413
|
+
for index, slot in enumerate(_objects(value.get("secrets"))):
|
|
414
|
+
name = slot.get("name", _UNDEFINED)
|
|
415
|
+
if any(_same_or_absent(name, other) for other in seen):
|
|
416
|
+
flag("/secrets/%d/name" % index, "secret slot %s is declared twice" % _js_string(name))
|
|
417
|
+
seen.append(name)
|
|
418
|
+
for field in ("name", "purpose"): # the message never repeats the value
|
|
419
|
+
text = slot.get(field)
|
|
420
|
+
if isinstance(text, str) and secret_value(text):
|
|
421
|
+
flag("/secrets/%d/%s" % (index, field), "a secret slot is named, never filled: this looks like a credential")
|
|
422
|
+
requires = value.get("requires")
|
|
423
|
+
if isinstance(requires, dict) and isinstance(requires.get("mcp"), list):
|
|
424
|
+
for index, requirement in enumerate(requires["mcp"]):
|
|
425
|
+
if not isinstance(requirement, dict):
|
|
426
|
+
continue
|
|
427
|
+
texts = [("role", requirement.get("role")), ("contract", requirement.get("contract"))]
|
|
428
|
+
if isinstance(requirement.get("tools"), list):
|
|
429
|
+
texts += [("tools/%d" % t, tool) for t, tool in enumerate(requirement["tools"])]
|
|
430
|
+
for field, text in texts:
|
|
431
|
+
if isinstance(text, str) and secret_value(text):
|
|
432
|
+
flag("/requires/mcp/%d/%s" % (index, field), "a required MCP server is named by role and tools, never by a credential")
|
|
433
|
+
service = value.get("service")
|
|
434
|
+
if isinstance(service, dict) and isinstance(service.get("entry"), list):
|
|
435
|
+
for index, arg in enumerate(service["entry"]):
|
|
436
|
+
if isinstance(arg, str) and secret_argument(arg):
|
|
437
|
+
flag("/service/entry/%d" % index, "a launch argument never carries a credential: the secret reaches the service from its slot")
|
|
438
|
+
return out
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _same_or_absent(a: Any, b: Any) -> bool:
|
|
442
|
+
"""Set membership as the contract's ``new Set`` tests it: SameValueZero for JSON values, identity for containers."""
|
|
443
|
+
if a is _UNDEFINED or b is _UNDEFINED:
|
|
444
|
+
return a is b
|
|
445
|
+
if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
|
|
446
|
+
return a is b
|
|
447
|
+
return _json_equal(a, b)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _agent_ref(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
451
|
+
if value.get("kind") != "agent" or not isinstance(value.get("publisher"), str) or not isinstance(value.get("id"), str):
|
|
452
|
+
return []
|
|
453
|
+
expected = agent_ref_of(value["publisher"], value["id"])
|
|
454
|
+
if value.get("agentRef") == expected and isinstance(value.get("agentRef"), str):
|
|
455
|
+
return []
|
|
456
|
+
return [{"code": "FAC-SEM-066", "instancePath": "/agentRef", "message": "the agent is %s: agentRef is derived from publisher and id" % expected}]
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _mcp_roles(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
460
|
+
seen = set()
|
|
461
|
+
declared = {slot.get("name") for slot in _objects(value.get("secrets")) if isinstance(slot.get("name"), str)}
|
|
462
|
+
feeds: Dict[str, str] = {}
|
|
463
|
+
out: List[Dict[str, str]] = []
|
|
464
|
+
for index, requirement in enumerate(_mcp(value)):
|
|
465
|
+
role = requirement.get("role")
|
|
466
|
+
if not isinstance(role, str):
|
|
467
|
+
continue
|
|
468
|
+
if role in seen:
|
|
469
|
+
out.append({"code": "FAC-SEM-102", "instancePath": "/requires/mcp/%d/role" % index, "message": "role %s is required twice: one role, one server" % role})
|
|
470
|
+
seen.add(role)
|
|
471
|
+
for s, slot in enumerate(requirement.get("secrets") if isinstance(requirement.get("secrets"), list) else []):
|
|
472
|
+
if not isinstance(slot, str):
|
|
473
|
+
continue
|
|
474
|
+
at = "/requires/mcp/%d/secrets/%d" % (index, s)
|
|
475
|
+
if slot not in declared:
|
|
476
|
+
out.append({"code": "FAC-SEM-102", "instancePath": at, "message": "slot %s is not declared in secrets[]" % slot})
|
|
477
|
+
other = feeds.get(slot)
|
|
478
|
+
if other is not None and other != role:
|
|
479
|
+
out.append({"code": "FAC-SEM-102", "instancePath": at, "message": "slot %s already feeds role %s: one slot, one role" % (slot, other)})
|
|
480
|
+
feeds[slot] = other if other is not None else role
|
|
481
|
+
return out
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def package_findings(pkg: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
485
|
+
"""FAC-SEM-064 (whole), 065 (no secret), 066 (agentRef derived) and 102 (one role, one server), in that order."""
|
|
486
|
+
return _whole(pkg) + _no_secret(pkg) + _agent_ref(pkg) + _mcp_roles(pkg)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# --- the folder check and the verifier ------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
def _parsed(data: Optional[bytes]) -> Optional[Dict[str, Any]]:
|
|
492
|
+
if data is None:
|
|
493
|
+
return None
|
|
494
|
+
try:
|
|
495
|
+
value = json.loads(data.decode("utf-8", "replace"))
|
|
496
|
+
except ValueError:
|
|
497
|
+
return None
|
|
498
|
+
return value if isinstance(value, dict) else None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def check_package_folder(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
|
|
502
|
+
"""DEC-0050's folder check: every listed file present with its SHA-256, nothing unlisted but
|
|
503
|
+
``fabric.package.json``, the plugin manifest naming the package's id and version, and the provider manifest
|
|
504
|
+
carrying the package's cost presets or none. ``files`` maps each forward-slash path to its bytes."""
|
|
505
|
+
problems: List[Dict[str, str]] = []
|
|
506
|
+
listed: Dict[str, Any] = {}
|
|
507
|
+
for entry in _objects(pkg.get("files")):
|
|
508
|
+
listed[_js_string(entry.get("path", _UNDEFINED))] = entry.get("sha256", _UNDEFINED)
|
|
509
|
+
for path, digest in listed.items():
|
|
510
|
+
data = files.get(path)
|
|
511
|
+
if data is None:
|
|
512
|
+
problems.append({"problem": "file-missing", "path": path})
|
|
513
|
+
elif digest is _UNDEFINED or sha256_of(data) != digest:
|
|
514
|
+
problems.append({"problem": "file-digest", "path": path})
|
|
515
|
+
for path in files:
|
|
516
|
+
if path != PACKAGE_FILE and path not in listed:
|
|
517
|
+
problems.append({"problem": "file-unlisted", "path": path})
|
|
518
|
+
if PLUGIN_MANIFEST in files:
|
|
519
|
+
plugin = _parsed(files.get(PLUGIN_MANIFEST))
|
|
520
|
+
if not plugin or not _same_member(plugin, "name", pkg.get("id", _UNDEFINED)) or not _same_member(plugin, "version", pkg.get("version", _UNDEFINED)):
|
|
521
|
+
problems.append({"problem": "plugin-manifest", "path": PLUGIN_MANIFEST})
|
|
522
|
+
provider = pkg.get("provider")
|
|
523
|
+
if isinstance(provider, str) and provider in files:
|
|
524
|
+
manifest = _parsed(files.get(provider))
|
|
525
|
+
if not manifest or ("costPresets" in manifest and not ("costPresets" in pkg and _json_equal(manifest["costPresets"], pkg["costPresets"]))):
|
|
526
|
+
problems.append({"problem": "provider-manifest", "path": provider})
|
|
527
|
+
return problems
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _key_for(keys: Any, kid: str, alg: str) -> Dict[str, Any]:
|
|
531
|
+
matches = [k for k in (keys if isinstance(keys, list) else []) if isinstance(k, dict) and k.get("kid") == kid]
|
|
532
|
+
if len(matches) > 1:
|
|
533
|
+
return {"problem": "duplicate-kid"}
|
|
534
|
+
if not matches:
|
|
535
|
+
return {"problem": "unknown-key"}
|
|
536
|
+
jwk = matches[0]
|
|
537
|
+
if jwk.get("alg", _UNDEFINED) != alg:
|
|
538
|
+
return {"problem": "key-algorithm-mismatch"}
|
|
539
|
+
x = jwk.get("x")
|
|
540
|
+
if (jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(x, str) or not _B64URL.match(x)
|
|
541
|
+
or "d" in jwk or ("use" in jwk and jwk["use"] != "sig")):
|
|
542
|
+
return {"problem": "unusable-key"}
|
|
543
|
+
raw = fd._b64decode(x)
|
|
544
|
+
if len(raw) != 32:
|
|
545
|
+
return {"problem": "unusable-key"}
|
|
546
|
+
if fd.is_small_order_key(raw): # DEC-0045: refused before any verification
|
|
547
|
+
return {"problem": "small-order-key"}
|
|
548
|
+
return {"key": raw}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def verify_package(pkg: Any, trust: Dict[str, Any], countersigned: bool = False) -> Dict[str, Any]:
|
|
552
|
+
"""The contract's verifyPackage (DEC-0051), in PK-1…PK-10's order."""
|
|
553
|
+
if not isinstance(pkg, dict) or not isinstance(pkg.get("digest"), str):
|
|
554
|
+
return {"ok": False, "problem": "malformed"}
|
|
555
|
+
try:
|
|
556
|
+
digest = package_digest(pkg)
|
|
557
|
+
except TypeError:
|
|
558
|
+
return {"ok": False, "problem": "malformed"}
|
|
559
|
+
if digest != pkg["digest"]:
|
|
560
|
+
return {"ok": False, "problem": "digest-mismatch"}
|
|
561
|
+
signatures = pkg.get("signatures", _UNDEFINED)
|
|
562
|
+
if signatures is _UNDEFINED or (isinstance(signatures, list) and not signatures):
|
|
563
|
+
return {"ok": False, "problem": "unsigned"}
|
|
564
|
+
if not isinstance(signatures, list):
|
|
565
|
+
return {"ok": False, "problem": "malformed"}
|
|
566
|
+
if sum(1 for e in signatures if isinstance(e, dict) and e.get("role") == "publisher") != 1:
|
|
567
|
+
return {"ok": False, "problem": "no-publisher-signature"}
|
|
568
|
+
signers: List[Dict[str, str]] = []
|
|
569
|
+
unverified: List[Dict[str, str]] = []
|
|
570
|
+
trust = trust if isinstance(trust, dict) else {}
|
|
571
|
+
for index, entry in enumerate(signatures):
|
|
572
|
+
if (not isinstance(entry, dict) or entry.get("role") not in ("publisher", "organization") or not isinstance(entry.get("alg"), str)
|
|
573
|
+
or not isinstance(entry.get("kid"), str) or not isinstance(entry.get("sig"), str) or not _B64URL.match(entry["sig"])):
|
|
574
|
+
return {"ok": False, "problem": "malformed", "signature": index}
|
|
575
|
+
if entry["alg"] not in PACKAGE_ALGORITHMS:
|
|
576
|
+
return {"ok": False, "problem": "unknown-algorithm", "signature": index}
|
|
577
|
+
found = _key_for(trust.get(entry["role"]) or [], entry["kid"], entry["alg"])
|
|
578
|
+
if found.get("problem") == "unknown-key" and entry["role"] == "organization" and countersigned is not True:
|
|
579
|
+
unverified.append({"role": entry["role"], "kid": entry["kid"]})
|
|
580
|
+
continue
|
|
581
|
+
if "problem" in found:
|
|
582
|
+
return {"ok": False, "problem": found["problem"], "signature": index}
|
|
583
|
+
signature = fd._b64decode(entry["sig"])
|
|
584
|
+
if len(signature) != 64 or not fd.ed25519_verify(found["key"], package_signing_input(digest, entry["role"]), signature):
|
|
585
|
+
return {"ok": False, "problem": "bad-signature", "signature": index}
|
|
586
|
+
signers.append({"role": entry["role"], "kid": entry["kid"]})
|
|
587
|
+
if countersigned is True and not any(s["role"] == "organization" for s in signers):
|
|
588
|
+
return {"ok": False, "problem": "no-countersignature"}
|
|
589
|
+
verdict: Dict[str, Any] = {"ok": True, "digest": digest, "signers": signers}
|
|
590
|
+
if unverified:
|
|
591
|
+
verdict["unverified"] = unverified
|
|
592
|
+
return verdict
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def consumer_placement(pkg: Any, configured: Any) -> List[Dict[str, str]]:
|
|
596
|
+
"""What a placement lacks for ``requires.mcp`` (DEC-0071): ``configured`` maps each registered role to its tools."""
|
|
597
|
+
if not isinstance(pkg, dict):
|
|
598
|
+
return []
|
|
599
|
+
roles = [r for r in _mcp(pkg) if isinstance(r.get("role"), str)]
|
|
600
|
+
if not isinstance(configured, dict):
|
|
601
|
+
return [{"problem": "missing-role", "role": r["role"]} for r in roles]
|
|
602
|
+
problems: List[Dict[str, str]] = []
|
|
603
|
+
for requirement in roles:
|
|
604
|
+
if requirement["role"] not in configured:
|
|
605
|
+
problems.append({"problem": "missing-role", "role": requirement["role"]})
|
|
606
|
+
continue
|
|
607
|
+
tools = configured[requirement["role"]] if isinstance(configured[requirement["role"]], list) else []
|
|
608
|
+
for tool in requirement.get("tools") if isinstance(requirement.get("tools"), list) else []:
|
|
609
|
+
if isinstance(tool, str) and tool not in tools:
|
|
610
|
+
problems.append({"problem": "missing-tool", "role": requirement["role"], "tool": tool})
|
|
611
|
+
return problems
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# --- the folder on disk -----------------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
def _litter(name: str) -> Optional[str]:
|
|
617
|
+
if JUNK.match(name):
|
|
618
|
+
return "litter"
|
|
619
|
+
if SECRET_FILE.match(name) and not SECRET_FILE_OK.match(name):
|
|
620
|
+
return "a credential file"
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def read_folder(folder: Any) -> Dict[str, bytes]:
|
|
625
|
+
"""Every regular file under ``folder`` by its forward-slash path. Refuses a symlink, a special file, version-control
|
|
626
|
+
or cache litter, and a file that is a credential by its name — each named, so the person can remove it or pack a
|
|
627
|
+
clean copy."""
|
|
628
|
+
root = Path(folder)
|
|
629
|
+
if not root.is_dir():
|
|
630
|
+
raise PackageError("%s is not a folder." % root)
|
|
631
|
+
files: Dict[str, bytes] = {}
|
|
632
|
+
refused: List[str] = []
|
|
633
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
634
|
+
here = Path(dirpath)
|
|
635
|
+
rel_dir = here.relative_to(root).as_posix()
|
|
636
|
+
for name in sorted(dirnames):
|
|
637
|
+
rel = name if rel_dir == "." else "%s/%s" % (rel_dir, name)
|
|
638
|
+
why = "a symlink" if (here / name).is_symlink() else _litter(name)
|
|
639
|
+
if why:
|
|
640
|
+
refused.append("%s (%s)" % (rel, why))
|
|
641
|
+
dirnames.remove(name)
|
|
642
|
+
for name in filenames:
|
|
643
|
+
rel = name if rel_dir == "." else "%s/%s" % (rel_dir, name)
|
|
644
|
+
full = here / name
|
|
645
|
+
why = "a symlink" if full.is_symlink() else (None if full.is_file() else "not a regular file") or _litter(name)
|
|
646
|
+
if why:
|
|
647
|
+
refused.append("%s (%s)" % (rel, why))
|
|
648
|
+
continue
|
|
649
|
+
files[rel] = full.read_bytes()
|
|
650
|
+
if refused:
|
|
651
|
+
raise PackageError("Not packed: remove these or pack a clean copy — " + "; ".join(sorted(refused)))
|
|
652
|
+
return files
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _load_json(data: bytes, what: str) -> Dict[str, Any]:
|
|
656
|
+
try:
|
|
657
|
+
value = json.loads(data.decode("utf-8"), object_pairs_hook=fd._no_duplicates)
|
|
658
|
+
except fd._Duplicate as exc:
|
|
659
|
+
raise PackageError("%s names %s twice in one object." % (what, json.dumps(exc.args[0], ensure_ascii=False))) from None
|
|
660
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
661
|
+
raise PackageError("%s is not JSON: %s" % (what, exc)) from None
|
|
662
|
+
if not isinstance(value, dict):
|
|
663
|
+
raise PackageError("%s is not a JSON object." % what)
|
|
664
|
+
return value
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def dumps(value: Any, indent: str = "") -> str:
|
|
668
|
+
"""``JSON.stringify(value, null, 2)``, so both kits write the same bytes."""
|
|
669
|
+
inner = indent + " "
|
|
670
|
+
if isinstance(value, dict):
|
|
671
|
+
if not value:
|
|
672
|
+
return "{}"
|
|
673
|
+
return "{\n" + ",\n".join(inner + fd._string(k) + ": " + dumps(v, inner) for k, v in value.items()) + "\n" + indent + "}"
|
|
674
|
+
if isinstance(value, list):
|
|
675
|
+
if not value:
|
|
676
|
+
return "[]"
|
|
677
|
+
return "[\n" + ",\n".join(inner + dumps(v, inner) for v in value) + "\n" + indent + "]"
|
|
678
|
+
return fd.jcs(value)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, Any], List[str]]:
|
|
682
|
+
"""The packed document: the author's fields in their order, ``agentRef`` derived after ``publisher`` for an
|
|
683
|
+
agent, then ``files[]`` (every file but this one, by path) and ``digest``. Old signatures are dropped."""
|
|
684
|
+
notes: List[str] = []
|
|
685
|
+
pkg: Dict[str, Any] = {}
|
|
686
|
+
agent = draft.get("kind") == "agent" and isinstance(draft.get("publisher"), str) and isinstance(draft.get("id"), str)
|
|
687
|
+
if agent:
|
|
688
|
+
derived = agent_ref_of(draft["publisher"], draft["id"])
|
|
689
|
+
if "agentRef" in draft and draft["agentRef"] != derived:
|
|
690
|
+
raise PackageError("agentRef is derived, never chosen: it is %s for this publisher and id (FAC-SEM-066). "
|
|
691
|
+
"Remove agentRef or fix publisher/id." % derived)
|
|
692
|
+
for key, value in draft.items():
|
|
693
|
+
if key in ("files", "digest", "signatures") or (agent and key == "agentRef"):
|
|
694
|
+
continue
|
|
695
|
+
pkg[key] = value
|
|
696
|
+
if agent and key == "publisher":
|
|
697
|
+
pkg["agentRef"] = agent_ref_of(draft["publisher"], draft["id"])
|
|
698
|
+
if isinstance(draft.get("signatures"), list) and draft["signatures"]:
|
|
699
|
+
notes.append("dropped %d signature(s): a repacked package is signed again" % len(draft["signatures"]))
|
|
700
|
+
pkg["files"] = [{"path": p, "sha256": sha256_of(files[p])} for p in sorted((p for p in files if p != PACKAGE_FILE),
|
|
701
|
+
key=lambda p: p.encode("utf-16-be", "surrogatepass"))]
|
|
702
|
+
pkg["digest"] = package_digest(pkg)
|
|
703
|
+
return pkg, notes
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
|
|
707
|
+
problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
|
|
708
|
+
problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
|
|
709
|
+
problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": p["problem"]} for p in check_package_folder(pkg, files)]
|
|
710
|
+
return problems
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
|
|
714
|
+
"""Pack ``folder`` (see the module's doc). The answer names the digest, ``agentRef`` and every problem; with
|
|
715
|
+
``apply`` and no problem, ``fabric.package.json`` is rewritten."""
|
|
716
|
+
root = Path(folder)
|
|
717
|
+
files = read_folder(root)
|
|
718
|
+
if PACKAGE_FILE not in files:
|
|
719
|
+
raise PackageError("Write %s in the folder first: schema, kind, id, version, publisher, licence and, for an agent, "
|
|
720
|
+
"resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest." % PACKAGE_FILE)
|
|
721
|
+
draft = _load_json(files[PACKAGE_FILE], PACKAGE_FILE)
|
|
722
|
+
pkg, notes = compose(draft, files)
|
|
723
|
+
problems = _all_problems(pkg, files)
|
|
724
|
+
suite = pkg.get("evals", {}).get("suite") if isinstance(pkg.get("evals"), dict) else None
|
|
725
|
+
if isinstance(suite, str) and suite in files and any(p.get("instancePath") == "/evals/receipt/suite" for p in problems):
|
|
726
|
+
notes.append("the suite packed is %s: run it again and write that receipt" % sha256_of(files[suite]))
|
|
727
|
+
answer = {"ok": not problems, "applied": False, "digest": pkg["digest"], "files": len(pkg["files"]), "problems": problems, "notes": notes}
|
|
728
|
+
if pkg.get("kind") == "agent" and "agentRef" in pkg:
|
|
729
|
+
answer["agentRef"] = pkg["agentRef"]
|
|
730
|
+
if apply and not problems:
|
|
731
|
+
fd_write(root / PACKAGE_FILE, dumps(pkg) + "\n")
|
|
732
|
+
answer["applied"] = True
|
|
733
|
+
answer["package"] = pkg
|
|
734
|
+
return answer
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def fd_write(path: Path, text: str) -> None:
|
|
738
|
+
"""Write through a temporary file in the same folder and rename it over, so a reader never sees half a file."""
|
|
739
|
+
temp = path.with_name(".%s.tmp-%d" % (path.name, os.getpid()))
|
|
740
|
+
temp.write_text(text, encoding="utf-8")
|
|
741
|
+
os.replace(temp, path)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _packed(root: Path) -> Tuple[Dict[str, Any], Dict[str, bytes]]:
|
|
745
|
+
files = read_folder(root)
|
|
746
|
+
if PACKAGE_FILE not in files:
|
|
747
|
+
raise PackageError("%s has no %s: pack it first." % (root, PACKAGE_FILE))
|
|
748
|
+
return _load_json(files[PACKAGE_FILE], PACKAGE_FILE), files
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def sign(folder: Any, key_path: Any, role: str = "publisher", kid: Optional[str] = None, apply: bool = False) -> Dict[str, Any]:
|
|
752
|
+
"""Add ``role``'s Ed25519 signature over the packed digest. Only a package that is whole and matches its folder is
|
|
753
|
+
signed. The signature of the same role and kid is replaced; a publisher signature is always one."""
|
|
754
|
+
if role not in ("publisher", "organization"):
|
|
755
|
+
raise PackageError("role is publisher or organization.")
|
|
756
|
+
root = Path(folder)
|
|
757
|
+
pkg, files = _packed(root)
|
|
758
|
+
problems = _all_problems(pkg, files)
|
|
759
|
+
if problems:
|
|
760
|
+
return {"ok": False, "applied": False, "problems": problems, "notes": ["pack the folder again before it is signed"]}
|
|
761
|
+
seed, kid = read_signing_key(key_path, kid)
|
|
762
|
+
entry = {"role": role, "alg": "EdDSA", "kid": kid, "sig": _b64url(ed25519_sign(seed, package_signing_input(pkg["digest"], role)))}
|
|
763
|
+
kept = [s for s in pkg.get("signatures", []) if isinstance(s, dict)
|
|
764
|
+
and not (s.get("role") == role and (role == "publisher" or s.get("kid") == kid))]
|
|
765
|
+
signed = dict(pkg)
|
|
766
|
+
signed["signatures"] = ([entry] + kept) if role == "publisher" else (kept + [entry])
|
|
767
|
+
jwk = public_jwk(seed, kid)
|
|
768
|
+
check = verify_package(signed, {role: [jwk]} if role == "publisher" else {"organization": [jwk], "publisher": []})
|
|
769
|
+
if role == "publisher" and not check.get("ok"):
|
|
770
|
+
raise PackageError("The signature does not verify under its own key (%s); nothing was written." % check.get("problem"))
|
|
771
|
+
answer = {"ok": True, "applied": False, "digest": pkg["digest"], "signature": entry, "publicKey": jwk, "problems": [], "notes": []}
|
|
772
|
+
if apply:
|
|
773
|
+
fd_write(root / PACKAGE_FILE, dumps(signed) + "\n")
|
|
774
|
+
answer["applied"] = True
|
|
775
|
+
return answer
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def verify(folder: Any, trust: Dict[str, Any], countersigned: bool = False) -> Dict[str, Any]:
|
|
779
|
+
"""The folder check, the contract's rules, then verifyPackage against the pinned ``trust``."""
|
|
780
|
+
pkg, files = _packed(Path(folder))
|
|
781
|
+
problems = _all_problems(pkg, files)
|
|
782
|
+
verdict = verify_package(pkg, trust, countersigned)
|
|
783
|
+
return {"ok": bool(verdict.get("ok")) and not problems, "verdict": verdict, "problems": problems}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
787
|
+
parser = argparse.ArgumentParser(prog="fabric_package.py", description="Pack, sign and verify a fabric-package/1 folder.")
|
|
788
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
789
|
+
p_pack = sub.add_parser("pack", help="list the files, derive agentRef, compute the digest and check the package")
|
|
790
|
+
p_pack.add_argument("folder")
|
|
791
|
+
mode = p_pack.add_mutually_exclusive_group()
|
|
792
|
+
mode.add_argument("--dry-run", action="store_true", help="show the package and write nothing (the default)")
|
|
793
|
+
mode.add_argument("--apply", action="store_true", help="write fabric.package.json when nothing is wrong")
|
|
794
|
+
p_sign = sub.add_parser("sign", help="add an Ed25519 signature over the packed digest")
|
|
795
|
+
p_sign.add_argument("folder")
|
|
796
|
+
p_sign.add_argument("--key", required=True, help="a private Ed25519 JWK or PKCS#8 PEM, readable by its owner only")
|
|
797
|
+
p_sign.add_argument("--kid", help="the key's name, when the key file does not carry one")
|
|
798
|
+
p_sign.add_argument("--role", choices=("publisher", "organization"), default="publisher")
|
|
799
|
+
mode = p_sign.add_mutually_exclusive_group()
|
|
800
|
+
mode.add_argument("--dry-run", action="store_true", help="show the signature and write nothing (the default)")
|
|
801
|
+
mode.add_argument("--apply", action="store_true", help="write the signature into fabric.package.json")
|
|
802
|
+
p_verify = sub.add_parser("verify", help="check the folder and the signatures against pinned keys")
|
|
803
|
+
p_verify.add_argument("folder")
|
|
804
|
+
p_verify.add_argument("--trust", required=True, help='JSON: {"publisher": [jwk...], "organization": [jwk...]}')
|
|
805
|
+
p_verify.add_argument("--countersigned", action="store_true", help="require an organization countersignature")
|
|
806
|
+
args = parser.parse_args(argv)
|
|
807
|
+
try:
|
|
808
|
+
if args.command == "pack":
|
|
809
|
+
answer = pack(args.folder, apply=args.apply)
|
|
810
|
+
answer.pop("package", None) if args.apply else None
|
|
811
|
+
elif args.command == "sign":
|
|
812
|
+
answer = sign(args.folder, args.key, role=args.role, kid=args.kid, apply=args.apply)
|
|
813
|
+
else:
|
|
814
|
+
trust = _load_json(Path(args.trust).read_bytes(), args.trust)
|
|
815
|
+
answer = verify(args.folder, trust, countersigned=args.countersigned)
|
|
816
|
+
except (PackageError, OSError) as exc:
|
|
817
|
+
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, separators=(",", ":")))
|
|
818
|
+
return 1
|
|
819
|
+
print(dumps(answer))
|
|
820
|
+
return 0 if answer.get("ok") else 1
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
if __name__ == "__main__":
|
|
824
|
+
sys.exit(main())
|