1use std::path::{Path, PathBuf};
27
28use crate::core::context_admission::{OutputCompleteness, SourceAdmission};
29use crate::core::rooted_read::AdmittedRootedRead;
30
31#[derive(Debug)]
33pub enum SourceRead {
34 Admitted(AdmittedRootedRead),
36 Withheld(String),
39 Unavailable(String),
42}
43
44impl SourceRead {
45 pub fn text(&self) -> Option<&str> {
47 match self {
48 Self::Admitted(read) => Some(&read.content),
49 Self::Withheld(_) | Self::Unavailable(_) => None,
50 }
51 }
52
53 pub fn storable(&self) -> Option<&str> {
56 match self {
57 Self::Admitted(read) if read.storable && read.complete => Some(&read.content),
58 Self::Admitted(_) | Self::Withheld(_) | Self::Unavailable(_) => None,
59 }
60 }
61
62 #[cfg(any(test, feature = "test-support"))]
64 pub fn withheld(&self) -> Option<&str> {
65 match self {
66 Self::Withheld(reason) => Some(reason),
67 Self::Admitted(_) | Self::Unavailable(_) => None,
68 }
69 }
70}
71
72pub fn read(path: &str, root: &str, tool: &str, remaining: &mut usize) -> SourceRead {
80 read_under(path, root, tool, remaining, None)
81}
82
83pub fn read_with_admission(
98 root: &str,
99 path: &str,
100 tool: &str,
101 admission: &crate::core::context_admission::stores::StoreAdmission,
102) -> SourceRead {
103 let mut remaining = crate::core::limits::max_read_bytes();
104 read_under(path, root, tool, &mut remaining, Some(admission))
105}
106
107fn read_under(
108 path: &str,
109 root: &str,
110 tool: &str,
111 remaining: &mut usize,
112 gateway: Option<&crate::core::context_admission::stores::StoreAdmission>,
113) -> SourceRead {
114 if root.trim().is_empty() {
115 return SourceRead::Unavailable(NO_PROJECT_ROOT.to_owned());
116 }
117 match crate::core::rooted_read::read_file_for_tool_rooted_admitted_with(
118 path, root, tool, remaining, gateway,
119 ) {
120 Ok(read) => SourceRead::Admitted(read),
121 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
122 SourceRead::Withheld(error.to_string())
123 }
124 Err(error) => SourceRead::Unavailable(error.to_string()),
125 }
126}
127
128pub fn as_agent_acquisition<T>(root: &str, operation: impl FnOnce() -> T) -> T {
137 let authority = crate::core::policy::runtime::REQUEST_PROJECT
138 .try_with(|slot| slot.borrow().clone())
139 .ok()
140 .flatten()
141 .unwrap_or_else(|| PathBuf::from(root));
142 crate::core::policy::runtime::REQUEST_PROJECT
143 .sync_scope(std::cell::RefCell::new(Some(authority)), operation)
144}
145
146pub fn request_root() -> String {
150 crate::core::policy::runtime::REQUEST_PROJECT
151 .try_with(|slot| slot.borrow().clone())
152 .ok()
153 .flatten()
154 .map(|project| project.to_string_lossy().into_owned())
155 .unwrap_or_default()
156}
157
158pub fn serve_originless(text: &str, subject: &str) -> Result<String, String> {
165 crate::core::context_admission::stored::serve_stored(text, None, None)
166 .map_err(|withheld| withheld.message(subject))
167}
168
169pub fn released_cache_content(
177 cache: &crate::core::cache::SessionCache,
178 path: &str,
179) -> Option<String> {
180 cache
181 .current_full_content(path)
182 .and_then(Result::ok)
183 .map(|(text, _)| text)
184}
185
186pub fn withheld_line(label: &str, reason: &str) -> String {
190 format!("[withheld] {label}: {reason}")
191}
192
193pub const NO_PROJECT_ROOT: &str = "project root unavailable";
195
196pub fn root_for(path: &str, roots: &[String]) -> String {
206 let mut named = roots.iter().filter(|root| !root.trim().is_empty());
207 let Some(first) = named.clone().next() else {
208 return String::new();
209 };
210 if Path::new(path).is_absolute() {
211 let target = canonical(Path::new(path));
212 if let Some(containing) = named.find(|root| target.starts_with(canonical(Path::new(root))))
213 {
214 return containing.clone();
215 }
216 }
217 first.clone()
218}
219
220#[cfg(any(test, feature = "test-support"))]
224pub fn fixture_root(path: &str) -> String {
225 let target = canonical(Path::new(path));
226 if target.is_dir() {
227 return target.to_string_lossy().into_owned();
228 }
229 target
230 .parent()
231 .map_or_else(String::new, |parent| parent.to_string_lossy().into_owned())
232}
233
234pub fn canonical(path: &Path) -> PathBuf {
235 crate::core::pathutil::safe_canonicalize_bounded(path, 2000)
236}
237
238fn project_policy_allows(text: &str, complete: bool, tool: &str) -> Result<(), String> {
242 if let Some(policy) = crate::core::policy::runtime::active() {
243 let outcome = crate::core::policy::content::evaluate_text(text, &policy);
244 crate::core::policy::audit_filter::audit_filter(tool, &outcome.audit, outcome.blocked);
245 if outcome.blocked || !complete {
246 return Err("source content withheld by policy".to_owned());
247 }
248 }
249 Ok(())
250}
251
252pub fn admit_remote_text(text: &str, tool: &str) -> Result<SourceAdmission, String> {
258 project_policy_allows(text, true, tool)?;
259 crate::core::context_admission::admit_context_source(text).map_err(|error| error.to_string())
260}
261
262pub fn admit_remote_file(
271 bytes: Vec<u8>,
272 origin_path: &str,
273 tool: &str,
274) -> Result<SourceAdmission, String> {
275 if crate::core::binary_detect::has_binary_extension(origin_path)
276 || crate::core::text_decode::looks_binary(&bytes[..bytes.len().min(8192)])
277 {
278 return Err("source format is not inspectable as text".to_owned());
279 }
280 let decoded = crate::core::text_decode::decode_checked(bytes);
281 let complete = decoded.complete();
282 project_policy_allows(&decoded.text, complete, tool)?;
283 crate::core::context_admission::admit_source_with_storage(
284 &decoded.text,
285 origin_path,
286 false,
287 OutputCompleteness {
288 decode_lossy: !complete,
289 ..Default::default()
290 },
291 )
292 .inspect(|_| crate::core::context_admission::capture::note_source_id(origin_path))
293 .map_err(|error| error.to_string())
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
301
302 fn policy(body: &str) -> crate::core::policy::ResolvedPolicy {
303 crate::core::policy::load(&format!(
304 "name = \"admitted-sources-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n{body}"
305 ))
306 .unwrap()
307 }
308
309 #[test]
310 fn a_rooted_read_is_sorted_into_admitted_withheld_and_unavailable() {
311 let _data = crate::core::data_dir::isolated_data_dir();
312 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy(
313 "[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
314 )));
315 let project = tempfile::tempdir().unwrap();
316 let root = project.path().to_string_lossy().into_owned();
317 let clean = project.path().join("clean.rs");
318 let blocked = project.path().join("blocked.rs");
319 std::fs::write(&clean, "pub fn clean() {}\n").unwrap();
320 std::fs::write(&blocked, "CONFIDENTIAL\npub fn hidden_marker() {}\n").unwrap();
321
322 let mut remaining = crate::core::limits::max_read_bytes();
323 let read = read(
324 &clean.to_string_lossy(),
325 &root,
326 "ctx_outline",
327 &mut remaining,
328 );
329 assert_eq!(read.text(), Some("pub fn clean() {}\n"));
330 assert_eq!(read.storable(), Some("pub fn clean() {}\n"));
331
332 let read = super::read(
333 &blocked.to_string_lossy(),
334 &root,
335 "ctx_outline",
336 &mut remaining,
337 );
338 let reason = read.withheld().expect("a blocked source is withheld");
339 assert!(!reason.contains("hidden_marker"), "{reason}");
340 assert!(read.text().is_none() && read.storable().is_none());
341
342 let missing = project.path().join("missing.rs");
343 let read = super::read(
344 &missing.to_string_lossy(),
345 &root,
346 "ctx_outline",
347 &mut remaining,
348 );
349 assert!(read.withheld().is_none() && read.text().is_none());
350 }
351
352 #[test]
353 fn a_restricted_original_is_delivered_but_never_storable() {
354 let _data = crate::core::data_dir::isolated_data_dir();
355 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
356 let project = tempfile::tempdir().unwrap();
357 let root = project.path().to_string_lossy().into_owned();
358 let marked = project.path().join("marked.rs");
359 std::fs::write(&marked, "// TOP SECRET\npub fn plan() {}\n").unwrap();
360 let mut remaining = crate::core::limits::max_read_bytes();
361 let read = read(
362 &marked.to_string_lossy(),
363 &root,
364 "ctx_preload",
365 &mut remaining,
366 );
367 assert!(read.text().is_some_and(|text| text.contains("pub fn plan")));
368 assert!(
369 read.storable().is_none(),
370 "a restricted original must not enter a derived store"
371 );
372 }
373
374 #[test]
375 fn remote_text_is_admitted_with_its_origin_and_clean_text_keeps_its_bytes() {
376 let _data = crate::core::data_dir::isolated_data_dir();
377 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
378
379 let clean = "# Title\n\nplain remote text\n";
380 let admitted =
381 admit_remote_file(clean.as_bytes().to_vec(), "README.md", "ctx_git_read").unwrap();
382 assert_eq!(admitted.text, clean);
383 assert!(admitted.storable);
384 assert_eq!(
385 admit_remote_text(clean, "ctx_url_read").unwrap().text,
386 clean
387 );
388
389 let keyed = format!("aws_access_key_id = {AWS_KEY}\n");
390 let admitted = admit_remote_text(&keyed, "ctx_url_read").unwrap();
391 assert!(!admitted.text.contains(AWS_KEY), "{}", admitted.text);
392
393 let admitted = admit_remote_file(b"PORT=8080\n".to_vec(), ".env", "ctx_git_read").unwrap();
395 assert!(!admitted.storable);
396
397 let binary = admit_remote_file(vec![0x7f, b'E', b'L', b'F', 0, 0, 0, 0], "tool", "x");
399 assert!(binary.is_err());
400 }
401
402 #[test]
403 fn remote_text_blocked_by_the_project_policy_is_refused_without_content() {
404 let _data = crate::core::data_dir::isolated_data_dir();
405 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy(
406 "[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
407 )));
408 let reason = admit_remote_text("CONFIDENTIAL\nroadmap-marker\n", "ctx_url_read")
409 .expect_err("blocked content is refused");
410 assert!(!reason.contains("roadmap-marker"), "{reason}");
411 let reason = admit_remote_file(
412 b"CONFIDENTIAL\nroadmap-marker\n".to_vec(),
413 "docs/plan.md",
414 "ctx_git_read",
415 )
416 .expect_err("blocked content is refused");
417 assert!(!reason.contains("roadmap-marker"), "{reason}");
418 }
419
420 #[test]
421 fn an_agent_acquisition_binds_the_project_and_keeps_one_already_bound() {
422 let bound = || {
423 crate::core::policy::runtime::REQUEST_PROJECT
424 .try_with(|slot| slot.borrow().clone())
425 .ok()
426 .flatten()
427 };
428 assert_eq!(bound(), None, "no request scope outside an acquisition");
429 assert_eq!(
430 as_agent_acquisition("/project/a", bound),
431 Some(PathBuf::from("/project/a"))
432 );
433 let nested =
434 as_agent_acquisition("/project/a", || as_agent_acquisition("/project/b", bound));
435 assert_eq!(nested, Some(PathBuf::from("/project/a")));
436 }
437
438 #[test]
439 fn a_path_is_read_under_the_root_that_contains_it() {
440 let project = tempfile::tempdir().unwrap();
441 let other = tempfile::tempdir().unwrap();
442 let root = project.path().to_string_lossy().into_owned();
443 let extra = other.path().to_string_lossy().into_owned();
444 let roots = vec![root.clone(), extra.clone()];
445 let inside = project.path().join("a.rs");
446 let in_extra = other.path().join("b.rs");
447 std::fs::write(&inside, "fn a() {}\n").unwrap();
448 std::fs::write(&in_extra, "fn b() {}\n").unwrap();
449
450 assert_eq!(root_for(&inside.to_string_lossy(), &roots), root);
451 assert_eq!(root_for(&in_extra.to_string_lossy(), &roots), extra);
452 assert_eq!(root_for("src/lib.rs", &roots), root);
453 }
454
455 #[test]
459 fn a_path_outside_every_root_is_not_read() {
460 let _data = crate::core::data_dir::isolated_data_dir();
461 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
462 let project = tempfile::tempdir().unwrap();
463 let elsewhere = tempfile::tempdir().unwrap();
464 let root = project.path().to_string_lossy().into_owned();
465 let outside = elsewhere.path().join("notes.txt");
466 std::fs::write(&outside, "outside-marker\n").unwrap();
467 let outside = outside.to_string_lossy().into_owned();
468
469 assert_eq!(root_for(&outside, std::slice::from_ref(&root)), root);
470 let mut remaining = crate::core::limits::max_read_bytes();
471 let refused = read(&outside, &root, "ctx_analyze", &mut remaining);
472 assert!(refused.text().is_none(), "a path outside the root is read");
473 let (SourceRead::Withheld(reason) | SourceRead::Unavailable(reason)) = &refused else {
474 panic!("a path outside the root is read");
475 };
476 assert!(!reason.contains("outside-marker"), "{reason}");
477
478 assert_eq!(root_for(&outside, &[]), "");
479 assert_eq!(root_for(&outside, &[String::new()]), "");
480 let SourceRead::Unavailable(reason) = read(&outside, "", "ctx_analyze", &mut remaining)
481 else {
482 panic!("an absent project root must withhold");
483 };
484 assert_eq!(reason, NO_PROJECT_ROOT);
485 }
486}