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

1"""Human-facing guidance for egress-policy denials. 

2 

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. 

8 

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. 

11 

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""" 

18 

19from __future__ import annotations 

20 

21from typing import Optional 

22 

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) 

29 

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} 

131 

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} 

151 

152 

153def denial_guidance(reason: str, *, target: Optional[str] = None) -> str: 

154 """Return a clear, user-facing explanation + instructions for a denial. 

155 

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 )