installgate 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.
Files changed (58) hide show
  1. contracts/README.md +11 -0
  2. contracts/__init__.py +31 -0
  3. contracts/contracts.py +260 -0
  4. contracts/contracts.ts +151 -0
  5. engine/README.md +36 -0
  6. engine/__init__.py +1 -0
  7. engine/cache.py +64 -0
  8. engine/checks.py +695 -0
  9. engine/clients/__init__.py +64 -0
  10. engine/clients/huggingface.py +69 -0
  11. engine/clients/npm.py +91 -0
  12. engine/clients/osv.py +91 -0
  13. engine/clients/pypi.py +197 -0
  14. engine/data/pypi-top5000-2026-10-27.txt +5011 -0
  15. engine/evidence.py +55 -0
  16. engine/parsers.py +216 -0
  17. engine/policy.py +153 -0
  18. engine/policy.yaml +92 -0
  19. engine/runtime_floors.yaml +67 -0
  20. engine/stage1.py +137 -0
  21. installgate-0.1.0.dist-info/METADATA +439 -0
  22. installgate-0.1.0.dist-info/RECORD +58 -0
  23. installgate-0.1.0.dist-info/WHEEL +4 -0
  24. installgate-0.1.0.dist-info/entry_points.txt +3 -0
  25. installgate-0.1.0.dist-info/licenses/LICENSE +202 -0
  26. mcp_server/README.md +19 -0
  27. mcp_server/__init__.py +1 -0
  28. mcp_server/agent.py +61 -0
  29. mcp_server/api.py +141 -0
  30. mcp_server/check.py +412 -0
  31. mcp_server/cli.py +270 -0
  32. mcp_server/limit.py +74 -0
  33. mcp_server/render.py +241 -0
  34. mcp_server/samples.py +53 -0
  35. mcp_server/server.py +27 -0
  36. mcp_server/spend.py +90 -0
  37. remediate/README.md +18 -0
  38. remediate/__init__.py +1 -0
  39. remediate/candidates.py +118 -0
  40. remediate/diff.py +13 -0
  41. remediate/pinning.py +27 -0
  42. remediate/sandbox.py +1 -0
  43. router/README.md +60 -0
  44. router/__init__.py +1 -0
  45. router/compare.py +572 -0
  46. router/prompts/explain.txt +8 -0
  47. router/prompts/extract.txt +3 -0
  48. router/prompts/rank_substitute.txt +1 -0
  49. router/receipt.py +41 -0
  50. router/smoke.py +49 -0
  51. router/tavily.py +129 -0
  52. router/tiers.py +321 -0
  53. router/token_factory.py +267 -0
  54. validator/README.md +11 -0
  55. validator/__init__.py +1 -0
  56. validator/citations.py +64 -0
  57. validator/failclosed.py +53 -0
  58. validator/lattice.py +52 -0
