@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.
- package/.dz-manifest.json +677 -0
- package/CHANGELOG.md +874 -0
- package/KNOWN_LIMITATIONS.md +327 -0
- package/MULTIPLATFORM_ROADMAP.md +239 -0
- package/README/eng/06_troubleshooting.md +1 -1
- package/README/eng/07_changelog.md +57 -0
- package/README/eng/README.md +2 -2
- package/README/ru/06_troubleshooting.md +1 -1
- package/README/ru/07_changelog.md +58 -0
- package/README/ru/README.md +2 -2
- package/README/ru/html/build.js +7 -7
- package/README/ru/html/index.html +31 -12
- package/README.md +59 -10
- package/package.json +10 -3
- package/sbom.json +1683 -0
- package/templates/.claude/skills/explore/SKILL.md +1 -1
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +141 -11
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +363 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +297 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +277 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v1_fact.json +21 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +462 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/source_tiers.py +170 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +784 -0
- package/templates/.claude/skills/problem-solver-enhanced/SKILL.md +1 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/SKILL.md +1 -1
- package/tests/e2e/lifecycle.test.js +973 -0
- package/tests/snapshot/baseline.json +125 -0
- package/tests/snapshot/templates.test.js +89 -0
- package/tests/snapshot/update-baseline.js +68 -0
- 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
|
|
161
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|