factmesh 0.1.0__py3-none-any.whl
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.
- factmesh/__init__.py +3 -0
- factmesh/check.py +155 -0
- factmesh/claim.py +279 -0
- factmesh/cli.py +181 -0
- factmesh/config.py +48 -0
- factmesh/event.py +110 -0
- factmesh/fetch.py +85 -0
- factmesh/home.py +26 -0
- factmesh/keys.py +26 -0
- factmesh/mcp_server.py +197 -0
- factmesh/receiver.py +212 -0
- factmesh/relay.py +262 -0
- factmesh/reputation.py +45 -0
- factmesh/seed.py +118 -0
- factmesh/store.py +106 -0
- factmesh/validate.py +76 -0
- factmesh-0.1.0.dist-info/METADATA +136 -0
- factmesh-0.1.0.dist-info/RECORD +21 -0
- factmesh-0.1.0.dist-info/WHEEL +4 -0
- factmesh-0.1.0.dist-info/entry_points.txt +2 -0
- factmesh-0.1.0.dist-info/licenses/LICENSE +202 -0
factmesh/__init__.py
ADDED
factmesh/check.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Running a claim's check against the web (SPEC.md §4.4, §4.5)."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
from .claim import Rejected, check_binding, url_parts
|
|
9
|
+
|
|
10
|
+
MAX_REDIRECTS = 3
|
|
11
|
+
REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Response:
|
|
16
|
+
status: int
|
|
17
|
+
headers: dict
|
|
18
|
+
body: bytes
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FetchError(Exception):
|
|
22
|
+
"""No conclusion: network failure, timeout, or size limit."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CheckResult:
|
|
27
|
+
result: str # "pass", "fail", or "error"
|
|
28
|
+
detail: str = ""
|
|
29
|
+
final_url: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_public(address):
|
|
33
|
+
ip = ipaddress.ip_address(address)
|
|
34
|
+
if ip.version == 6 and ip.ipv4_mapped:
|
|
35
|
+
ip = ip.ipv4_mapped
|
|
36
|
+
return ip.is_global and not ip.is_multicast
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_pointer(doc, pointer):
|
|
40
|
+
if pointer == "":
|
|
41
|
+
return doc
|
|
42
|
+
node = doc
|
|
43
|
+
for raw in pointer[1:].split("/"):
|
|
44
|
+
token = raw.replace("~1", "/").replace("~0", "~")
|
|
45
|
+
if isinstance(node, dict) and token in node:
|
|
46
|
+
node = node[token]
|
|
47
|
+
elif (isinstance(node, list) and token.isdigit()
|
|
48
|
+
and (token == "0" or not token.startswith("0")) and int(token) < len(node)):
|
|
49
|
+
node = node[int(token)]
|
|
50
|
+
else:
|
|
51
|
+
raise KeyError(pointer)
|
|
52
|
+
return node
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def json_equal(a, b):
|
|
56
|
+
"""Strict JSON equality for scalars: bool is not a number, 1 == 1.0."""
|
|
57
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
58
|
+
return type(a) is type(b) and a == b
|
|
59
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
60
|
+
return a == b
|
|
61
|
+
return type(a) is type(b) and a == b
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _compare(check, resp):
|
|
65
|
+
kind = check["kind"]
|
|
66
|
+
if kind == "http-status":
|
|
67
|
+
ok = resp.status == check["expect"]
|
|
68
|
+
return CheckResult("pass" if ok else "fail", f"status {resp.status}")
|
|
69
|
+
if resp.status != 200:
|
|
70
|
+
return CheckResult("fail", f"status {resp.status}")
|
|
71
|
+
try:
|
|
72
|
+
text = resp.body.decode("utf-8")
|
|
73
|
+
except UnicodeDecodeError:
|
|
74
|
+
return CheckResult("fail", "body is not UTF-8")
|
|
75
|
+
if kind == "text-contains":
|
|
76
|
+
ok = check["expect"] in text
|
|
77
|
+
return CheckResult("pass" if ok else "fail", "text found" if ok else "text not found")
|
|
78
|
+
try:
|
|
79
|
+
doc = json.loads(text)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return CheckResult("fail", "body is not JSON")
|
|
82
|
+
except RecursionError:
|
|
83
|
+
return CheckResult("fail", "JSON nested too deeply")
|
|
84
|
+
try:
|
|
85
|
+
value = _resolve_pointer(doc, check["pointer"])
|
|
86
|
+
except KeyError:
|
|
87
|
+
return CheckResult("fail", "pointer does not resolve")
|
|
88
|
+
if isinstance(value, (dict, list)):
|
|
89
|
+
return CheckResult("fail", "pointer refers to an object or array")
|
|
90
|
+
ok = json_equal(value, check["expect"])
|
|
91
|
+
return CheckResult("pass" if ok else "fail", f"value {json.dumps(value)[:80]}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_check(topic, check, fetcher):
|
|
95
|
+
"""Run one check. `fetcher` provides resolve(host) and get(url, address).
|
|
96
|
+
|
|
97
|
+
Exceptions other than FetchError propagate, so a test harness can stop a
|
|
98
|
+
receiver that makes a request it should not.
|
|
99
|
+
"""
|
|
100
|
+
got = fetch(topic, check["url"], check["kind"] != "http-status", fetcher)
|
|
101
|
+
if isinstance(got, CheckResult):
|
|
102
|
+
return got
|
|
103
|
+
resp, url = got
|
|
104
|
+
r = _compare(check, resp)
|
|
105
|
+
return CheckResult(r.result, r.detail, url)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def observe_json(topic, url, pointer, fetcher):
|
|
109
|
+
"""The current value at `pointer` in the JSON at `url`, fetched under the
|
|
110
|
+
same rules as a check. Returns (value, None) or (None, reason)."""
|
|
111
|
+
got = fetch(topic, url, True, fetcher)
|
|
112
|
+
if isinstance(got, CheckResult):
|
|
113
|
+
return None, got.detail
|
|
114
|
+
resp, _ = got
|
|
115
|
+
if resp.status != 200:
|
|
116
|
+
return None, f"status {resp.status}"
|
|
117
|
+
try:
|
|
118
|
+
value = _resolve_pointer(json.loads(resp.body.decode("utf-8")), pointer)
|
|
119
|
+
except (ValueError, KeyError, RecursionError, UnicodeDecodeError):
|
|
120
|
+
return None, "no value at pointer"
|
|
121
|
+
if isinstance(value, (dict, list)):
|
|
122
|
+
return None, "pointer refers to an object or array"
|
|
123
|
+
return value, None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def fetch(topic, url, follow, fetcher):
|
|
127
|
+
"""GET `url` under SPEC.md §4.4, following redirects inside the topic
|
|
128
|
+
binding if `follow`. Returns (Response, final url) or an error
|
|
129
|
+
CheckResult."""
|
|
130
|
+
for hop in range(MAX_REDIRECTS + 1):
|
|
131
|
+
host, _ = url_parts(url)
|
|
132
|
+
try:
|
|
133
|
+
addresses = fetcher.resolve(host)
|
|
134
|
+
except FetchError as e:
|
|
135
|
+
return CheckResult("error", f"resolve {host}: {e}", url)
|
|
136
|
+
if not addresses or not all(is_public(a) for a in addresses):
|
|
137
|
+
return CheckResult("error", f"{host} resolves to a non-public address", url)
|
|
138
|
+
try:
|
|
139
|
+
resp = fetcher.get(url, addresses[0])
|
|
140
|
+
except FetchError as e:
|
|
141
|
+
return CheckResult("error", str(e), url)
|
|
142
|
+
|
|
143
|
+
if not (follow and resp.status in REDIRECT_STATUSES):
|
|
144
|
+
return resp, url
|
|
145
|
+
if hop == MAX_REDIRECTS:
|
|
146
|
+
return CheckResult("error", "too many redirects", url)
|
|
147
|
+
location = resp.headers.get("location")
|
|
148
|
+
if not location:
|
|
149
|
+
return CheckResult("error", "redirect without location", url)
|
|
150
|
+
url = urljoin(url, location)
|
|
151
|
+
try:
|
|
152
|
+
check_binding(topic, url)
|
|
153
|
+
except Rejected as e:
|
|
154
|
+
return CheckResult("error", f"redirect not allowed: {e}", url)
|
|
155
|
+
raise AssertionError("unreachable")
|
factmesh/claim.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Claim files: parsing and the offline rules of SPEC.md §2–§4."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
MAX_CONTENT_BYTES = 8192
|
|
12
|
+
MAX_LIFETIME = timedelta(days=7)
|
|
13
|
+
MAX_DEPTH = 4
|
|
14
|
+
MAX_URL_LENGTH = 300
|
|
15
|
+
ALLOWLIST = frozenset({"registry.npmjs.org", "pypi.org", "api.github.com", "github.com"})
|
|
16
|
+
|
|
17
|
+
CHECK_FIELDS = {
|
|
18
|
+
"http-status": {"kind", "url", "expect"},
|
|
19
|
+
"json-field-equals": {"kind", "url", "pointer", "expect"},
|
|
20
|
+
"text-contains": {"kind", "url", "expect"},
|
|
21
|
+
}
|
|
22
|
+
QUESTION_FIELDS = {
|
|
23
|
+
"http-status": ("kind", "url"),
|
|
24
|
+
"json-field-equals": ("kind", "url", "pointer"),
|
|
25
|
+
"text-contains": ("kind", "url", "expect"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_NPM_PART = r"[a-z0-9][a-z0-9._~-]{0,213}"
|
|
29
|
+
TOPIC_PATTERNS = {
|
|
30
|
+
"npm": re.compile(rf"(?:@{_NPM_PART}/)?{_NPM_PART}"),
|
|
31
|
+
"pypi": re.compile(r"[a-z0-9](?:[a-z0-9]|-(?!-))*(?<!-)"),
|
|
32
|
+
"github": re.compile(r"[a-z0-9](?:[a-z0-9]|-(?!-)){0,38}/[a-z0-9._-]{1,100}"),
|
|
33
|
+
}
|
|
34
|
+
_TIME_STRING = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Rejected(Exception):
|
|
38
|
+
"""A claim or event broke a rule. `reason` is a SPEC.md §7 code."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, reason, detail=""):
|
|
41
|
+
super().__init__(f"{reason}: {detail}" if detail else reason)
|
|
42
|
+
self.reason = reason
|
|
43
|
+
self.detail = detail
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Claim:
|
|
48
|
+
topic: str
|
|
49
|
+
check: dict
|
|
50
|
+
generated_by: str
|
|
51
|
+
generated_at: datetime
|
|
52
|
+
stale_after: datetime
|
|
53
|
+
status: str
|
|
54
|
+
header: dict = field(repr=False)
|
|
55
|
+
content: str = field(repr=False)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def key(self):
|
|
59
|
+
return claim_key(self.check)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def retracted(self):
|
|
63
|
+
return self.status == "deprecated"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- YAML -------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
class _StrictLoader(yaml.SafeLoader):
|
|
69
|
+
"""SafeLoader that refuses repeated mapping keys."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _construct_mapping(loader, node, deep=False):
|
|
73
|
+
seen = set()
|
|
74
|
+
for key_node, _ in node.value:
|
|
75
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
76
|
+
if key in seen:
|
|
77
|
+
raise Rejected("bad-header", f"repeated key {key!r}")
|
|
78
|
+
seen.add(key)
|
|
79
|
+
return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
|
83
|
+
_construct_mapping)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _depth(value):
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
return 1 + max((_depth(v) for v in value.values()), default=0)
|
|
89
|
+
if isinstance(value, list):
|
|
90
|
+
return 1 + max((_depth(v) for v in value), default=0)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_header(content):
|
|
95
|
+
"""Split a claim file and load its YAML header (SPEC.md §2.1)."""
|
|
96
|
+
if not content.startswith("---\n"):
|
|
97
|
+
raise Rejected("bad-header", "file does not start with a --- line")
|
|
98
|
+
lines = content.split("\n")
|
|
99
|
+
try:
|
|
100
|
+
end = lines.index("---", 1)
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise Rejected("bad-header", "no closing --- line") from None
|
|
103
|
+
text = "\n".join(lines[1:end])
|
|
104
|
+
try:
|
|
105
|
+
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
|
106
|
+
if isinstance(token, (yaml.AnchorToken, yaml.AliasToken, yaml.TagToken)):
|
|
107
|
+
raise Rejected("bad-header", f"{type(token).__name__[:-5].lower()} not allowed")
|
|
108
|
+
header = yaml.load(text, Loader=_StrictLoader)
|
|
109
|
+
except yaml.YAMLError as e:
|
|
110
|
+
raise Rejected("bad-header", str(e).splitlines()[0]) from None
|
|
111
|
+
if not isinstance(header, dict):
|
|
112
|
+
raise Rejected("bad-header", "header is not a mapping")
|
|
113
|
+
if _depth(header) > MAX_DEPTH:
|
|
114
|
+
raise Rejected("bad-header", f"nests deeper than {MAX_DEPTH} levels")
|
|
115
|
+
return header
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- fields -----------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def parse_time(value, name):
|
|
121
|
+
if isinstance(value, datetime):
|
|
122
|
+
if value.tzinfo is None or value.utcoffset() != timedelta(0):
|
|
123
|
+
raise Rejected("bad-field", f"{name} must be UTC with a Z suffix")
|
|
124
|
+
return value.astimezone(timezone.utc)
|
|
125
|
+
if isinstance(value, str) and _TIME_STRING.fullmatch(value):
|
|
126
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
127
|
+
raise Rejected("bad-field", f"{name} is not a UTC date-time")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fields(header):
|
|
131
|
+
if header.get("type") != "Claim":
|
|
132
|
+
raise Rejected("bad-field", "type is not Claim")
|
|
133
|
+
for name in ("topic", "check", "generated", "stale_after"):
|
|
134
|
+
if name not in header:
|
|
135
|
+
raise Rejected("bad-field", f"missing {name}")
|
|
136
|
+
if not isinstance(header["topic"], str):
|
|
137
|
+
raise Rejected("bad-field", "topic is not a string")
|
|
138
|
+
gen = header["generated"]
|
|
139
|
+
if not isinstance(gen, dict) or not isinstance(gen.get("by"), str) or not gen["by"]:
|
|
140
|
+
raise Rejected("bad-field", "generated.by missing")
|
|
141
|
+
if "at" not in gen:
|
|
142
|
+
raise Rejected("bad-field", "generated.at missing")
|
|
143
|
+
generated_at = parse_time(gen["at"], "generated.at")
|
|
144
|
+
stale_after = parse_time(header["stale_after"], "stale_after")
|
|
145
|
+
if stale_after <= generated_at:
|
|
146
|
+
raise Rejected("bad-field", "stale_after is not after generated.at")
|
|
147
|
+
if stale_after - generated_at > MAX_LIFETIME:
|
|
148
|
+
raise Rejected("bad-field", "lifetime over 7 days")
|
|
149
|
+
status = header.get("status", "stable")
|
|
150
|
+
if status not in ("stable", "deprecated"):
|
|
151
|
+
raise Rejected("bad-field", f"unknown status {status!r}")
|
|
152
|
+
for name, kind in (("title", str), ("tags", list), ("sources", list)):
|
|
153
|
+
if name in header and not isinstance(header[name], kind):
|
|
154
|
+
raise Rejected("bad-field", f"{name} has the wrong type")
|
|
155
|
+
return gen["by"], generated_at, stale_after, status
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate_topic(topic):
|
|
159
|
+
prefix, sep, name = topic.partition(":")
|
|
160
|
+
pattern = TOPIC_PATTERNS.get(prefix)
|
|
161
|
+
if not sep or pattern is None or not pattern.fullmatch(name):
|
|
162
|
+
raise Rejected("bad-topic", topic)
|
|
163
|
+
if prefix == "github" and name.split("/")[1] in (".", ".."):
|
|
164
|
+
raise Rejected("bad-topic", topic)
|
|
165
|
+
return prefix, name
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_json_scalar(v):
|
|
169
|
+
return v is None or isinstance(v, (str, bool, int, float))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def validate_check(check):
|
|
173
|
+
if not isinstance(check, dict):
|
|
174
|
+
raise Rejected("bad-check", "check is not a mapping")
|
|
175
|
+
kind = check.get("kind")
|
|
176
|
+
if kind not in CHECK_FIELDS:
|
|
177
|
+
raise Rejected("bad-check", f"unknown kind {kind!r}")
|
|
178
|
+
if set(check) != CHECK_FIELDS[kind]:
|
|
179
|
+
raise Rejected("bad-check", f"fields must be exactly {sorted(CHECK_FIELDS[kind])}")
|
|
180
|
+
if not isinstance(check["url"], str):
|
|
181
|
+
raise Rejected("bad-check", "url is not a string")
|
|
182
|
+
expect = check["expect"]
|
|
183
|
+
if kind == "http-status":
|
|
184
|
+
if not isinstance(expect, int) or isinstance(expect, bool) or not 100 <= expect <= 599:
|
|
185
|
+
raise Rejected("bad-check", "expect must be an integer 100-599")
|
|
186
|
+
elif kind == "json-field-equals":
|
|
187
|
+
pointer = check["pointer"]
|
|
188
|
+
if not isinstance(pointer, str) or (pointer and not pointer.startswith("/")):
|
|
189
|
+
raise Rejected("bad-check", "pointer is not a JSON Pointer")
|
|
190
|
+
if not _is_json_scalar(expect):
|
|
191
|
+
raise Rejected("bad-check", "expect must be a JSON scalar")
|
|
192
|
+
elif not isinstance(expect, str) or not 8 <= len(expect) <= 200:
|
|
193
|
+
raise Rejected("bad-check", "expect must be a string of 8-200 characters")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --- URLs -------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def url_parts(url):
|
|
199
|
+
"""Return (host, path segments) or raise bad-url (SPEC.md §4.2).
|
|
200
|
+
|
|
201
|
+
Works on the string as written: no normalization, so `..` and encoded
|
|
202
|
+
characters cannot slip past the binding rules.
|
|
203
|
+
"""
|
|
204
|
+
if len(url) > MAX_URL_LENGTH:
|
|
205
|
+
raise Rejected("bad-url", f"longer than {MAX_URL_LENGTH} characters")
|
|
206
|
+
if not url.startswith("https://"):
|
|
207
|
+
raise Rejected("bad-url", "scheme must be https")
|
|
208
|
+
if any(c in url for c in "%?#") or any(c.isspace() for c in url):
|
|
209
|
+
raise Rejected("bad-url", "no %, query, fragment, or whitespace allowed")
|
|
210
|
+
rest = url[len("https://"):]
|
|
211
|
+
host, slash, path = rest.partition("/")
|
|
212
|
+
if "@" in host or ":" in host:
|
|
213
|
+
raise Rejected("bad-url", "no user information or port allowed")
|
|
214
|
+
if host not in ALLOWLIST:
|
|
215
|
+
raise Rejected("bad-url", f"host {host!r} not on the allowlist")
|
|
216
|
+
return host, path.split("/") if slash else []
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def check_binding(topic, url):
|
|
220
|
+
"""Raise topic-mismatch unless `url` is an allowed path for `topic` (§4.3)."""
|
|
221
|
+
prefix, name = validate_topic(topic)
|
|
222
|
+
host, segs = url_parts(url)
|
|
223
|
+
if not segs or any(s in ("", ".", "..") for s in segs):
|
|
224
|
+
raise Rejected("topic-mismatch", "empty or dot path segment")
|
|
225
|
+
name_segs = name.split("/")
|
|
226
|
+
n = len(name_segs)
|
|
227
|
+
|
|
228
|
+
ok = False
|
|
229
|
+
if prefix == "npm" and host == "registry.npmjs.org":
|
|
230
|
+
ok = (segs == ["-", "package", *name_segs, "dist-tags"]
|
|
231
|
+
or segs[:n] == name_segs and len(segs) in (n, n + 1))
|
|
232
|
+
elif prefix == "pypi" and host == "pypi.org":
|
|
233
|
+
ok = segs[:2] == ["pypi", name] and segs[-1] == "json" and len(segs) in (3, 4)
|
|
234
|
+
elif prefix == "github" and host == "api.github.com":
|
|
235
|
+
ok = segs[:3] == ["repos", *name_segs]
|
|
236
|
+
elif prefix == "github" and host == "github.com":
|
|
237
|
+
ok = segs[:2] == name_segs
|
|
238
|
+
if not ok:
|
|
239
|
+
raise Rejected("topic-mismatch", f"{url} is not allowed for {topic}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- whole claim ------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def claim_key(check):
|
|
245
|
+
"""First 32 hex chars of SHA-256 over the check's question fields (§5.1)."""
|
|
246
|
+
names = QUESTION_FIELDS.get(check.get("kind"), ("kind", "url"))
|
|
247
|
+
q = {k: check[k] for k in names if k in check}
|
|
248
|
+
canon = json.dumps(q, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
249
|
+
return hashlib.sha256(canon.encode()).hexdigest()[:32]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def parse_claim(content):
|
|
253
|
+
"""Parse and statically validate a claim file (SPEC.md §7 steps 5-11)."""
|
|
254
|
+
if len(content.encode()) > MAX_CONTENT_BYTES:
|
|
255
|
+
raise Rejected("too-large", f"over {MAX_CONTENT_BYTES} bytes")
|
|
256
|
+
header = parse_header(content)
|
|
257
|
+
by, generated_at, stale_after, status = _fields(header)
|
|
258
|
+
validate_topic(header["topic"])
|
|
259
|
+
validate_check(header["check"])
|
|
260
|
+
url_parts(header["check"]["url"])
|
|
261
|
+
check_binding(header["topic"], header["check"]["url"])
|
|
262
|
+
return Claim(topic=header["topic"], check=header["check"], generated_by=by,
|
|
263
|
+
generated_at=generated_at, stale_after=stale_after, status=status,
|
|
264
|
+
header=header, content=content)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def claim_text(topic, kind, url, expect, pointer, hours, agent, now: datetime):
|
|
268
|
+
"""Build a claim file. Values are written as JSON scalars, which YAML
|
|
269
|
+
reads back unchanged."""
|
|
270
|
+
z = lambda t: t.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
271
|
+
check = [f" kind: {json.dumps(kind)}", f" url: {json.dumps(url)}"]
|
|
272
|
+
if pointer is not None:
|
|
273
|
+
check.append(f" pointer: {json.dumps(pointer)}")
|
|
274
|
+
check.append(f" expect: {json.dumps(expect)}")
|
|
275
|
+
return ("---\ntype: Claim\n"
|
|
276
|
+
f"topic: {json.dumps(topic)}\n"
|
|
277
|
+
f"generated: {{ by: {json.dumps(agent)}, at: {z(now)} }}\n"
|
|
278
|
+
f"stale_after: {z(now + timedelta(hours=hours))}\n"
|
|
279
|
+
"check:\n" + "\n".join(check) + "\n---\n")
|
factmesh/cli.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Command line: check, sign, publish, and look up claims; run the MCP server;
|
|
2
|
+
show or rotate the key."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import keys
|
|
13
|
+
from .check import run_check
|
|
14
|
+
from .claim import Rejected, parse_claim
|
|
15
|
+
from .fetch import Fetcher
|
|
16
|
+
from .receiver import Receiver
|
|
17
|
+
from .relay import RelayPool
|
|
18
|
+
from .reputation import Reputation
|
|
19
|
+
from .store import Store
|
|
20
|
+
from .validate import validate_event, validate_file
|
|
21
|
+
|
|
22
|
+
EXIT_CODES = {"pass": 0, "fail": 1, "rejected": 2, "error": 3, "retracted": 4}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _now(value):
|
|
26
|
+
if value is None:
|
|
27
|
+
return datetime.now(timezone.utc)
|
|
28
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_check(args):
|
|
32
|
+
text = Path(args.path).read_text()
|
|
33
|
+
now = _now(args.now)
|
|
34
|
+
if args.path.endswith(".json"):
|
|
35
|
+
doc = json.loads(text)
|
|
36
|
+
result = validate_event(doc.get("event", doc), now, Fetcher())
|
|
37
|
+
else:
|
|
38
|
+
result = validate_file(text, now, Fetcher())
|
|
39
|
+
line = result.result
|
|
40
|
+
if result.reason:
|
|
41
|
+
line += f": {result.reason}"
|
|
42
|
+
if result.detail:
|
|
43
|
+
line += f" ({result.detail})"
|
|
44
|
+
print(line)
|
|
45
|
+
return EXIT_CODES[result.result]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_sign(args):
|
|
49
|
+
try:
|
|
50
|
+
claim = parse_claim(Path(args.path).read_text())
|
|
51
|
+
except Rejected as e:
|
|
52
|
+
print(f"not signed: {e}", file=sys.stderr)
|
|
53
|
+
return EXIT_CODES["rejected"]
|
|
54
|
+
ev = keys.load_signer().claim_event(claim, int(claim.generated_at.timestamp()))
|
|
55
|
+
print(json.dumps(ev, indent=2, ensure_ascii=False))
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_publish(args):
|
|
60
|
+
try:
|
|
61
|
+
claim = parse_claim(Path(args.path).read_text())
|
|
62
|
+
except Rejected as e:
|
|
63
|
+
print(f"not published: {e}", file=sys.stderr)
|
|
64
|
+
return EXIT_CODES["rejected"]
|
|
65
|
+
if claim.retracted:
|
|
66
|
+
print("publishing a retraction; its check is not run", file=sys.stderr)
|
|
67
|
+
else:
|
|
68
|
+
r = run_check(claim.topic, claim.check, Fetcher())
|
|
69
|
+
if r.result != "pass":
|
|
70
|
+
print(f"not published: own check gave {r.result} ({r.detail})", file=sys.stderr)
|
|
71
|
+
return EXIT_CODES[r.result]
|
|
72
|
+
ev = keys.load_signer().claim_event(claim, int(claim.generated_at.timestamp()))
|
|
73
|
+
status = asyncio.run(RelayPool(args.relay or None).publish(ev))
|
|
74
|
+
for url, s in status.items():
|
|
75
|
+
print(f"{url} {s}")
|
|
76
|
+
confirmed = sum(s == "confirmed" for s in status.values())
|
|
77
|
+
print(f"{confirmed} of {len(status)} relays confirmed event {ev['id']}")
|
|
78
|
+
return 0 if confirmed else EXIT_CODES["error"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_lookup(args):
|
|
82
|
+
receiver = Receiver(RelayPool(args.relay or None), Store(), Reputation(), Fetcher())
|
|
83
|
+
found, stats = asyncio.run(receiver.lookup(args.topic))
|
|
84
|
+
print(json.dumps({"facts": found, "stats": stats.__dict__}, indent=2))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_seed(args):
|
|
89
|
+
from .event import Signer
|
|
90
|
+
from .seed import load_targets, run_seed
|
|
91
|
+
try:
|
|
92
|
+
targets = load_targets(args.packages)
|
|
93
|
+
except ValueError as e:
|
|
94
|
+
print(e, file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
if args.key_env:
|
|
97
|
+
secret = os.environ.get(args.key_env, "").strip()
|
|
98
|
+
if len(secret) != 64:
|
|
99
|
+
print(f"{args.key_env} must hold a 64-character hex key", file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
signer = Signer(bytes.fromhex(secret))
|
|
102
|
+
else:
|
|
103
|
+
signer = keys.load_signer()
|
|
104
|
+
report = asyncio.run(run_seed(targets, RelayPool(args.relay or None), signer, Fetcher(),
|
|
105
|
+
valid_hours=args.valid_hours,
|
|
106
|
+
refresh_hours=args.refresh_hours, dry_run=args.dry_run))
|
|
107
|
+
print(f"key {signer.pubkey}")
|
|
108
|
+
for topic, value in report.would_publish:
|
|
109
|
+
print(f"would publish {topic} = {value}")
|
|
110
|
+
unconfirmed = 0
|
|
111
|
+
for topic, value, status in report.published:
|
|
112
|
+
ok = sum(s == "confirmed" for s in status.values())
|
|
113
|
+
unconfirmed += ok == 0
|
|
114
|
+
print(f"published {topic} = {value} ({ok}/{len(status)} relays)")
|
|
115
|
+
for topic, why in report.skipped:
|
|
116
|
+
print(f"skipped {topic}: {why}")
|
|
117
|
+
print(f"{len(report.published)} published, {len(report.would_publish)} would publish, "
|
|
118
|
+
f"{len(report.fresh)} already current, {len(report.skipped)} skipped")
|
|
119
|
+
return 1 if unconfirmed else 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_mcp(args):
|
|
123
|
+
from .mcp_server import main as serve
|
|
124
|
+
serve()
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_key(args):
|
|
129
|
+
signer = keys.rotate() if args.rotate else keys.load_signer()
|
|
130
|
+
print(signer.pubkey)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv=None):
|
|
135
|
+
ap = argparse.ArgumentParser(prog="factmesh", description=__doc__)
|
|
136
|
+
sub = ap.add_subparsers(required=True)
|
|
137
|
+
|
|
138
|
+
p = sub.add_parser("check", help="validate a claim file (.md) or event (.json) "
|
|
139
|
+
"and run its check against the web")
|
|
140
|
+
p.add_argument("path")
|
|
141
|
+
p.add_argument("--now", help="override the current time (ISO 8601, UTC)")
|
|
142
|
+
p.set_defaults(func=cmd_check)
|
|
143
|
+
|
|
144
|
+
p = sub.add_parser("sign", help="sign a claim file with this installation's key")
|
|
145
|
+
p.add_argument("path")
|
|
146
|
+
p.set_defaults(func=cmd_sign)
|
|
147
|
+
|
|
148
|
+
p = sub.add_parser("publish", help="check a claim file yourself, sign it, and "
|
|
149
|
+
"send it to relays")
|
|
150
|
+
p.add_argument("path")
|
|
151
|
+
p.add_argument("--relay", action="append", help="relay URL (repeatable)")
|
|
152
|
+
p.set_defaults(func=cmd_publish)
|
|
153
|
+
|
|
154
|
+
p = sub.add_parser("lookup", help="fetch, verify, and print facts for a topic")
|
|
155
|
+
p.add_argument("topic")
|
|
156
|
+
p.add_argument("--relay", action="append", help="relay URL (repeatable)")
|
|
157
|
+
p.set_defaults(func=cmd_lookup)
|
|
158
|
+
|
|
159
|
+
p = sub.add_parser("seed", help="publish latest-version claims for a package list")
|
|
160
|
+
p.add_argument("--packages", default="seed/packages.toml")
|
|
161
|
+
p.add_argument("--key-env", help="environment variable holding the signing key (hex)")
|
|
162
|
+
p.add_argument("--valid-hours", type=int, default=24)
|
|
163
|
+
p.add_argument("--refresh-hours", type=int, default=6,
|
|
164
|
+
help="republish when the current claim expires within this many hours")
|
|
165
|
+
p.add_argument("--dry-run", action="store_true", help="show what would be published")
|
|
166
|
+
p.add_argument("--relay", action="append", help="relay URL (repeatable)")
|
|
167
|
+
p.set_defaults(func=cmd_seed)
|
|
168
|
+
|
|
169
|
+
p = sub.add_parser("mcp", help="run the MCP server on stdio, for agents")
|
|
170
|
+
p.set_defaults(func=cmd_mcp)
|
|
171
|
+
|
|
172
|
+
p = sub.add_parser("key", help="print this installation's public key")
|
|
173
|
+
p.add_argument("--rotate", action="store_true", help="replace it with a new key")
|
|
174
|
+
p.set_defaults(func=cmd_key)
|
|
175
|
+
|
|
176
|
+
args = ap.parse_args(argv)
|
|
177
|
+
return args.func(args)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
sys.exit(main())
|
factmesh/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""User settings, in <home>/config.toml. Created with defaults on first use so
|
|
2
|
+
the user can find and edit it."""
|
|
3
|
+
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .home import home, write_atomic
|
|
10
|
+
from .relay import DEFAULT_RELAYS
|
|
11
|
+
|
|
12
|
+
DEFAULT_TEXT = f'''# factmesh client settings.
|
|
13
|
+
|
|
14
|
+
# Let your agent publish claims to the network. Off by default: with it off,
|
|
15
|
+
# the agent can look up and verify facts but never sends anything.
|
|
16
|
+
publish = false
|
|
17
|
+
|
|
18
|
+
# Relays to read from and publish to.
|
|
19
|
+
relays = [
|
|
20
|
+
{"".join(f' "{r}",{chr(10)}' for r in DEFAULT_RELAYS)}]
|
|
21
|
+
|
|
22
|
+
# How published claims name their author. Name the software, never a person.
|
|
23
|
+
agent = "factmesh-mcp/{__version__}"
|
|
24
|
+
'''
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Config:
|
|
29
|
+
path: Path
|
|
30
|
+
publish: bool = False
|
|
31
|
+
relays: list = field(default_factory=lambda: list(DEFAULT_RELAYS))
|
|
32
|
+
agent: str = f"factmesh-mcp/{__version__}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load(path: Path | None = None) -> Config:
|
|
36
|
+
path = Path(path) if path else home() / "config.toml"
|
|
37
|
+
if not path.exists():
|
|
38
|
+
write_atomic(path, DEFAULT_TEXT.encode())
|
|
39
|
+
data = tomllib.loads(path.read_text())
|
|
40
|
+
cfg = Config(path=path)
|
|
41
|
+
if isinstance(data.get("publish"), bool):
|
|
42
|
+
cfg.publish = data["publish"]
|
|
43
|
+
if isinstance(data.get("relays"), list) and data["relays"]:
|
|
44
|
+
cfg.relays = [r for r in data["relays"]
|
|
45
|
+
if isinstance(r, str) and r.startswith(("wss://", "ws://"))]
|
|
46
|
+
if isinstance(data.get("agent"), str) and data["agent"].strip():
|
|
47
|
+
cfg.agent = data["agent"].strip()
|
|
48
|
+
return cfg
|