Skip to main content

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}