Coverage for src/local_deep_research/security/egress/guidance.py: 100%
13 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""Human-facing guidance for egress-policy denials.
3The PDP returns terse machine reason codes (``scope_mismatch_private_only``,
4``provider_cloud_only``, …) — great for logs, useless for a user staring at a
5blocked action. This module maps each reason to a CLEAR sentence that says
6WHAT was blocked, WHY, and — crucially — HOW TO ALLOW IT (the exact setting to
7change), so a block is never a dead end.
9Use :func:`denial_guidance` at any USER-FACING surface (HTTP error responses,
10the research form, tool errors). Keep the raw ``reason`` code in audit logs.
12Implementation note: messages are plain strings with ``{target}`` and
13``{scope_setting}`` placeholders, resolved by a single ``str.format`` in
14:func:`denial_guidance` — NOT f-strings — so every entry uses the same
15``{target}`` syntax (no ``{{target}}`` escaping traps). Don't put a literal
16``{``/``}`` in a message, or ``.format`` will choke.
17"""
19from __future__ import annotations
21from typing import Optional
23# Where the user changes the egress scope, named once so the wording stays
24# consistent everywhere. Injected as the ``{scope_setting}`` placeholder.
25_SCOPE_SETTING = (
26 "Settings → Privacy & Egress → Egress Scope (or the Privacy & Egress panel "
27 "on the research form, for a one-off run)"
28)
30# reason code -> (what happened, how to allow it). ``{target}`` is filled with
31# the blocked engine/provider/host; ``{scope_setting}`` with _SCOPE_SETTING.
32_GUIDANCE: dict[str, tuple[str, str]] = {
33 "scope_mismatch_private_only": (
34 "{target} was blocked because your Egress Scope is set to "
35 "Private only — only local sources (your collections, local engines) "
36 "may run, and nothing leaves the machine.",
37 "To use it, change your Egress Scope to Both or Public only in "
38 "{scope_setting}.",
39 ),
40 "scope_mismatch_public_only": (
41 "{target} was blocked because your Egress Scope is set to "
42 "Public only — local/private sources are excluded from this run.",
43 "To use it, change your Egress Scope to Both or Private only in "
44 "{scope_setting}; or, if this is a collection whose contents are "
45 "non-sensitive, mark it Public on the collection page.",
46 ),
47 "strict_public_host": (
48 "{target} was blocked because your Egress Scope is Strict — only your "
49 "single primary engine may run, with no expansion to other hosts.",
50 "To allow it, change your Egress Scope away from Strict in "
51 "{scope_setting}.",
52 ),
53 "strict_not_primary": (
54 "{target} was blocked because your Egress Scope is Strict — only your "
55 "chosen primary search engine may run.",
56 "To use {target}, either make it your primary engine, or change "
57 "your Egress Scope away from Strict in {scope_setting}.",
58 ),
59 "blocked_metadata_ip": (
60 "{target} was blocked because it targets a cloud-metadata endpoint "
61 "(e.g. 169.254.169.254). These are NEVER permitted under any scope — "
62 "they are a common credential-theft (SSRF) target.",
63 "This is a hard safety rule and cannot be overridden in settings. If "
64 "you believe this is a mistake, the host genuinely resolves to a "
65 "cloud-metadata address.",
66 ),
67 "provider_cloud": (
68 "Cloud embeddings ({target}) were blocked because local embeddings are "
69 "required for this run (Private only scope, or the “Require local "
70 "embeddings” toggle).",
71 "To use cloud embeddings, turn off “Require local embeddings”, or "
72 "change your Egress Scope away from Private only in {scope_setting}; "
73 "or configure a local embeddings endpoint (e.g. sentence_transformers "
74 "or a local Ollama URL).",
75 ),
76 "provider_cloud_only": (
77 "The cloud LLM provider “{target}” was blocked because a local LLM is "
78 "required for this run (Private only scope, or the “Require local LLM "
79 "endpoint” toggle).",
80 "To use this provider, turn off “Require local LLM endpoint”, or change "
81 "your Egress Scope away from Private only in {scope_setting}; or "
82 "switch to a local provider (Ollama, LM Studio, LlamaCpp).",
83 ),
84 "provider_remote": (
85 "“{target}” was blocked because its configured endpoint resolves to a "
86 "remote (non-local) host, and a local endpoint is required for this "
87 "run.",
88 "To use it, point its URL at a local address (localhost / your LAN), "
89 "turn off the “Require local” toggle, or change your Egress Scope in "
90 "{scope_setting}.",
91 ),
92 "provider_url_unset": (
93 "“{target}” was blocked because no local endpoint URL is configured for "
94 "it, so it can't be certified as local while a local endpoint is "
95 "required.",
96 "Configure a local URL for the provider, or use a local-default "
97 "provider (Ollama, LM Studio, LlamaCpp), or turn off the “Require "
98 "local” toggle.",
99 ),
100 "elasticsearch_cloud_id_public_egress": (
101 "The Elasticsearch Cloud ID was blocked because your Egress Scope is "
102 "Private only / Strict — a Cloud ID points at hosted Elastic Cloud "
103 "(public egress).",
104 "To use Elasticsearch locally, configure a local hosts= URL instead of "
105 "a Cloud ID; or change your Egress Scope away from Private only / "
106 "Strict in {scope_setting}.",
107 ),
108 "denial_quota_exceeded": (
109 "This run hit its limit of blocked URL fetches — typically a document "
110 "that loops the agent through many forbidden links.",
111 "The run continues, but further blocked fetches are refused to protect "
112 "performance. Widen your Egress Scope if these URLs should be allowed, "
113 "or ignore this if the blocked links are junk.",
114 ),
115 "unknown_egress_scope": (
116 "Your saved Egress Scope value is unrecognised (corrupted or set to an "
117 "invalid value), so the run was refused rather than guessing.",
118 "Re-select a valid Egress Scope in {scope_setting}.",
119 ),
120 "engine_unknown": (
121 "The search engine “{target}” isn't recognised, so it was refused "
122 "(fail-closed).",
123 "Check the engine name, or pick a different search engine.",
124 ),
125 "unclassified": (
126 "“{target}” couldn't be classified as public or local, so it was "
127 "refused (fail-closed).",
128 "Pick a recognised engine, or check the engine's configuration.",
129 ),
130}
132# Reasons that are NOT really a policy block the user can act on — they are
133# parse/format failures. Given a short, honest explanation instead of "change
134# a setting".
135_NON_POLICY = {
136 "url_malformed": "The URL is malformed and could not be fetched.",
137 "no_hostname": "The URL has no host and could not be fetched.",
138 "unsupported_scheme": (
139 "The link uses a scheme that can't be fetched (only http/https URLs "
140 "are retrieved)."
141 ),
142 "dangerous_scheme": (
143 "The link uses a non-web scheme (javascript:/data:/file:/…) and was "
144 "skipped — it isn't fetchable content."
145 ),
146 "host_unclassified": "The host could not be resolved or classified.",
147 "internal_error": (
148 "An internal error occurred while evaluating the egress policy."
149 ),
150}
153def denial_guidance(reason: str, *, target: Optional[str] = None) -> str:
154 """Return a clear, user-facing explanation + instructions for a denial.
156 ``reason`` is the PDP machine code; ``target`` is the blocked thing
157 (engine / provider / host), inserted into the message. Always returns a
158 non-empty string, even for unknown reasons.
159 """
160 label = target or "This action"
161 if reason in _GUIDANCE:
162 what, how = _GUIDANCE[reason]
163 # Two-step: the f-string only CONCATENATES the two plain templates
164 # (no placeholder interpolation — {target}/{scope_setting} are literal
165 # here), then .format() fills them. Don't collapse this into a single
166 # f-string or the placeholders would need escaping again.
167 return f"{what} {how}".format(
168 target=label, scope_setting=_SCOPE_SETTING
169 )
170 if reason in _NON_POLICY:
171 return _NON_POLICY[reason]
172 # Unknown reason — be honest, don't invent an instruction.
173 return (
174 f"{label} was blocked by the egress policy (reason: {reason}). Check "
175 f"your Egress Scope and the “Require local” toggles in {_SCOPE_SETTING}."
176 )