1use 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
44pub struct KnowledgeAdmission {
48 root: String,
49 gateway: StoreAdmission,
50 project: Option<Arc<runtime::ActivePolicy>>,
51 project_digest: Option<[u8; 32]>,
52 rules: [u8; 32],
54}
55
56static VERDICTS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<[u8; 32], bool>>> =
62 std::sync::LazyLock::new(Default::default);
63const 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 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}