@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -0
- package/README.md +9 -3
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +144 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +130 -35
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +25 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py
CHANGED
|
@@ -16,12 +16,13 @@ library only; the answer is JSON on stdout, exit 0 when it holds, 1 when it does
|
|
|
16
16
|
|
|
17
17
|
import argparse
|
|
18
18
|
import base64
|
|
19
|
+
import errno
|
|
19
20
|
import hashlib
|
|
20
21
|
import json
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
24
|
+
import stat as _stat
|
|
23
25
|
import sys
|
|
24
|
-
from datetime import datetime
|
|
25
26
|
from pathlib import Path
|
|
26
27
|
from typing import Any, Dict, List, Optional, Tuple
|
|
27
28
|
|
|
@@ -35,12 +36,20 @@ SIGNATURE_STATEMENT = "fabric-package-signature/1"
|
|
|
35
36
|
# DEC-0051: ES256 joins when Fabric records its decision D2; until then a verifier refuses it as unknown-algorithm.
|
|
36
37
|
PACKAGE_ALGORITHMS = ("EdDSA",)
|
|
37
38
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
# What pack refuses to list: version-control and cache litter, and files that are credentials by their name. Matched
|
|
40
|
+
# whole (fullmatch) and with JavaScript's `.`, so a name ending in a newline is judged as fabric-package.mjs judges it.
|
|
41
|
+
_JS_DOT = "[^\n\r\u2028\u2029]"
|
|
42
|
+
JUNK = re.compile(r"\.git|__pycache__|\.DS_Store|%s*\.pyc" % _JS_DOT)
|
|
43
|
+
SECRET_FILE = re.compile(r"\.env(\.%s+)?|%s*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.(npmrc|netrc|pypirc)" % (_JS_DOT, _JS_DOT))
|
|
44
|
+
SECRET_FILE_OK = re.compile(r"\.env\.(example|sample|template)")
|
|
45
|
+
_B64URL = re.compile(r"[A-Za-z0-9_-]+")
|
|
43
46
|
_PKCS8_ED25519 = bytes.fromhex("302e020100300506032b657004220420")
|
|
47
|
+
#: How deep a document read from disk may nest. Python's recursion limit and Node's stack give out at different depths,
|
|
48
|
+
#: so both readers refuse past this one, well below either (FAA-78, finding 43); no package document comes near it.
|
|
49
|
+
MAX_NESTING = 256
|
|
50
|
+
#: JavaScript's whitespace, what String.prototype.trim and the regular expression class \s take (ECMA-262 WhiteSpace
|
|
51
|
+
#: and LineTerminator), so a key file and a URI are read as fabric-package.mjs reads them.
|
|
52
|
+
JS_WHITESPACE = "\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
44
53
|
|
|
45
54
|
|
|
46
55
|
class PackageError(Exception):
|
|
@@ -63,9 +72,12 @@ def sha256_of(data: Any) -> str:
|
|
|
63
72
|
|
|
64
73
|
def package_digest(pkg: Dict[str, Any]) -> str:
|
|
65
74
|
"""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."""
|
|
75
|
+
Raises TypeError for a value with no RFC 8785 form, a value nested past the stack included."""
|
|
67
76
|
covered = {k: v for k, v in pkg.items() if k not in ("digest", "signatures")}
|
|
68
|
-
|
|
77
|
+
try:
|
|
78
|
+
return sha256_of(_jsv.jcs(covered))
|
|
79
|
+
except RecursionError: # nested past Python's stack: no form this kit can write, as Node's RangeError is none
|
|
80
|
+
raise TypeError("the package nests too deeply to have an RFC 8785 form") from None
|
|
69
81
|
|
|
70
82
|
|
|
71
83
|
def package_signing_input(digest: str, role: str) -> bytes:
|
|
@@ -73,34 +85,16 @@ def package_signing_input(digest: str, role: str) -> bytes:
|
|
|
73
85
|
return _jsv.jcs({"type": SIGNATURE_STATEMENT, "role": role, "digest": digest}).encode("utf-8")
|
|
74
86
|
|
|
75
87
|
|
|
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
|
-
|
|
88
|
+
# --- Ed25519 signing: the core's (fabric_crypto, RFC 8032 §5.1.6), which never branches on the secret (finding 45) ---
|
|
85
89
|
|
|
86
90
|
def ed25519_public_key(seed: bytes) -> bytes:
|
|
87
91
|
"""The 32-byte public key of a 32-byte Ed25519 seed."""
|
|
88
92
|
if len(seed) != 32:
|
|
89
93
|
raise PackageError("An Ed25519 private key is 32 bytes.")
|
|
90
|
-
|
|
91
|
-
return _crypto._ed_encode_point(_crypto._ed_mul(a, _crypto._ED_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 = _crypto._ed_encode_point(_crypto._ed_mul(a, _crypto._ED_G))
|
|
98
|
-
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _crypto._ED_L
|
|
99
|
-
big_r = _crypto._ed_encode_point(_crypto._ed_mul(r, _crypto._ED_G))
|
|
100
|
-
k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % _crypto._ED_L
|
|
101
|
-
return big_r + ((r + k * a) % _crypto._ED_L).to_bytes(32, "little")
|
|
94
|
+
return _crypto.ed25519_seed_public_key(seed)
|
|
102
95
|
|
|
103
96
|
|
|
97
|
+
from fabric_crypto import ed25519_sign # noqa: E402,F401 - the core's: the bytes Node's crypto.sign gives
|
|
104
98
|
from fabric_crypto import b64url as _b64url # noqa: E402 - the core's (FAA-65)
|
|
105
99
|
|
|
106
100
|
|
|
@@ -121,7 +115,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
|
|
|
121
115
|
raise PackageError("Cannot read the key file %s: %s." % (path, exc.strerror)) from None
|
|
122
116
|
if os.name == "posix" and stat.st_mode & 0o077:
|
|
123
117
|
raise PackageError("The key file %s is readable by others (mode %o); chmod 600 it first." % (path, stat.st_mode & 0o777))
|
|
124
|
-
text = raw.decode("utf-8", "replace").strip()
|
|
118
|
+
text = raw.decode("utf-8", "replace").strip(JS_WHITESPACE) # as JavaScript's trim: a leading BOM goes too
|
|
125
119
|
if text.startswith("{"):
|
|
126
120
|
try:
|
|
127
121
|
jwk = json.loads(text)
|
|
@@ -131,7 +125,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
|
|
|
131
125
|
raise PackageError("The key file must be an Ed25519 private JWK (kty OKP, crv Ed25519, with d).")
|
|
132
126
|
if jwk.get("alg", "EdDSA") != "EdDSA":
|
|
133
127
|
raise PackageError("The key names alg %r; a package signature is EdDSA (DEC-0051)." % jwk.get("alg"))
|
|
134
|
-
seed = _crypto.b64url_decode(jwk["d"]) if _B64URL.
|
|
128
|
+
seed = _crypto.b64url_decode(jwk["d"]) if _B64URL.fullmatch(jwk["d"]) else b""
|
|
135
129
|
if len(seed) != 32:
|
|
136
130
|
raise PackageError("The key's d is not a 32-byte Ed25519 private key.")
|
|
137
131
|
if isinstance(jwk.get("x"), str) and jwk["x"] != _b64url(ed25519_public_key(seed)):
|
|
@@ -151,7 +145,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
|
|
|
151
145
|
seed = der[16:]
|
|
152
146
|
else:
|
|
153
147
|
raise PackageError("The key file is neither a JWK nor a PEM private key.")
|
|
154
|
-
if not isinstance(kid, str) or not re.
|
|
148
|
+
if not isinstance(kid, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", kid):
|
|
155
149
|
raise PackageError("Name the key with --kid (1-128 of A-Z a-z 0-9 . _ : -): the installing machine pins it by that name.")
|
|
156
150
|
return seed, kid
|
|
157
151
|
|
|
@@ -186,27 +180,67 @@ def _type_of(value: Any) -> str:
|
|
|
186
180
|
return "object"
|
|
187
181
|
|
|
188
182
|
|
|
189
|
-
|
|
190
|
-
|
|
183
|
+
# ASCII digits only and the whole string (fullmatch): Python's \d takes any Unicode digit and its $ a trailing newline,
|
|
184
|
+
# which JavaScript's do not (FAA-78, finding 43).
|
|
185
|
+
_DATE_TIME = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))")
|
|
186
|
+
_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^%s]*" % JS_WHITESPACE)
|
|
187
|
+
_MONTH_DAYS = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _leap(year: int) -> bool:
|
|
191
|
+
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
191
192
|
|
|
192
193
|
|
|
193
194
|
def _format_ok(fmt: str, value: Any) -> bool:
|
|
194
195
|
if not isinstance(value, str):
|
|
195
196
|
return True
|
|
196
197
|
if fmt == "date-time":
|
|
197
|
-
|
|
198
|
+
m = _DATE_TIME.fullmatch(value)
|
|
199
|
+
if not m:
|
|
198
200
|
return False
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
year, month, day, hour, minute, second = (int(g) for g in m.groups()[:6])
|
|
202
|
+
# The proleptic calendar the contract's ajv-formats checks: year 0000 exists and is a leap year (0000-02-29
|
|
203
|
+
# holds). datetime starts at year 1, so the days are counted here, as fabric-package.mjs counts them.
|
|
204
|
+
if not 1 <= month <= 12 or not 1 <= day <= (29 if month == 2 and _leap(year) else _MONTH_DAYS[month]):
|
|
202
205
|
return False
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
if hour > 23 or minute > 59 or second > 59:
|
|
207
|
+
return False
|
|
208
|
+
return m.group(8) in ("Z", "z") or (int(m.group(9)) < 24 and int(m.group(10)) < 60)
|
|
205
209
|
if fmt == "uri":
|
|
206
|
-
return bool(_URI.
|
|
210
|
+
return bool(_URI.fullmatch(value))
|
|
207
211
|
return True
|
|
208
212
|
|
|
209
213
|
|
|
214
|
+
_PATTERNS: Dict[str, Any] = {}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _js_pattern(source: str) -> Any:
|
|
218
|
+
"""A schema's ECMAScript pattern (``u`` flag) as Python reads it the same way: ``$`` is the end of the string, not
|
|
219
|
+
the end before a final newline; ``.`` stops at JavaScript's line terminators (\\n, \\r, U+2028, U+2029); ``\\d`` and
|
|
220
|
+
``\\w`` are ASCII. Escapes and character classes are copied as written."""
|
|
221
|
+
if source not in _PATTERNS:
|
|
222
|
+
out: List[str] = []
|
|
223
|
+
i, in_class = 0, False
|
|
224
|
+
while i < len(source):
|
|
225
|
+
ch = source[i]
|
|
226
|
+
if ch == "\\":
|
|
227
|
+
out.append(source[i:i + 2])
|
|
228
|
+
i += 2
|
|
229
|
+
continue
|
|
230
|
+
if in_class:
|
|
231
|
+
in_class = ch != "]"
|
|
232
|
+
elif ch == "[":
|
|
233
|
+
in_class = True
|
|
234
|
+
elif ch == "$":
|
|
235
|
+
ch = r"\Z"
|
|
236
|
+
elif ch == ".":
|
|
237
|
+
ch = _JS_DOT
|
|
238
|
+
out.append(ch)
|
|
239
|
+
i += 1
|
|
240
|
+
_PATTERNS[source] = re.compile("".join(out), re.ASCII)
|
|
241
|
+
return _PATTERNS[source]
|
|
242
|
+
|
|
243
|
+
|
|
210
244
|
def _pointer(token: Any) -> str:
|
|
211
245
|
return str(token).replace("~", "~0").replace("/", "~1")
|
|
212
246
|
|
|
@@ -239,7 +273,7 @@ def _validate(schema: Any, value: Any, at: str, out: List[Dict[str, str]]) -> No
|
|
|
239
273
|
out.append({"instancePath": at, "message": "is shorter than %d" % schema["minLength"]})
|
|
240
274
|
if "maxLength" in schema and length > schema["maxLength"]:
|
|
241
275
|
out.append({"instancePath": at, "message": "is longer than %d" % schema["maxLength"]})
|
|
242
|
-
if "pattern" in schema and not
|
|
276
|
+
if "pattern" in schema and not _js_pattern(schema["pattern"]).search(value):
|
|
243
277
|
out.append({"instancePath": at, "message": "does not match %s" % schema["pattern"]})
|
|
244
278
|
if "format" in schema and not _format_ok(schema["format"], value):
|
|
245
279
|
out.append({"instancePath": at, "message": "is not a %s" % schema["format"]})
|
|
@@ -459,6 +493,63 @@ def _mcp_roles(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
459
493
|
return out
|
|
460
494
|
|
|
461
495
|
|
|
496
|
+
# --- the path rule (FAA-78, finding 40; decision D5) ----------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
def _control(ch: str) -> bool:
|
|
499
|
+
"""C0 and C1 controls, DEL, and the two Unicode line terminators JavaScript's ``.`` stops at."""
|
|
500
|
+
code = ord(ch)
|
|
501
|
+
return code < 0x20 or 0x7F <= code <= 0x9F or code in (0x2028, 0x2029)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def path_problem(path: Any) -> Optional[str]:
|
|
505
|
+
"""Why ``path`` is no packaged path, or None. The schema's pattern says the same in one expression, but JavaScript's
|
|
506
|
+
``.`` stops at U+2028 and U+2029, so in Node and in the contract's own ajv its look-aheads never see a ``..``, a
|
|
507
|
+
backslash or a ``//`` after one (``a\u2028/../../etc/passwd`` passes there). This rule reads the path character by
|
|
508
|
+
character in both kits, and refuses C1 controls, DEL and the line terminators besides. A non-string is the
|
|
509
|
+
schema's to refuse."""
|
|
510
|
+
if not isinstance(path, str):
|
|
511
|
+
return None
|
|
512
|
+
if not 1 <= len(path) <= 256:
|
|
513
|
+
return "a path is 1 to 256 characters"
|
|
514
|
+
if any(_control(ch) for ch in path):
|
|
515
|
+
return "a path carries no control character and no line or paragraph separator"
|
|
516
|
+
if path[0] in "/~":
|
|
517
|
+
return "a path is relative to the package folder: it never starts with / or ~"
|
|
518
|
+
if "\\" in path or ":" in path:
|
|
519
|
+
return "a path is written with forward slashes and no colon"
|
|
520
|
+
if "//" in path or path.endswith("/"):
|
|
521
|
+
return "a path names a file: no empty segment and no trailing slash"
|
|
522
|
+
if any(segment in (".", "..") for segment in path.split("/")):
|
|
523
|
+
return "a path never steps through . or .."
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _path_fields(pkg: Dict[str, Any]) -> List[Tuple[str, Any]]:
|
|
528
|
+
"""Every member the schema types as a path, by its JSON pointer."""
|
|
529
|
+
fields: List[Tuple[str, Any]] = [("/provider", pkg.get("provider", _UNDEFINED))]
|
|
530
|
+
evals = pkg.get("evals")
|
|
531
|
+
if isinstance(evals, dict):
|
|
532
|
+
fields.append(("/evals/suite", evals.get("suite", _UNDEFINED)))
|
|
533
|
+
for name in ("state", "dependencies", "files"):
|
|
534
|
+
items = pkg.get(name)
|
|
535
|
+
for index, entry in enumerate(items if isinstance(items, list) else []):
|
|
536
|
+
if isinstance(entry, dict):
|
|
537
|
+
fields.append(("/%s/%d/path" % (name, index), entry.get("path", _UNDEFINED)))
|
|
538
|
+
return [(at, value) for at, value in fields if value is not _UNDEFINED]
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def path_problems(pkg: Any) -> List[Dict[str, str]]:
|
|
542
|
+
"""The path rule over every path the package names: ``{instancePath, message}`` per path refused."""
|
|
543
|
+
if not isinstance(pkg, dict):
|
|
544
|
+
return []
|
|
545
|
+
out = []
|
|
546
|
+
for at, value in _path_fields(pkg):
|
|
547
|
+
why = path_problem(value)
|
|
548
|
+
if why is not None:
|
|
549
|
+
out.append({"instancePath": at, "message": why})
|
|
550
|
+
return out
|
|
551
|
+
|
|
552
|
+
|
|
462
553
|
def package_findings(pkg: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
463
554
|
"""FAC-SEM-064 (whole), 065 (no secret), 066 (agentRef derived) and 102 (one role, one server), in that order."""
|
|
464
555
|
return _whole(pkg) + _no_secret(pkg) + _agent_ref(pkg) + _mcp_roles(pkg)
|
|
@@ -470,8 +561,8 @@ def _parsed(data: Optional[bytes]) -> Optional[Dict[str, Any]]:
|
|
|
470
561
|
if data is None:
|
|
471
562
|
return None
|
|
472
563
|
try:
|
|
473
|
-
value = json.loads(data.decode("utf-8"
|
|
474
|
-
except ValueError:
|
|
564
|
+
value = json.loads(data.decode("utf-8"))
|
|
565
|
+
except (ValueError, RecursionError): # invalid UTF-8 is no manifest, in both kits
|
|
475
566
|
return None
|
|
476
567
|
return value if isinstance(value, dict) else None
|
|
477
568
|
|
|
@@ -515,7 +606,7 @@ def _key_for(keys: Any, kid: str, alg: str) -> Dict[str, Any]:
|
|
|
515
606
|
if jwk.get("alg", _UNDEFINED) != alg:
|
|
516
607
|
return {"problem": "key-algorithm-mismatch"}
|
|
517
608
|
x = jwk.get("x")
|
|
518
|
-
if (jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(x, str) or not _B64URL.
|
|
609
|
+
if (jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(x, str) or not _B64URL.fullmatch(x)
|
|
519
610
|
or "d" in jwk or ("use" in jwk and jwk["use"] != "sig")):
|
|
520
611
|
return {"problem": "unusable-key"}
|
|
521
612
|
raw = _crypto.b64url_decode(x)
|
|
@@ -548,7 +639,7 @@ def verify_package(pkg: Any, trust: Dict[str, Any], countersigned: bool = False)
|
|
|
548
639
|
trust = trust if isinstance(trust, dict) else {}
|
|
549
640
|
for index, entry in enumerate(signatures):
|
|
550
641
|
if (not isinstance(entry, dict) or entry.get("role") not in ("publisher", "organization") or not isinstance(entry.get("alg"), str)
|
|
551
|
-
or not isinstance(entry.get("kid"), str) or not isinstance(entry.get("sig"), str) or not _B64URL.
|
|
642
|
+
or not isinstance(entry.get("kid"), str) or not isinstance(entry.get("sig"), str) or not _B64URL.fullmatch(entry["sig"])):
|
|
552
643
|
return {"ok": False, "problem": "malformed", "signature": index}
|
|
553
644
|
if entry["alg"] not in PACKAGE_ALGORITHMS:
|
|
554
645
|
return {"ok": False, "problem": "unknown-algorithm", "signature": index}
|
|
@@ -592,50 +683,142 @@ def consumer_placement(pkg: Any, configured: Any) -> List[Dict[str, str]]:
|
|
|
592
683
|
# --- the folder on disk -----------------------------------------------------------------------------------
|
|
593
684
|
|
|
594
685
|
def _litter(name: str) -> Optional[str]:
|
|
595
|
-
if JUNK.
|
|
686
|
+
if JUNK.fullmatch(name):
|
|
596
687
|
return "litter"
|
|
597
|
-
if SECRET_FILE.
|
|
688
|
+
if SECRET_FILE.fullmatch(name) and not SECRET_FILE_OK.fullmatch(name):
|
|
598
689
|
return "a credential file"
|
|
599
690
|
return None
|
|
600
691
|
|
|
601
692
|
|
|
693
|
+
#: Opening a packaged file: never through a link, never waiting on a pipe (FAA-78, finding 42). Folders are opened
|
|
694
|
+
#: without following a link too, and what is in one is opened relative to it, so a folder swapped for a link mid-walk is
|
|
695
|
+
#: never followed either; where the platform has no such calls (Windows), the walk goes by path.
|
|
696
|
+
_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
697
|
+
_FILE_FLAGS = os.O_RDONLY | _NOFOLLOW | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
|
|
698
|
+
_DIR_FLAGS = os.O_RDONLY | _NOFOLLOW | getattr(os, "O_DIRECTORY", 0)
|
|
699
|
+
_LINK_ERRNOS = (errno.ELOOP, errno.EMLINK) # open() on a link it may not follow: ELOOP (Linux, macOS), EMLINK (FreeBSD)
|
|
700
|
+
_BY_FOLDER = bool(_NOFOLLOW) and hasattr(os, "O_DIRECTORY") and os.open in os.supports_dir_fd and os.scandir in getattr(os, "supports_fd", set())
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _unreadable(rel: str, exc: OSError) -> PackageError:
|
|
704
|
+
"""``Cannot read <path>: EACCES.``: the error's symbolic name, as Node's error.code names it."""
|
|
705
|
+
return PackageError("Cannot read %s: %s." % (rel or ".", errno.errorcode.get(exc.errno or -1) or exc.strerror or exc))
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _read_file(name: str, dir_fd: Optional[int]) -> Tuple[Optional[bytes], Optional[str]]:
|
|
709
|
+
"""``(bytes, None)`` for a regular file, ``(None, why)`` for a link or anything else. Raises OSError."""
|
|
710
|
+
try:
|
|
711
|
+
handle = os.open(name, _FILE_FLAGS, dir_fd=dir_fd)
|
|
712
|
+
except OSError as exc:
|
|
713
|
+
if _NOFOLLOW and exc.errno in _LINK_ERRNOS:
|
|
714
|
+
return None, "a symlink"
|
|
715
|
+
raise
|
|
716
|
+
try:
|
|
717
|
+
if not _stat.S_ISREG(os.fstat(handle).st_mode):
|
|
718
|
+
return None, "not a regular file"
|
|
719
|
+
chunks = []
|
|
720
|
+
while True:
|
|
721
|
+
chunk = os.read(handle, 1 << 20)
|
|
722
|
+
if not chunk:
|
|
723
|
+
return b"".join(chunks), None
|
|
724
|
+
chunks.append(chunk)
|
|
725
|
+
finally:
|
|
726
|
+
os.close(handle)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _walk(here: Any, rel: str, files: Dict[str, bytes], refused: List[str]) -> None:
|
|
730
|
+
"""One folder: ``here`` is its descriptor when the walk goes by folder, else its path."""
|
|
731
|
+
by_fd = isinstance(here, int)
|
|
732
|
+
try:
|
|
733
|
+
with os.scandir(here) as listing:
|
|
734
|
+
entries = sorted(listing, key=lambda e: e.name)
|
|
735
|
+
except OSError as exc:
|
|
736
|
+
raise _unreadable(rel, exc) from None
|
|
737
|
+
for entry in entries:
|
|
738
|
+
where = "%s/%s" % (rel, entry.name) if rel else entry.name
|
|
739
|
+
try:
|
|
740
|
+
why = "a symlink" if entry.is_symlink() else _litter(entry.name)
|
|
741
|
+
folder = entry.is_dir(follow_symlinks=False)
|
|
742
|
+
if why is None and not folder and not entry.is_file(follow_symlinks=False):
|
|
743
|
+
why = "not a regular file"
|
|
744
|
+
if why is None and folder:
|
|
745
|
+
inner = os.open(entry.name, _DIR_FLAGS, dir_fd=here) if by_fd else entry.path
|
|
746
|
+
try:
|
|
747
|
+
_walk(inner, where, files, refused)
|
|
748
|
+
finally:
|
|
749
|
+
if by_fd:
|
|
750
|
+
os.close(inner)
|
|
751
|
+
continue
|
|
752
|
+
if why is None:
|
|
753
|
+
data, why = _read_file(entry.name if by_fd else entry.path, here if by_fd else None)
|
|
754
|
+
if why is None:
|
|
755
|
+
files[where] = data # type: ignore[assignment]
|
|
756
|
+
continue
|
|
757
|
+
except OSError as exc:
|
|
758
|
+
if not (_NOFOLLOW and exc.errno in _LINK_ERRNOS):
|
|
759
|
+
raise _unreadable(where, exc) from None
|
|
760
|
+
why = "a symlink"
|
|
761
|
+
refused.append("%s (%s)" % (where, why))
|
|
762
|
+
|
|
763
|
+
|
|
602
764
|
def read_folder(folder: Any) -> Dict[str, bytes]:
|
|
603
765
|
"""Every regular file under ``folder`` by its forward-slash path. Refuses a symlink, a special file, version-control
|
|
604
|
-
or cache litter, and a file that is a credential by its name
|
|
605
|
-
clean copy.
|
|
766
|
+
or cache litter, and a file that is a credential by its name, each named, so the person can remove it or pack a
|
|
767
|
+
clean copy. A file or folder it cannot read is a refusal naming it (``Cannot read <path>: EACCES.``), never
|
|
768
|
+
skipped (FAA-78, finding 41); a file is opened without following a link and read only when it is a regular file
|
|
769
|
+
(finding 42)."""
|
|
606
770
|
root = Path(folder)
|
|
607
771
|
if not root.is_dir():
|
|
608
772
|
raise PackageError("%s is not a folder." % root)
|
|
609
773
|
files: Dict[str, bytes] = {}
|
|
610
774
|
refused: List[str] = []
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
for name in filenames:
|
|
621
|
-
rel = name if rel_dir == "." else "%s/%s" % (rel_dir, name)
|
|
622
|
-
full = here / name
|
|
623
|
-
why = "a symlink" if full.is_symlink() else (None if full.is_file() else "not a regular file") or _litter(name)
|
|
624
|
-
if why:
|
|
625
|
-
refused.append("%s (%s)" % (rel, why))
|
|
626
|
-
continue
|
|
627
|
-
files[rel] = full.read_bytes()
|
|
775
|
+
try:
|
|
776
|
+
top = os.open(str(root), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) if _BY_FOLDER else str(root)
|
|
777
|
+
except OSError as exc:
|
|
778
|
+
raise _unreadable(str(root), exc) from None
|
|
779
|
+
try:
|
|
780
|
+
_walk(top, "", files, refused)
|
|
781
|
+
finally:
|
|
782
|
+
if _BY_FOLDER:
|
|
783
|
+
os.close(top) # type: ignore[arg-type]
|
|
628
784
|
if refused:
|
|
629
785
|
raise PackageError("Not packed: remove these or pack a clean copy: " + "; ".join(sorted(refused)))
|
|
630
786
|
return files
|
|
631
787
|
|
|
632
788
|
|
|
789
|
+
def _nesting(text: str) -> int:
|
|
790
|
+
"""How deep the arrays and objects of a JSON text nest, read without recursion (strings skipped)."""
|
|
791
|
+
depth = deepest = 0
|
|
792
|
+
i, end = 0, len(text)
|
|
793
|
+
while i < end:
|
|
794
|
+
ch = text[i]
|
|
795
|
+
if ch == '"':
|
|
796
|
+
i += 1
|
|
797
|
+
while i < end and text[i] != '"':
|
|
798
|
+
i += 2 if text[i] == "\\" else 1
|
|
799
|
+
elif ch in "[{":
|
|
800
|
+
depth += 1
|
|
801
|
+
deepest = max(deepest, depth)
|
|
802
|
+
elif ch in "]}":
|
|
803
|
+
depth -= 1
|
|
804
|
+
i += 1
|
|
805
|
+
return deepest
|
|
806
|
+
|
|
807
|
+
|
|
633
808
|
def _load_json(data: bytes, what: str) -> Dict[str, Any]:
|
|
809
|
+
"""A JSON object from strict UTF-8 bytes: no member named twice, nesting at most MAX_NESTING deep. Every refusal
|
|
810
|
+
is a PackageError, never a traceback (a RecursionError included)."""
|
|
634
811
|
try:
|
|
635
|
-
|
|
812
|
+
text = data.decode("utf-8")
|
|
813
|
+
except UnicodeDecodeError as exc:
|
|
814
|
+
raise PackageError("%s is not JSON: %s" % (what, exc)) from None
|
|
815
|
+
if _nesting(text) > MAX_NESTING:
|
|
816
|
+
raise PackageError("%s nests deeper than %d levels." % (what, MAX_NESTING))
|
|
817
|
+
try:
|
|
818
|
+
value = json.loads(text, object_pairs_hook=fd._no_duplicates)
|
|
636
819
|
except fd._Duplicate as exc:
|
|
637
820
|
raise PackageError("%s names %s twice in one object." % (what, json.dumps(exc.args[0], ensure_ascii=False))) from None
|
|
638
|
-
except (ValueError,
|
|
821
|
+
except (ValueError, RecursionError) as exc:
|
|
639
822
|
raise PackageError("%s is not JSON: %s" % (what, exc)) from None
|
|
640
823
|
if not isinstance(value, dict):
|
|
641
824
|
raise PackageError("%s is not a JSON object." % what)
|
|
@@ -677,12 +860,16 @@ def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, A
|
|
|
677
860
|
notes.append("dropped %d signature(s): a repacked package is signed again" % len(draft["signatures"]))
|
|
678
861
|
pkg["files"] = [{"path": p, "sha256": sha256_of(files[p])} for p in sorted((p for p in files if p != PACKAGE_FILE),
|
|
679
862
|
key=lambda p: p.encode("utf-16-be", "surrogatepass"))]
|
|
680
|
-
|
|
863
|
+
try:
|
|
864
|
+
pkg["digest"] = package_digest(pkg)
|
|
865
|
+
except TypeError as exc:
|
|
866
|
+
raise PackageError("%s has no RFC 8785 form, so it has no digest: %s" % (PACKAGE_FILE, exc)) from None
|
|
681
867
|
return pkg, notes
|
|
682
868
|
|
|
683
869
|
|
|
684
870
|
def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
|
|
685
871
|
problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
|
|
872
|
+
problems += [dict(p, rule="path") for p in path_problems(pkg)]
|
|
686
873
|
problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
|
|
687
874
|
problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": p["problem"]} for p in check_package_folder(pkg, files)]
|
|
688
875
|
return problems
|
|
@@ -713,9 +900,9 @@ def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
|
|
|
713
900
|
|
|
714
901
|
|
|
715
902
|
def fd_write(path: Path, text: str) -> None:
|
|
716
|
-
"""Write through a temporary file
|
|
903
|
+
"""Write through a temporary file and rename it over; bytes, as the Node kit writes them (text mode: \r\n on Windows)."""
|
|
717
904
|
temp = path.with_name(".%s.tmp-%d" % (path.name, os.getpid()))
|
|
718
|
-
temp.
|
|
905
|
+
temp.write_bytes(text.encode("utf-8"))
|
|
719
906
|
os.replace(temp, path)
|
|
720
907
|
|
|
721
908
|
|
|
@@ -761,6 +948,17 @@ def verify(folder: Any, trust: Dict[str, Any], countersigned: bool = False) -> D
|
|
|
761
948
|
return {"ok": bool(verdict.get("ok")) and not problems, "verdict": verdict, "problems": problems}
|
|
762
949
|
|
|
763
950
|
|
|
951
|
+
def _emit(text: str) -> None:
|
|
952
|
+
"""One answer line, UTF-8 and \\n as the Node kit writes it, whatever the locale (a Windows pipe is cp1252; FAA-72)."""
|
|
953
|
+
buffer = getattr(sys.stdout, "buffer", None)
|
|
954
|
+
if buffer is None: # a stream a caller put in place takes text
|
|
955
|
+
sys.stdout.write(text + "\n")
|
|
956
|
+
return
|
|
957
|
+
sys.stdout.flush()
|
|
958
|
+
buffer.write((text + "\n").encode("utf-8"))
|
|
959
|
+
buffer.flush()
|
|
960
|
+
|
|
961
|
+
|
|
764
962
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
765
963
|
parser = argparse.ArgumentParser(prog="fabric_package.py", description="Pack, sign and verify a fabric-package/1 folder.")
|
|
766
964
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -792,9 +990,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
792
990
|
trust = _load_json(Path(args.trust).read_bytes(), args.trust)
|
|
793
991
|
answer = verify(args.folder, trust, countersigned=args.countersigned)
|
|
794
992
|
except (PackageError, OSError) as exc:
|
|
795
|
-
|
|
993
|
+
_emit(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, separators=(",", ":")))
|
|
796
994
|
return 1
|
|
797
|
-
|
|
995
|
+
_emit(dumps(answer))
|
|
798
996
|
return 0 if answer.get("ok") else 1
|
|
799
997
|
|
|
800
998
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -36,7 +36,7 @@ MAX_PROBE_MS = 5000
|
|
|
36
36
|
#: excludes nothing by it — and what a lane reads when it reads what a member, a client or the public sends.
|
|
37
37
|
OPEN_VENDOR = "open-vendor"
|
|
38
38
|
USER_CONTENT = "user-content"
|
|
39
|
-
_BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]
|
|
39
|
+
_BARE_BINARY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z")
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
class ProfileError(ValueError):
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py
CHANGED
|
@@ -65,7 +65,7 @@ def secret_value(value: str) -> bool:
|
|
|
65
65
|
def _path_like(piece: str) -> bool:
|
|
66
66
|
return bool(_PATH_LIKE.match(piece)) or (
|
|
67
67
|
bool(re.search(r"[\\/]", piece))
|
|
68
|
-
and (bool(re.search(r"\.[A-Za-z0-9]{1,8}
|
|
68
|
+
and (bool(re.search(r"\.[A-Za-z0-9]{1,8}\Z", piece)) or any(re.fullmatch(r"[a-z][a-z0-9_-]{2,}", s) for s in re.split(r"[\\/]", piece))))
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def secret_argument(value: str) -> bool:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py
CHANGED
|
@@ -12,6 +12,7 @@ import subprocess
|
|
|
12
12
|
import sys
|
|
13
13
|
import tempfile
|
|
14
14
|
import time
|
|
15
|
+
import urllib.error
|
|
15
16
|
import urllib.request
|
|
16
17
|
from pathlib import Path, PurePath
|
|
17
18
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
@@ -40,6 +41,9 @@ from fabric_lock import (
|
|
|
40
41
|
DEFAULT_EXIT_TIMEOUT = 15
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,250}\Z") # what launchd and the Node twin take as a label
|
|
45
|
+
|
|
46
|
+
|
|
43
47
|
def _no_marker_argument(argv: Sequence[str]) -> None:
|
|
44
48
|
"""The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
|
|
45
49
|
if _marker_arguments(list(argv)):
|
|
@@ -64,10 +68,12 @@ def _plain(value: str, what: str) -> str:
|
|
|
64
68
|
return value
|
|
65
69
|
|
|
66
70
|
|
|
67
|
-
def _systemd_quote(value: str) -> str:
|
|
71
|
+
def _systemd_quote(value: str, dollar: bool = True) -> str:
|
|
72
|
+
"""A quoted systemd value. ExecStart= expands $VAR, so its $ is doubled; Environment= expands none, so `dollar`
|
|
73
|
+
False leaves it alone (finding 17). Both expand % specifiers."""
|
|
68
74
|
_plain(value, "an argument")
|
|
69
|
-
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
|
|
70
|
-
return '"%s"' % escaped
|
|
75
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
|
|
76
|
+
return '"%s"' % (escaped.replace("$", "$$") if dollar else escaped)
|
|
71
77
|
|
|
72
78
|
|
|
73
79
|
def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
@@ -96,9 +102,9 @@ def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directo
|
|
|
96
102
|
# Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
|
|
97
103
|
"WorkingDirectory=%s" % cwd.replace("%", "%%")]
|
|
98
104
|
for name in env:
|
|
99
|
-
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]
|
|
105
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*\Z", name):
|
|
100
106
|
raise ServiceError("environment name %r is not a variable name" % name)
|
|
101
|
-
lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v)) for k, v in sorted(env.items())]
|
|
107
|
+
lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v), dollar=False) for k, v in sorted(env.items())]
|
|
102
108
|
lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
|
|
103
109
|
"[Install]", "WantedBy=default.target", ""]
|
|
104
110
|
return "\n".join(lines)
|
|
@@ -237,17 +243,36 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
|
|
|
237
243
|
|
|
238
244
|
``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
|
|
239
245
|
SIGKILL never arrives first. ``FABRIC_SERVICE_SUPERVISOR=launchd`` tells the lock to back off
|
|
240
|
-
instead of exiting into a respawn loop.
|
|
246
|
+
instead of exiting into a respawn loop.
|
|
247
|
+
|
|
248
|
+
The same refusals as the Node twin (finding 10): a label launchd cannot name, no program, an ExitTimeOut that does
|
|
249
|
+
not outlast the drain, an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or
|
|
250
|
+
an argument shaped like a credential, and a control character in any string the plist carries."""
|
|
251
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
252
|
+
if not _LABEL.match(str(label)):
|
|
253
|
+
raise ServiceError("label must match %s" % _LABEL.pattern)
|
|
254
|
+
argv = [str(a) for a in program_arguments]
|
|
255
|
+
if not argv:
|
|
256
|
+
raise ServiceError("A launchd job starts a program; ProgramArguments is empty.")
|
|
257
|
+
if not exit_timeout > DRAIN_SECONDS:
|
|
258
|
+
raise ServiceError("ExitTimeOut must outlast the %d s drain." % DRAIN_SECONDS)
|
|
241
259
|
env = dict(environment or {})
|
|
242
|
-
for key in env:
|
|
243
|
-
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)
|
|
260
|
+
for key, value in env.items():
|
|
261
|
+
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)\Z", key) and not key.endswith("_FILE"):
|
|
244
262
|
raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
|
|
263
|
+
if fabric_secrets.secret_value(str(value)):
|
|
264
|
+
raise ServiceError("Environment %s holds a value shaped like a credential; a plist never carries one." % key)
|
|
265
|
+
for arg in argv:
|
|
266
|
+
if fabric_secrets.secret_argument(arg):
|
|
267
|
+
raise ServiceError("An argument carries a credential; a plist never does (read it from the token file or a secret reference).")
|
|
268
|
+
for value in [str(label), str(working_directory), str(stdout_path)] + argv + [str(x) for pair in env.items() for x in pair]:
|
|
269
|
+
_plain(value, "a plist string")
|
|
245
270
|
env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
|
|
246
|
-
_no_marker_argument(
|
|
271
|
+
_no_marker_argument(argv)
|
|
247
272
|
env[SUPERVISOR_ENV] = "launchd"
|
|
248
273
|
plist = {
|
|
249
274
|
"Label": label,
|
|
250
|
-
"ProgramArguments":
|
|
275
|
+
"ProgramArguments": argv,
|
|
251
276
|
"WorkingDirectory": str(working_directory),
|
|
252
277
|
"RunAtLoad": True,
|
|
253
278
|
"KeepAlive": True,
|
|
@@ -287,7 +312,7 @@ def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, An
|
|
|
287
312
|
return None
|
|
288
313
|
|
|
289
314
|
|
|
290
|
-
_OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s
|
|
315
|
+
_OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*\Z')
|
|
291
316
|
|
|
292
317
|
|
|
293
318
|
def launchd_override(label: str) -> Optional[str]:
|