@passioncode-ai/fabric-agent-adapter 0.29.0 → 0.31.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.
@@ -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:
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())