cortex_llm/sensitivity.rs
1//! Remote-prompt data-classification sensitivity gate.
2//!
3//! Any memory or context item that exceeds the operator-configured
4//! [`MaxSensitivity`] level is excluded from remote prompts before they are
5//! assembled. This prevents inadvertent data exfiltration to external hosted
6//! models (Anthropic API, remote Ollama, etc.) when the operator has not
7//! explicitly opted in to sending high-sensitivity data off-machine.
8//!
9//! ## Architecture (ADR 0048 §3 follow-on)
10//!
11//! The primary enforcement point is now a real per-memory domain-tag query:
12//! before a prompt is dispatched to a remote endpoint, `cortex-cli`'s run
13//! pipeline calls `MemoryRepo::max_sensitivity_for_active_memories`, parses
14//! the result as a [`MaxSensitivity`], and refuses with
15//! [`LlmError::InvalidRequest`] when active memories exceed the configured
16//! threshold. See `crates/cortex-cli/src/cmd/run.rs` for the call site.
17//!
18//! [`check_remote_prompt_sensitivity`] remains as the adapter-layer fallback
19//! for inline `[SENSITIVITY:HIGH]` markers. It is called inside
20//! `ClaudeHttpAdapter::complete` as a defense-in-depth guard after the
21//! store-query check in the run pipeline.
22
23use std::str::FromStr;
24
25use crate::adapter::LlmError;
26
27/// Maximum data-classification level permitted in a remote prompt.
28///
29/// Variants are ordered from most restrictive to least restrictive so that
30/// `PartialOrd` / `Ord` comparisons work naturally: a memory at level `"low"`
31/// is always allowed when the gate is `Low`, `Medium`, or `High`; a memory at
32/// level `"high"` is only allowed when the gate is `High`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34pub enum MaxSensitivity {
35 /// Only low-sensitivity memories may appear in remote prompts.
36 Low,
37 /// Low- and medium-sensitivity memories are permitted (operator default).
38 Medium,
39 /// All memories are permitted, including high-sensitivity data.
40 /// This is an explicit operator opt-in and risks data exfiltration to
41 /// external hosted models.
42 High,
43}
44
45impl FromStr for MaxSensitivity {
46 type Err = String;
47
48 /// Parse a sensitivity level from a string token.
49 ///
50 /// Accepted values (case-insensitive): `"low"`, `"medium"`, `"high"`.
51 ///
52 /// # Errors
53 ///
54 /// Returns a descriptive error string when the token is unrecognised.
55 fn from_str(s: &str) -> Result<Self, Self::Err> {
56 match s.to_ascii_lowercase().as_str() {
57 "low" => Ok(Self::Low),
58 "medium" => Ok(Self::Medium),
59 "high" => Ok(Self::High),
60 other => Err(format!(
61 "unrecognised sensitivity level {other:?}; expected one of: low, medium, high"
62 )),
63 }
64 }
65}
66
67impl MaxSensitivity {
68 /// Return `true` when a memory whose classification is `level` is
69 /// permitted at this gate setting.
70 ///
71 /// Comparison is case-insensitive. An unrecognised `level` string is
72 /// conservatively treated as `"high"` (denied unless the gate is `High`).
73 #[must_use]
74 pub fn allows(&self, level: &str) -> bool {
75 let candidate = match level.to_ascii_lowercase().as_str() {
76 "low" => Self::Low,
77 "medium" => Self::Medium,
78 "high" => Self::High,
79 // Unknown classification is treated as maximally sensitive.
80 _ => Self::High,
81 };
82 candidate <= *self
83 }
84}
85
86/// Result of a domain-tag sensitivity gate evaluation (ADR 0048 §3).
87///
88/// Produced by the run pipeline after calling
89/// `MemoryRepo::max_sensitivity_for_active_memories` and comparing against the
90/// configured [`MaxSensitivity`] ceiling. The `allowed` field is the decisive
91/// outcome; the other fields are preserved for audit logging.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct SensitivityGateResult {
94 /// Maximum sensitivity level found across all active memories.
95 ///
96 /// One of `"high"`, `"medium"`, `"low"`, or `"none"`. Sourced directly
97 /// from `MemoryRepo::max_sensitivity_for_active_memories`.
98 pub max_memory_sensitivity: String,
99 /// Operator-configured ceiling from the cortex.toml `[llm.claude]`
100 /// `max_sensitivity` field.
101 pub configured_max: MaxSensitivity,
102 /// `true` when the active memories' maximum sensitivity is at or below
103 /// the configured ceiling; `false` when the gate blocks dispatch.
104 pub allowed: bool,
105}
106
107impl SensitivityGateResult {
108 /// Evaluate the domain-tag gate given the memory's maximum sensitivity
109 /// level (as returned by `MemoryRepo::max_sensitivity_for_active_memories`)
110 /// and the operator-configured ceiling.
111 ///
112 /// `memory_max_str` is one of `"high"`, `"medium"`, `"low"`, or `"none"`.
113 /// Any unrecognised string is conservatively treated as `"high"` so that an
114 /// unknown tag value is never silently permitted.
115 #[must_use]
116 pub fn evaluate(memory_max_str: &str, configured_max: MaxSensitivity) -> Self {
117 let allowed = configured_max.allows(memory_max_str);
118 Self {
119 max_memory_sensitivity: memory_max_str.to_string(),
120 configured_max,
121 allowed,
122 }
123 }
124}
125
126/// Gate that returns `Ok` when the prompt content passes the max-sensitivity
127/// threshold for remote delivery.
128///
129/// This is an adapter-layer defense-in-depth guard that scans for inline
130/// `[SENSITIVITY:HIGH]` or `[sens:high]` markers. The primary enforcement
131/// path is the domain-tag store query called from the run pipeline before
132/// `LlmRequest` is dispatched (see `cortex-cli/src/cmd/run.rs`).
133///
134/// # Behaviour
135///
136/// - `MaxSensitivity::High`: always passes — the operator has explicitly opted
137/// in to sending all sensitivity classes to the remote endpoint.
138/// - `MaxSensitivity::Medium` / `MaxSensitivity::Low`: scans the prompt for
139/// inline markers `[SENSITIVITY:HIGH]` or `[sens:high]` (case-insensitive).
140/// If any such marker is found the gate returns
141/// [`LlmError::InvalidRequest`] with reason
142/// `sensitivity_exceeds_remote_threshold`.
143///
144/// # Errors
145///
146/// Returns [`LlmError::InvalidRequest`] when the gate determines that the
147/// prompt contains high-sensitivity content above the configured `max` level.
148pub fn check_remote_prompt_sensitivity(prompt: &str, max: MaxSensitivity) -> Result<(), LlmError> {
149 tracing::info!(
150 max = ?max,
151 prompt_len = prompt.len(),
152 "remote prompt sensitivity gate: evaluating"
153 );
154
155 // High gate: operator has explicitly opted in — all content is permitted.
156 if max == MaxSensitivity::High {
157 tracing::debug!("remote prompt sensitivity gate: max=High, unconditional pass");
158 return Ok(());
159 }
160
161 // Scan for inline high-sensitivity markers that memory-assembly injects.
162 // Using byte search on the lowercased copy avoids regex dependency.
163 let lower = prompt.to_ascii_lowercase();
164 let has_high_marker = lower.contains("[sensitivity:high]") || lower.contains("[sens:high]");
165
166 if has_high_marker {
167 tracing::info!(
168 max = ?max,
169 "remote prompt sensitivity gate: high-sensitivity marker found; excluding prompt"
170 );
171 return Err(LlmError::InvalidRequest(
172 "sensitivity_exceeds_remote_threshold: prompt contains high-sensitivity content \
173 above the configured max_sensitivity level; memory excluded from remote dispatch"
174 .to_string(),
175 ));
176 }
177
178 tracing::debug!(max = ?max, "remote prompt sensitivity gate: pass (no high-sensitivity markers)");
179 Ok(())
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn from_str_parses_all_variants() {
188 assert_eq!(
189 "low".parse::<MaxSensitivity>().unwrap(),
190 MaxSensitivity::Low
191 );
192 assert_eq!(
193 "medium".parse::<MaxSensitivity>().unwrap(),
194 MaxSensitivity::Medium
195 );
196 assert_eq!(
197 "high".parse::<MaxSensitivity>().unwrap(),
198 MaxSensitivity::High
199 );
200 }
201
202 #[test]
203 fn from_str_is_case_insensitive() {
204 assert_eq!(
205 "LOW".parse::<MaxSensitivity>().unwrap(),
206 MaxSensitivity::Low
207 );
208 assert_eq!(
209 "Medium".parse::<MaxSensitivity>().unwrap(),
210 MaxSensitivity::Medium
211 );
212 assert_eq!(
213 "HIGH".parse::<MaxSensitivity>().unwrap(),
214 MaxSensitivity::High
215 );
216 }
217
218 #[test]
219 fn from_str_rejects_unknown() {
220 assert!("critical".parse::<MaxSensitivity>().is_err());
221 assert!("".parse::<MaxSensitivity>().is_err());
222 }
223
224 #[test]
225 fn allows_low_gate_permits_only_low() {
226 let gate = MaxSensitivity::Low;
227 assert!(gate.allows("low"));
228 assert!(!gate.allows("medium"));
229 assert!(!gate.allows("high"));
230 }
231
232 #[test]
233 fn allows_medium_gate_permits_low_and_medium() {
234 let gate = MaxSensitivity::Medium;
235 assert!(gate.allows("low"));
236 assert!(gate.allows("medium"));
237 assert!(!gate.allows("high"));
238 }
239
240 #[test]
241 fn allows_high_gate_permits_all() {
242 let gate = MaxSensitivity::High;
243 assert!(gate.allows("low"));
244 assert!(gate.allows("medium"));
245 assert!(gate.allows("high"));
246 }
247
248 #[test]
249 fn allows_unknown_level_treated_as_high_sensitivity() {
250 // An unrecognised classification label is conservatively denied unless
251 // the gate is High.
252 assert!(!MaxSensitivity::Low.allows("classified"));
253 assert!(!MaxSensitivity::Medium.allows("classified"));
254 assert!(MaxSensitivity::High.allows("classified"));
255 }
256
257 #[test]
258 fn check_remote_prompt_sensitivity_passes_unmarked_prompt_at_all_gates() {
259 // A prompt with no high-sensitivity markers is always permitted.
260 assert!(check_remote_prompt_sensitivity("some prompt text", MaxSensitivity::Low).is_ok());
261 assert!(
262 check_remote_prompt_sensitivity("some prompt text", MaxSensitivity::Medium).is_ok()
263 );
264 assert!(check_remote_prompt_sensitivity("some prompt text", MaxSensitivity::High).is_ok());
265 }
266
267 #[test]
268 fn check_remote_prompt_sensitivity_high_gate_allows_marked_prompt() {
269 // MaxSensitivity::High always passes — operator opted in.
270 let marked = "Context: [SENSITIVITY:HIGH] — user medical history.";
271 assert!(
272 check_remote_prompt_sensitivity(marked, MaxSensitivity::High).is_ok(),
273 "High gate must pass even when high-sensitivity marker is present"
274 );
275 }
276
277 #[test]
278 fn check_remote_prompt_sensitivity_medium_gate_rejects_marked_prompt() {
279 let marked = "Context: [SENSITIVITY:HIGH] — confidential data.";
280 let err = check_remote_prompt_sensitivity(marked, MaxSensitivity::Medium)
281 .expect_err("Medium gate must reject a prompt with a high-sensitivity marker");
282 assert!(
283 matches!(err, LlmError::InvalidRequest(ref msg) if msg.contains("sensitivity_exceeds_remote_threshold")),
284 "error must name the stable invariant: {err:?}"
285 );
286 }
287
288 #[test]
289 fn check_remote_prompt_sensitivity_low_gate_rejects_sens_high_marker() {
290 let marked = "User profile: [sens:high] present.";
291 let err = check_remote_prompt_sensitivity(marked, MaxSensitivity::Low)
292 .expect_err("Low gate must reject [sens:high] marker");
293 assert!(
294 matches!(err, LlmError::InvalidRequest(_)),
295 "expected InvalidRequest: {err:?}"
296 );
297 }
298
299 #[test]
300 fn check_remote_prompt_sensitivity_marker_matching_is_case_insensitive() {
301 // Mixed-case variants of the marker must be caught.
302 for marker in &["[Sensitivity:High]", "[SENSITIVITY:HIGH]", "[sens:HIGH]"] {
303 let prompt = format!("data: {marker} info");
304 let err = check_remote_prompt_sensitivity(&prompt, MaxSensitivity::Medium)
305 .expect_err("case-variant marker must be rejected");
306 assert!(
307 matches!(err, LlmError::InvalidRequest(_)),
308 "marker {marker} not caught: {err:?}"
309 );
310 }
311 }
312
313 #[test]
314 fn max_sensitivity_ordering() {
315 assert!(MaxSensitivity::Low < MaxSensitivity::Medium);
316 assert!(MaxSensitivity::Medium < MaxSensitivity::High);
317 assert!(MaxSensitivity::Low < MaxSensitivity::High);
318 }
319}