67 """Validate ONE fetch hop and return ``True`` if it was admitted as LOCAL.
69 The single place the per-hop destination rule lives, so the initial URL and
70 every redirect target are judged by exactly the same code:
72 - a **local candidate** (``http``/``https`` on ``localhost`` / ``127.0.0.1``
73 / ``::1``) must be admitted by :func:`~oc_chat.tools._local_fetch.admit_local_url`
74 — the ``permissive`` profile plus an explicit, allowlisted port — or it
75 raises :class:`~oc_chat.tools._local_fetch.LocalFetchRefused`. A candidate is
76 NEVER re-judged as a public target, so its refusal names the real reason
77 (plan 232 P4) instead of the generic non-public denial;
78 - everything else goes through the unchanged M8.0 WU-4 egress guard
79 (§5.1.3, F2/F2a), which raises :class:`~oc_chat.core.egress_guard.EgressDenied`
80 for a non-``https`` scheme, URL credentials, or any host resolving to a
81 non-public address — ``127.0.0.2``, RFC1918, link-local and cloud-metadata
82 included, whatever the port allowlist says.
84 func =
"_validate_hop"
87 if is_local_target(url):
89 _dbg(
"OC_CHAT.TOOLS", func, f
"{source_label}: local mode {url}", 2)
91 validate_url_egress(url, source_label=source_label)
95def fetch_url(url: str, max_chars: int = 10_000) -> dict:
96 """Fetch a URL and return its cleaned text content.
98 M8.0 WU-4 (§5.1.3, F2/F2a): ``https``-only; URL credentials refused; the host
99 is resolved and refused if it maps to a non-public address (loopback / RFC1918
100 / link-local / cloud-metadata); redirects are followed MANUALLY with
101 ``follow_redirects=False`` and each hop re-validated; ``trust_env=False`` so a
102 poisoned ``HTTP(S)_PROXY`` / ``SSL_CERT_*`` cannot redirect or MITM the fetch.
104 Plan 232 / WEB-01 adds the ONE operator-ruled exception: a **local mode** for
105 a delivery check against a dev server. A hop on ``localhost`` / ``127.0.0.1``
106 / ``::1`` is admitted only under the ``permissive`` profile and only when its
107 explicit port is listed in the visible per-project file
108 ``.oc_chat/local_fetch.json`` (:mod:`oc_chat.tools._local_fetch`); the
109 admitted result additionally carries ``status``, a bounded ``headers``
110 allowlist and ``local: True``, and a non-2xx status is returned as DATA
111 (a 404 is exactly what a delivery check needs to see). Nothing else changes:
112 every other address stays refused, the public path stays ``https``-only, and
113 a local candidate that is not admitted refuses BY NAME rather than falling
114 through to the public path.
118 max_chars = clamp_bytes(max_chars)
119 _dbg(
"OC_CHAT.TOOLS", func, f
"url={url} max_chars={max_chars}", 3)
123 return {
"error":
"httpx not installed. Run: pip install httpx"}
128 if tool_cancel_requested():
129 return {
"cancelled":
True,
"url": url}
131 from urllib.parse
import urljoin
134 external_fetch_allowed, make_external_gate_block, normalize_source,
141 status:
"int | None" =
None
144 with httpx.Client(trust_env=
False, follow_redirects=
False, timeout=15)
as client:
145 for _hop
in range(MAX_REDIRECTS + 1):
159 hop_source = normalize_source(current)
160 if not external_fetch_allowed(hop_source):
161 _dbg(
"OC_CHAT.TOOLS", func, f
"external-content gate: {hop_source!r} not trusted", 2)
162 return make_external_gate_block(
"fetch_url", hop_source, current)
163 response = client.get(current)
164 if response.is_redirect
and response.headers.get(
"location"):
165 current = urljoin(current, response.headers[
"location"])
171 status = response.status_code
172 headers = bounded_response_headers(response.headers)
174 response.raise_for_status()
175 raw_html = response.text
179 if len(raw_html) > MAX_BYTES:
180 raw_html = raw_html[:MAX_BYTES] +
"\n...[body capped at 5MB]..."
183 return {
"error": f
"Too many redirects (> {MAX_REDIRECTS})"}
184 except LocalFetchRefused
as e:
188 _dbg(
"OC_CHAT.TOOLS", func, f
"local fetch refused: {e}", 2)
189 return {
"error": f
"Local fetch refused: {e}"}
190 except EgressDenied
as e:
191 _dbg(
"OC_CHAT.TOOLS", func, f
"egress denied: {e}", 2)
192 return {
"error": f
"Egress denied: {e}"}
193 except Exception
as e:
194 return {
"error": redact_secrets(str(e))}
199 text = trafilatura.extract(raw_html)
or raw_html
203 if len(text) > max_chars:
204 text = text[:max_chars] +
"\n...[truncated]..."
206 result = {
"url": url,
"content": text,
"truncated": len(text) >= max_chars}
211 result[
"status"] = status
212 result[
"headers"] = headers
213 result[
"local"] =
True
218 """Search the web using DuckDuckGo and return top result snippets (no API key required)."""
221 max_results = clamp_results(max_results)
225 return {
"error":
"httpx not installed. Run: pip install httpx"}
228 if tool_cancel_requested():
229 return {
"cancelled":
True,
"query": query}
236 safe_query = redact_secrets(query)
242 external_fetch_allowed, make_external_gate_block,
244 _SEARCH_SOURCE =
"api.duckduckgo.com"
245 if not external_fetch_allowed(_SEARCH_SOURCE):
246 _dbg(
"OC_CHAT.TOOLS", func, f
"external-content gate: {_SEARCH_SOURCE} not trusted", 2)
247 return make_external_gate_block(
"web_search", _SEARCH_SOURCE, f
"search: {safe_query}")
251 "https://api.duckduckgo.com/",
252 params={
"q": safe_query,
"format":
"json",
"no_html":
"1",
"skip_disambig":
"1"},
255 follow_redirects=
False,
257 resp.raise_for_status()
259 except Exception
as e:
260 return {
"error": redact_secrets(str(e))}
264 if data.get(
"AbstractText"):
265 results.append({
"title": data.get(
"Heading",
""),
"snippet": data[
"AbstractText"],
"url": data.get(
"AbstractURL",
"")})
267 for item
in data.get(
"RelatedTopics", []):
268 if len(results) >= max_results:
270 if isinstance(item, dict)
and item.get(
"Text"):
271 results.append({
"title":
"",
"snippet": item[
"Text"],
"url": item.get(
"FirstURL",
"")})
273 return {
"query": safe_query,
"results": results}