engine/checks.py ADDED
@@ -0,0 +1,695 @@
1
+ """Stage 1 checks. Pure functions: facts in, Evidence out. They never fetch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+ from contracts.contracts import Ecosystem, Evidence, EvidenceKind, TargetRef
14
+ from rapidfuzz.distance import DamerauLevenshtein
15
+
16
+ from engine.evidence import EvidenceBuilder
17
+
18
+ DATA_DIR = Path(__file__).parent / "data"
19
+ FLOORS_PATH = Path(__file__).parent / "runtime_floors.yaml"
20
+
21
+ NEW_PROJECT_DAYS = 30
22
+ NEW_RELEASE_DAYS = 30
23
+ TYPOSQUAT_NEW_PROJECT_DAYS = 7
24
+ TYPOSQUAT_MAX_DISTANCE = 2
25
+ # Below this length a 1-edit distance is normal between unrelated names.
26
+ TYPOSQUAT_MIN_LENGTH = 5
27
+ # Minimum reference names an ecosystem needs before a near-miss means anything,
28
+ # per ecosystem. `None` disables the check for that ecosystem outright.
29
+ #
30
+ # A snapshot smaller than its threshold is a misconfiguration, not a coverage
31
+ # decision: the file was meant to be there and is short, so the check raises and
32
+ # the pipeline fails closed. A `None` is a deliberate "we do not cover this yet".
33
+ MIN_SNAPSHOT_NAMES: dict[Ecosystem, int | None] = {
34
+ # A real top-5000 snapshot is in place (see the file header for its source
35
+ # and date). The bar sits just below it so a truncated or half-written file
36
+ # fails the check instead of quietly shrinking its coverage.
37
+ Ecosystem.PYPI: 4000,
38
+ }
39
+
40
+ # Ecosystems the typosquat check applies to at all. Container images and model
41
+ # repositories are namespaced, so a flat popular-name list is the wrong tool.
42
+ # npm has no vetted popular-name snapshot; exists + pin still run (P1/P9).
43
+ TYPOSQUAT_ECOSYSTEMS = frozenset({Ecosystem.PYPI})
44
+
45
+ # Dated snapshots, fetched by eval/fetch_top_pypi.py. npm has no entry: the
46
+ # check is switched off for it, so there is nothing to point at.
47
+ _SNAPSHOTS = {
48
+ Ecosystem.PYPI: "pypi-top5000-2026-10-27.txt",
49
+ }
50
+
51
+ _SOURCE_FOR = {
52
+ Ecosystem.PYPI: "pypi",
53
+ Ecosystem.NPM: "npm",
54
+ Ecosystem.HUGGINGFACE: "huggingface",
55
+ Ecosystem.DOCKER: "docker_registry",
56
+ }
57
+
58
+
59
+ class SnapshotUnavailable(RuntimeError):
60
+ """The typosquat reference set is missing or too small to decide anything."""
61
+
62
+
63
+ _PEP503_SEPARATORS = re.compile(r"[-_.]+")
64
+
65
+
66
+ def normalize_name(name: str, ecosystem: Ecosystem) -> str:
67
+ """PEP 503 normalisation for PyPI, plain lowercase elsewhere.
68
+
69
+ `Requests_OAuthlib` and `requests-oauthlib` are the same project to PyPI, so
70
+ comparing them unnormalised reports a two-edit "typosquat" of a package
71
+ against itself.
72
+ """
73
+ lowered = name.strip().lower()
74
+ if ecosystem is Ecosystem.PYPI:
75
+ return _PEP503_SEPARATORS.sub("-", lowered)
76
+ return lowered
77
+
78
+
79
+ def _parse_iso(raw: Any) -> datetime | None:
80
+ if not raw:
81
+ return None
82
+ try:
83
+ return datetime.fromisoformat(str(raw).replace("Z", "+00:00")).astimezone(UTC)
84
+ except ValueError:
85
+ return None
86
+
87
+
88
+ @functools.lru_cache(maxsize=4)
89
+ def load_top_names(ecosystem: Ecosystem) -> tuple[str, ...]:
90
+ """Dated top-5k snapshot used as the typosquat reference set."""
91
+ filename = _SNAPSHOTS.get(ecosystem)
92
+ if filename is None:
93
+ return ()
94
+ path = DATA_DIR / filename
95
+ if not path.exists():
96
+ return ()
97
+ names = [
98
+ normalize_name(line, ecosystem)
99
+ for line in path.read_text(encoding="utf-8").splitlines()
100
+ if line.strip() and not line.startswith("#")
101
+ ]
102
+ # Normalisation collapses spellings, so dedupe while keeping file order.
103
+ return tuple(dict.fromkeys(names))
104
+
105
+
106
+ @functools.lru_cache(maxsize=1)
107
+ def load_runtime_floors() -> dict[str, dict[str, str]]:
108
+ """Image -> release branch -> patched floor.
109
+
110
+ A single floor per image cannot describe a project that patches several
111
+ branches at once, so floors are keyed by branch (`"8.2"`, `"20"`).
112
+ """
113
+ if not FLOORS_PATH.exists():
114
+ return {}
115
+ data = yaml.safe_load(FLOORS_PATH.read_text(encoding="utf-8")) or {}
116
+ images = data.get("images") or {}
117
+ out: dict[str, dict[str, str]] = {}
118
+ for image, spec in images.items():
119
+ branches = (spec or {}).get("branches") or {}
120
+ out[str(image)] = {str(b): str(f) for b, f in branches.items()}
121
+ return out
122
+
123
+
124
+ def _check_unavailable(
125
+ builder: EvidenceBuilder, target: TargetRef, reason: str, detail: str, **extra: Any
126
+ ) -> Evidence:
127
+ """Soft evidence that a check could not run, so a silent gap cannot read as clean.
128
+
129
+ `reason` must be one of CHECK_UNAVAILABLE_REASONS; the contract enforces it.
130
+ The records come from our own tables (policy_yaml), not from a registry.
131
+ """
132
+ return builder.build(
133
+ EvidenceKind.CHECK_UNAVAILABLE,
134
+ source="policy_yaml",
135
+ trust="deterministic",
136
+ severity="soft",
137
+ target=target,
138
+ observed={"reason": reason, "detail": detail, **extra},
139
+ )
140
+
141
+
142
+ def check_exists(
143
+ target: TargetRef, facts: dict[str, Any], builder: EvidenceBuilder
144
+ ) -> Evidence | None:
145
+ """P1: the package does not exist. Hard — a hallucinated name can be squatted tomorrow."""
146
+ if facts.get("exists"):
147
+ return None
148
+ return builder.build(
149
+ EvidenceKind.NOT_FOUND,
150
+ source=_SOURCE_FOR[target.ecosystem], # type: ignore[arg-type]
151
+ trust="deterministic",
152
+ severity="hard",
153
+ target=target,
154
+ observed={"name": target.name, "ecosystem": target.ecosystem.value},
155
+ )
156
+
157
+
158
+ def check_age(
159
+ target: TargetRef,
160
+ facts: dict[str, Any],
161
+ builder: EvidenceBuilder,
162
+ now: datetime | None = None,
163
+ ) -> list[Evidence]:
164
+ """P6: a brand-new project. Soft. A new release of an old project is not P6."""
165
+ if not facts.get("exists"):
166
+ return []
167
+ now = now or datetime.now(UTC)
168
+ out: list[Evidence] = []
169
+ source = _SOURCE_FOR[target.ecosystem]
170
+
171
+ first = _parse_iso(facts.get("first_upload"))
172
+ if first is not None:
173
+ # A release dated after the clock means the clock is stale, not that the
174
+ # package is from the future. Clamp, or a negative age silently satisfies
175
+ # every "younger than N days" test.
176
+ age_days = max((now - first).days, 0)
177
+ if age_days < NEW_PROJECT_DAYS:
178
+ out.append(
179
+ builder.build(
180
+ EvidenceKind.NEW_PROJECT,
181
+ source=source, # type: ignore[arg-type]
182
+ trust="deterministic",
183
+ severity="soft",
184
+ target=target,
185
+ observed={"first_upload": first.isoformat(), "age_days": age_days},
186
+ )
187
+ )
188
+
189
+ latest = _parse_iso(facts.get("latest_upload"))
190
+ if latest is not None:
191
+ age_days = max((now - latest).days, 0)
192
+ if age_days < NEW_RELEASE_DAYS:
193
+ # A new version of an old project is not P6. numpy shipping 2.5.4
194
+ # is not a review; a brand-new name, or a missing first_upload, is.
195
+ established = first is not None and max((now - first).days, 0) >= NEW_PROJECT_DAYS
196
+ if not established:
197
+ out.append(
198
+ builder.build(
199
+ EvidenceKind.NEW_RELEASE,
200
+ source=source, # type: ignore[arg-type]
201
+ trust="deterministic",
202
+ severity="soft",
203
+ target=target,
204
+ observed={
205
+ "latest_upload": latest.isoformat(),
206
+ "age_days": age_days,
207
+ "latest_version": facts.get("latest_version"),
208
+ },
209
+ )
210
+ )
211
+ return out
212
+
213
+
214
+ def check_maintainers(
215
+ target: TargetRef,
216
+ facts: dict[str, Any],
217
+ builder: EvidenceBuilder,
218
+ known_maintainers: list[str] | None = None,
219
+ now: datetime | None = None,
220
+ ) -> Evidence | None:
221
+ """P8: the maintainer set changed and a release followed soon after.
222
+
223
+ `known_maintainers` is the previously cached set. With no prior snapshot there is
224
+ nothing to compare against, so this check stays silent rather than guessing.
225
+ """
226
+ if not facts.get("exists") or known_maintainers is None:
227
+ return None
228
+ current = set(facts.get("maintainers") or [])
229
+ previous = set(known_maintainers)
230
+ if not previous or current == previous:
231
+ return None
232
+
233
+ now = now or datetime.now(UTC)
234
+ latest = _parse_iso(facts.get("latest_upload"))
235
+ if latest is None or max((now - latest).days, 0) >= NEW_RELEASE_DAYS:
236
+ return None
237
+
238
+ return builder.build(
239
+ EvidenceKind.MAINTAINER_CHANGE,
240
+ source=_SOURCE_FOR[target.ecosystem], # type: ignore[arg-type]
241
+ trust="deterministic",
242
+ severity="soft",
243
+ target=target,
244
+ observed={
245
+ "added": sorted(current - previous),
246
+ "removed": sorted(previous - current),
247
+ "latest_upload": latest.isoformat(),
248
+ },
249
+ )
250
+
251
+
252
+ def _same_version(left: str, right: str) -> bool:
253
+ """Compare two versions, tolerating `1.0` against `1.0.0` where possible."""
254
+ try:
255
+ from packaging.version import InvalidVersion, Version
256
+ except ImportError: # pragma: no cover - packaging ships with the toolchain
257
+ return left.strip() == right.strip()
258
+ try:
259
+ return Version(left) == Version(right)
260
+ except InvalidVersion:
261
+ return left.strip() == right.strip()
262
+
263
+
264
+ def _parse_version(raw: str) -> Any | None:
265
+ try:
266
+ from packaging.version import InvalidVersion, Version
267
+ except ImportError: # pragma: no cover - packaging ships with the toolchain
268
+ return None
269
+ try:
270
+ return Version(raw)
271
+ except InvalidVersion:
272
+ return None
273
+
274
+
275
+ # Range event keys we know how to read on a version-ordered range.
276
+ _KNOWN_EVENTS = frozenset({"introduced", "fixed", "last_affected"})
277
+
278
+
279
+ def _range_covers(rng: dict[str, Any], version: str | None) -> bool | None:
280
+ """Evaluate one OSV range against a version.
281
+
282
+ The overwhelmingly common shape for a malicious package is a single
283
+ `introduced: "0"` with no fix: the project was malicious from its first
284
+ release, so every version is covered and no comparison is needed. Treating
285
+ that as undecided downgrades a real BLOCK to REVIEW.
286
+ """
287
+ events = rng.get("events") or []
288
+
289
+ # A GIT range is ordered by commit, not by version. Mapping one to a release
290
+ # needs the repository, which we do not read, so there is nothing to compare
291
+ # and the answer is "cannot tell" rather than a guess in either direction.
292
+ if str(rng.get("type", "")).upper() == "GIT":
293
+ return None
294
+
295
+ # `limit` bounds a GIT traversal and has no version meaning on an
296
+ # ECOSYSTEM or SEMVER range. Seeing one here means the record is shaped in
297
+ # a way this code was not written for; say so instead of ignoring it.
298
+ if any(key not in _KNOWN_EVENTS for event in events for key in event):
299
+ return None
300
+
301
+ introduced = [str(e["introduced"]) for e in events if "introduced" in e]
302
+ closing = [
303
+ str(e.get("fixed") or e.get("last_affected"))
304
+ for e in events
305
+ if "fixed" in e or "last_affected" in e
306
+ ]
307
+
308
+ if not closing and "0" in introduced:
309
+ return True
310
+ if version is None:
311
+ return None
312
+
313
+ current = _parse_version(version)
314
+ if current is None:
315
+ return None
316
+
317
+ # Pair each introduced with the next closing event, in order.
318
+ lower = _parse_version(introduced[0]) if introduced and introduced[0] != "0" else None
319
+ if introduced and introduced[0] != "0" and lower is None:
320
+ return None
321
+ if lower is not None and bool(current < lower):
322
+ return False
323
+ if not closing:
324
+ return True
325
+
326
+ upper_raw = closing[0]
327
+ upper = _parse_version(upper_raw)
328
+ if upper is None:
329
+ return None
330
+ is_fixed = any("fixed" in e for e in events)
331
+ return bool(current < upper) if is_fixed else bool(current <= upper)
332
+
333
+
334
+ def record_covers(record: dict[str, Any], version: str | None) -> bool | None:
335
+ """Does this advisory cover `version`? None when it cannot be decided.
336
+
337
+ True - the record names this version, covers the whole project, or has a
338
+ range open from the first release.
339
+ False - the record scopes itself and this release falls outside.
340
+ None - the version is unknown, or a range we cannot order. Undecided must
341
+ never read as clean.
342
+ """
343
+ if record.get("whole_package"):
344
+ return True
345
+
346
+ versions = [str(v) for v in (record.get("versions") or [])]
347
+ if version is not None and versions and any(_same_version(version, v) for v in versions):
348
+ return True
349
+
350
+ verdicts = [_range_covers(r, version) for r in (record.get("ranges") or [])]
351
+ if any(v is True for v in verdicts):
352
+ return True
353
+ if any(v is None for v in verdicts):
354
+ return None
355
+
356
+ if version is None:
357
+ return None
358
+ if versions or verdicts:
359
+ return False
360
+ return None
361
+
362
+
363
+ def check_osv_malicious(
364
+ target: TargetRef, facts: dict[str, Any], builder: EvidenceBuilder
365
+ ) -> Evidence | None:
366
+ """P2: OSV holds a MAL- record covering this release.
367
+
368
+ A MAL- record is usually scoped to particular versions. `litellm` carries
369
+ MAL-2026-2144 for three releases out of hundreds, so matching on the name
370
+ alone would hard-block every clean version of a widely used package.
371
+
372
+ Severity carries how certain the match is, and the policy turns an
373
+ uncertain one into REVIEW rather than BLOCK:
374
+ hard - the record covers this exact version, or the whole project
375
+ soft - a record exists for the name but this install cannot be placed in
376
+ it: nothing is pinned yet, or the record uses version ranges
377
+ none - the record names versions and this release is not among them
378
+ """
379
+ if not facts.get("malicious"):
380
+ return None
381
+
382
+ records = facts.get("mal_records")
383
+ if not records:
384
+ # Facts from an older cache or a hand-written fixture carry no version
385
+ # detail, so a name match is the whole of what we know.
386
+ records = [{"id": i, "whole_package": True} for i in (facts.get("mal_ids") or [])]
387
+
388
+ verdicts = [(r, record_covers(r, target.version)) for r in records]
389
+ covering = [r for r, covered in verdicts if covered is True]
390
+ undecided = [r for r, covered in verdicts if covered is None]
391
+
392
+ matched = covering or undecided
393
+ if not matched:
394
+ return None
395
+
396
+ return builder.build(
397
+ EvidenceKind.OSV_MALICIOUS,
398
+ source="osv",
399
+ trust="deterministic",
400
+ severity="hard" if covering else "soft",
401
+ target=target,
402
+ observed={
403
+ "mal_ids": sorted(str(r.get("id")) for r in matched),
404
+ "version_checked": target.version,
405
+ "affected_versions": sorted(
406
+ {str(v) for r in matched for v in (r.get("versions") or [])}
407
+ ),
408
+ "match": "version_covered" if covering else "version_undetermined",
409
+ },
410
+ source_url="https://api.osv.dev/v1/query",
411
+ )
412
+
413
+
414
+ def typosquat_coverage(ecosystem: Ecosystem) -> str | None:
415
+ """Why this ecosystem has no typosquat coverage, or None when it needs none.
416
+
417
+ Only name-based package registries are in scope. A container image or a
418
+ model repository is addressed by `namespace/name`, and a near-miss there is
419
+ a different problem with different reference data — so the check is not
420
+ applicable rather than missing, and must not hold back an ALLOW.
421
+ """
422
+ if ecosystem not in TYPOSQUAT_ECOSYSTEMS:
423
+ return None
424
+ if MIN_SNAPSHOT_NAMES.get(ecosystem) is None:
425
+ return (
426
+ f"typosquat check is disabled for {ecosystem.value}: no vetted "
427
+ "popular-name source for this milestone"
428
+ )
429
+ if ecosystem not in _SNAPSHOTS:
430
+ return f"no popular-name reference set exists for {ecosystem.value}"
431
+ return None
432
+
433
+
434
+ def nearest_popular_name(name: str, ecosystem: Ecosystem) -> tuple[str, int] | None:
435
+ """Closest name in the snapshot, if it is a near-miss rather than a match.
436
+
437
+ Raises SnapshotUnavailable when a snapshot that should be there is too short
438
+ to be trusted — an empty answer would be indistinguishable from "name is fine".
439
+ An ecosystem with the check switched off is handled by `typosquat_coverage`
440
+ before this is called.
441
+ """
442
+ threshold = MIN_SNAPSHOT_NAMES.get(ecosystem)
443
+ candidate_names = load_top_names(ecosystem)
444
+ if threshold is not None and len(candidate_names) < threshold:
445
+ raise SnapshotUnavailable(
446
+ f"{ecosystem.value} reference set has {len(candidate_names)} names, "
447
+ f"fewer than the {threshold} required to judge a typosquat"
448
+ )
449
+ if not candidate_names:
450
+ return None
451
+ lowered = normalize_name(name, ecosystem)
452
+ if lowered in candidate_names or len(lowered) < TYPOSQUAT_MIN_LENGTH:
453
+ return None
454
+
455
+ best: tuple[str, int] | None = None
456
+ for popular in candidate_names:
457
+ if abs(len(popular) - len(lowered)) > TYPOSQUAT_MAX_DISTANCE:
458
+ continue
459
+ distance = DamerauLevenshtein.distance(
460
+ lowered, popular, score_cutoff=TYPOSQUAT_MAX_DISTANCE
461
+ )
462
+ if distance == 0 or distance > TYPOSQUAT_MAX_DISTANCE:
463
+ continue
464
+ if best is None or distance < best[1]:
465
+ best = (popular, distance)
466
+ if distance == 1:
467
+ break
468
+ return best
469
+
470
+
471
+ @dataclass(frozen=True)
472
+ class TyposquatResult:
473
+ """Outcome of the near-miss check.
474
+
475
+ `covered` is False when a snapshot that should exist is missing. npm is not
476
+ in that set: typosquat does not apply, exists/pin still can ALLOW.
477
+ """
478
+
479
+ covered: bool
480
+ evidence: list[Evidence]
481
+ reason: str | None = None
482
+
483
+
484
+ def check_typosquat(
485
+ target: TargetRef,
486
+ facts: dict[str, Any],
487
+ builder: EvidenceBuilder,
488
+ now: datetime | None = None,
489
+ ) -> TyposquatResult:
490
+ """P5 typosquat (soft), and P4 typosquat on a days-old project (hard, derived)."""
491
+ uncovered = typosquat_coverage(target.ecosystem)
492
+ if uncovered is not None:
493
+ unavailable = _check_unavailable(
494
+ builder, target, "typosquat_unsupported_ecosystem", uncovered
495
+ )
496
+ return TyposquatResult(covered=False, evidence=[unavailable], reason=uncovered)
497
+
498
+ match = nearest_popular_name(target.name, target.ecosystem)
499
+ if match is None:
500
+ return TyposquatResult(covered=True, evidence=[])
501
+ popular, distance = match
502
+ source = _SOURCE_FOR[target.ecosystem]
503
+ observed = {
504
+ "candidate": target.name,
505
+ "nearest_popular": popular,
506
+ "distance": distance,
507
+ "snapshot": _SNAPSHOTS.get(target.ecosystem),
508
+ }
509
+
510
+ out = [
511
+ builder.build(
512
+ EvidenceKind.TYPOSQUAT,
513
+ source=source, # type: ignore[arg-type]
514
+ trust="deterministic",
515
+ severity="soft",
516
+ target=target,
517
+ observed=observed,
518
+ )
519
+ ]
520
+
521
+ now = now or datetime.now(UTC)
522
+ first = _parse_iso(facts.get("first_upload"))
523
+ if first is not None and max((now - first).days, 0) < TYPOSQUAT_NEW_PROJECT_DAYS:
524
+ out.append(
525
+ builder.build(
526
+ EvidenceKind.TYPOSQUAT_NEW_PROJECT,
527
+ source=source, # type: ignore[arg-type]
528
+ trust="deterministic",
529
+ severity="hard",
530
+ target=target,
531
+ observed={
532
+ **observed,
533
+ "first_upload": first.isoformat(),
534
+ "age_days": max((now - first).days, 0),
535
+ },
536
+ )
537
+ )
538
+ return TyposquatResult(covered=True, evidence=out)
539
+
540
+
541
+ def check_hf_format(
542
+ target: TargetRef, facts: dict[str, Any], builder: EvidenceBuilder
543
+ ) -> list[Evidence]:
544
+ """P7: pickle weights with no safetensors sibling. Loading a pickle executes code."""
545
+ if not facts.get("exists") or not facts.get("has_pickle"):
546
+ return []
547
+
548
+ out = [
549
+ builder.build(
550
+ EvidenceKind.HF_PICKLE,
551
+ source="huggingface",
552
+ trust="deterministic",
553
+ severity="soft",
554
+ target=target,
555
+ observed={"pickle_files": facts.get("pickle_files") or []},
556
+ source_url=f"https://huggingface.co/{target.name}",
557
+ )
558
+ ]
559
+ if facts.get("has_safetensors"):
560
+ out.append(
561
+ builder.build(
562
+ EvidenceKind.HF_SAFETENSORS_SIBLING,
563
+ source="huggingface",
564
+ trust="deterministic",
565
+ severity="info",
566
+ target=target,
567
+ observed={"safetensors_files": facts.get("safetensors_files") or []},
568
+ source_url=f"https://huggingface.co/{target.name}",
569
+ )
570
+ )
571
+ return out
572
+
573
+
574
+ _VERSION_PART = re.compile(r"\d+")
575
+
576
+
577
+ def _version_tuple(version: str) -> tuple[int, ...] | None:
578
+ """Numeric prefix of a tag. `3.11.9-slim` -> (3, 11, 9). Non-numeric tags -> None."""
579
+ head = version.split("-")[0]
580
+ parts = head.split(".")
581
+ if not parts or not _VERSION_PART.fullmatch(parts[0]):
582
+ return None
583
+ numbers: list[int] = []
584
+ for part in parts:
585
+ if not _VERSION_PART.fullmatch(part):
586
+ break
587
+ numbers.append(int(part))
588
+ return tuple(numbers) or None
589
+
590
+
591
+ @dataclass(frozen=True)
592
+ class RuntimeFloorResult:
593
+ """Outcome of the base-tag check.
594
+
595
+ `comparable` is the half that matters for FR-09: when we cannot place a tag
596
+ on a known release branch we have an absence of knowledge, not evidence of an
597
+ unpatched runtime. `evidence` is then a soft check_unavailable record, which
598
+ P10 turns into REVIEW, never BLOCK.
599
+ """
600
+
601
+ comparable: bool
602
+ evidence: Evidence | None
603
+ reason: str
604
+ branch: str | None = None
605
+ floor: str | None = None
606
+
607
+
608
+ def _match_branch(tag: tuple[int, ...], branches: dict[str, str]) -> tuple[str, str] | None:
609
+ """Longest branch whose version tuple prefixes the tag. `8.2.20` -> branch `8.2`."""
610
+ best: tuple[str, str] | None = None
611
+ best_len = -1
612
+ for branch, floor in branches.items():
613
+ prefix = _version_tuple(branch)
614
+ if prefix is None or len(prefix) > len(tag):
615
+ continue
616
+ if tag[: len(prefix)] == prefix and len(prefix) > best_len:
617
+ best, best_len = (branch, floor), len(prefix)
618
+ return best
619
+
620
+
621
+ def check_runtime_floor(
622
+ target: TargetRef,
623
+ builder: EvidenceBuilder,
624
+ floors: dict[str, dict[str, str]] | None = None,
625
+ ) -> RuntimeFloorResult:
626
+ """P3: a Dockerfile base tag below the patched floor of its own release branch.
627
+
628
+ BLOCK is reserved for a tag we can place on a known branch and show to be
629
+ behind that branch's floor. Everything else — an image we hold no floors for,
630
+ a branch not in the table, a floating tag (`latest`, `bookworm`), or a
631
+ digest-only reference with no tag — is not comparable and resolves to REVIEW
632
+ (FR-09).
633
+ """
634
+ if target.ecosystem != Ecosystem.DOCKER:
635
+ return RuntimeFloorResult(True, None, "not a container image")
636
+
637
+ floors = floors if floors is not None else load_runtime_floors()
638
+ image = target.name.rsplit("/", 1)[-1].lower()
639
+ branches = floors.get(image)
640
+
641
+ def unavailable(code: str, detail: str) -> RuntimeFloorResult:
642
+ record = _check_unavailable(builder, target, code, detail, image=image, tag=target.version)
643
+ return RuntimeFloorResult(False, record, detail)
644
+
645
+ if not target.version:
646
+ return unavailable(
647
+ "digest_only", "digest-only reference: no tag to compare against a floor"
648
+ )
649
+ if not branches:
650
+ return unavailable("image_not_in_table", f"no patched floors recorded for {image!r}")
651
+
652
+ actual = _version_tuple(target.version)
653
+ if actual is None:
654
+ return unavailable(
655
+ "floating_tag", f"floating tag {target.version!r} carries no comparable version"
656
+ )
657
+
658
+ matched = _match_branch(actual, branches)
659
+ if matched is None:
660
+ return unavailable(
661
+ "no_floor_for_branch",
662
+ f"{image} {target.version!r} is on a branch with no recorded floor",
663
+ )
664
+
665
+ branch, floor = matched
666
+ required = _version_tuple(floor)
667
+ if required is None:
668
+ return unavailable(
669
+ "no_floor_for_branch", f"floor {floor!r} for branch {branch} is unusable"
670
+ )
671
+
672
+ depth = min(len(actual), len(required))
673
+ if actual[:depth] >= required[:depth]:
674
+ if len(actual) < len(required):
675
+ # `8.3` or `8.3-apache` floats to whatever patch the registry serves
676
+ # today, so it cannot be shown to be at or above the floor.
677
+ return unavailable(
678
+ "floating_tag", f"tag {target.version!r} does not pin a patch release"
679
+ )
680
+ return RuntimeFloorResult(True, None, "at or above the branch floor", branch, floor)
681
+
682
+ evidence = builder.build(
683
+ EvidenceKind.RUNTIME_BELOW_FLOOR,
684
+ source="docker_registry",
685
+ trust="deterministic",
686
+ severity="hard",
687
+ target=target,
688
+ observed={
689
+ "image": image,
690
+ "tag": target.version,
691
+ "branch": branch,
692
+ "patched_floor": floor,
693
+ },
694
+ )
695
+ return RuntimeFloorResult(True, evidence, "below the branch floor", branch, floor)