@dzhechkov/p-replicator 1.5.13 → 1.5.15

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 (31) hide show
  1. package/.dz-manifest.json +677 -0
  2. package/CHANGELOG.md +874 -0
  3. package/KNOWN_LIMITATIONS.md +327 -0
  4. package/MULTIPLATFORM_ROADMAP.md +239 -0
  5. package/README/eng/06_troubleshooting.md +1 -1
  6. package/README/eng/07_changelog.md +57 -0
  7. package/README/eng/README.md +2 -2
  8. package/README/ru/06_troubleshooting.md +1 -1
  9. package/README/ru/07_changelog.md +58 -0
  10. package/README/ru/README.md +2 -2
  11. package/README/ru/html/build.js +7 -7
  12. package/README/ru/html/index.html +31 -12
  13. package/README.md +59 -10
  14. package/package.json +10 -3
  15. package/sbom.json +1683 -0
  16. package/templates/.claude/skills/explore/SKILL.md +1 -1
  17. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +141 -11
  18. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +363 -0
  19. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +297 -4
  20. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +277 -0
  21. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v1_fact.json +21 -0
  22. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +462 -0
  23. package/templates/.claude/skills/goap-research-ed25519/scripts/source_tiers.py +170 -0
  24. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +784 -0
  25. package/templates/.claude/skills/problem-solver-enhanced/SKILL.md +1 -1
  26. package/templates/.claude/skills/reverse-engineering-unicorn/SKILL.md +1 -1
  27. package/tests/e2e/lifecycle.test.js +973 -0
  28. package/tests/snapshot/baseline.json +125 -0
  29. package/tests/snapshot/templates.test.js +89 -0
  30. package/tests/snapshot/update-baseline.js +68 -0
  31. package/tests/unit/utils.test.js +636 -0
@@ -46,6 +46,41 @@ TRUST_CLASS_ISSUER_SIGNED = "ISSUER_SIGNED"
46
46
  TRUST_CLASS_SELF_ATTESTED = "SELF_ATTESTED"
47
47
  TRUST_CLASS_UNVERIFIED = "UNVERIFIED"
48
48
 
49
+ # --- Evidence provenance (ADR-001): a SECOND, ORTHOGONAL axis -----------------
50
+ # trust_class answers "was this record altered after signing?".
51
+ # evidence_class answers "did anyone actually open the source?".
52
+ # A fact can be ISSUER_SIGNED *and* ASSERTED — that combination is legal, and it
53
+ # is exactly the dangerous one: a cryptographically perfect record of something
54
+ # the model recited from memory. Merging the two axes into one field would make
55
+ # that state inexpressible (and would repeat the whitelist_available defect,
56
+ # where "issuer-signed" and "belongs to a trusted class" were fused).
57
+ EVIDENCE_FETCH_VERIFIED = "FETCH_VERIFIED" # this script performed the HTTP request itself
58
+ EVIDENCE_LISTING_ONLY = "LISTING_ONLY" # URL known from a listing / body supplied by hand
59
+ EVIDENCE_ASSERTED = "ASSERTED" # stated from model memory, source never opened
60
+
61
+ EVIDENCE_CLASSES = (EVIDENCE_FETCH_VERIFIED, EVIDENCE_LISTING_ONLY, EVIDENCE_ASSERTED)
62
+
63
+ # Confidence ceilings per evidence class. ASSERTED is 0.0 by construction, not
64
+ # "low": a claim nobody checked is not weak evidence, it is no evidence.
65
+ EVIDENCE_CEILINGS = {
66
+ EVIDENCE_FETCH_VERIFIED: 1.0,
67
+ EVIDENCE_LISTING_ONLY: 0.50,
68
+ EVIDENCE_ASSERTED: 0.0,
69
+ }
70
+
71
+ # Signed-message schema marker (ADR-002 + its AM-1 amendment).
72
+ #
73
+ # HONEST ROLE: this marker is self-description and the growth point for a future
74
+ # v3 (slice C will add study_population). It is NOT what stops tampering.
75
+ # What stops tampering is that the v2 message CONTAINS the three new keys at all:
76
+ # strip evidence_class → verifier builds the 6-key v1 text ≠ signed v2 text → fail
77
+ # add evidence_class → verifier builds the v2 text ≠ signed v1 text → fail
78
+ # The first draft of ADR-002 credited this marker with closing the downgrade
79
+ # attack; the discrimination run refuted that (removing the marker left
80
+ # tamper_strip green). Keeping the wrong attribution would have been the exact
81
+ # class of error this feature exists to prevent.
82
+ FACT_SCHEMA_V2 = "fact-v2"
83
+
49
84
 
