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.
- fetchgate/__init__.py +21 -0
- fetchgate/app.py +20 -0
- fetchgate/config.py +91 -0
- fetchgate/detect.py +196 -0
- fetchgate/envelope.py +83 -0
- fetchgate/eval/__init__.py +1 -0
- fetchgate/eval/corpus/eval_manifest.json +564 -0
- fetchgate/eval/corpus/sample_envelope.json +46 -0
- fetchgate/eval/run_eval.py +122 -0
- fetchgate/eval/spine.py +124 -0
- fetchgate/extract.py +116 -0
- fetchgate/fetch.py +112 -0
- fetchgate/gate.py +100 -0
- fetchgate/mcp_server.py +55 -0
- fetchgate/transports.py +106 -0
- fetchgate/types.py +150 -0
- fetchgate/verify.py +75 -0
- fetchgate-0.1.0.dist-info/METADATA +147 -0
- fetchgate-0.1.0.dist-info/RECORD +22 -0
- fetchgate-0.1.0.dist-info/WHEEL +4 -0
- fetchgate-0.1.0.dist-info/entry_points.txt +2 -0
- fetchgate-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)")
|
fetchgate/eval/spine.py
ADDED
|
@@ -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")
|
fetchgate/mcp_server.py
ADDED
|
@@ -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()
|