sourcelock 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.
- hc_source/__init__.py +5 -0
- hc_source/adapters/__init__.py +500 -0
- hc_source/adapters/_demo.py +258 -0
- hc_source/adapters/_demo_fixture.json +25 -0
- hc_source/adapters/_leie_sample.csv +15 -0
- hc_source/adapters/codes.py +1232 -0
- hc_source/adapters/coverage.py +1569 -0
- hc_source/adapters/hcc.py +1450 -0
- hc_source/adapters/leie.py +1310 -0
- hc_source/adapters/provider.py +1159 -0
- hc_source/cache.py +664 -0
- hc_source/cli.py +959 -0
- hc_source/cli_manifest.py +207 -0
- hc_source/data/codes/hcpcs_2026q3.csv.gz +0 -0
- hc_source/data/codes/icd10cm_fy2026.csv.gz +0 -0
- hc_source/data/codes/icd10cm_fy2027.csv.gz +0 -0
- hc_source/data/codes/manifest.json +75 -0
- hc_source/data/codes/regenerate.py +291 -0
- hc_source/data/hcc/hcc_data.json.zlib +0 -0
- hc_source/doctor.py +472 -0
- hc_source/guard.py +877 -0
- hc_source/http.py +541 -0
- hc_source/interfaces.py +395 -0
- hc_source/lockfile.py +236 -0
- hc_source/manifest.py +422 -0
- hc_source/mcp_server.py +203 -0
- hc_source/npi.py +50 -0
- hc_source/receipts.py +74 -0
- hc_source/schemas.py +339 -0
- sourcelock-0.1.0.dist-info/METADATA +272 -0
- sourcelock-0.1.0.dist-info/RECORD +34 -0
- sourcelock-0.1.0.dist-info/WHEEL +4 -0
- sourcelock-0.1.0.dist-info/entry_points.txt +2 -0
- sourcelock-0.1.0.dist-info/licenses/LICENSE +21 -0
hc_source/manifest.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Signed release manifests: prove *which* pins and vendored data a run used.
|
|
2
|
+
|
|
3
|
+
A manifest is a JSON **envelope** with two top-level keys::
|
|
4
|
+
|
|
5
|
+
{"payload": {...}, "signature": {...} | null}
|
|
6
|
+
|
|
7
|
+
``payload`` captures the current state of the world as this install sees it:
|
|
8
|
+
|
|
9
|
+
* the lockfile pins (raw sha256 of the file plus the parsed sources), so a
|
|
10
|
+
customer can check the manifest they received against the ``source-lock.json``
|
|
11
|
+
they committed;
|
|
12
|
+
* a sha256 for every vendored data file packaged under ``hc_source/data/``;
|
|
13
|
+
* tool versions (sourcelock itself, python, and the load-bearing libraries);
|
|
14
|
+
* ``signed_at``, written by ``sign_envelope``. It lives in the payload rather
|
|
15
|
+
than beside the signature because a signing time nobody signed is a signing
|
|
16
|
+
time anyone can edit, and "this was signed before the incident" is precisely
|
|
17
|
+
what a manifest gets used to claim.
|
|
18
|
+
|
|
19
|
+
Signing is Ed25519 (via ``cryptography``), computed over the **canonical
|
|
20
|
+
serialization of the payload only**: ``json.dumps(payload, sort_keys=True,
|
|
21
|
+
separators=(",", ":"), ensure_ascii=False).encode("utf-8")``. The envelope file
|
|
22
|
+
itself may be pretty-printed, re-indented, or re-ordered without breaking the
|
|
23
|
+
signature -- only the payload's *content* is covered. The signature block is
|
|
24
|
+
detached in exactly that sense: it lives next to the payload, never inside it.
|
|
25
|
+
|
|
26
|
+
Trust model: ``verify`` trusts ONLY the public key handed to it (``--key``).
|
|
27
|
+
The fingerprint embedded in the signature block identifies which key was used
|
|
28
|
+
(useful during rotation) but is never itself a verification input.
|
|
29
|
+
|
|
30
|
+
Keys are PEM: PKCS8 for the private key, SubjectPublicKeyInfo for the public
|
|
31
|
+
key. ``generate_keypair`` writes the private key ``0600`` and refuses to
|
|
32
|
+
overwrite existing files. Private keys never belong in this repository --
|
|
33
|
+
see the ops runbook for storage and rotation.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import base64
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import platform
|
|
42
|
+
from importlib import metadata
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from cryptography.exceptions import InvalidSignature
|
|
46
|
+
from cryptography.hazmat.primitives import serialization
|
|
47
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
48
|
+
Ed25519PrivateKey,
|
|
49
|
+
Ed25519PublicKey,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
from .lockfile import load_lockfile
|
|
53
|
+
from .schemas import utcnow
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"MANIFEST_KIND",
|
|
57
|
+
"MANIFEST_VERSION",
|
|
58
|
+
"ManifestError",
|
|
59
|
+
"SIGNATURE_ALGORITHM",
|
|
60
|
+
"SIGNING_KEY_ENV",
|
|
61
|
+
"build_envelope",
|
|
62
|
+
"build_payload",
|
|
63
|
+
"canonical_payload_bytes",
|
|
64
|
+
"generate_keypair",
|
|
65
|
+
"load_envelope",
|
|
66
|
+
"load_private_key",
|
|
67
|
+
"load_public_key",
|
|
68
|
+
"public_key_fingerprint",
|
|
69
|
+
"save_envelope",
|
|
70
|
+
"sign_envelope",
|
|
71
|
+
"tool_versions",
|
|
72
|
+
"vendored_data_hashes",
|
|
73
|
+
"verify_envelope",
|
|
74
|
+
"verify_lockfile",
|
|
75
|
+
"verify_vendored_data",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
MANIFEST_VERSION = 1
|
|
79
|
+
MANIFEST_KIND = "sourcelock-manifest"
|
|
80
|
+
SIGNATURE_ALGORITHM = "Ed25519"
|
|
81
|
+
#: Environment variable naming the private-key path for ``hc-source manifest sign``.
|
|
82
|
+
SIGNING_KEY_ENV = "HC_SOURCE_SIGNING_KEY"
|
|
83
|
+
|
|
84
|
+
#: Distributions whose versions are recorded in every manifest.
|
|
85
|
+
_TRACKED_DISTS = ("sourcelock", "httpx", "typer", "rich", "pydantic", "mcp", "cryptography")
|
|
86
|
+
|
|
87
|
+
_DATA_DIR = Path(__file__).resolve().parent / "data"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ManifestError(Exception):
|
|
91
|
+
"""A manifest envelope, key, or signature is unusable. The message says why."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# canonicalization
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def canonical_payload_bytes(payload: dict) -> bytes:
|
|
100
|
+
"""The exact bytes a signature covers. Deterministic for equal payloads."""
|
|
101
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
102
|
+
"utf-8"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _sha256_bytes(data: bytes) -> str:
|
|
107
|
+
return hashlib.sha256(data).hexdigest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# payload
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def vendored_data_hashes(data_dir: Path | None = None) -> dict[str, dict]:
|
|
116
|
+
"""sha256 + size for every file packaged under ``hc_source/data/``.
|
|
117
|
+
|
|
118
|
+
Keys are POSIX-style paths relative to the data dir, sorted, so the map is
|
|
119
|
+
byte-stable across platforms. ``__pycache__`` droppings are excluded.
|
|
120
|
+
"""
|
|
121
|
+
root = Path(data_dir) if data_dir is not None else _DATA_DIR
|
|
122
|
+
if not root.is_dir():
|
|
123
|
+
raise ManifestError(f"vendored data directory not found: {root}")
|
|
124
|
+
out: dict[str, dict] = {}
|
|
125
|
+
for path in sorted(root.rglob("*")):
|
|
126
|
+
if not path.is_file() or "__pycache__" in path.parts:
|
|
127
|
+
continue
|
|
128
|
+
raw = path.read_bytes()
|
|
129
|
+
out[path.relative_to(root).as_posix()] = {
|
|
130
|
+
"sha256": _sha256_bytes(raw),
|
|
131
|
+
"bytes": len(raw),
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def tool_versions() -> dict[str, str]:
|
|
137
|
+
versions: dict[str, str] = {"python": platform.python_version()}
|
|
138
|
+
for dist in _TRACKED_DISTS:
|
|
139
|
+
try:
|
|
140
|
+
versions[dist] = metadata.version(dist)
|
|
141
|
+
except metadata.PackageNotFoundError:
|
|
142
|
+
versions[dist] = "not-installed"
|
|
143
|
+
return versions
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_payload(lock_path: Path) -> dict:
|
|
147
|
+
"""Assemble the manifest payload from a lockfile on disk.
|
|
148
|
+
|
|
149
|
+
Raises ``FileNotFoundError`` (with the fix) when the lockfile is absent and
|
|
150
|
+
``pydantic.ValidationError`` when it does not parse -- same contract as the
|
|
151
|
+
rest of the CLI.
|
|
152
|
+
"""
|
|
153
|
+
lock_path = Path(lock_path)
|
|
154
|
+
lockfile = load_lockfile(lock_path) # validates; raises with remediation
|
|
155
|
+
raw = lock_path.read_bytes()
|
|
156
|
+
return {
|
|
157
|
+
"kind": MANIFEST_KIND,
|
|
158
|
+
"manifest_version": MANIFEST_VERSION,
|
|
159
|
+
"generated_at": utcnow().isoformat(),
|
|
160
|
+
"lockfile": {
|
|
161
|
+
"path": lock_path.name,
|
|
162
|
+
"sha256": _sha256_bytes(raw),
|
|
163
|
+
"generated_at": lockfile.generated_at.isoformat(),
|
|
164
|
+
"sources": {
|
|
165
|
+
sid: entry.model_dump(mode="json") for sid, entry in lockfile.sources.items()
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
"vendored_data": vendored_data_hashes(),
|
|
169
|
+
"tool_versions": tool_versions(),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_envelope(lock_path: Path) -> dict:
|
|
174
|
+
return {"payload": build_payload(lock_path), "signature": None}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# envelope io
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_envelope(path: Path) -> dict:
|
|
183
|
+
path = Path(path)
|
|
184
|
+
try:
|
|
185
|
+
raw = path.read_text(encoding="utf-8")
|
|
186
|
+
except OSError as exc:
|
|
187
|
+
raise ManifestError(f"cannot read manifest {path}: {type(exc).__name__}") from exc
|
|
188
|
+
try:
|
|
189
|
+
envelope = json.loads(raw)
|
|
190
|
+
except json.JSONDecodeError as exc:
|
|
191
|
+
raise ManifestError(f"manifest {path} is not valid JSON: {exc}") from exc
|
|
192
|
+
|
|
193
|
+
if not isinstance(envelope, dict) or "payload" not in envelope:
|
|
194
|
+
raise ManifestError(f"manifest {path} is not an envelope: expected a 'payload' key")
|
|
195
|
+
payload = envelope["payload"]
|
|
196
|
+
if not isinstance(payload, dict):
|
|
197
|
+
raise ManifestError(f"manifest {path}: payload must be an object")
|
|
198
|
+
if payload.get("kind") != MANIFEST_KIND:
|
|
199
|
+
raise ManifestError(
|
|
200
|
+
f"manifest {path}: payload.kind is {payload.get('kind')!r}, expected {MANIFEST_KIND!r}"
|
|
201
|
+
)
|
|
202
|
+
if payload.get("manifest_version") != MANIFEST_VERSION:
|
|
203
|
+
raise ManifestError(
|
|
204
|
+
f"manifest {path}: manifest_version {payload.get('manifest_version')!r} "
|
|
205
|
+
f"is not supported (this tool speaks version {MANIFEST_VERSION})"
|
|
206
|
+
)
|
|
207
|
+
envelope.setdefault("signature", None)
|
|
208
|
+
return envelope
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def save_envelope(envelope: dict, path: Path) -> Path:
|
|
212
|
+
"""Write an envelope pretty-printed. Formatting never affects the signature."""
|
|
213
|
+
path = Path(path)
|
|
214
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
path.write_text(json.dumps(envelope, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
216
|
+
return path
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# keys
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def load_private_key(path: Path) -> Ed25519PrivateKey:
|
|
225
|
+
path = Path(path)
|
|
226
|
+
try:
|
|
227
|
+
raw = path.read_bytes()
|
|
228
|
+
except OSError as exc:
|
|
229
|
+
raise ManifestError(f"cannot read private key {path}: {type(exc).__name__}") from exc
|
|
230
|
+
try:
|
|
231
|
+
key = serialization.load_pem_private_key(raw, password=None)
|
|
232
|
+
except (ValueError, TypeError) as exc:
|
|
233
|
+
raise ManifestError(
|
|
234
|
+
f"{path} is not a readable unencrypted PEM private key: {exc}"
|
|
235
|
+
) from exc
|
|
236
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
237
|
+
raise ManifestError(f"{path} is not an Ed25519 private key ({type(key).__name__})")
|
|
238
|
+
return key
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_public_key(path: Path) -> Ed25519PublicKey:
|
|
242
|
+
path = Path(path)
|
|
243
|
+
try:
|
|
244
|
+
raw = path.read_bytes()
|
|
245
|
+
except OSError as exc:
|
|
246
|
+
raise ManifestError(f"cannot read public key {path}: {type(exc).__name__}") from exc
|
|
247
|
+
try:
|
|
248
|
+
key = serialization.load_pem_public_key(raw)
|
|
249
|
+
except ValueError as exc:
|
|
250
|
+
raise ManifestError(f"{path} is not a readable PEM public key: {exc}") from exc
|
|
251
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
252
|
+
raise ManifestError(f"{path} is not an Ed25519 public key ({type(key).__name__})")
|
|
253
|
+
return key
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def public_key_fingerprint(public_key: Ed25519PublicKey) -> str:
|
|
257
|
+
"""sha256 hex over the 32 raw public-key bytes. Identifies a key, proves nothing."""
|
|
258
|
+
raw = public_key.public_bytes(
|
|
259
|
+
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
|
|
260
|
+
)
|
|
261
|
+
return _sha256_bytes(raw)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def generate_keypair(out_dir: Path, name: str = "manifest-signing") -> tuple[Path, Path]:
|
|
265
|
+
"""Write ``<name>.key`` (PKCS8 PEM, mode 0600) and ``<name>.pub`` (PEM).
|
|
266
|
+
|
|
267
|
+
Refuses to overwrite: rotating a key must be an explicit new name, never a
|
|
268
|
+
silent replacement of the file a customer's verify step still trusts.
|
|
269
|
+
"""
|
|
270
|
+
out_dir = Path(out_dir)
|
|
271
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
priv_path = out_dir / f"{name}.key"
|
|
273
|
+
pub_path = out_dir / f"{name}.pub"
|
|
274
|
+
for existing in (priv_path, pub_path):
|
|
275
|
+
if existing.exists():
|
|
276
|
+
raise ManifestError(
|
|
277
|
+
f"{existing} already exists; pick a new --name instead of overwriting a key"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
private_key = Ed25519PrivateKey.generate()
|
|
281
|
+
priv_pem = private_key.private_bytes(
|
|
282
|
+
encoding=serialization.Encoding.PEM,
|
|
283
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
284
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
285
|
+
)
|
|
286
|
+
pub_pem = private_key.public_key().public_bytes(
|
|
287
|
+
encoding=serialization.Encoding.PEM,
|
|
288
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
priv_path.touch(mode=0o600)
|
|
292
|
+
priv_path.write_bytes(priv_pem)
|
|
293
|
+
priv_path.chmod(0o600)
|
|
294
|
+
pub_path.write_bytes(pub_pem)
|
|
295
|
+
return priv_path, pub_path
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# sign / verify
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def sign_envelope(envelope: dict, private_key: Ed25519PrivateKey) -> dict:
|
|
304
|
+
"""Return a new envelope whose signature block covers the payload.
|
|
305
|
+
|
|
306
|
+
``signed_at`` goes INSIDE the payload. It used to sit in the signature block
|
|
307
|
+
beside the signature itself, where nothing covered it: anyone holding a
|
|
308
|
+
signed manifest could edit the timestamp and it still verified. A signing
|
|
309
|
+
time that can be rewritten by the person you are proving something to is not
|
|
310
|
+
evidence, and "this manifest was signed before the incident" is exactly the
|
|
311
|
+
claim a manifest gets used for.
|
|
312
|
+
"""
|
|
313
|
+
payload = envelope.get("payload")
|
|
314
|
+
if not isinstance(payload, dict):
|
|
315
|
+
raise ManifestError("envelope has no payload to sign")
|
|
316
|
+
payload = {**payload, "signed_at": utcnow().isoformat()}
|
|
317
|
+
signature = private_key.sign(canonical_payload_bytes(payload))
|
|
318
|
+
return {
|
|
319
|
+
"payload": payload,
|
|
320
|
+
"signature": {
|
|
321
|
+
"algorithm": SIGNATURE_ALGORITHM,
|
|
322
|
+
"public_key_fingerprint": public_key_fingerprint(private_key.public_key()),
|
|
323
|
+
"signature_b64": base64.b64encode(signature).decode("ascii"),
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def verify_envelope(envelope: dict, public_key: Ed25519PublicKey) -> None:
|
|
329
|
+
"""Raise :class:`ManifestError` unless the signature verifies with this key.
|
|
330
|
+
|
|
331
|
+
The provided key is the only trust anchor. The embedded fingerprint is
|
|
332
|
+
checked first purely to produce a better error message when the manifest
|
|
333
|
+
was signed by a *different* key (e.g. after rotation).
|
|
334
|
+
"""
|
|
335
|
+
payload = envelope.get("payload")
|
|
336
|
+
if not isinstance(payload, dict):
|
|
337
|
+
raise ManifestError("envelope has no payload")
|
|
338
|
+
signature = envelope.get("signature")
|
|
339
|
+
if not isinstance(signature, dict):
|
|
340
|
+
raise ManifestError("manifest is unsigned: run `hc-source manifest sign` first")
|
|
341
|
+
|
|
342
|
+
algorithm = signature.get("algorithm")
|
|
343
|
+
if algorithm != SIGNATURE_ALGORITHM:
|
|
344
|
+
raise ManifestError(
|
|
345
|
+
f"unsupported signature algorithm {algorithm!r} (expected {SIGNATURE_ALGORITHM!r})"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
embedded = signature.get("public_key_fingerprint")
|
|
349
|
+
ours = public_key_fingerprint(public_key)
|
|
350
|
+
if isinstance(embedded, str) and embedded != ours:
|
|
351
|
+
raise ManifestError(
|
|
352
|
+
"manifest was signed by a different key "
|
|
353
|
+
f"(manifest fingerprint {embedded[:16]}..., verifying key {ours[:16]}...); "
|
|
354
|
+
"if the signing key was rotated, fetch the current public key"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
sig_b64 = signature.get("signature_b64")
|
|
358
|
+
if not isinstance(sig_b64, str):
|
|
359
|
+
raise ManifestError("signature block has no signature_b64")
|
|
360
|
+
try:
|
|
361
|
+
raw_sig = base64.b64decode(sig_b64, validate=True)
|
|
362
|
+
except (ValueError, TypeError) as exc:
|
|
363
|
+
raise ManifestError(f"signature_b64 is not valid base64: {exc}") from exc
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
public_key.verify(raw_sig, canonical_payload_bytes(payload))
|
|
367
|
+
except InvalidSignature:
|
|
368
|
+
raise ManifestError(
|
|
369
|
+
"signature does NOT verify: the payload was modified after signing, "
|
|
370
|
+
"or it was signed by a different key"
|
|
371
|
+
) from None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def verify_lockfile(payload: dict, lock_path: Path) -> list[str]:
|
|
375
|
+
"""Compare the payload's lockfile hash to the lockfile at ``lock_path``.
|
|
376
|
+
|
|
377
|
+
Without this, a signed manifest proves only that SOME lockfile was signed at
|
|
378
|
+
some point. It says nothing about the ``source-lock.json`` doctor and the
|
|
379
|
+
runtime are actually reading, which is the one a customer cares about --
|
|
380
|
+
"the pins were signed" and "the pins in use were signed" are different
|
|
381
|
+
claims, and only the second is worth anything in an audit.
|
|
382
|
+
"""
|
|
383
|
+
recorded = payload.get("lockfile")
|
|
384
|
+
if not isinstance(recorded, dict) or not isinstance(recorded.get("sha256"), str):
|
|
385
|
+
return ["payload has no lockfile hash to compare against"]
|
|
386
|
+
lock_path = Path(lock_path)
|
|
387
|
+
try:
|
|
388
|
+
raw = lock_path.read_bytes()
|
|
389
|
+
except OSError as exc:
|
|
390
|
+
return [f"{lock_path}: cannot read the lockfile ({type(exc).__name__})"]
|
|
391
|
+
local = _sha256_bytes(raw)
|
|
392
|
+
if local != recorded["sha256"]:
|
|
393
|
+
return [
|
|
394
|
+
f"{lock_path}: sha256 {local[:16]}... does not match the signed manifest's "
|
|
395
|
+
f"{recorded['sha256'][:16]}.... The manifest describes a different set of pins "
|
|
396
|
+
"than the ones this install would check against. Re-run `hc-source manifest "
|
|
397
|
+
"build` and re-sign, or find out who changed the lockfile."
|
|
398
|
+
]
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def verify_vendored_data(payload: dict, data_dir: Path | None = None) -> list[str]:
|
|
403
|
+
"""Compare the payload's vendored-data hashes to this install's files.
|
|
404
|
+
|
|
405
|
+
Returns a list of human-readable mismatches (empty means everything
|
|
406
|
+
matches). This answers "is the data I am running the data that was
|
|
407
|
+
signed?", which the signature alone cannot.
|
|
408
|
+
"""
|
|
409
|
+
recorded = payload.get("vendored_data")
|
|
410
|
+
if not isinstance(recorded, dict):
|
|
411
|
+
return ["payload has no vendored_data map"]
|
|
412
|
+
local = vendored_data_hashes(data_dir)
|
|
413
|
+
problems: list[str] = []
|
|
414
|
+
for rel, meta in sorted(recorded.items()):
|
|
415
|
+
if rel not in local:
|
|
416
|
+
problems.append(f"{rel}: recorded in manifest but missing from this install")
|
|
417
|
+
continue
|
|
418
|
+
if not isinstance(meta, dict) or local[rel]["sha256"] != meta.get("sha256"):
|
|
419
|
+
problems.append(f"{rel}: sha256 differs from the signed manifest")
|
|
420
|
+
for rel in sorted(set(local) - set(recorded)):
|
|
421
|
+
problems.append(f"{rel}: present in this install but absent from the signed manifest")
|
|
422
|
+
return problems
|
hc_source/mcp_server.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""MCP server mode: ``hc-source mcp``.
|
|
2
|
+
|
|
3
|
+
Every discovered tool is served over MCP stdio with the adapter's own parameter
|
|
4
|
+
schema and the same zero-PHI guard the CLI uses, so an agent cannot reach a
|
|
5
|
+
route by a path that skips the guard.
|
|
6
|
+
|
|
7
|
+
Two notes on the library, both verified against the installed ``mcp`` 2.0.0
|
|
8
|
+
(the version is pinned in pyproject.toml because this module depends on the
|
|
9
|
+
API shape):
|
|
10
|
+
|
|
11
|
+
* As of ``mcp`` 2.0 the FastMCP class is named ``MCPServer``; ``FastMCP`` is no
|
|
12
|
+
longer exported.
|
|
13
|
+
* We build on the low-level ``mcp.server.lowlevel.Server`` rather than
|
|
14
|
+
``MCPServer`` deliberately. ``MCPServer`` derives each tool's argument model
|
|
15
|
+
from the handler's Python signature and validates against it before the
|
|
16
|
+
handler runs. That has two consequences SourceLock cannot accept: unknown
|
|
17
|
+
arguments are silently dropped -- so a parameter like ``ssn`` never reaches
|
|
18
|
+
the guard and the call succeeds as though it had been filtered -- and its
|
|
19
|
+
validation errors echo the raw input value back to the client, which is the
|
|
20
|
+
one thing the guard exists to prevent. The low-level server hands us the
|
|
21
|
+
arguments untouched, so the guard sees exactly what the client sent.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import anyio
|
|
30
|
+
import mcp.types as types
|
|
31
|
+
from mcp.server.lowlevel import Server
|
|
32
|
+
from mcp.server.models import InitializationOptions
|
|
33
|
+
from mcp.server.stdio import stdio_server
|
|
34
|
+
|
|
35
|
+
from . import __version__
|
|
36
|
+
from .adapters import AdapterError, discovery_errors, find_tool, iter_tools
|
|
37
|
+
from .guard import (
|
|
38
|
+
ParamsRejected,
|
|
39
|
+
PHIRejected,
|
|
40
|
+
safe_tool_name,
|
|
41
|
+
safe_validation_message,
|
|
42
|
+
)
|
|
43
|
+
from .http import SourceUnreachable
|
|
44
|
+
|
|
45
|
+
__all__ = ["build_server", "call_tool_payload", "list_tool_names", "serve_stdio", "list_mcp_tools"]
|
|
46
|
+
|
|
47
|
+
SERVER_NAME = "sourcelock"
|
|
48
|
+
INSTRUCTIONS = (
|
|
49
|
+
"SourceLock serves public healthcare data sources with an evidence receipt on every "
|
|
50
|
+
"answer. Parameters are public and typed; input shaped like a patient record is refused "
|
|
51
|
+
"on a structural best-effort basis -- that is a guard, not a compliance control, so do "
|
|
52
|
+
"not send patient data to these tools on the strength of it. Each "
|
|
53
|
+
"result carries a receipt naming the source release, the SHA-256 of the raw bytes behind "
|
|
54
|
+
"the answer, and explicit non-claims stating what the answer does not prove. Read the "
|
|
55
|
+
"non_claims before acting on a result."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def call_tool_payload(name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
60
|
+
"""Invoke a tool by name and return a JSON-serialisable payload.
|
|
61
|
+
|
|
62
|
+
Raises ``KeyError`` for an unknown tool and
|
|
63
|
+
:class:`hc_source.guard.PHIRejected` for patient-shaped arguments.
|
|
64
|
+
"""
|
|
65
|
+
tool = find_tool(name)
|
|
66
|
+
result = tool.invoke(arguments or {})
|
|
67
|
+
return {"data": result.data, "receipt": result.receipt.model_dump(mode="json")}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def list_tool_names() -> list[str]:
|
|
71
|
+
return [t.name for t in iter_tools()]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_mcp_tools() -> list[types.Tool]:
|
|
75
|
+
"""Advertise each adapter's own parameter schema, unknown-fields-forbidden."""
|
|
76
|
+
return [
|
|
77
|
+
types.Tool(
|
|
78
|
+
name=spec.name,
|
|
79
|
+
description=spec.description,
|
|
80
|
+
inputSchema=spec.params_schema(),
|
|
81
|
+
)
|
|
82
|
+
for spec in iter_tools()
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _text_result(payload: str, *, is_error: bool = False) -> types.CallToolResult:
|
|
87
|
+
return types.CallToolResult(
|
|
88
|
+
content=[types.TextContent(type="text", text=payload)], isError=is_error
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _on_list_tools(ctx: Any, params: Any) -> types.ListToolsResult:
|
|
93
|
+
return types.ListToolsResult(tools=list_mcp_tools())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _on_call_tool(ctx: Any, params: types.CallToolRequestParams) -> types.CallToolResult:
|
|
97
|
+
"""Run a tool. Arguments arrive exactly as the client sent them."""
|
|
98
|
+
arguments = dict(params.arguments or {})
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
payload = await anyio.to_thread.run_sync(
|
|
102
|
+
lambda: call_tool_payload(params.name, arguments)
|
|
103
|
+
)
|
|
104
|
+
except KeyError:
|
|
105
|
+
# The name is client-supplied text. Reflecting it back verbatim let a
|
|
106
|
+
# client write an arbitrary string into the server's error output --
|
|
107
|
+
# including one it had just been refused for sending as a parameter.
|
|
108
|
+
return _text_result(
|
|
109
|
+
f"No tool named {safe_tool_name(params.name)}. Call tools/list to see what "
|
|
110
|
+
"this server offers.",
|
|
111
|
+
is_error=True,
|
|
112
|
+
)
|
|
113
|
+
except SourceUnreachable as exc:
|
|
114
|
+
# Not the client's fault and not retryable by fixing the arguments.
|
|
115
|
+
# Carries a URL and a transport reason only -- no body, no parameters.
|
|
116
|
+
return _text_result(
|
|
117
|
+
f"{safe_tool_name(params.name)}: source unreachable -- {exc.url} ({exc.reason}"
|
|
118
|
+
+ (f", HTTP {exc.status}" if exc.status else "")
|
|
119
|
+
+ "). No answer was returned because there would be no evidence behind it.",
|
|
120
|
+
is_error=True,
|
|
121
|
+
)
|
|
122
|
+
except (PHIRejected, ParamsRejected) as exc:
|
|
123
|
+
# Both carry field names and rule text, never values, by construction.
|
|
124
|
+
return _text_result(str(exc), is_error=True)
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
# Defence in depth: ToolSpec.invoke converts pydantic's ValidationError
|
|
127
|
+
# into ParamsRejected, so a bare ValueError here came from adapter code.
|
|
128
|
+
# Report the field and the rule, never the value: a rejected value may
|
|
129
|
+
# be exactly what must not be echoed.
|
|
130
|
+
return _text_result(
|
|
131
|
+
f"{safe_tool_name(params.name)}: invalid parameters. {_safe_reason(exc)}",
|
|
132
|
+
is_error=True,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
text = json.dumps(payload, indent=2)
|
|
136
|
+
return types.CallToolResult(
|
|
137
|
+
content=[types.TextContent(type="text", text=text)],
|
|
138
|
+
structuredContent=payload,
|
|
139
|
+
isError=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _safe_reason(exc: ValueError) -> str:
|
|
144
|
+
"""Summarise a validation failure without repeating any input value.
|
|
145
|
+
|
|
146
|
+
Round-1 finding C1: the previous version read ``error["msg"]`` directly and
|
|
147
|
+
correctly avoided ``error["input"]`` -- but pydantic copies a custom
|
|
148
|
+
validator's text into ``msg`` verbatim, so a validator that quoted its input
|
|
149
|
+
put the value straight back into the client's error. ``safe_validation_message``
|
|
150
|
+
scrubs ``msg`` against ``input`` before it is used.
|
|
151
|
+
"""
|
|
152
|
+
errors = getattr(exc, "errors", None)
|
|
153
|
+
if not callable(errors):
|
|
154
|
+
return type(exc).__name__
|
|
155
|
+
return safe_validation_message(exc)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_server() -> Server:
|
|
159
|
+
"""A low-level MCP Server with SourceLock's tool handlers wired in."""
|
|
160
|
+
return Server(
|
|
161
|
+
name=SERVER_NAME,
|
|
162
|
+
version=__version__,
|
|
163
|
+
instructions=INSTRUCTIONS,
|
|
164
|
+
on_list_tools=_on_list_tools,
|
|
165
|
+
on_call_tool=_on_call_tool,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def serve_stdio() -> None:
|
|
170
|
+
"""Run the server over stdio. Blocks until the client disconnects.
|
|
171
|
+
|
|
172
|
+
Refuses to start if any adapter module failed to import. Everywhere else a
|
|
173
|
+
load failure can be printed and read by a human; here the consumer is an
|
|
174
|
+
agent that sees only ``tools/list``, where a broken adapter and an
|
|
175
|
+
uninstalled one are indistinguishable. An agent that cannot see the gap will
|
|
176
|
+
answer around it, which is the worst outcome this product has: a confident
|
|
177
|
+
answer with a source silently missing from it.
|
|
178
|
+
"""
|
|
179
|
+
errors = discovery_errors()
|
|
180
|
+
if errors:
|
|
181
|
+
raise AdapterError(
|
|
182
|
+
"refusing to serve MCP: "
|
|
183
|
+
+ "; ".join(str(e) for e in errors)
|
|
184
|
+
+ ". An MCP client cannot see a load failure -- it sees a shorter tools/list "
|
|
185
|
+
"and assumes that is the whole product. Fix the adapter, or remove its module "
|
|
186
|
+
"deliberately, before serving."
|
|
187
|
+
)
|
|
188
|
+
server = build_server()
|
|
189
|
+
|
|
190
|
+
async def _run() -> None:
|
|
191
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
192
|
+
await server.run(
|
|
193
|
+
read_stream,
|
|
194
|
+
write_stream,
|
|
195
|
+
InitializationOptions(
|
|
196
|
+
server_name=SERVER_NAME,
|
|
197
|
+
server_version=__version__,
|
|
198
|
+
capabilities=server.get_capabilities(),
|
|
199
|
+
instructions=INSTRUCTIONS,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
anyio.run(_run)
|
hc_source/npi.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The one NPI validator.
|
|
2
|
+
|
|
3
|
+
The National Provider Identifier is a 10-digit number whose last digit is a Luhn
|
|
4
|
+
check digit computed over the number prefixed with the NPI issuer prefix
|
|
5
|
+
``80840``. Because that prefix is constant, its Luhn contribution is the constant
|
|
6
|
+
``24``, which is why the loop below starts there.
|
|
7
|
+
|
|
8
|
+
This lives in ``hc_source/`` rather than in an adapter because two adapters need
|
|
9
|
+
it and a second copy is a second chance to be wrong: ``provider`` rejected
|
|
10
|
+
mistyped NPIs at the parameter boundary while ``leie`` accepted any ten digits,
|
|
11
|
+
so a transposed NPI produced a confident "not excluded" screen against a number
|
|
12
|
+
that cannot exist.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__all__ = ["NPI_PATTERN", "npi_check_digit_ok", "validate_npi"]
|
|
18
|
+
|
|
19
|
+
#: Shape only. Shape is not validity -- always follow it with the check digit.
|
|
20
|
+
NPI_PATTERN = r"^\d{10}$"
|
|
21
|
+
|
|
22
|
+
#: Luhn contribution of the constant ``80840`` issuer prefix.
|
|
23
|
+
_PREFIX_CONTRIBUTION = 24
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def npi_check_digit_ok(npi: str) -> bool:
|
|
27
|
+
"""True when ``npi`` is ten digits whose check digit passes the 80840 Luhn."""
|
|
28
|
+
if len(npi) != 10 or not npi.isdigit():
|
|
29
|
+
return False
|
|
30
|
+
digits = [int(c) for c in npi]
|
|
31
|
+
total = _PREFIX_CONTRIBUTION
|
|
32
|
+
for i, d in enumerate(digits[:9]):
|
|
33
|
+
if i % 2 == 0: # doubling starts from the rightmost base digit (index 8)
|
|
34
|
+
d *= 2
|
|
35
|
+
if d > 9:
|
|
36
|
+
d -= 9
|
|
37
|
+
total += d
|
|
38
|
+
return (10 - total % 10) % 10 == digits[9]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_npi(npi: str) -> str:
|
|
42
|
+
"""Return ``npi`` unchanged, or raise ``ValueError`` naming the rule it broke.
|
|
43
|
+
|
|
44
|
+
Suitable as the body of a pydantic ``field_validator``. The message never
|
|
45
|
+
repeats the value: a rejected NPI is caller-supplied input, and echoing it
|
|
46
|
+
back is how a value we refused ends up in a log.
|
|
47
|
+
"""
|
|
48
|
+
if not npi_check_digit_ok(npi):
|
|
49
|
+
raise ValueError("not a valid NPI: check digit fails the 80840 Luhn test")
|
|
50
|
+
return npi
|