50
85
  @dataclass
51
86
  class PinnedKey:
@@ -92,6 +127,11 @@ class SignedFact:
92
127
  trust_class: str = TRUST_CLASS_SELF_ATTESTED
93
128
  research_context: Optional[str] = None
94
129
  metadata: Dict[str, Any] = field(default_factory=dict)
130
+ # --- evidence axis (ADR-001). None = legacy fact: evidence is UNKNOWN, which
131
+ # is neither ASSERTED nor FETCH_VERIFIED. Unknown is named, never guessed.
132
+ evidence_class: Optional[str] = None
133
+ fetch_date: Optional[str] = None
134
+ source_date: Optional[str] = None
95
135
 
96
136
  def to_dict(self) -> Dict[str, Any]:
97
137
  return asdict(self)
@@ -157,13 +197,39 @@ def canonical_json(data: Dict[str, Any]) -> str:
157
197
  return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
158
198
 
159
199
 
160
- def canonical_fact_message(fact: SignedFact) -> str:
161
- """Canonical signed message for a fact."""
200
+ def canonical_fact_message_v1(fact: SignedFact) -> str:
201
+ """Legacy signed message. Frozen: every fact signed before the evidence axis
202
+ existed verifies against exactly these six fields."""
203
+ return canonical_json(
204
+ {
205
+ "claim": fact.claim,
206
+ "issuer": fact.issuer,
207
+ "research_context": fact.research_context,
208
+ "source_hash": fact.source_hash,
209
+ "source_url": fact.source_url,
210
+ "timestamp": fact.timestamp,
211
+ }
212
+ )
213
+
214
+
215
+ def canonical_fact_message_v2(fact: SignedFact) -> str:
216
+ """Signed message that COVERS the evidence axis (ADR-002).
217
+
218
+ The `schema` marker is load-bearing, not decoration: it makes the v1 and v2
219
+ messages differ even when the six shared fields are identical, so BOTH tamper
220
+ directions break the signature —
221
+ * strip evidence_class → verifier picks v1 → v1 text != signed v2 text → fail
222
+ * add evidence_class → verifier picks v2 → v2 text != signed v1 text → fail
223
+ """
162
224
  return canonical_json(
163
225
  {
226
+ "schema": FACT_SCHEMA_V2,
164
227
  "claim": fact.claim,
228
+ "evidence_class": fact.evidence_class,
229
+ "fetch_date": fact.fetch_date,
165
230
  "issuer": fact.issuer,
166
231
  "research_context": fact.research_context,
232
+ "source_date": fact.source_date,
167
233
  "source_hash": fact.source_hash,
168
234
  "source_url": fact.source_url,
169
235
  "timestamp": fact.timestamp,
@@ -171,6 +237,58 @@ def canonical_fact_message(fact: SignedFact) -> str:
171
237
  )
172
238
 
173
239
 
240
+ def fact_schema_version(fact: SignedFact) -> int:
241
+ """Which signed-message schema this fact uses. Presence of the evidence axis
242
+ IS the discriminator — a legacy fact has none and stays on v1 forever."""
243
+ return 2 if fact.evidence_class is not None else 1
244
+
245
+
246
+ def canonical_fact_message(fact: SignedFact) -> str:
247
+ """Canonical signed message for a fact — dispatched by schema version."""
248
+ return canonical_fact_message_v2(fact) if fact_schema_version(fact) == 2 else canonical_fact_message_v1(fact)
249
+
250
+
251
+ def evidence_ceiling(evidence_class: Optional[str]) -> float:
252
+ """Confidence ceiling contributed by the evidence axis. An UNKNOWN (legacy)
253
+ evidence class contributes NO ceiling (1.0) — absence of evidence data is not
254
+ evidence of absence; the legacy fact is judged by its trust class alone.
255
+ An UNRECOGNISED class is 0.0: a misspelling must not read as permission."""
256
+ if evidence_class is None:
257
+ return 1.0
258
+ return EVIDENCE_CEILINGS.get(evidence_class, 0.0)
259
+
260
+
261
+ def source_tier_ceiling(fact: SignedFact) -> float:
262
+ """Ceiling from the source-class tier (FR-5). Wired because a formula printed
263
+ in SKILL.md but never called is a false claim — the first draft computed
264
+ min(trust, evidence) while the docs promised min(trust, evidence, tier), so an
265
+ unknown-domain fact kept 0.60 instead of the promised 0.40 (Codex QE #6).
266
+
267
+ APPLIES TO v2 FACTS ONLY. A new ceiling must not retroactively re-score records
268
+ signed before it existed: wiring it globally silently downgraded a legacy
269
+ ISSUER_SIGNED fact from 0.95 to 0.40 and broke the backward-compatibility test
270
+ that is this feature's NFR-2 evidence. Old facts keep the semantics they were
271
+ created under; the tier applies from the schema that introduced it.
272
+
273
+ Import is local and fail-open: source_tiers is a DATA module, and missing data
274
+ must never break signature verification."""
275
+ if fact_schema_version(fact) != 2 or not fact.source_url:
276
+ return 1.0
277
+ # FAIL CLOSED (Codex QE r2): the first version returned 1.0 when source_tiers
278
+ # was missing or raised, so losing the security-data module SILENTLY RAISED
279
+ # every confidence. A ceiling that disappears when its data disappears is not a
280
+ # ceiling. Absent data ⇒ the most cautious tier, and the caller sees the low
281
+ # number rather than a comfortable one.
282
+ try:
283
+ from source_tiers import classify_source, TIER_CEILINGS, TIER_D
284
+ except Exception:
285
+ return 0.40
286
+ try:
287
+ return classify_source(fact.source_url).ceiling
288
+ except Exception:
289
+ return TIER_CEILINGS.get(TIER_D, 0.40)
290
+
291
+
174
292
  def fact_content_hash(fact: SignedFact) -> str:
175
293
  """Stable hash for chain linkage. Excludes parent links and chain position."""
176
294
  return hashlib.sha256(canonical_fact_message(fact).encode("utf-8")).hexdigest()
@@ -391,6 +509,165 @@ class Ed25519Verifier:
391
509
  fact.signature, _ = self.sign_content(canonical_fact_message(fact))
392
510
  return fact
393
511
 
512
+ # ---------------------------------------------------------------- evidence axis
513
+ # THREE constructors, not one with an `evidence_class=` parameter (ADR-003).
514
+ # The manual paths below cannot emit FETCH_VERIFIED because they never receive
515
+ # a FetchRecord — the restriction is in the API shape, not in the author's
516
+ # discipline. A single constructor taking the class as an argument would put
517
+ # the guarantee back on the caller's honesty, which is the thing that failed.
518
+
519
+ def _sign_evidence_fact(
520
+ self,
521
+ claim: str,
522
+ source_url: str,
523
+ source_hash: str,
524
+ issuer: str,
525
+ evidence_class: str,
526
+ fetch_date: Optional[str],
527
+ source_date: Optional[str],
528
+ metadata: Optional[Dict[str, Any]],
529
+ research_context: Optional[str],
530
+ base_confidence: float,
531
+ ) -> SignedFact:
532
+ if self._private_key is None or self._public_key is None:
533
+ raise ValueError("No keypair loaded.")
534
+ if evidence_class not in EVIDENCE_CLASSES:
535
+ raise ValueError(f"unknown evidence_class {evidence_class!r}; expected one of {EVIDENCE_CLASSES}")
536
+ fact = SignedFact(
537
+ claim=claim,
538
+ source_url=source_url,
539
+ source_hash=source_hash,
540
+ issuer=issuer,
541
+ issuer_pubkey=f"ed25519:{self.get_public_key_b64()}",
542
+ signature="",
543
+ timestamp=datetime.utcnow().isoformat() + "Z",
544
+ confidence=min(base_confidence, evidence_ceiling(evidence_class)),
545
+ trust_class=TRUST_CLASS_SELF_ATTESTED,
546
+ research_context=research_context,
547
+ metadata=metadata or {},
548
+ evidence_class=evidence_class,
549
+ fetch_date=fetch_date,
550
+ source_date=source_date,
551
+ )
552
+ fact.signature, _ = self.sign_content(canonical_fact_message(fact))
553
+ return fact
554
+
555
+ def create_fetched_fact(
556
+ self,
557
+ claim: str,
558
+ fetch_record: Any,
559
+ issuer: str,
560
+ source_date: Optional[str] = None,
561
+ metadata: Optional[Dict[str, Any]] = None,
562
+ research_context: Optional[str] = None,
563
+ ) -> SignedFact:
564
+ """FETCH_VERIFIED — requires proof the request actually happened.
565
+
566
+ `fetch_record` must be an evidence_fetch.FetchRecord for a 2xx response.
567
+ Duck-typed on purpose (the verifier must not import the network module),
568
+ but validated: anything lacking the proof fields is refused outright
569
+ rather than silently downgraded, because a caller reaching for THIS
570
+ constructor is asserting a fetch occurred.
571
+ """
572
+ # A duck-typed check was NOT enough (Codex QE #1): a SimpleNamespace with
573
+ # four plausible attributes minted FETCH_VERIFIED without any network I/O.
574
+ # The record must be the real type AND carry this process's fetch witness,
575
+ # which only evidence_fetch.fetch_source() hands out.
576
+ if not getattr(fetch_record, "is_authentic", None) or not fetch_record.is_authentic():
577
+ raise ValueError(
578
+ "create_fetched_fact requires a FetchRecord produced by evidence_fetch.fetch_source() "
579
+ "in this process — a hand-built or duck-typed record is not proof that a fetch happened"
580
+ )
581
+ required = ("sha256_body", "final_url", "status", "fetched_at")
582
+ missing = [f for f in required if getattr(fetch_record, f, None) is None]
583
+ if missing:
584
+ raise ValueError(
585
+ f"create_fetched_fact requires a FetchRecord; missing proof fields: {', '.join(missing)}"
586
+ )
587
+ if not (200 <= int(getattr(fetch_record, "status")) < 300):
588
+ raise ValueError(
589
+ f"create_fetched_fact refuses a non-2xx fetch (status {getattr(fetch_record, 'status')}) — "
590
+ "an error page is not the source it stands for"
591
+ )
592
+ meta = dict(metadata or {})
593
+ meta.setdefault("fetch_status", int(getattr(fetch_record, "status")))
594
+ meta.setdefault("fetch_bytes", getattr(fetch_record, "bytes_len", None))
595
+ if getattr(fetch_record, "final_url", None) != getattr(fetch_record, "url", None):
596
+ meta.setdefault("redirected_from", getattr(fetch_record, "url", None))
597
+ return self._sign_evidence_fact(
598
+ claim=claim,
599
+ source_url=getattr(fetch_record, "final_url"),
600
+ source_hash=getattr(fetch_record, "sha256_body"),
601
+ issuer=issuer,
602
+ evidence_class=EVIDENCE_FETCH_VERIFIED,
603
+ fetch_date=getattr(fetch_record, "fetched_at"),
604
+ source_date=source_date,
605
+ metadata=meta,
606
+ research_context=research_context,
607
+ base_confidence=0.60,
608
+ )
609
+
610
+ def create_listing_fact(
611
+ self,
612
+ claim: str,
613
+ source_url: str,
614
+ reason: str,
615
+ source_content: Optional[str] = None,
616
+ issuer: str = "researcher",
617
+ source_date: Optional[str] = None,
618
+ metadata: Optional[Dict[str, Any]] = None,
619
+ research_context: Optional[str] = None,
620
+ ) -> SignedFact:
621
+ """LISTING_ONLY — the URL is known but this script did not fetch it, or a
622
+ body was supplied by hand. `reason` is MANDATORY and stored verbatim: a
623
+ degradation whose cause is not recorded is indistinguishable from a bug.
624
+ """
625
+ if not reason or not reason.strip():
626
+ raise ValueError("create_listing_fact requires a non-empty reason (why was this not fetched?)")
627
+ meta = dict(metadata or {})
628
+ meta["evidence_note"] = reason.strip()
629
+ digest = hashlib.sha256((source_content or "").encode("utf-8")).hexdigest()
630
+ if source_content is None:
631
+ meta.setdefault("source_body", "not supplied — source_hash is the hash of an empty body")
632
+ return self._sign_evidence_fact(
633
+ claim=claim,
634
+ source_url=source_url,
635
+ source_hash=digest,
636
+ issuer=issuer,
637
+ evidence_class=EVIDENCE_LISTING_ONLY,
638
+ fetch_date=None,
639
+ source_date=source_date,
640
+ metadata=meta,
641
+ research_context=research_context,
642
+ base_confidence=0.50,
643
+ )
644
+
645
+ def create_asserted_fact(
646
+ self,
647
+ claim: str,
648
+ issuer: str = "researcher",
649
+ source_url: str = "",
650
+ metadata: Optional[Dict[str, Any]] = None,
651
+ research_context: Optional[str] = None,
652
+ ) -> SignedFact:
653
+ """ASSERTED — stated from model memory, source never opened. Confidence is
654
+ 0.0 by construction: this is not weak evidence, it is no evidence. Such a
655
+ fact exists so it can be RECORDED and then refused by the report gate,
656
+ rather than quietly becoming a sentence in a medical document.
657
+ """
658
+ return self._sign_evidence_fact(
659
+ claim=claim,
660
+ source_url=source_url,
661
+ source_hash=hashlib.sha256(b"").hexdigest(),
662
+ issuer=issuer,
663
+ evidence_class=EVIDENCE_ASSERTED,
664
+ fetch_date=None,
665
+ source_date=None,
666
+ metadata=metadata,
667
+ research_context=research_context,
668
+ base_confidence=0.0,
669
+ )
670
+
394
671
  def create_issuer_signed_fact(
395
672
  self,
396
673
  claim: str,
@@ -462,7 +739,15 @@ class Ed25519Verifier:
462
739
  except Exception:
463
740
  return self._result(fact, False, 0.0, TRUST_CLASS_UNVERIFIED, "Invalid embedded public key")
464
741
  if self.verify_signature(canonical_fact_message(fact), fact.signature, public_key):
465
- return self._result(fact, True, min(fact.confidence or 0.60, 0.60), TRUST_CLASS_SELF_ATTESTED, None)
742
+ # Weakest link, not the average: a signed-but-unread claim is capped by
743
+ # the evidence axis regardless of how sound its signature is (ADR-001).
744
+ capped = min(
745
+ fact.confidence or 0.60,
746
+ 0.60,
747
+ evidence_ceiling(fact.evidence_class),
748
+ source_tier_ceiling(fact),
749
+ )
750
+ return self._result(fact, True, capped, TRUST_CLASS_SELF_ATTESTED, None)
466
751
  return self._result(fact, False, 0.0, TRUST_CLASS_UNVERIFIED, "Self-attestation signature failed")
467
752
 
468
753
  pin = self.registry.get(fact.issuer)
@@ -481,7 +766,15 @@ class Ed25519Verifier:
481
766
  verified = self.verify_signature(canonical_fact_message(fact), fact.signature, public_key)
482
767
  if not verified:
483
768
  return self._result(fact, False, 0.0, TRUST_CLASS_UNVERIFIED, "Signature verification failed")
484
- return self._result(fact, True, min(fact.confidence or 0.95, 0.95), TRUST_CLASS_ISSUER_SIGNED, None)
769
+ # An ISSUER_SIGNED fact that nobody actually read is still capped by the
770
+ # evidence axis — this is the dangerous quadrant the axis exists to expose.
771
+ capped = min(
772
+ fact.confidence or 0.95,
773
+ 0.95,
774
+ evidence_ceiling(fact.evidence_class),
775
+ source_tier_ceiling(fact),
776
+ )
777
+ return self._result(fact, True, capped, TRUST_CLASS_ISSUER_SIGNED, None)
485
778
 
486
779
  def chain_message(self, chain: CitationChain) -> str:
487
780
  return canonical_json({"chain_id": chain.chain_id, "hashes": chain.ordered_hashes()})
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Evidence fetch — the ONLY place that can mint the artifact behind FETCH_VERIFIED.
4
+
5
+ Why this file exists (ADR-003). Before it, the package had no network path at all
6
+ (`grep -rn "requests.get\\|urlopen\\|httpx"` over the skill returned nothing), and
7
+ `create_signed_fact(source_content=...)` hashed whatever string the caller passed.
8
+ So `source_hash` was a hash of the agent's own words. Any "did you read it?" class
9
+ assigned on the agent's word would repeat the failure it is meant to catch: the
10
+ model that confidently recites an unread source will just as confidently label it
11
+ verified. A checklist item is satisfied by writing it; only running something
12
+ produces an artifact.
13
+
14
+ So FETCH_VERIFIED is earned by an artifact obtainable only by performing the
15
+ request: the sha256 of a real response body, its HTTP status, its final URL after
16
+ redirects, and the date it happened.
17
+
18
+ HONEST SCOPE — printed by every surface that shows the class:
19
+ FETCH_VERIFIED means "this script issued an HTTP request and received a body
20
+ with this byte hash on this date".
21
+ It does NOT mean the source is authoritative, that the claim follows from it,
22
+ or that the reader understood it. It is provenance, not truth.
23
+
24
+ Constraints: stdlib only (no new dependency in a medical package); the network is
25
+ never touched implicitly — a caller must ask; offline degrades LOUDLY to
26
+ LISTING_ONLY with a stated reason, it never fails the run.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import http.client
33
+ import ipaddress
34
+ import socket
35
+ from dataclasses import asdict, dataclass, replace
36
+ from datetime import datetime, timezone
37
+ from typing import Any, Dict, Optional
38
+ from urllib import error as urlerror
39
+ from urllib import request as urlrequest
40
+ from urllib.parse import urlparse
41
+
42
+ DEFAULT_TIMEOUT_SECONDS = 20
43
+ DEFAULT_MAX_BYTES = 5 * 1024 * 1024 # 5 MiB — a paper/abstract page, not a dataset
44
+ # Hard ceiling the caller cannot raise: `read(max_bytes + 1)` with an unbounded
45
+ # max_bytes is an unbounded-memory path (Codex QE #12).
46
+ HARD_MAX_BYTES = 32 * 1024 * 1024
47
+ DEFAULT_MAX_REDIRECTS = 5
48
+ USER_AGENT = "health-advisor-evidence-fetch/1.0 (+provenance; stdlib urllib)"
49
+
50
+ # Only these schemes may be fetched. file:// and friends would let a "fetch"
51
+ # read the local disk and pass as network evidence.
52
+ ALLOWED_SCHEMES = ("http", "https")
53
+
54
+
55
+ class FetchRefused(ValueError):
56
+ """The request was refused before any I/O (bad scheme, malformed URL)."""
57
+
58
+
59
+ # Process-local witness. A FetchRecord is only accepted as proof if it carries
60
+ # THIS process's token, which is handed out exclusively by fetch_source().
61
+ # HONEST THREAT MODEL (the narrow promise): the adversary here is a MODEL TAKING A
62
+ # SHORTCUT, not an attacker with code execution. Anything running inside this
63
+ # interpreter can read _FETCH_WITNESS and forge a record — in-process Python
64
+ # cannot be made tamper-proof against its own caller, and pretending otherwise
65
+ # would be the very over-claim this feature exists to delete. What this stops is
66
+ # the realistic failure: a caller that constructs a plausible-looking record (a
67
+ # SimpleNamespace, a hand-built FetchRecord) instead of performing the request.
68
+ _FETCH_WITNESS = object()
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class FetchRecord:
73
+ """Proof-of-fetch. Frozen on purpose: the artifact must not be edited after
74
+ the fact by the code that consumes it."""
75
+
76
+ url: str
77
+ final_url: str
78
+ status: int
79
+ sha256_body: str
80
+ bytes_len: int
81
+ fetched_at: str
82
+ content_type: Optional[str] = None
83
+ witness: Any = None
84
+
85
+ def is_authentic(self) -> bool:
86
+ """True only for a record minted by fetch_source() in this process."""
87
+ return self.witness is _FETCH_WITNESS
88
+
89
+ @property
90
+ def ok(self) -> bool:
91
+ """2xx only. A 404 body is a real body, but it is not the source."""
92
+ return 200 <= self.status < 300
93
+
94
+ def to_dict(self) -> Dict[str, Any]:
95
+ """Serializable view. The witness is a process-local sentinel: exporting it
96
+ breaks json.dumps, and `asdict()` deep-copies it — destroying the very
97
+ identity that makes it proof (Codex QE r2). It is deliberately dropped."""
98
+ data = {k: v for k, v in asdict(self).items() if k != "witness"}
99
+ data["authentic"] = self.is_authentic()
100
+ return data
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class FetchFailure:
105
+ """A named failure. Never raised into the caller's face — the caller degrades
106
+ to LISTING_ONLY and records `reason` verbatim, so the downgrade is auditable."""
107
+
108
+ url: str
109
+ reason: str
110
+ attempted_at: str
111
+
112
+ def to_dict(self) -> Dict[str, Any]:
113
+ return asdict(self)
114
+
115
+
116
+ def _now_iso() -> str:
117
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
118
+
119
+
120
+ def _resolved_addresses(host: str, port: Optional[int]) -> list:
121
+ """Every address the host resolves to. ALL of them are checked, because a
122
+ name that resolves to one public and one private address would otherwise
123
+ pass on the public one and connect on the private one."""
124
+ try:
125
+ infos = socket.getaddrinfo(host, port or 0, proto=socket.IPPROTO_TCP)
126
+ except (socket.gaierror, UnicodeError, ValueError) as exc:
127
+ raise FetchRefused(f"cannot resolve host {host!r}: {exc}") from exc
128
+ return [info[4][0] for info in infos]
129
+
130
+
131
+ def _is_public_address(address: str) -> bool:
132
+ try:
133
+ ip = ipaddress.ip_address(address)
134
+ except ValueError:
135
+ return False
136
+ # Loopback, RFC1918, link-local (incl. 169.254.169.254 cloud metadata),
137
+ # multicast, reserved — all off limits for evidence fetching.
138
+ return not (
139
+ ip.is_private
140
+ or ip.is_loopback
141
+ or ip.is_link_local
142
+ or ip.is_multicast
143
+ or ip.is_reserved
144
+ or ip.is_unspecified
145
+ )
146
+
147
+
148
+ def _validate_url(url: str, allow_private: bool = False) -> str:
149
+ """Scheme + host + destination-address check.
150
+
151
+ SSRF matters here even though the body is only hashed: a fetch against an
152
+ internal address is still a GET with side effects, and the status/length/hash
153
+ it returns is an oracle about the private network. A research tool has no
154
+ business reaching anything but public web sources (Codex QE #8).
155
+
156
+ `allow_private` exists ONLY for the local test server; it is never set by the
157
+ library's own code paths.
158
+ """
159
+ if not isinstance(url, str):
160
+ raise FetchRefused(f"refusing non-string URL of type {type(url).__name__}")
161
+ try:
162
+ parsed = urlparse(url)
163
+ except (ValueError, UnicodeError) as exc: # malformed IPv6, control chars, lone surrogates
164
+ raise FetchRefused(f"refusing malformed URL {url!r}: {exc}") from exc
165
+ if parsed.scheme.lower() not in ALLOWED_SCHEMES:
166
+ raise FetchRefused(
167
+ f"refusing to fetch scheme {parsed.scheme!r}: only {'/'.join(ALLOWED_SCHEMES)} may back FETCH_VERIFIED"
168
+ )
169
+ try:
170
+ host = parsed.hostname
171
+ port = parsed.port
172
+ except ValueError as exc: # invalid port
173
+ raise FetchRefused(f"refusing malformed URL {url!r}: {exc}") from exc
174
+ if not host:
175
+ raise FetchRefused(f"refusing to fetch malformed URL {url!r}: no host")
176
+ if allow_private:
177
+ return url
178
+ for address in _resolved_addresses(host, port):
179
+ if not _is_public_address(address):
180
+ raise FetchRefused(
181
+ f"refusing to fetch {host!r}: resolves to non-public address {address} "
182
+ "(loopback/private/link-local are not evidence sources)"
183
+ )
184
+ return url
185
+
186
+
187
+ class _CappedRedirectHandler(urlrequest.HTTPRedirectHandler):
188
+ """Redirects are followed but FULLY re-validated — scheme AND destination
189
+ address. A public URL redirecting into the internal network is the classic
190
+ SSRF bypass, and checking only the scheme on the hop leaves it open."""
191
+
192
+ max_repeats = DEFAULT_MAX_REDIRECTS
193
+ max_redirections = DEFAULT_MAX_REDIRECTS
194
+
195
+ def __init__(self, allow_private: bool = False):
196
+ super().__init__()
197
+ self._allow_private = allow_private
198
+
199
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
200
+ _validate_url(newurl, allow_private=self._allow_private)
201
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
202
+
203
+
204
+ def fetch_source(
205
+ url: str,
206
+ timeout: int = DEFAULT_TIMEOUT_SECONDS,
207
+ max_bytes: int = DEFAULT_MAX_BYTES,
208
+ _allow_private: bool = False,
209
+ ) -> "FetchRecord | FetchFailure":
210
+ """Perform the request and return proof of it, or a NAMED failure.
211
+
212
+ Never raises for network conditions: an unreachable source is a normal state
213
+ of the world, and the caller's correct response is to degrade the evidence
214
+ class, not to abort the research. `_allow_private` is for the test server
215
+ only — the library never sets it.
216
+ """
217
+ try:
218
+ max_bytes = int(max_bytes)
219
+ except (TypeError, ValueError):
220
+ return FetchFailure(url=str(url), reason="max_bytes is not an integer", attempted_at=_now_iso())
221
+ if max_bytes <= 0 or max_bytes > HARD_MAX_BYTES:
222
+ return FetchFailure(
223
+ url=str(url),
224
+ reason=f"max_bytes must be in 1..{HARD_MAX_BYTES}; refusing an unbounded read",
225
+ attempted_at=_now_iso(),
226
+ )
227
+ try:
228
+ _validate_url(url, allow_private=_allow_private)
229
+ except FetchRefused as exc:
230
+ return FetchFailure(url=str(url), reason=str(exc), attempted_at=_now_iso())
231
+
232
+ opener = urlrequest.build_opener(_CappedRedirectHandler(allow_private=_allow_private))
233
+ try:
234
+ req = urlrequest.Request(url, headers={"User-Agent": USER_AGENT})
235
+ except ValueError as exc:
236
+ return FetchFailure(url=str(url), reason=f"malformed request: {exc}", attempted_at=_now_iso())
237
+ try:
238
+ with opener.open(req, timeout=timeout) as response:
239
+ # Read ONE byte past the cap so truncation is detectable rather than
240
+ # silent: a truncated body would hash to something that no repeat
241
+ # fetch could ever reproduce.
242
+ body = response.read(max_bytes + 1)
243
+ if len(body) > max_bytes:
244
+ return FetchFailure(
245
+ url=url,
246
+ reason=f"response exceeds max_bytes={max_bytes}; refusing to hash a truncated body",
247
+ attempted_at=_now_iso(),
248
+ )
249
+ status = getattr(response, "status", None) or response.getcode()
250
+ final_url = response.geturl()
251
+ content_type = response.headers.get("Content-Type") if response.headers else None
252
+ except urlerror.HTTPError as exc:
253
+ # An HTTP error still carries a status — report it as a failure with the
254
+ # status named, because a 403/404 page is not the source it stands for.
255
+ return FetchFailure(url=url, reason=f"HTTP {exc.code} {exc.reason}", attempted_at=_now_iso())
256
+ except (urlerror.URLError, socket.timeout, TimeoutError, OSError, UnicodeError) as exc:
257
+ return FetchFailure(url=str(url), reason=f"network error: {exc}", attempted_at=_now_iso())
258
+ except FetchRefused as exc:
259
+ return FetchFailure(url=url, reason=str(exc), attempted_at=_now_iso())
260
+ except (http.client.HTTPException, ValueError) as exc:
261
+ # InvalidURL, bad chunking, control characters in the URL — a NAMED
262
+ # failure, never an exception in the caller's face (Codex QE #11).
263
+ return FetchFailure(url=str(url), reason=f"protocol/URL error: {exc}", attempted_at=_now_iso())
264
+
265
+ record = FetchRecord(
266
+ url=url,
267
+ final_url=final_url,
268
+ status=int(status),
269
+ sha256_body=hashlib.sha256(body).hexdigest(),
270
+ bytes_len=len(body),
271
+ fetched_at=_now_iso(),
272
+ content_type=content_type,
273
+ witness=_FETCH_WITNESS, # only real fetches carry it
274
+ )
275
+ if not record.ok:
276
+ return FetchFailure(url=url, reason=f"non-2xx status {record.status}", attempted_at=record.fetched_at)
277
+ return record
@@ -0,0 +1,21 @@
1
+ {
2
+ "fact": {
3
+ "claim": "Ference 2012 Mendelian randomization on LDL",
4
+ "confidence": 0.6,
5
+ "evidence_class": null,
6
+ "fetch_date": null,
7
+ "issuer": "researcher",
8
+ "issuer_pubkey": "ed25519:DVZqfSVe8GBRSSIK38iOabeJE2YRm1mIUvZCUEGGVGY=",
9
+ "metadata": {},
10
+ "parent_citation": null,
11
+ "parent_hash": null,
12
+ "research_context": null,
13
+ "signature": "5g7V4a87VxWpiozUFrPTmCQEkwUVo9rCMFS0nU3ivO1UbDX4PbGteSmKEVdYzhdxumFMkp7tcpy2kQKaBjTLCw==",
14
+ "source_date": null,
15
+ "source_hash": "a24eb38dea1b79268072302d9adee7b2e109cb580bb31ff05aa632d390fc694d",
16
+ "source_url": "https://pubmed.ncbi.nlm.nih.gov/23083789",
17
+ "timestamp": "2026-08-03T12:16:18.191716Z",
18
+ "trust_class": "SELF_ATTESTED"
19
+ },
20
+ "pubkey_b64": "DVZqfSVe8GBRSSIK38iOabeJE2YRm1mIUvZCUEGGVGY="
21
+ }