Secure async web fetching for AI agents. Combines pre-request secret/PII scanning, SSRF blocking, and post-fetch prompt injection detection into a single safe_fetch() call.
from safe_fetch import safe_fetch, SafeFetchConfig, Policy
result = await safe_fetch("https://example.com/article")
print(result.content) # wrapped, LLM-ready markdown
print(result.raw_content) # unwrapped markdown
print(result.safe_content) # unwrapped safe markdown
print(result.metadata.final_url)
print(result.risk.level)
print(result.extraction_method) # how it was obtained
print(result.response_findings) # injection findings (empty if clean)# Custom policy
config = SafeFetchConfig(
request_policy=Policy.STRICT, # raise on secrets/PII in URL/headers
response_policy=Policy.WARN, # log + redact injections, don't raise
)
result = await safe_fetch("https://example.com/docs", config=config)Both request_policy and response_policy accept a Policy enum value.
| Policy | Request behavior | Response behavior |
|---|---|---|
STRICT |
Raise SecretLeakError / PIILeakError on any finding |
Raise InjectionDetectedError on any finding |
WARN |
Log finding, record in request_findings, continue |
Log finding, redact matched content, record in response_findings |
PERMISSIVE |
Record in request_findings, continue |
Record in response_findings, return content unmodified |
Note: SSRF blocking is always enforced regardless of
request_policy.SSRFBlockedErroris never suppressed.
Defaults: request_policy=STRICT, response_policy=WARN
@dataclass
class SafeFetchConfig:
request_policy: Policy = Policy.STRICT
response_policy: Policy = Policy.WARN
connect_timeout: float = 10.0 # seconds
read_timeout: float = 30.0 # seconds
total_timeout: float | None = 60.0
max_response_bytes: int = 10_000_000
max_redirects: int = 5
allow_http: bool = True
allowed_hosts: set[str] = field(default_factory=set)
blocked_cidrs: set[str] = field(default_factory=set)
allowed_content_types: set[str] = field(default_factory=lambda: {
"text/html", "text/plain", "text/markdown", "application/xhtml+xml"
})
safe_markdown: bool = True
page_links: bool = True # append a "Links on this page" section
max_page_links: int = 100
llm_client: Any = None # optional; must implement classify_injection(text) -> bool
classifier_timeout: float = 5.0
classifier_failure_policy: Policy = Policy.WARN
user_agent: str = "safe-fetch/1.0 (LLM-agent)"
extra_headers: dict = field(default_factory=dict)Preset constructors are available:
SafeFetchConfig.agent_default()
SafeFetchConfig.strict_enterprise()
SafeFetchConfig.permissive_research()Content is retrieved in this order, returning at the first success:
- Content negotiation —
Accept: text/markdownheader; if server returns markdown/plain text, used directly (extraction_method="content-negotiation") - .md probe — for HTML responses, probes the same path with
.mdappended while extraction runs (extraction_method="md-probe") - trafilatura — extracts main content from HTML as markdown (
extraction_method="trafilatura") - readability + markdownify — fallback HTML extraction (
extraction_method="readability+markdownify")
The pipeline canonicalizes every caller URL, redirect target, and .md probe before network access. By default it blocks non-global destination addresses, local hostnames, ambiguous IP encodings, URL credentials, fragments, malformed ports, unsupported content types, non-2xx statuses, oversized responses, and redirect chains beyond max_redirects.
result.raw_content preserves extracted Markdown after response scanning. result.safe_content neutralizes Markdown images, reference images, raw HTML, comments, SVG/script/template/noscript blocks, autolinks, and active links while preserving visible text. result.content wraps safe_content in <web_content> boundary tags with a redacted source URL and timestamp.
result.metadata, result.integrity, result.safety_events, and result.risk provide provenance, SHA-256 content hashes, sanitizer/neutralization/classifier events, and an advisory risk score with reasons. The risk score is not a trust guarantee; treat fetched content as untrusted data and keep tools, credentials, and approvals least-privileged.
While inline links are neutralized by default, safe-fetch appends a generated ## Links on this page section listing every discovered link as - [text](url), so an agent can walk a site (e.g. follow docs navigation) one fetch at a time:
...extracted page content...
## Links on this page
- [Getting Started](https://docs.example.com/start)
- [API Reference](https://docs.example.com/api)Links are harvested from the whole sanitized page (navigation and sidebars included; hidden-element links are already stripped) — or from the Markdown source for .md responses — then resolved against the final URL and filtered through the same scheme/host/CIDR policy as link_policy="allow-safe": javascript:/file: schemes, private addresses, and blocked hosts never appear. Link text is treated as untrusted (invisible characters stripped, Markdown structure removed, length-capped). The section is capped at max_page_links (default 100) and reported via page_links safety events (links_appended, links_dropped_policy, links_truncated, harvest_capped), which do not affect the risk score. Set page_links=False to disable.
The CLI is configured only through SAFE_FETCH_* environment variables, including policy, timeout, response size, redirect, host/CIDR, content-type, status, redaction, safe Markdown, and classifier controls:
SAFE_FETCH_MAX_RESPONSE_BYTES=1048576 \
SAFE_FETCH_ALLOWED_HOST_SUFFIXES=.example.com \
SAFE_FETCH_SAFE_MARKDOWN=true \
safe-fetch --json https://docs.example.com/pageRun safe-fetch --help for the full environment variable and exit code table.
All exceptions inherit from SafeFetchError.
| Exception | When raised |
|---|---|
InvalidSchemeError |
URL scheme is not http or https |
InvalidURLError |
URL is malformed, ambiguous, or unsafe |
HostPolicyError |
Host or CIDR policy rejected the target |
SSRFBlockedError |
URL resolves to a private/reserved IP (always raised) |
SecretLeakError |
Secret detected in URL query params or headers (STRICT) |
PIILeakError |
PII (email, phone, credit card, SSN) detected in URL or headers (STRICT) |
FetchTimeoutError |
Connect, read, or total timeout |
RedirectLimitError |
Too many HTTP redirects |
ResponseTooLargeError |
Response exceeded max_response_bytes |
UnsupportedContentTypeError |
Content type is not allowlisted |
HTTPStatusError |
Status policy rejected the response |
ClassifierError |
Classifier escalation failed under strict policy |
ExtractionFailedError |
All extraction methods failed |
InjectionDetectedError |
Injection finding in response (STRICT) |
safe-fetch is designed for untrusted web retrieval in agent and RAG workflows. It reduces SSRF, request secret leakage, hidden-content injection, Markdown exfiltration, and prompt-injection risks, but it does not prove remote content is safe to obey. Downstream systems should still separate instructions from retrieved data, restrict tool permissions, avoid passing secrets into retrieval URLs, prefer source allowlists, and require human approval for sensitive actions.
Detection limits: The regex-based injection scanner catches known patterns and control tokens but cannot detect paraphrased or novel injection attempts on its own. When an llm_client is wired, the LLM classifier becomes the primary detector for novel and paraphrased injection — it runs over the full fetched content on every fetch, independent of whether structural heuristics fired. Code-block scanning uses a narrow high-signal subset for obvious control tokens. Robust agent architectures should:
- Use content boundary markers (
result.content_marker) so the LLM can distinguish retrieved data from trusted instructions. - Wire in an LLM classifier (
SafeFetchConfig(llm_client=...)) so paraphrased and novel injections are caught semantically, not just by pattern matching. - Design tools with least-privilege — retrieved web content should not have the authority to invoke high-impact actions without a human approval step.
Patterns that remain out of scope by design: arbitrary external-CSS-hidden content (requires a rendered browser) and injections in embedded media or binary formats.
from safe_fetch import SafeFetchError
try:
result = await safe_fetch(url)
except SafeFetchError as e:
print(f"safe-fetch error: {e}")When an llm_client is provided, safe-fetch classifies fetched content directly for prompt injection on every fetch — catching paraphrased and novel injections that regex patterns cannot detect. The classifier is invoked in up to classifier_max_chunks overlapping windows of classifier_max_chars characters (default: 4 calls × 12,000 chars). A positive verdict produces a HIGH-confidence finding handled per response_policy.
Cost note: Each fetch with an llm_client makes up to classifier_max_chunks classifier calls (default 4). For cost-sensitive deployments, set classifier_mode="escalate" to revert to the cheaper legacy behavior (classifier fires only when structural heuristics produced a MEDIUM finding).
The package exports a reference prompt template and a fail-closed verdict parser to help build the llm_client:
import anthropic
from safe_fetch import (
REFERENCE_CLASSIFIER_PROMPT,
SafeFetchConfig,
parse_classifier_verdict,
safe_fetch,
)
client = anthropic.Anthropic()
class MyClassifierClient:
async def classify_injection(self, text: str) -> bool:
# Substitute content into the reference prompt template
prompt = REFERENCE_CLASSIFIER_PROMPT.replace("{content}", text)
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=5,
messages=[{"role": "user", "content": prompt}],
)
reply = response.content[0].text
# Fail-closed: only exact "CLEAN" (stripped, case-insensitive) is not adversarial.
# Any verbose, malformed, or content-hijacked reply is treated as adversarial.
return parse_classifier_verdict(reply)
config = SafeFetchConfig(llm_client=MyClassifierClient())
result = await safe_fetch(url, config=config)parse_classifier_verdict is fail-closed: it returns False (not adversarial) only for the exact token CLEAN (whitespace-stripped, case-insensitive). Any other reply — including verbose explanations or content that hijacks the classifier's output — returns True (adversarial). This bias toward false positives is intentional for security.
uv add safe-fetch
# With optional Playwright support for JS-heavy pages:
uv add "safe-fetch[playwright]"