oc_chat
Loading...
Searching...
No Matches
web.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/tools/web.py
4
5"""
6Web tools for URL fetching and DuckDuckGo search.
7
8Purpose
9-------
10Provides :func:`fetch_url` (cleaned text extraction via
11``trafilatura``) and :func:`web_search` (DuckDuckGo instant answers).
12Falls back to clear error messages when optional dependencies are
13missing so the agent can decide whether to ask the user to install
14or skip the web step.
15
16Responsibilities
17----------------
18- :func:`fetch_url` — HTTP GET with redirect-follow, then strip
19 boilerplate via ``trafilatura.extract`` (or fall back to raw HTML
20 when extraction is unavailable).
21- :func:`web_search` — DuckDuckGo instant-answer JSON API; returns
22 the abstract + related topics.
23- ``TOOLS`` and ``SCHEMAS`` for auto-registration.
24
25Requires: ``pip install httpx trafilatura``
26
27Diagnostics
28-----------
29Domain: OC_CHAT.TOOLS
30Levels:
31 L2 — lifecycle events
32 L3 — semantic details
33 L4 — deep tracing
34
35Contracts
36---------
37- Missing optional deps return ``{"error": "<package> not installed.
38 Run: pip install <package>"}`` rather than raising.
39- ``fetch_url`` honours ``max_chars`` (default 10000) and signals
40 truncation in the return shape.
41- HTTP failures are caught and surfaced as ``{"error": "..."}``
42 rather than propagating.
43- All network I/O has a finite timeout (default 15 s).
44
45Dependencies
46------------
47- oc_diagnostics (structured debug logging via _dbg)
48- oc_chat.core.cancellation (lazy; tool_cancel_requested - skips the request once a Ctrl+C has arrived)
49- oc_chat.core.egress_guard (lazy; validate_url_egress, EgressDenied and MAX_REDIRECTS - the public-host egress check on every hop)
50- oc_chat.core.external_content_gate (lazy; the external-content trust gate for each fetch hop and the search source)
51- oc_chat.core.resource_caps (lazy; clamp_bytes, clamp_results and MAX_BYTES - the size and result-count ceilings)
52- oc_chat.tools._local_fetch (lazy; local-mode admission, its named refusal and the bounded response headers)
53- oc_chat.tools._sandbox (lazy; redact_secrets - redacts the search query and error text)
54- httpx (lazy; the HTTP client for both tools, optional)
55- trafilatura (lazy; boilerplate stripping for fetch_url, optional)
56
57See Also
58--------
59oc_chat/docs/conventions/CONTRACTS.md §3 Tool registry contract; §4 Tool return-
60value conventions.
61"""
62
63from oc_diagnostics import _dbg
64
65
66def _validate_hop(url: str, *, source_label: str) -> bool:
67 """Validate ONE fetch hop and return ``True`` if it was admitted as LOCAL.
68
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:
71
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.
83 """
84 func = "_validate_hop"
85 from oc_chat.core.egress_guard import validate_url_egress
86 from oc_chat.tools._local_fetch import admit_local_url, is_local_target
87 if is_local_target(url):
88 admit_local_url(url)
89 _dbg("OC_CHAT.TOOLS", func, f"{source_label}: local mode {url}", 2)
90 return True
91 validate_url_egress(url, source_label=source_label)
92 return False
93
94
95def fetch_url(url: str, max_chars: int = 10_000) -> dict:
96 """Fetch a URL and return its cleaned text content.
97
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.
103
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.
115 """
116 func = "fetch_url"
117 from oc_chat.core.resource_caps import clamp_bytes
118 max_chars = clamp_bytes(max_chars) # M8.0 WU-9a: cannot exceed the 5 MB ceiling
119 _dbg("OC_CHAT.TOOLS", func, f"url={url} max_chars={max_chars}", 3)
120 try:
121 import httpx
122 except ImportError:
123 return {"error": "httpx not installed. Run: pip install httpx"}
124
125 # Cooperative cancel: bail before issuing the request if a Ctrl+C arrived.
126 # (The request itself is bounded by its timeout below.)
127 from oc_chat.core.cancellation import tool_cancel_requested
128 if tool_cancel_requested():
129 return {"cancelled": True, "url": url}
130
131 from urllib.parse import urljoin
132 from oc_chat.core.egress_guard import EgressDenied, MAX_REDIRECTS
134 external_fetch_allowed, make_external_gate_block, normalize_source,
135 )
136 from oc_chat.tools._local_fetch import LocalFetchRefused, bounded_response_headers
137 from oc_chat.tools._sandbox import redact_secrets
138
139 current = url
140 local = False
141 status: "int | None" = None
142 headers: dict = {}
143 try:
144 with httpx.Client(trust_env=False, follow_redirects=False, timeout=15) as client:
145 for _hop in range(MAX_REDIRECTS + 1):
146 # SSRF (F2/F2a): deny an internal/cloud-metadata target outright on
147 # EVERY hop — never offered for confirmation. Plan 232 (WEB-01) adds
148 # the single exception: a localhost/127.0.0.1/::1 hop is a LOCAL
149 # CANDIDATE, admitted only by the port allowlist under `permissive`
150 # and otherwise refused BY NAME — it never reaches the public path.
151 local = _validate_hop(current, source_label=func)
152 # M8.0 F67 (§7.3): gate the CONTENT SOURCE of THIS hop. A redirect to
153 # a new host is re-gated — trusting/allowing the initial source does
154 # NOT extend to a host it redirects to (egress is checked first so an
155 # internal redirect target is denied outright, not offered to confirm).
156 # A LOCAL hop is gated here too (plan 232): local mode narrows WHICH
157 # addresses may be reached, never whether a human approves pulling
158 # their content into the deciding context.
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"])
166 continue
167 if local:
168 # Plan 232 P3: on a LOCAL hop a non-2xx status is DATA, not an
169 # error — seeing the 404 is the whole point of a delivery check.
170 # The public path keeps raise_for_status() unchanged.
171 status = response.status_code
172 headers = bounded_response_headers(response.headers)
173 else:
174 response.raise_for_status()
175 raw_html = response.text
176 # M8.0 WU-9a (Appendix B / F72): cap the body that enters context at
177 # 5 MB (truncate + note) so a huge page cannot flood the transcript.
178 from oc_chat.core.resource_caps import MAX_BYTES
179 if len(raw_html) > MAX_BYTES:
180 raw_html = raw_html[:MAX_BYTES] + "\n...[body capped at 5MB]..."
181 break
182 else:
183 return {"error": f"Too many redirects (> {MAX_REDIRECTS})"}
184 except LocalFetchRefused as e:
185 # Plan 232 P4: a named refusal. Never a silent fallthrough to the public
186 # path, so the operator always learns WHICH rule refused: the profile,
187 # the port, or the allowlist file.
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))}
195
196 # Try trafilatura for clean text extraction; fall back to raw HTML excerpt
197 try:
198 import trafilatura
199 text = trafilatura.extract(raw_html) or raw_html
200 except ImportError:
201 text = raw_html
202
203 if len(text) > max_chars:
204 text = text[:max_chars] + "\n...[truncated]..."
205
206 result = {"url": url, "content": text, "truncated": len(text) >= max_chars}
207 if local:
208 # Plan 232 P3: the delivery-check shape. `status` is the integer as served
209 # (a 404 is data), `headers` is the bounded allowlist — never cookies or
210 # auth headers — and `local` marks which rule admitted the final hop.
211 result["status"] = status
212 result["headers"] = headers
213 result["local"] = True
214 return result
215
216
217def web_search(query: str, max_results: int = 5) -> dict:
218 """Search the web using DuckDuckGo and return top result snippets (no API key required)."""
219 func = "web_search"
220 from oc_chat.core.resource_caps import clamp_results
221 max_results = clamp_results(max_results) # M8.0 WU-9a: cannot exceed 100
222 try:
223 import httpx
224 except ImportError:
225 return {"error": "httpx not installed. Run: pip install httpx"}
226
227 from oc_chat.core.cancellation import tool_cancel_requested
228 if tool_cancel_requested():
229 return {"cancelled": True, "query": query}
230
231 # M8.0 WU-4 (§5.1.3): pinned host; trust_env=False (a poisoned proxy/cert env
232 # cannot MITM or move the host); follow_redirects=False (a regional-mirror 3xx
233 # cannot move egress off the pinned host); the model-controlled query is
234 # secret-pattern redacted before it leaves the process (F51).
235 from oc_chat.tools._sandbox import redact_secrets
236 safe_query = redact_secrets(query)
237
238 # M8.0 F67 (§7.3): web_search egress is the model-controlled query leaving the
239 # process AND untrusted results re-entering. Confirm before the search unless
240 # the (fixed) search source is session-trusted; headless fails closed.
242 external_fetch_allowed, make_external_gate_block,
243 )
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}")
248
249 try:
250 resp = httpx.get(
251 "https://api.duckduckgo.com/",
252 params={"q": safe_query, "format": "json", "no_html": "1", "skip_disambig": "1"},
253 timeout=10,
254 trust_env=False,
255 follow_redirects=False,
256 )
257 resp.raise_for_status()
258 data = resp.json()
259 except Exception as e:
260 return {"error": redact_secrets(str(e))}
261
262 results = []
263
264 if data.get("AbstractText"):
265 results.append({"title": data.get("Heading", ""), "snippet": data["AbstractText"], "url": data.get("AbstractURL", "")})
266
267 for item in data.get("RelatedTopics", []):
268 if len(results) >= max_results:
269 break
270 if isinstance(item, dict) and item.get("Text"):
271 results.append({"title": "", "snippet": item["Text"], "url": item.get("FirstURL", "")})
272
273 return {"query": safe_query, "results": results}
274
275
276TOOLS = {
277 "fetch_url": fetch_url,
278 "web_search": web_search,
279}
280
281SCHEMAS = [
282 {
283 "type": "function",
284 "function": {
285 "name": "fetch_url",
286 "description": "Fetch a URL and return its cleaned text content.",
287 "parameters": {
288 "type": "object",
289 "properties": {
290 "url": {"type": "string", "description": "URL to fetch"},
291 "max_chars": {"type": "integer", "description": "Max characters to return (default 10000)"},
292 },
293 "required": ["url"],
294 },
295 },
296 },
297 {
298 "type": "function",
299 "function": {
300 "name": "web_search",
301 "description": "Search the web using DuckDuckGo and return top result snippets.",
302 "parameters": {
303 "type": "object",
304 "properties": {
305 "query": {"type": "string", "description": "Search query"},
306 "max_results": {"type": "integer", "description": "Number of results (default 5)"},
307 },
308 "required": ["query"],
309 },
310 },
311 },
312]
dict web_search(str query, int max_results=5)
Definition web.py:217
dict fetch_url(str url, int max_chars=10_000)
Definition web.py:95
bool _validate_hop(str url, *, str source_label)
Definition web.py:66