Skip to main content

rightkit_memory_client/
screening.rs

1//! Write-time screening for durable memory (ENG-81).
2//!
3//! Every candidate memory passes through [`screen`] before it may reach the
4//! store. Screening is deliberately conservative and dependency-free (no
5//! regex crate in this workspace member): it rejects anything that looks
6//! like a secret/credential, anything empty or too short to be a durable
7//! fact, and a short list of volatile/ephemeral junk phrases that pollute
8//! recall without adding information.
9
10use serde::{Deserialize, Serialize};
11
12/// Why a candidate memory was rejected at write time.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RejectReason {
16    /// Empty or whitespace-only content.
17    Empty,
18    /// Below the minimum durable-fact length; almost certainly a fragment
19    /// or a status ping, not something worth persisting cross-session.
20    TooShort,
21    /// Matched a credential/secret heuristic (keyword-adjacent, PEM block,
22    /// JWT shape, or a long high-entropy opaque token).
23    Credential,
24    /// Matched a known volatile/ephemeral phrase (loading states, generic
25    /// acknowledgements, bare timestamps) that carries no durable meaning.
26    VolatileJunk,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case", tag = "verdict")]
31pub enum ScreeningVerdict {
32    Admit,
33    Reject { reason: RejectReason },
34}
35
36impl ScreeningVerdict {
37    pub fn is_admit(&self) -> bool {
38        matches!(self, ScreeningVerdict::Admit)
39    }
40}
41
42/// Minimum trimmed length (bytes) for a candidate to be considered a durable
43/// fact rather than a fragment. Deliberately short so legitimate terse facts
44/// ("Adrian uses pnpm.") still pass.
45pub const MIN_DURABLE_LEN: usize = 8;
46
47/// Keyword stems that, when they appear immediately before an assignment-
48/// like separator (`:`, `=`, whitespace) followed by a plausible secret
49/// value, mark the whole candidate as a credential. Matching is
50/// case-insensitive and stem-based (substring) on purpose: screening must
51/// stay a hard gate, not a fooled-by-casing gate.
52const CREDENTIAL_KEYWORDS: &[&str] = &[
53    "password",
54    "passwd",
55    "pwd",
56    "api_key",
57    "apikey",
58    "api-key",
59    "secret",
60    "access_key",
61    "access-key",
62    "accesskey",
63    "private_key",
64    "private-key",
65    "privatekey",
66    "client_secret",
67    "auth_token",
68    "authtoken",
69    "bearer",
70    "authorization:",
71    "token",
72    "credential",
73    "ssn",
74    "passphrase",
75];
76
77const VOLATILE_JUNK_PHRASES: &[&str] = &[
78    "ok",
79    "okay",
80    "done",
81    "loading",
82    "loading...",
83    "pong",
84    "ping",
85    "yes",
86    "no",
87    "thanks",
88    "thank you",
89    "hi",
90    "hello",
91    "test",
92    "testing",
93    "n/a",
94    "none",
95    "in progress",
96    "working on it",
97];
98
99/// Screen a candidate memory's raw content. Pure function: no I/O, no store
100/// access, so it can run identically on every write path (CLI, daemon,
101/// mobile FFI) before a record is ever persisted.
102pub fn screen(content: &str) -> ScreeningVerdict {
103    let trimmed = content.trim();
104    if trimmed.is_empty() {
105        return ScreeningVerdict::Reject {
106            reason: RejectReason::Empty,
107        };
108    }
109    if trimmed.len() < MIN_DURABLE_LEN {
110        return ScreeningVerdict::Reject {
111            reason: RejectReason::TooShort,
112        };
113    }
114    if looks_like_credential(trimmed) {
115        return ScreeningVerdict::Reject {
116            reason: RejectReason::Credential,
117        };
118    }
119    if is_volatile_junk(trimmed) {
120        return ScreeningVerdict::Reject {
121            reason: RejectReason::VolatileJunk,
122        };
123    }
124    ScreeningVerdict::Admit
125}
126
127fn looks_like_credential(trimmed: &str) -> bool {
128    let lower = trimmed.to_ascii_lowercase();
129
130    if lower.contains("-----begin ") && lower.contains("private key") {
131        return true;
132    }
133
134    if CREDENTIAL_KEYWORDS
135        .iter()
136        .any(|kw| keyword_near_assignment(&lower, kw))
137    {
138        return true;
139    }
140
141    if looks_like_jwt(trimmed) {
142        return true;
143    }
144
145    // A single opaque token with no whitespace, long enough and high enough
146    // entropy to be a raw secret rather than prose (e.g. a pasted API key
147    // with no surrounding keyword). Multi-word content never trips this.
148    if !trimmed.contains(char::is_whitespace)
149        && trimmed.len() >= 24
150        && trimmed.len() <= 512
151        && looks_high_entropy(trimmed)
152    {
153        return true;
154    }
155
156    false
157}
158
159/// `true` when `keyword` appears in `lower` immediately followed (after
160/// optional whitespace) by one of `:`, `=`, or `-` and then a non-trivial
161/// value. This keeps prose like "the password policy requires 12 characters"
162/// from tripping the gate while still catching `password: hunter2`,
163/// `API_KEY=sk-abc123`, `secret = "xyz"`.
164fn keyword_near_assignment(lower: &str, keyword: &str) -> bool {
165    let mut search_from = 0;
166    while let Some(rel) = lower[search_from..].find(keyword) {
167        let start = search_from + rel;
168        let after = &lower[start + keyword.len()..];
169        let after_trimmed = after.trim_start();
170        if let Some(rest) = after_trimmed
171            .strip_prefix(':')
172            .or_else(|| after_trimmed.strip_prefix('='))
173        {
174            let value = rest.trim();
175            if value.len() >= 3 {
176                return true;
177            }
178        }
179        search_from = start + keyword.len();
180        if search_from >= lower.len() {
181            break;
182        }
183    }
184    false
185}
186
187/// Cheap JWT shape check: three dot-separated segments, each base64url-ish,
188/// first segment decodes (loosely, by charset) to a header-like token.
189fn looks_like_jwt(trimmed: &str) -> bool {
190    let parts: Vec<&str> = trimmed.split('.').collect();
191    if parts.len() != 3 {
192        return false;
193    }
194    parts
195        .iter()
196        .all(|p| p.len() >= 8 && p.chars().all(is_base64url_char))
197}
198
199fn is_base64url_char(c: char) -> bool {
200    c.is_ascii_alphanumeric() || c == '-' || c == '_'
201}
202
203/// Very cheap entropy heuristic: a mix of upper, lower, and digit classes
204/// with no repeated-character runs is treated as opaque/random rather than a
205/// natural-language token. Not cryptographic entropy -- just enough to keep
206/// obvious API-key-shaped pastes out of durable memory without a dependency.
207fn looks_high_entropy(token: &str) -> bool {
208    let has_lower = token.chars().any(|c| c.is_ascii_lowercase());
209    let has_upper = token.chars().any(|c| c.is_ascii_uppercase());
210    let has_digit = token.chars().any(|c| c.is_ascii_digit());
211    let class_count = [has_lower, has_upper, has_digit]
212        .iter()
213        .filter(|b| **b)
214        .count();
215    class_count >= 2
216}
217
218fn is_volatile_junk(trimmed: &str) -> bool {
219    let lower = trimmed.to_ascii_lowercase();
220    let normalized = lower.trim_end_matches(['.', '!', '?']).trim();
221    if VOLATILE_JUNK_PHRASES.contains(&normalized) {
222        return true;
223    }
224    // Bare ISO-8601-shaped timestamp with nothing else: carries no durable
225    // meaning on its own.
226    if normalized.len() <= 32
227        && normalized.chars().enumerate().all(|(i, c)| {
228            c.is_ascii_digit() || matches!(c, '-' | ':' | 't' | 'z' | '.' | '+') || i == 0
229        })
230        && normalized.chars().any(|c| c.is_ascii_digit())
231        && normalized.chars().filter(|c| c.is_ascii_digit()).count() >= 8
232        && !normalized
233            .chars()
234            .any(|c| c.is_ascii_alphabetic() && !matches!(c, 't' | 'z'))
235    {
236        return true;
237    }
238    false
239}