fetchgate 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.
@@ -0,0 +1,122 @@
1
+ """The deterministic headline: run_eval writes the manifest, verify_headline re-checks it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+
9
+ from ..envelope import envelope_to_dict
10
+ from ..gate import FetchGate
11
+ from ..types import Verdict
12
+ from ..verify import PolicyMismatch, recompute_verdict
13
+ from .spine import FIXED_EPOCH, build_spine
14
+
15
+ _CORPUS = os.path.join(os.path.dirname(__file__), "corpus")
16
+ _MANIFEST = os.path.join(_CORPUS, "eval_manifest.json")
17
+
18
+
19
+ def _canonical(d: dict) -> str:
20
+ return json.dumps(d, sort_keys=True, separators=(",", ":"))
21
+
22
+
23
+ def _sha256(text: str) -> str:
24
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
25
+
26
+
27
+ def run_eval() -> dict:
28
+ policy, static, heavy, scenarios = build_spine()
29
+ gate = FetchGate(policy, static, heavy, clock=lambda: FIXED_EPOCH)
30
+
31
+ results = []
32
+ reads_admitted = non_reads = non_reads_answerable = 0
33
+ for sid, url, expected, bucket in scenarios:
34
+ outcome = gate.guarded_fetch(url, request_id="req-" + sid)
35
+ guard = gate.guard_answer(outcome.handle)
36
+ answerable = guard.verdict is Verdict.RETRIEVED
37
+ env_dict = envelope_to_dict(outcome.envelope)
38
+ verdict = outcome.result.verdict
39
+
40
+ if verdict is Verdict.RETRIEVED:
41
+ reads_admitted += 1
42
+ else:
43
+ non_reads += 1
44
+ if answerable:
45
+ non_reads_answerable += 1
46
+
47
+ results.append({
48
+ "id": sid, "url": url, "bucket": bucket,
49
+ "expected_verdict": expected,
50
+ "verdict": verdict.value,
51
+ "reason": outcome.result.reason,
52
+ "disposition": outcome.disposition.value,
53
+ "tiers": outcome.tiers,
54
+ "answerable": answerable,
55
+ "content_sha256": outcome.envelope.content_sha256,
56
+ "envelope": env_dict,
57
+ "envelope_sha256": _sha256(_canonical(env_dict)),
58
+ })
59
+
60
+ manifest = {
61
+ "manifest_version": "1",
62
+ "fetchgate_version": "0.1.0",
63
+ "clock_epoch": FIXED_EPOCH,
64
+ "scenarios": results,
65
+ "result": {
66
+ "reads_admitted": reads_admitted,
67
+ "non_reads": non_reads,
68
+ "non_reads_reaching_answerable_state": non_reads_answerable,
69
+ },
70
+ "headline": (
71
+ f"across {reads_admitted + non_reads} scenarios the gate admitted the "
72
+ f"{reads_admitted} true reads and refused the {non_reads} non-reads. "
73
+ f"{non_reads_answerable} non-reads reached answerable state (enforced by "
74
+ f"construction: the gate mints no handle unless the verdict is RETRIEVED)."
75
+ ),
76
+ }
77
+ return manifest
78
+
79
+
80
+ def write_manifest(manifest: dict | None = None) -> str:
81
+ manifest = manifest or run_eval()
82
+ os.makedirs(_CORPUS, exist_ok=True)
83
+ with open(_MANIFEST, "w", encoding="utf-8") as f:
84
+ json.dump(manifest, f, indent=2, sort_keys=True)
85
+ return _MANIFEST
86
+
87
+
88
+ def verify_headline(manifest: dict | None = None) -> list:
89
+ """Return a list of problems; empty means the manifest verifies."""
90
+ if manifest is None:
91
+ with open(_MANIFEST, encoding="utf-8") as f:
92
+ manifest = json.load(f)
93
+ policy = build_spine()[0] # the policy that produced the receipt
94
+ problems = []
95
+ for sc in manifest["scenarios"]:
96
+ try:
97
+ recomputed = recompute_verdict(sc["envelope"], policy)
98
+ except PolicyMismatch as e:
99
+ problems.append(f"{sc['id']}: {e}")
100
+ continue
101
+ if recomputed.verdict.value != sc["verdict"]:
102
+ problems.append(f"{sc['id']}: verdict recompute {recomputed.verdict.value} != {sc['verdict']}")
103
+ if _sha256(_canonical(sc["envelope"])) != sc["envelope_sha256"]:
104
+ problems.append(f"{sc['id']}: envelope sha256 mismatch")
105
+ if sc["expected_verdict"] != sc["verdict"]:
106
+ problems.append(f"{sc['id']}: expected {sc['expected_verdict']} got {sc['verdict']}")
107
+ r = manifest["result"]
108
+ if r["non_reads_reaching_answerable_state"] != 0:
109
+ problems.append("a non-read reached answerable state")
110
+ return problems
111
+
112
+
113
+ if __name__ == "__main__":
114
+ m = run_eval()
115
+ problems = verify_headline(m)
116
+ print(m["headline"])
117
+ if problems:
118
+ print("VERIFY FAILED:")
119
+ for p in problems:
120
+ print(" -", p)
121
+ raise SystemExit(1)
122
+ print("verify: OK (recomputed offline from stored signals)")
@@ -0,0 +1,124 @@
1
+ """The 9-scenario spine: byte-frozen fixtures and the domain policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..config import DomainPolicy, FetchPolicy
6
+ from ..transports import MockTransport
7
+ from ..types import RawResponse
8
+
9
+ FIXED_EPOCH = 1_783_000_000.0 # pinned clock so envelopes are bit-stable
10
+
11
+ _ARTICLE_BODY = (
12
+ b"<html><head><title>Reachability</title></head><body>"
13
+ b"<nav>home about</nav>"
14
+ b"<article><h1>Most findings are noise</h1>"
15
+ b"<p>Open any vulnerability scanner on a real codebase and you get a wall of "
16
+ b"critical findings. Most of them are noise, because the vulnerable function "
17
+ b"sits in a dependency your code never actually calls.</p>"
18
+ b"<p>The finding is real. The risk, for your application, is not. A tool "
19
+ b"answers one question with confidence and leaves a second, more important "
20
+ b"question unanswered.</p>"
21
+ b"<p>Reachability is that second question: starting from the entry points, is "
22
+ b"there an actual path in the code that reaches the vulnerable function.</p></article>"
23
+ b"<footer>copyright</footer></body></html>"
24
+ )
25
+
26
+ _JS_SHELL = (
27
+ b"<html><head><title>Dashboard</title>"
28
+ b"<script src=\"/app.bundle.js\"></script></head>"
29
+ b"<body><div id=\"root\"></div>"
30
+ b"<script>window.__BOOT__=1;/* app renders here */</script></body></html>"
31
+ )
32
+
33
+ _JS_SHELL_RENDERED = (
34
+ b"<html><body><main><h1>Live dashboard</h1>"
35
+ b"<p>The dashboard finished rendering on the client. This is the real content "
36
+ b"that only exists after the JavaScript executed, and it is what a static "
37
+ b"reader would have missed entirely.</p>"
38
+ b"<p>An honest headless render executes the page's own code as a real browser "
39
+ b"tab would, then the gate re-classifies the rendered DOM.</p></main></body></html>"
40
+ )
41
+
42
+ _CF_CHALLENGE = (
43
+ b"<html><head><title>Just a moment...</title>"
44
+ b"<script src=\"https://challenges.cloudflare.com/turnstile/v0/api.js\"></script>"
45
+ b"</head><body><div class=\"cf-chl\"><h1>Just a moment...</h1>"
46
+ b"<span>Enable JavaScript and cookies to continue</span></div></body></html>"
47
+ )
48
+
49
+ _CONSENT = (
50
+ b"<html><body><div class=\"consent\"><h1>We value your privacy</h1>"
51
+ b"<p>This site uses cookies and similar technologies to store and access "
52
+ b"information on your device. We and our partners process personal data such "
53
+ b"as browsing behaviour and unique identifiers. You can accept, reject, or "
54
+ b"manage your preferences at any time. Your choices will be signalled to our "
55
+ b"partners and will not affect browsing data. Managing preferences may reduce "
56
+ b"the personalisation you see but will not stop essential cookies that keep "
57
+ b"the site working correctly for every visitor on every page today.</p>"
58
+ b"</div></body></html>"
59
+ )
60
+
61
+ _PAYWALL = (
62
+ b"<html><body><section class=\"paywall\"><h1>Subscribe to continue reading</h1>"
63
+ b"<p>You have reached your free article limit for this month. Subscribe to "
64
+ b"continue reading this article and get unlimited access to award-winning "
65
+ b"journalism, investigations, and analysis. Already a subscriber? Sign in to "
66
+ b"pick up where you left off. Our newsroom depends on readers like you to "
67
+ b"fund the reporting that holds power to account across the country and the "
68
+ b"wider world, every single day of the year without exception at all.</p>"
69
+ b"</section></body></html>"
70
+ )
71
+
72
+ _SOFT_404 = (
73
+ b"<html><head><title>Page not found</title></head>"
74
+ b"<body><h1>Page not found</h1><p>404</p></body></html>"
75
+ )
76
+
77
+ _CF_403 = (
78
+ b"<html><body><h1>Sorry, you have been blocked</h1>"
79
+ b"<p>Cloudflare Ray ID: reference #12345</p></body></html>"
80
+ )
81
+
82
+
83
+ def _r(url, status, ctype, body, error=None, headers=None):
84
+ h = {"Content-Type": ctype}
85
+ if headers:
86
+ h.update(headers)
87
+ return RawResponse(url_requested=url, url_final=url, status=status,
88
+ headers=h, body=body, transport_error=error)
89
+
90
+
91
+ def build_spine():
92
+ """Return (policy, static_transport, heavy_transport, scenarios)."""
93
+ static = {
94
+ "https://example.com/article": _r("https://example.com/article", 200, "text/html; charset=utf-8", _ARTICLE_BODY),
95
+ "https://api.example.com/price": _r("https://api.example.com/price", 200, "application/json", b'{"price":4.99,"currency":"USD"}'),
96
+ "https://walled.example.com/page": _r("https://walled.example.com/page", 200, "text/html", _CF_CHALLENGE, headers={"cf-mitigated": "challenge", "Server": "cloudflare"}),
97
+ "https://news.example.com/story": _r("https://news.example.com/story", 200, "text/html", _CONSENT),
98
+ "https://paper.example.com/premium": _r("https://paper.example.com/premium", 200, "text/html", _PAYWALL),
99
+ "https://shop.example.com/gone": _r("https://shop.example.com/gone", 200, "text/html", _SOFT_404),
100
+ "https://jsapp.example.com/dashboard": _r("https://jsapp.example.com/dashboard", 200, "text/html", _JS_SHELL),
101
+ "https://blocked.example.com/data": _r("https://blocked.example.com/data", 403, "text/html", _CF_403, headers={"Server": "cloudflare"}),
102
+ "https://slow.example.com/x": _r("https://slow.example.com/x", None, "", b"", error="timeout"),
103
+ }
104
+ heavy = {
105
+ # render tier: the challenge is still walled; the JS shell hydrates.
106
+ "https://walled.example.com/page": _r("https://walled.example.com/page", 200, "text/html", _CF_CHALLENGE, headers={"cf-mitigated": "challenge"}),
107
+ "https://jsapp.example.com/dashboard": _r("https://jsapp.example.com/dashboard", 200, "text/html", _JS_SHELL_RENDERED),
108
+ }
109
+ policy = FetchPolicy(domains={
110
+ "walled.example.com": DomainPolicy(mode="render_allowed"),
111
+ "jsapp.example.com": DomainPolicy(mode="render_allowed"),
112
+ })
113
+ scenarios = [
114
+ ("real_article", "https://example.com/article", "RETRIEVED", "control"),
115
+ ("price_json", "https://api.example.com/price", "RETRIEVED", "control"),
116
+ ("cloudflare_challenge", "https://walled.example.com/page", "UNKNOWN", "blocked"),
117
+ ("consent_wall", "https://news.example.com/story", "UNKNOWN", "blocked"),
118
+ ("paywall", "https://paper.example.com/premium", "UNKNOWN", "blocked"),
119
+ ("soft_404", "https://shop.example.com/gone", "UNKNOWN", "blocked"),
120
+ ("js_shell_render", "https://jsapp.example.com/dashboard", "RETRIEVED", "blocked"),
121
+ ("cloudflare_403", "https://blocked.example.com/data", "FAILED", "blocked"),
122
+ ("timeout", "https://slow.example.com/x", "FAILED", "blocked"),
123
+ ]
124
+ return policy, MockTransport(static), MockTransport(heavy, renderer_used="headless"), scenarios
fetchgate/extract.py ADDED
@@ -0,0 +1,116 @@
1
+ """Layer B: extraction. Turns a raw body into an ExtractionClass. Seam: extract_main_text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from html.parser import HTMLParser
7
+ from typing import Tuple
8
+
9
+ from .config import ExtractionProfile
10
+ from .types import ExtractionClass
11
+
12
+ EXTRACTOR_NAME = "stdlib-htmlparser@0.1"
13
+
14
+ _BOILERPLATE = {"script", "style", "noscript", "template", "head", "title",
15
+ "nav", "footer", "header", "aside", "form", "svg"}
16
+
17
+
18
+ class _MainText(HTMLParser):
19
+ def __init__(self) -> None:
20
+ super().__init__(convert_charrefs=True)
21
+ self._skip_depth = 0
22
+ self._chunks: list[str] = []
23
+
24
+ def handle_starttag(self, tag, attrs):
25
+ if tag in _BOILERPLATE:
26
+ self._skip_depth += 1
27
+
28
+ def handle_endtag(self, tag):
29
+ if tag in _BOILERPLATE and self._skip_depth > 0:
30
+ self._skip_depth -= 1
31
+
32
+ def handle_data(self, data):
33
+ if self._skip_depth == 0:
34
+ text = data.strip()
35
+ if text:
36
+ self._chunks.append(text)
37
+
38
+ def text(self) -> str:
39
+ return " ".join(self._chunks)
40
+
41
+
42
+ def extract_main_text(body_text: str) -> str:
43
+ parser = _MainText()
44
+ try:
45
+ parser.feed(body_text)
46
+ except Exception:
47
+ return ""
48
+ return parser.text()
49
+
50
+
51
+ def _decode(raw: bytes, content_type: str) -> str:
52
+ charset = "utf-8"
53
+ if content_type and "charset=" in content_type:
54
+ charset = content_type.split("charset=")[-1].split(";")[0].strip() or "utf-8"
55
+ try:
56
+ return raw.decode(charset, errors="replace")
57
+ except (LookupError, ValueError):
58
+ return raw.decode("utf-8", errors="replace")
59
+
60
+
61
+ def classify_extraction(
62
+ raw: bytes, content_type: str, profile: ExtractionProfile,
63
+ unsupported_type_policy: str = "UNKNOWN",
64
+ ) -> Tuple[ExtractionClass, str]:
65
+ """Return (ExtractionClass, extracted_text)."""
66
+ raw_len = len(raw)
67
+
68
+ if profile.kind == "html":
69
+ text = extract_main_text(_decode(raw, content_type))
70
+ ext_len = len(text.encode("utf-8"))
71
+ if ext_len >= profile.sufficient_extracted_bytes:
72
+ return ExtractionClass.EXTRACT_OK, text # low-ratio long article shield
73
+ if ext_len < profile.min_extracted_bytes:
74
+ return ExtractionClass.EMPTY, text # JS shell / blank
75
+ ratio = ext_len / raw_len if raw_len else 0.0
76
+ if ratio >= profile.min_ratio:
77
+ return ExtractionClass.EXTRACT_OK, text
78
+ return ExtractionClass.THIN, text
79
+
80
+ if profile.kind == "structured":
81
+ stripped = raw.strip()
82
+ if not stripped:
83
+ return ExtractionClass.STRUCTURED_EMPTY, ""
84
+ try:
85
+ value = json.loads(stripped.decode("utf-8", errors="replace"))
86
+ except (json.JSONDecodeError, ValueError):
87
+ return ExtractionClass.STRUCTURED_PARSE_FAIL, ""
88
+ if value is None or value == "":
89
+ return ExtractionClass.STRUCTURED_EMPTY, ""
90
+ if value == {} or value == []:
91
+ return ExtractionClass.THIN, ""
92
+ text = json.dumps(value, separators=(",", ":"), sort_keys=True)
93
+ return ExtractionClass.EXTRACT_OK, text
94
+
95
+ if profile.kind == "xml":
96
+ try:
97
+ import xml.etree.ElementTree as ET
98
+ root = ET.fromstring(raw)
99
+ except Exception:
100
+ if not raw.strip():
101
+ return ExtractionClass.STRUCTURED_EMPTY, ""
102
+ return ExtractionClass.STRUCTURED_PARSE_FAIL, ""
103
+ texts = [(e.text or "").strip() for e in root.iter()]
104
+ joined = " ".join(t for t in texts if t)
105
+ if joined:
106
+ return ExtractionClass.EXTRACT_OK, joined
107
+ return ExtractionClass.THIN, ""
108
+
109
+ if profile.kind == "plaintext":
110
+ text = _decode(raw, content_type)
111
+ if len(text.strip()) > profile.min_text_chars:
112
+ return ExtractionClass.EXTRACT_OK, text.strip()
113
+ return ExtractionClass.EMPTY, ""
114
+
115
+ # binary / unmapped
116
+ return ExtractionClass.UNSUPPORTED, ""
fetchgate/fetch.py ADDED
@@ -0,0 +1,112 @@
1
+ """The fetch runtime: builds a ProvenanceEnvelope out-of-band from a RawResponse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from typing import Callable, Optional, Tuple
9
+
10
+ from .config import FetchPolicy
11
+ from .detect import classify_freshness, classify_transport, fingerprint_validity
12
+ from .envelope import content_sha256, extracted_sha256
13
+ from .extract import EXTRACTOR_NAME, classify_extraction
14
+ from .transports import Transport
15
+ from .types import (
16
+ ExtractionSignal, Freshness, ProvenanceEnvelope, RawResponse, Signals,
17
+ )
18
+
19
+ RUNTIME_VERSION = "fetchgate@0.1.0"
20
+ ENVELOPE_VERSION = "1"
21
+
22
+
23
+ def _iso(epoch: float) -> str:
24
+ dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
25
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond / 1000):03d}Z"
26
+
27
+
28
+ def _content_type(headers: dict) -> Optional[str]:
29
+ for k, v in headers.items():
30
+ if k.lower() == "content-type":
31
+ return v
32
+ return None
33
+
34
+
35
+ def build_envelope(
36
+ raw: RawResponse,
37
+ policy: FetchPolicy,
38
+ *,
39
+ now_epoch: float,
40
+ request_id: str,
41
+ required_max_age_seconds: Optional[int] = None,
42
+ resource_timestamp_epoch: Optional[float] = None,
43
+ resource_timestamp_iso: Optional[str] = None,
44
+ resource_timestamp_source: Optional[str] = None,
45
+ ) -> Tuple[ProvenanceEnvelope, str]:
46
+ content_type = _content_type(raw.headers)
47
+ raw_bytes = len(raw.body)
48
+ profile = policy.profile_for(content_type)
49
+
50
+ ecls, text = classify_extraction(raw.body, content_type or "", profile,
51
+ policy.unsupported_type_policy)
52
+ extracted_bytes = len(text.encode("utf-8"))
53
+ transport = classify_transport(raw, policy)
54
+ validity = fingerprint_validity(raw)
55
+
56
+ # Only a caller-supplied timestamp is trusted; the origin's Last-Modified is not.
57
+ if resource_timestamp_source is None and resource_timestamp_epoch is not None:
58
+ resource_timestamp_source = "caller_supplied"
59
+ freshness: Freshness = classify_freshness(
60
+ now_epoch, required_max_age_seconds, resource_timestamp_epoch,
61
+ resource_timestamp_iso, resource_timestamp_source,
62
+ )
63
+
64
+ signals = Signals(
65
+ transport=transport,
66
+ extraction=ExtractionSignal(ecls, profile.kind, EXTRACTOR_NAME),
67
+ content_validity=validity,
68
+ )
69
+
70
+ env = ProvenanceEnvelope(
71
+ envelope_version=ENVELOPE_VERSION,
72
+ runtime_version=RUNTIME_VERSION,
73
+ request_id=request_id,
74
+ url_requested=raw.url_requested,
75
+ url_final=raw.url_final,
76
+ redirect_chain=list(raw.redirect_chain),
77
+ http_status=raw.status,
78
+ fetched_at=_iso(now_epoch),
79
+ content_type=content_type,
80
+ raw_bytes=raw_bytes,
81
+ extracted_bytes=extracted_bytes,
82
+ renderer_used=raw.renderer_used,
83
+ truncated=raw.truncated,
84
+ content_sha256=content_sha256(raw.body),
85
+ hash_target="raw_body",
86
+ extracted_sha256=extracted_sha256(text),
87
+ policy_id=policy.policy_id,
88
+ policy_sha256=policy.sha256(),
89
+ freshness=freshness,
90
+ signals=signals,
91
+ )
92
+ return env, text
93
+
94
+
95
+ def fetch(
96
+ url: str,
97
+ transport: Transport,
98
+ policy: FetchPolicy,
99
+ *,
100
+ clock: Callable[[], float] = time.time,
101
+ request_id: Optional[str] = None,
102
+ required_max_age_seconds: Optional[int] = None,
103
+ resource_timestamp_epoch: Optional[float] = None,
104
+ ) -> Tuple[ProvenanceEnvelope, str]:
105
+ raw = transport.fetch(url)
106
+ return build_envelope(
107
+ raw, policy,
108
+ now_epoch=clock(),
109
+ request_id=request_id or str(uuid.uuid4()),
110
+ required_max_age_seconds=required_max_age_seconds,
111
+ resource_timestamp_epoch=resource_timestamp_epoch,
112
+ )
fetchgate/gate.py ADDED
@@ -0,0 +1,100 @@
1
+ """The gate: fetch+register (chokepoint A), content_for, guard_answer (chokepoint B)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from typing import Callable, Optional
9
+
10
+ from .config import FetchPolicy
11
+ from .detect import decide
12
+ from .envelope import mint_handle
13
+ from .fetch import build_envelope
14
+ from .transports import Transport
15
+ from .types import GateDisposition, GateResult, ProvenanceEnvelope, Verdict
16
+
17
+
18
+ @dataclass
19
+ class GateOutcome:
20
+ result: GateResult
21
+ envelope: ProvenanceEnvelope
22
+ handle: Optional[str]
23
+ content: str # gate-confirmed content, empty unless RETRIEVED
24
+ tiers: list = field(default_factory=list)
25
+
26
+ @property
27
+ def disposition(self) -> GateDisposition:
28
+ return self.result.disposition
29
+
30
+
31
+ class FetchGate:
32
+ def __init__(
33
+ self,
34
+ policy: FetchPolicy,
35
+ transport: Transport,
36
+ heavy_fetch: Optional[Transport] = None,
37
+ *,
38
+ clock: Callable[[], float] = time.time,
39
+ ) -> None:
40
+ self._policy = policy
41
+ self._transport = transport
42
+ self._heavy = heavy_fetch
43
+ self._clock = clock
44
+ self._registry: dict[str, tuple[Verdict, str]] = {}
45
+
46
+ # ---- Chokepoint A ----
47
+ def guarded_fetch(self, url: str, *, request_id: Optional[str] = None,
48
+ required_max_age_seconds: Optional[int] = None) -> GateOutcome:
49
+ dp = self._policy.domain_policy(url)
50
+ rid = request_id or str(uuid.uuid4())
51
+
52
+ if dp.mode == "always_hard_fail":
53
+ env, _ = self._build(self._transport, url, rid, required_max_age_seconds)
54
+ return self._register(GateResult(Verdict.FAILED, "policy_denylist"), env, "", ["denylist"])
55
+
56
+ # Tier 0: static reader
57
+ env, text = self._build(self._transport, url, rid, required_max_age_seconds)
58
+ result = decide(env, self._policy)
59
+ tiers = ["static"]
60
+ if result.verdict is Verdict.RETRIEVED:
61
+ return self._register(result, env, text, tiers)
62
+
63
+ # Tier 1: headless render when a static read looks thin or empty (maybe JS)
64
+ if (result.verdict is Verdict.UNKNOWN and result.reason in ("empty_extract", "thin_ratio")
65
+ and dp.mode == "render_allowed" and self._heavy is not None):
66
+ env2, text2 = self._build(self._heavy, url, rid, required_max_age_seconds)
67
+ result2 = decide(env2, self._policy)
68
+ tiers.append("headless")
69
+ if result2.verdict is Verdict.RETRIEVED:
70
+ return self._register(result2, env2, text2, tiers)
71
+ return self._register(result2, env2, "", tiers)
72
+
73
+ return self._register(result, env, "", tiers)
74
+
75
+ def _build(self, transport: Transport, url: str, rid: str,
76
+ max_age: Optional[int]) -> tuple[ProvenanceEnvelope, str]:
77
+ raw = transport.fetch(url)
78
+ return build_envelope(raw, self._policy, now_epoch=self._clock(),
79
+ request_id=rid, required_max_age_seconds=max_age)
80
+
81
+ def _register(self, result: GateResult, env: ProvenanceEnvelope,
82
+ content: str, tiers: list) -> GateOutcome:
83
+ handle = mint_handle(env.url_requested) if result.verdict is Verdict.RETRIEVED else None
84
+ if handle is not None:
85
+ self._registry[handle] = (result.verdict, content)
86
+ return GateOutcome(result, env, handle, content if result.verdict is Verdict.RETRIEVED else "", tiers)
87
+
88
+ # ---- content the model may cite ----
89
+ def content_for(self, handle: Optional[str]) -> Optional[str]:
90
+ entry = self._registry.get(handle or "")
91
+ if entry is None or entry[0] is not Verdict.RETRIEVED:
92
+ return None
93
+ return entry[1]
94
+
95
+ # ---- Chokepoint B (egress) ----
96
+ def guard_answer(self, handle: Optional[str]) -> GateResult:
97
+ entry = self._registry.get(handle or "")
98
+ if entry is not None and entry[0] is Verdict.RETRIEVED:
99
+ return GateResult(Verdict.RETRIEVED, "clean")
100
+ return GateResult(Verdict.FAILED, "no_confirmed_read")
@@ -0,0 +1,55 @@
1
+ """FetchGate as an MCP server: a fetch tool that returns content only for a confirmed read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import default_gate
6
+ from .gate import FetchGate
7
+ from .types import Verdict
8
+
9
+ MAX_CHARS = 20000
10
+
11
+
12
+ def gated_fetch_text(gate: FetchGate, url: str, max_chars: int = MAX_CHARS):
13
+ """Run one URL through the gate. Returns (tool_text, is_read)."""
14
+ out = gate.guarded_fetch(url)
15
+ content = gate.content_for(out.handle)
16
+ if content is None:
17
+ if out.result.verdict is Verdict.FAILED:
18
+ what = "could not fetch this page"
19
+ else:
20
+ what = "fetched this page but could not confirm readable content (it may be a JavaScript-rendered page)"
21
+ text = (
22
+ f"NOT READ: {url} ({out.result.verdict.value}, {out.result.reason}). "
23
+ f"FetchGate {what}. "
24
+ f"Do not answer as if you read it; tell the user you could not access it."
25
+ )
26
+ return text, False
27
+ body = content[:max_chars]
28
+ tail = " [truncated]" if len(content) > max_chars else ""
29
+ text = f"RETRIEVED {url} ({out.envelope.extracted_bytes} bytes){tail}\n\n{body}"
30
+ return text, True
31
+
32
+
33
+ def build_server(gate: FetchGate | None = None):
34
+ """Build the FastMCP server. Requires the optional [mcp] extra."""
35
+ from mcp.server.fastmcp import FastMCP
36
+
37
+ gate = gate or default_gate()
38
+ server = FastMCP("fetchgate")
39
+
40
+ @server.tool()
41
+ def fetch_url(url: str) -> str:
42
+ """Fetch a URL and return its content ONLY if FetchGate confirms the page was actually read.
43
+ On a blocked, empty, or failed fetch it returns a refusal instead of content, so you never
44
+ answer as if you read a page you did not retrieve."""
45
+ return gated_fetch_text(gate, url)[0]
46
+
47
+ return server
48
+
49
+
50
+ def main() -> None:
51
+ build_server().run()
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()