Skip to main content

lean_ctx/core/knowledge/
protection.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Current project policy at durable knowledge boundaries.
3use std::path::Path;
4use std::sync::Arc;
5
6use super::{
7    ConsolidatedInsight, KnowledgeFact, ProjectKnowledge, ProjectPattern, WithheldKnowledge,
8};
9use crate::core::context_admission::stores::StoreAdmission;
10use crate::core::policy::{diagnostics, runtime};
11
12#[cfg(test)]
13#[path = "protection_tests.rs"]
14mod tests;
15
16#[cfg(test)]
17#[path = "protection_transition_tests.rs"]
18mod transition_tests;
19
20pub fn current(root: &str) -> Result<Option<Arc<runtime::ActivePolicy>>, String> {
21    if let Some(request) = diagnostics::request_project()
22        && crate::core::pathutil::safe_canonicalize_bounded(&request, 2000)
23            != crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000)
24    {
25        return Err("knowledge project does not match bound request".into());
26    }
27    let policy = runtime::for_project(Path::new(root))?;
28    if policy
29        .as_ref()
30        .is_some_and(|p| !p.tool_allowed("ctx_knowledge"))
31    {
32        return Err("knowledge access withheld by current policy".into());
33    }
34    Ok(policy)
35}
36
37#[derive(Clone, Copy)]
38enum TypedSessionField {
39    Generic,
40    SourceSession,
41    FromSessions,
42}
43
44/// One policy snapshot used for a complete knowledge operation. Text is never
45/// rewritten in a persisted record: any blocked or changed string withholds the
46/// complete record so its version identity and lineage remain truthful.
47pub struct KnowledgeAdmission {
48    root: String,
49    gateway: StoreAdmission,
50    project: Option<Arc<runtime::ActivePolicy>>,
51    project_digest: Option<[u8; 32]>,
52    /// Both policies of this snapshot: the key of remembered verdicts.
53    rules: [u8; 32],
54}
55
56/// Remembered `exact_record` verdicts, keyed by the rules and the record's
57/// exact bytes. A record is admitted the same way under the same rules, so
58/// a store that grows without limit runs the detectors only on records that
59/// are new or changed since (owner decision 24.10.: no knowledge limits;
60/// every load used to admit every fact again). Changed rules give new keys.
61static VERDICTS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<[u8; 32], bool>>> =
62    std::sync::LazyLock::new(Default::default);
63/// Past this many verdicts the memory starts over (about 40 bytes each).
64const VERDICTS_MAX: usize = 4_000_000;
65
66impl KnowledgeAdmission {
67    pub fn capture(root: &str) -> Result<Self, String> {
68        let project = current(root)?;
69        let project_digest = project.as_deref().map(policy_digest).transpose()?;
70        let gateway = StoreAdmission::current();
71        let mut rules = blake3::Hasher::new();
72        rules.update(gateway.digest().as_bytes());
73        rules.update(&[u8::from(project_digest.is_some())]);
74        rules.update(project_digest.as_ref().map_or(&[][..], |d| &d[..]));
75        Ok(Self {
76            root: root.to_owned(),
77            gateway,
78            project,
79            project_digest,
80            rules: *rules.finalize().as_bytes(),
81        })
82    }
83
84    fn apply_project(&self, text: &str) -> Option<String> {
85        let Some(policy) = &self.project else {
86            return Some(text.to_owned());
87        };
88        if !policy.tool_allowed("ctx_knowledge") {
89            return None;
90        }
91        let outcome = crate::core::policy::content::evaluate_text(text, policy);
92        (!outcome.blocked).then_some(outcome.text)
93    }
94
95    /// Compose project and global rules over the original text in both orders.
96    /// If they disagree, fail closed instead of losing a rule's original signal.
97    pub(crate) fn admit_text(&self, original: &str) -> Option<String> {
98        let project_original = self.apply_project(original)?;
99        let gateway_original = self.gateway.admit_instance_text(original)?;
100        let gateway_then_project = self.apply_project(&gateway_original)?;
101        let project_then_gateway = self.gateway.admit_instance_text(&project_original)?;
102        (gateway_then_project == project_then_gateway).then_some(gateway_then_project)
103    }
104
105    fn exact_text(&self, original: &str) -> bool {
106        self.admit_text(original).as_deref() == Some(original)
107    }
108
109    fn exact_value(&self, value: &serde_json::Value) -> bool {
110        match value {
111            serde_json::Value::String(text) => self.exact_text(text),
112            serde_json::Value::Array(values) => values.iter().all(|value| self.exact_value(value)),
113            serde_json::Value::Object(values) => values
114                .iter()
115                .all(|(key, value)| self.exact_text(key) && self.exact_value(value)),
116            _ => true,
117        }
118    }
119
120    fn exact_session_identity(&self, text: &str) -> bool {
121        let Some(id) = crate::core::session::parse_engine_session_id(text) else {
122            return self.exact_text(text);
123        };
124        let project_allows = self.project.as_deref().is_none_or(|policy| {
125            crate::core::policy::content::protect_engine_identity_with_active(id, policy)
126                .is_ok_and(|admitted| admitted.as_ref() == text)
127        });
128        project_allows && self.gateway.admit_engine_session_id(id).as_deref() == Some(text)
129    }
130
131    fn exact_record_value(
132        &self,
133        value: &serde_json::Value,
134        typed_session_field: TypedSessionField,
135    ) -> bool {
136        let Some(fields) = value.as_object() else {
137            return self.exact_value(value);
138        };
139        fields.iter().all(|(key, value)| {
140            let admitted = match (typed_session_field, key.as_str()) {
141                (TypedSessionField::SourceSession, "source_session") => value
142                    .as_str()
143                    .is_some_and(|text| self.exact_session_identity(text)),
144                (TypedSessionField::FromSessions, "from_sessions") => {
145                    value.as_array().is_some_and(|items| {
146                        items.iter().all(|item| {
147                            item.as_str()
148                                .is_some_and(|text| self.exact_session_identity(text))
149                        })
150                    })
151                }
152                _ => self.exact_value(value),
153            };
154            self.exact_text(key) && admitted
155        })
156    }
157
158    fn exact_record<T: serde::Serialize>(
159        &self,
160        record: &T,
161        typed_session_field: TypedSessionField,
162    ) -> bool {
163        let Ok(bytes) = serde_json::to_vec(record) else {
164            return false;
165        };
166        let mut key = blake3::Hasher::new();
167        key.update(&self.rules);
168        key.update(&[typed_session_field as u8]);
169        key.update(&bytes);
170        let key = *key.finalize().as_bytes();
171        let remembered = VERDICTS
172            .lock()
173            .unwrap_or_else(std::sync::PoisonError::into_inner)
174            .get(&key)
175            .copied();
176        if let Some(verdict) = remembered {
177            return verdict;
178        }
179        let verdict = serde_json::from_slice::<serde_json::Value>(&bytes)
180            .is_ok_and(|value| self.exact_record_value(&value, typed_session_field));
181        let mut verdicts = VERDICTS
182            .lock()
183            .unwrap_or_else(std::sync::PoisonError::into_inner);
184        if verdicts.len() >= VERDICTS_MAX {
185            verdicts.clear();
186        }
187        verdicts.insert(key, verdict);
188        verdict
189    }
190
191    fn admits_archive_record_with_generic_fallback<T>(
192        &self,
193        value: &serde_json::Value,
194        typed_session_field: TypedSessionField,
195    ) -> bool
196    where
197        T: serde::de::DeserializeOwned + serde::Serialize,
198    {
199        let Ok(record) = serde_json::from_value::<T>(value.clone()) else {
200            return self.exact_value(value);
201        };
202        let Some(input_fields) = value.as_object() else {
203            return self.exact_value(value);
204        };
205        let Ok(known_record) = serde_json::to_value(record) else {
206            return false;
207        };
208        let Some(known_fields) = known_record.as_object() else {
209            return false;
210        };
211        if input_fields.len() != known_fields.len()
212            || input_fields
213                .keys()
214                .any(|key| !known_fields.contains_key(key))
215            || known_fields
216                .keys()
217                .any(|key| !input_fields.contains_key(key))
218        {
219            return self.exact_value(value);
220        }
221        self.exact_record_value(value, typed_session_field)
222    }
223
224    fn admits_complete_typed_archive_record<T>(
225        &self,
226        value: &serde_json::Value,
227        typed_session_field: TypedSessionField,
228    ) -> bool
229    where
230        T: serde::de::DeserializeOwned + serde::Serialize,
231    {
232        let Ok(record) = serde_json::from_value::<T>(value.clone()) else {
233            return false;
234        };
235        let Some(input_fields) = value.as_object() else {
236            return false;
237        };
238        let Ok(known_record) = serde_json::to_value(record) else {
239            return false;
240        };
241        let Some(known_fields) = known_record.as_object() else {
242            return false;
243        };
244        if input_fields.len() != known_fields.len()
245            || input_fields
246                .keys()
247                .any(|key| !known_fields.contains_key(key))
248            || known_fields
249                .keys()
250                .any(|key| !input_fields.contains_key(key))
251        {
252            return false;
253        }
254        self.exact_record_value(value, typed_session_field)
255    }
256
257    pub(crate) fn admits_archive_fact_value(&self, value: &serde_json::Value) -> bool {
258        self.admits_archive_record_with_generic_fallback::<KnowledgeFact>(
259            value,
260            TypedSessionField::SourceSession,
261        )
262    }
263
264    pub(crate) fn admits_archive_history_value(&self, value: &serde_json::Value) -> bool {
265        self.admits_archive_record_with_generic_fallback::<ConsolidatedInsight>(
266            value,
267            TypedSessionField::FromSessions,
268        )
269    }
270
271    pub(crate) fn admits_archive_pattern_value(&self, value: &serde_json::Value) -> bool {
272        self.admits_archive_record_with_generic_fallback::<ProjectPattern>(
273            value,
274            TypedSessionField::SourceSession,
275        )
276    }
277
278    pub(crate) fn admits_typed_archive_fact_value(&self, value: &serde_json::Value) -> bool {
279        self.admits_complete_typed_archive_record::<KnowledgeFact>(
280            value,
281            TypedSessionField::SourceSession,
282        )
283    }
284
285    pub(crate) fn admits_typed_archive_history_value(&self, value: &serde_json::Value) -> bool {
286        self.admits_complete_typed_archive_record::<ConsolidatedInsight>(
287            value,
288            TypedSessionField::FromSessions,
289        )
290    }
291
292    pub(crate) fn admits_typed_archive_pattern_value(&self, value: &serde_json::Value) -> bool {
293        self.admits_complete_typed_archive_record::<ProjectPattern>(
294            value,
295            TypedSessionField::SourceSession,
296        )
297    }
298
299    pub(crate) fn view(&self, knowledge: &ProjectKnowledge) -> Result<ProjectKnowledge, String> {
300        if !self.exact_text(&knowledge.project_root) || !self.exact_text(&knowledge.project_hash) {
301            return Err("knowledge storage identity is withheld by current policy".into());
302        }
303        let mut result = knowledge.clone();
304        result.facts.clear();
305        result.patterns.clear();
306        result.history.clear();
307        result.judged_pairs.clear();
308        for fact in &knowledge.facts {
309            if self.exact_record(fact, TypedSessionField::SourceSession) {
310                result.facts.push(fact.clone());
311            } else {
312                result.withheld.push(WithheldKnowledge::Fact(fact.clone()));
313            }
314        }
315        for pattern in &knowledge.patterns {
316            if self.exact_record(pattern, TypedSessionField::SourceSession) {
317                result.patterns.push(pattern.clone());
318            } else {
319                result
320                    .withheld
321                    .push(WithheldKnowledge::Pattern(pattern.clone()));
322            }
323        }
324        for history in &knowledge.history {
325            if self.exact_record(history, TypedSessionField::FromSessions) {
326                result.history.push(history.clone());
327            } else {
328                result
329                    .withheld
330                    .push(WithheldKnowledge::History(history.clone()));
331            }
332        }
333        for pair in &knowledge.judged_pairs {
334            if self.exact_record(pair, TypedSessionField::Generic) {
335                result.judged_pairs.push(pair.clone());
336            } else {
337                result
338                    .withheld
339                    .push(WithheldKnowledge::JudgedPair(pair.clone()));
340            }
341        }
342        result.rebuild_index();
343        Ok(result)
344    }
345
346    pub(crate) fn require_safe(&self, knowledge: &ProjectKnowledge) -> Result<(), String> {
347        if !self.exact_text(&knowledge.project_root)
348            || !self.exact_text(&knowledge.project_hash)
349            || !knowledge
350                .facts
351                .iter()
352                .all(|item| self.exact_record(item, TypedSessionField::SourceSession))
353            || !knowledge
354                .patterns
355                .iter()
356                .all(|item| self.exact_record(item, TypedSessionField::SourceSession))
357            || !knowledge
358                .history
359                .iter()
360                .all(|item| self.exact_record(item, TypedSessionField::FromSessions))
361            || !knowledge
362                .judged_pairs
363                .iter()
364                .all(|item| self.exact_record(item, TypedSessionField::Generic))
365            || !knowledge.withheld.iter().all(|item| match item {
366                WithheldKnowledge::Fact(item) => {
367                    self.exact_record(item, TypedSessionField::SourceSession)
368                }
369                WithheldKnowledge::Pattern(item) => {
370                    self.exact_record(item, TypedSessionField::SourceSession)
371                }
372                WithheldKnowledge::History(item) => {
373                    self.exact_record(item, TypedSessionField::FromSessions)
374                }
375                WithheldKnowledge::JudgedPair(item) => {
376                    self.exact_record(item, TypedSessionField::Generic)
377                }
378            })
379        {
380            return Err("knowledge requires an explicit policy-safe migration".into());
381        }
382        Ok(())
383    }
384
385    pub(crate) fn gateway_snapshot(&self) -> &StoreAdmission {
386        &self.gateway
387    }
388
389    pub fn admits_archive_value(&self, value: &serde_json::Value) -> bool {
390        self.exact_value(value)
391    }
392
393    pub fn authorize_gateway(&self) -> Result<(), String> {
394        if !self.gateway.is_current() || StoreAdmission::current().digest() != self.gateway.digest()
395        {
396            return Err("knowledge Gateway policy changed; retry".into());
397        }
398        let project = current(&self.root)?;
399        if project.as_deref().map(policy_digest).transpose()? != self.project_digest {
400            return Err("knowledge project policy changed; retry".into());
401        }
402        Ok(())
403    }
404
405    pub fn authorize_publication(
406        &self,
407        authority: &runtime::PublicationAuthority,
408    ) -> Result<(), String> {
409        self.authorize_gateway()?;
410        authority.verify()
411    }
412}
413
414fn policy_digest(policy: &runtime::ActivePolicy) -> Result<[u8; 32], String> {
415    let bytes = serde_json::to_vec(&policy.resolved).map_err(|_| "knowledge policy unavailable")?;
416    Ok(*blake3::hash(&bytes).as_bytes())
417}