Coverage for src/local_deep_research/security/egress/guidance.py: 95%
27 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +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#
33# The two ``scope_mismatch_*`` reasons have a DYNAMIC ``how`` (see
34# ``_scope_mismatch_how`` below): Adaptive (default) is only mentioned when
35# it's actually reliable, i.e. the blocked engine IS the run's primary
36# engine. Otherwise Adaptive would follow the primary, which may sit in the
37# same restrictive bucket and resolve back to the same denial — so we point
38# the user at the explicit compatible scope only.
39_GUIDANCE: dict[str, tuple[str, str]] = {
40 "scope_mismatch_private_only": (
41 "{target} was blocked because your Egress Scope is set to "
42 "Private only — only local sources (your collections, local engines) "
43 "may run, and nothing leaves the machine.",
44 # placeholder — replaced at call time by _scope_mismatch_how()
45 "__SCOPE_MISMATCH_HOW__",
46 ),
47 "scope_mismatch_public_only": (
48 "{target} was blocked because your Egress Scope is set to "
49 "Public only — local/private sources are excluded from this run.",
50 # placeholder — replaced at call time by _scope_mismatch_how()
51 "__SCOPE_MISMATCH_HOW__",
52 ),
53 "strict_public_host": (
54 "{target} was blocked because your Egress Scope is Strict — only your "
55 "single primary engine may run, with no expansion to other hosts.",
56 "To allow it, change your Egress Scope away from Strict in "
57 "{scope_setting}.",
58 ),
59 "strict_not_primary": (
60 "{target} was blocked because your Egress Scope is Strict — only your "
61 "chosen primary search engine may run.",
62 "To use {target}, either make it your primary engine, or change "
63 "your Egress Scope away from Strict in {scope_setting}.",
64 ),
65 "blocked_metadata_ip": (
66 "{target} was blocked because it targets a cloud-metadata endpoint "
67 "(e.g. 169.254.169.254). These are NEVER permitted under any scope — "
68 "they are a common credential-theft (SSRF) target.",
69 "This is a hard safety rule and cannot be overridden in settings. If "
70 "you believe this is a mistake, the host genuinely resolves to a "
71 "cloud-metadata address.",
72 ),
73 "provider_cloud": (
74 "Cloud embeddings ({target}) were blocked because local embeddings are "
75 "required for this run (Private only scope, or the “Require local "
76 "embeddings” toggle).",
77 "To use cloud embeddings, turn off “Require local embeddings”, or "
78 "change your Egress Scope away from Private only in {scope_setting}; "
79 "or configure a local embeddings endpoint (e.g. sentence_transformers "
80 "or a local Ollama URL).",
81 ),
82 "provider_cloud_only": (
83 "The cloud LLM provider “{target}” was blocked because a local LLM is "
84 "required for this run (Private only scope, or the “Require local LLM "
85 "endpoint” toggle).",
86 "To use this provider, turn off “Require local LLM endpoint”, or change "
87 "your Egress Scope away from Private only in {scope_setting}; or "
88 "switch to a local provider (Ollama, LM Studio, LlamaCpp).",
89 ),
90 "provider_remote": (
91 "“{target}” was blocked because its configured endpoint resolves to a "
92 "remote (non-local) host, and a local endpoint is required for this "
93 "run.",
94 "To use it, point its URL at a local address (localhost / your LAN), "
95 "turn off the “Require local” toggle, or change your Egress Scope in "
96 "{scope_setting}.",
97 ),
98 "provider_url_unset": (
99 "“{target}” was blocked because no local endpoint URL is configured for "
100 "it, so it can't be certified as local while a local endpoint is "
101 "required.",
102 "Configure a local URL for the provider, or use a local-default "
103 "provider (Ollama, LM Studio, LlamaCpp), or turn off the “Require "
104 "local” toggle.",
105 ),
106 "elasticsearch_cloud_id_public_egress": (
107 "The Elasticsearch Cloud ID was blocked because your Egress Scope is "
108 "Private only / Strict — a Cloud ID points at hosted Elastic Cloud "
109 "(public egress).",
110 "To use Elasticsearch locally, configure a local hosts= URL instead of "
111 "a Cloud ID; or change your Egress Scope away from Private only / "
112 "Strict in {scope_setting}.",
113 ),
114 "denial_quota_exceeded": (
115 "This run hit its limit of blocked URL fetches — typically a document "
116 "that loops the agent through many forbidden links.",
117 "The run continues, but further blocked fetches are refused to protect "
118 "performance. Widen your Egress Scope if these URLs should be allowed, "
119 "or ignore this if the blocked links are junk.",
120 ),
121 "unknown_egress_scope": (
122 "Your saved Egress Scope value is unrecognised (corrupted or set to an "
123 "invalid value), so the run was refused rather than guessing.",
124 "Re-select a valid Egress Scope in {scope_setting}.",
125 ),
126 "engine_unknown": (
127 "The search engine “{target}” isn't recognised, so it was refused "
128 "(fail-closed).",
129 "Check the engine name, or pick a different search engine.",
130 ),
131 "unclassified": (
132 "“{target}” couldn't be classified as public or local, so it was "
133 "refused (fail-closed).",
134 "Pick a recognised engine, or check the engine's configuration.",
135 ),
136}
138# Reasons that are NOT really a policy block the user can act on — they are
139# parse/format failures. Given a short, honest explanation instead of "change
140# a setting".
141_NON_POLICY = {
142 "url_malformed": "The URL is malformed and could not be fetched.",
143 "no_hostname": "The URL has no host and could not be fetched.",
144 "unsupported_scheme": (
145 "The link uses a scheme that can't be fetched (only http/https URLs "
146 "are retrieved)."
147 ),
148 "dangerous_scheme": (
149 "The link uses a non-web scheme (javascript:/data:/file:/…) and was "
150 "skipped — it isn't fetchable content."
151 ),
152 "host_unclassified": "The host could not be resolved or classified.",
153 "internal_error": (
154 "An internal error occurred while evaluating the egress policy."
155 ),
156}
159def _scope_mismatch_how(
160 reason: str,
161 *,
162 target_id: Optional[str],
163 primary_engine: Optional[str],
164) -> str:
165 """Build the "how to allow it" sentence for the two scope-mismatch reasons.
167 Adaptive (default) is reliable ONLY when the blocked engine is also the
168 run's primary engine — Adaptive follows the primary, so if the primary
169 sits in the same restrictive bucket (the common case: primary=arxiv and
170 a library collection blocked under Private only), switching to Adaptive
171 resolves back to Private only and the blocked engine is still denied.
172 In that case we name only the explicit compatible scope, which is always
173 reliable.
175 ``target_id`` is the RAW blocked engine id (used for the comparison);
176 ``target`` (the display string the caller passes to
177 :func:`denial_guidance`) is intentionally NOT used here, because the
178 web/routers/research.py caller formats it as ``"Search engine '<id>'"`` for the
179 user-facing message and we don't want that wrapper participating in the
180 equality check.
182 Returns a string that still contains the ``{scope_setting}`` placeholder
183 for the outer ``str.format`` pass — mirroring the static templates in
184 ``_GUIDANCE`` so the call site doesn't need to special-case the
185 substitution.
186 """
187 if reason == "scope_mismatch_private_only":
188 # Blocked target is public; current scope is Private only. Reliable
189 # remedy: Public only. (Adaptive works only if the blocked target
190 # IS the primary.)
191 reliable = "Public only"
192 suffix = ""
193 elif reason == "scope_mismatch_public_only": 193 ↛ 206line 193 didn't jump to line 206 because the condition on line 193 was always true
194 # Blocked target is local; current scope is Public only. Reliable
195 # remedy: Private only. The collection-specific escape hatch is
196 # preserved so users with non-sensitive local collections can
197 # promote the collection instead of loosening global egress.
198 reliable = "Private only"
199 suffix = (
200 "; or, if this is a collection whose contents are non-sensitive, "
201 "mark it Public on the collection page"
202 )
203 else:
204 # Caller asked for a non-scope-mismatch reason — fall back to the
205 # generic template so we never silently drop the "how" instruction.
206 return (
207 "To use it, change your Egress Scope to a mode compatible with "
208 "{target} in {scope_setting}."
209 )
211 adaptive_reliable = bool(
212 target_id and primary_engine and target_id == primary_engine
213 )
214 # ``reliable`` already contains the trailing "only" (e.g. "Public only",
215 # "Private only") — it's the full scope name. Don't add a second "only"
216 # in the template, or the rendered message reads "Private only only in
217 # Settings ..." (the bug this comment is in place to prevent).
218 if adaptive_reliable:
219 return (
220 f"To use it, change your Egress Scope to Adaptive (default) or "
221 f"{reliable} in {{scope_setting}}{suffix}."
222 )
223 # Target isn't the primary (or primary isn't known) — the explicit
224 # compatible scope is the only reliable fix.
225 return (
226 f"To use it, change your Egress Scope to {reliable} in "
227 f"{{scope_setting}}{suffix}."
228 )
231def denial_guidance(
232 reason: str,
233 *,
234 target: Optional[str] = None,
235 primary_engine: Optional[str] = None,
236 target_id: Optional[str] = None,
237) -> str:
238 """Return a clear, user-facing explanation + instructions for a denial.
240 ``reason`` is the PDP machine code; ``target`` is the display string for
241 the blocked thing (engine / provider / host) and is inserted into the
242 message verbatim; ``target_id`` is the raw blocked engine id used ONLY
243 for the Adaptive-reliability check on the scope-mismatch reasons — keep
244 it separate from ``target`` so the web/routers/research.py caller can format the
245 display target however it likes (``"Search engine 'library'"``) without
246 breaking the equality check; ``primary_engine`` is the run's resolved
247 primary search engine id, used to decide whether the Adaptive (default)
248 scope is a reliable remedy (it is only when the blocked target IS the
249 primary — see :func:`_scope_mismatch_how`). Always returns a non-empty
250 string, even for unknown reasons.
251 """
252 label = target or "This action"
253 if reason in _GUIDANCE:
254 what, how = _GUIDANCE[reason]
255 if how == "__SCOPE_MISMATCH_HOW__":
256 how = _scope_mismatch_how(
257 reason, target_id=target_id, primary_engine=primary_engine
258 )
259 # Two-step: the f-string only CONCATENATES the two plain templates
260 # (no placeholder interpolation — {target}/{scope_setting} are literal
261 # here), then .format() fills them. Don't collapse this into a single
262 # f-string or the placeholders would need escaping again.
263 return f"{what} {how}".format(
264 target=label, scope_setting=_SCOPE_SETTING
265 )
266 if reason in _NON_POLICY:
267 return _NON_POLICY[reason]
268 # Unknown reason — be honest, don't invent an instruction.
269 return (
270 f"{label} was blocked by the egress policy (reason: {reason}). Check "
271 f"your Egress Scope and the “Require local” toggles in {_SCOPE_SETTING}."
272 )