Skip to main content

lean_ctx/core/
admitted_sources.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Admitted acquisition for tools that rank, summarize, cache or index what
3//! they read (G01).
4//!
5//! A tool that derives something from a source — a score, an outline, a
6//! snippet, a cache entry — must derive it from the ADMITTED text, and must
7//! know what the admission of the ORIGINAL decided: whether the source may be
8//! kept in a derived store and whether it was decoded completely. The release
9//! point (`server::policy_guard::release`) is the last line for a finished
10//! result; it is not this admission.
11//!
12//! Two shapes:
13//!
14//! - **project files** go through the common rooted reader
15//!   (`core::rooted_read::read_file_for_tool_rooted_admitted`): path jail,
16//!   budget charged on the original bytes, project policy and gateway decided
17//!   on the original. `read` only sorts its outcome into the three cases a
18//!   tool has to render differently;
19//! - **content that is not a project file** (a file of a remote repository
20//!   clone, a fetched URL body) is admitted as a context source with what is
21//!   known about its origin (`admit_remote_file`, `admit_remote_text`).
22//!
23//! Every refusal is a content-free sentence. A tool shows it instead of the
24//! source; it never treats a withheld source as an empty or absent one.
25
26use std::path::{Path, PathBuf};
27
28use crate::core::context_admission::origin::{InputOrigin, SourceClass};
29use crate::core::context_admission::{OutputCompleteness, SourceAdmission};
30use crate::core::rooted_read::AdmittedRootedRead;
31
32/// Outcome of one rooted source acquisition.
33#[derive(Debug)]
34pub enum SourceRead {
35    /// The delivered text and the outcome of admitting the original.
36    Admitted(AdmittedRootedRead),
37    /// Refused by a path rule, the project policy or the gateway. The reason
38    /// is content-free.
39    Withheld(String),
40    /// Not a readable text source: missing, too large, binary, or the call's
41    /// read budget is used up. Nothing was inspected and nothing is delivered.
42    Unavailable(String),
43}
44
45impl SourceRead {
46    /// The admitted text, when the source was delivered.
47    pub fn text(&self) -> Option<&str> {
48        match self {
49            Self::Admitted(read) => Some(&read.content),
50            Self::Withheld(_) | Self::Unavailable(_) => None,
51        }
52    }
53
54    /// The admitted text, when the ORIGINAL may enter a cache, an index or
55    /// any other derived store: delivered, completely decoded, not restricted.
56    pub fn storable(&self) -> Option<&str> {
57        match self {
58            Self::Admitted(read) if read.storable && read.complete => Some(&read.content),
59            Self::Admitted(_) | Self::Withheld(_) | Self::Unavailable(_) => None,
60        }
61    }
62
63    /// The content-free reason of a refusal.
64    #[cfg(any(test, feature = "test-support"))]
65    pub fn withheld(&self) -> Option<&str> {
66        match self {
67            Self::Withheld(reason) => Some(reason),
68            Self::Admitted(_) | Self::Unavailable(_) => None,
69        }
70    }
71}
72
73/// Read `path` under `root` for `tool` through the common rooted admitted
74/// reader. `remaining` is the call's byte budget; it is charged on the
75/// original bytes, also for a source that is then withheld.
76///
77/// An absent root withholds: with no project root there is no authority to
78/// read under, and none is derived from the path (no ancestor, no directory
79/// of the file itself).
80pub fn read(path: &str, root: &str, tool: &str, remaining: &mut usize) -> SourceRead {
81    read_under(path, root, tool, remaining, None)
82}
83
84/// [`read`] under the gateway snapshot the CALLER captured
85/// (`context_admission::carrier::gateway_snapshot()`, or any
86/// `StoreAdmission`): the same rooted admitted read in every respect — path
87/// jail, no-follow open, project policy on the original, source publication —
88/// except that the gateway rules are those of `admission`, not the
89/// configuration of this moment. A caller that judged its payload under a
90/// snapshot has its sources judged under that same snapshot, so a
91/// configuration that changes and changes back in between cannot admit a
92/// source the snapshot refuses. The pack side is pinned by the caller's
93/// source view, as for every read.
94///
95/// NOTE the argument order (root first) and that there is no call budget:
96/// one source is bounded by the read cap (`limits::max_read_bytes`), as a
97/// fresh budget of [`read`] would bound it.
98pub fn read_with_admission(
99    root: &str,
100    path: &str,
101    tool: &str,
102    admission: &crate::core::context_admission::stores::StoreAdmission,
103) -> SourceRead {
104    let mut remaining = crate::core::limits::max_read_bytes();
105    read_under(path, root, tool, &mut remaining, Some(admission))
106}
107
108fn read_under(
109    path: &str,
110    root: &str,
111    tool: &str,
112    remaining: &mut usize,
113    gateway: Option<&crate::core::context_admission::stores::StoreAdmission>,
114) -> SourceRead {
115    if root.trim().is_empty() {
116        return SourceRead::Unavailable(NO_PROJECT_ROOT.to_owned());
117    }
118    match crate::core::rooted_read::read_file_for_tool_rooted_admitted_with(
119        path, root, tool, remaining, gateway,
120    ) {
121        Ok(read) => SourceRead::Admitted(read),
122        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
123            SourceRead::Withheld(error.to_string())
124        }
125        Err(error) => SourceRead::Unavailable(error.to_string()),
126    }
127}
128
129/// Run `operation` as an agent acquisition for `root`.
130///
131/// The unrooted read path (`tools::ctx_read::handle` and friends) admits a
132/// source only inside a request scope; outside one it is a plain local read.
133/// A tool that delegates a delivery to that path binds the scope here, so the
134/// delivery is admitted whoever called the tool — MCP dispatch, the CLI or a
135/// test. A project the request already bound is kept; `root` is used only
136/// when none is bound (the rule the rooted reader applies).
137pub fn as_agent_acquisition<T>(root: &str, operation: impl FnOnce() -> T) -> T {
138    let authority = crate::core::policy::runtime::REQUEST_PROJECT
139        .try_with(|slot| slot.borrow().clone())
140        .ok()
141        .flatten()
142        .unwrap_or_else(|| PathBuf::from(root));
143    crate::core::policy::runtime::REQUEST_PROJECT
144        .sync_scope(std::cell::RefCell::new(Some(authority)), operation)
145}
146
147/// The project the running request is bound to; empty when there is none.
148/// For readers whose callers pass no root: the request's project is the only
149/// authority they may read under, and without one [`read`] withholds.
150pub fn request_root() -> String {
151    crate::core::policy::runtime::REQUEST_PROJECT
152        .try_with(|slot| slot.borrow().clone())
153        .ok()
154        .flatten()
155        .map(|project| project.to_string_lossy().into_owned())
156        .unwrap_or_default()
157}
158
159/// Serve text that lean-ctx stored earlier without a source binding (a
160/// package description, a shared-context record, another project's fact) as
161/// a new delivery. This is the existing originless rule of
162/// `context_admission::stored::serve_stored`, not a second accessor: withheld
163/// while protection is active in any layer, re-admitted under today's content
164/// rules in Community developer mode. `Err` is the content-free reason.
165pub fn serve_originless(text: &str, subject: &str) -> Result<String, String> {
166    crate::core::context_admission::stored::serve_stored(text, None, None)
167        .map_err(|withheld| withheld.message(subject))
168}
169
170/// The content of one session-cache entry as the cache's current-rights
171/// accessor releases it NOW (`SessionCache::current_full_content`): the
172/// source still exists and is permitted under today's policy, an entry
173/// without a source follows the originless rule, and a copy whose original
174/// may not be kept is not served. `None`: no entry, or not released. Tools
175/// that derive from cached entries (checkpoints, dedup analysis) use this
176/// instead of the raw entry.
177pub fn released_cache_content(
178    cache: &crate::core::cache::SessionCache,
179    path: &str,
180) -> Option<String> {
181    cache
182        .current_full_content(path)
183        .and_then(Result::ok)
184        .map(|(text, _)| text)
185}
186
187/// One line that stands where the content of a withheld source would have
188/// been. Stable and content-free (#498): the label the tool already shows and
189/// the typed reason.
190pub fn withheld_line(label: &str, reason: &str) -> String {
191    format!("[withheld] {label}: {reason}")
192}
193
194/// The content-free reason of a read that has no project root to run under.
195pub const NO_PROJECT_ROOT: &str = "project root unavailable";
196
197/// The root a caller-named `path` is read under.
198///
199/// The first of `roots` (project root, then session roots) that contains it.
200/// Every other path — relative, or outside every named root — is read under
201/// the FIRST named root, so the rooted admitted reader and the path jail
202/// decide, and refuse, exactly as they do for `ctx_read`. A root is never
203/// derived from the path: neither a project detected above it nor its own
204/// directory is an authority the caller may choose. With no named root the
205/// result is empty and [`read`] withholds.
206pub fn root_for(path: &str, roots: &[String]) -> String {
207    let mut named = roots.iter().filter(|root| !root.trim().is_empty());
208    let Some(first) = named.clone().next() else {
209        return String::new();
210    };
211    if Path::new(path).is_absolute() {
212        let target = canonical(Path::new(path));
213        if let Some(containing) = named.find(|root| target.starts_with(canonical(Path::new(root))))
214        {
215            return containing.clone();
216        }
217    }
218    first.clone()
219}
220
221/// The explicit root of a unit test's fixture: the directory itself, or the
222/// directory of the file. Test-only — production code always names the
223/// call's project root.
224#[cfg(any(test, feature = "test-support"))]
225pub fn fixture_root(path: &str) -> String {
226    let target = canonical(Path::new(path));
227    if target.is_dir() {
228        return target.to_string_lossy().into_owned();
229    }
230    target
231        .parent()
232        .map_or_else(String::new, |parent| parent.to_string_lossy().into_owned())
233}
234
235pub fn canonical(path: &Path) -> PathBuf {
236    crate::core::pathutil::safe_canonicalize_bounded(path, 2000)
237}
238
239/// The project policy judges the ORIGINAL first and records its privacy-safe
240/// audit, exactly as the rooted reader does; a lossy decode under an active
241/// pack is refused there too.
242fn project_policy_allows(text: &str, complete: bool, tool: &str) -> Result<(), String> {
243    if let Some(policy) = crate::core::policy::runtime::active() {
244        let outcome = crate::core::policy::content::evaluate_text(text, &policy);
245        crate::core::policy::audit_filter::audit_filter(tool, &outcome.audit, outcome.blocked);
246        if outcome.blocked || !complete {
247            return Err("source content withheld by policy".to_owned());
248        }
249    }
250    Ok(())
251}
252
253/// Admit text that did not come from a file at all: a fetched page, a
254/// transcript. Nothing is known about its origin beyond the destination the
255/// caller named, so it is admitted as a context source without a path. The
256/// result is the delivered text and whether the original may be kept; `Err`
257/// is the content-free reason.
258pub fn admit_remote_text(text: &str, tool: &str) -> Result<SourceAdmission, String> {
259    admit_remote_class(text, tool, &REMOTE_UNKNOWN)
260}
261
262/// [`admit_remote_text`] with the origin the caller knows (the fetched URL,
263/// the foreign ref): untrusted by origin, whatever its content (pz21 §1.1).
264pub fn admit_remote_text_as(
265    text: &str,
266    tool: &str,
267    origin: &InputOrigin,
268) -> Result<SourceAdmission, String> {
269    admit_remote_class(text, tool, &SourceClass::External(origin.clone()))
270}
271
272const REMOTE_UNKNOWN: SourceClass = SourceClass::Unclassified { tool: "remote" };
273
274fn admit_remote_class(
275    text: &str,
276    tool: &str,
277    class: &SourceClass,
278) -> Result<SourceAdmission, String> {
279    // First, before the project policy can refuse (pz21 §1.5 C1).
280    crate::core::context_admission::capture::note_origin(class);
281    project_policy_allows(text, true, tool)?;
282    crate::core::context_admission::admit_context_source(text, class)
283        .map_err(|error| error.to_string())
284}
285
286/// Admit the bytes of a file that is not a file of the project (a file of a
287/// remote repository clone). `origin_path` is the path it has at its origin:
288/// a secret-like name restricts it as it would a local file. The bytes are
289/// decoded like every other source; what the decoder could not represent
290/// makes the source incomplete, and the mode decides — delivered with the
291/// "not fully inspected" note in developer mode, refused in governed and
292/// sovereign mode and under a project pack. A binary body is not inspectable
293/// as text and is refused in every mode.
294pub fn admit_remote_file(
295    bytes: Vec<u8>,
296    origin_path: &str,
297    tool: &str,
298) -> Result<SourceAdmission, String> {
299    admit_remote_file_class(bytes, origin_path, tool, &REMOTE_UNKNOWN)
300}
301
302/// [`admit_remote_file`] with the origin the caller knows (`remote_file`).
303pub fn admit_remote_file_as(
304    bytes: Vec<u8>,
305    origin_path: &str,
306    tool: &str,
307    origin: &InputOrigin,
308) -> Result<SourceAdmission, String> {
309    admit_remote_file_class(
310        bytes,
311        origin_path,
312        tool,
313        &SourceClass::External(origin.clone()),
314    )
315}
316
317fn admit_remote_file_class(
318    bytes: Vec<u8>,
319    origin_path: &str,
320    tool: &str,
321    class: &SourceClass,
322) -> Result<SourceAdmission, String> {
323    crate::core::context_admission::capture::note_origin(class);
324    if crate::core::binary_detect::has_binary_extension(origin_path)
325        || crate::core::text_decode::looks_binary(&bytes[..bytes.len().min(8192)])
326    {
327        return Err("source format is not inspectable as text".to_owned());
328    }
329    let decoded = crate::core::text_decode::decode_checked(bytes);
330    let complete = decoded.complete();
331    project_policy_allows(&decoded.text, complete, tool)?;
332    crate::core::context_admission::admit_source_with_storage(
333        &decoded.text,
334        origin_path,
335        false,
336        OutputCompleteness {
337            decode_lossy: !complete,
338            ..Default::default()
339        },
340        class,
341    )
342    .inspect(|_| crate::core::context_admission::capture::note_source_id(origin_path))
343    .map_err(|error| error.to_string())
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
351
352    fn policy(body: &str) -> crate::core::policy::ResolvedPolicy {
353        crate::core::policy::load(&format!(
354            "name = \"admitted-sources-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n{body}"
355        ))
356        .unwrap()
357    }
358
359    #[test]
360    fn a_rooted_read_is_sorted_into_admitted_withheld_and_unavailable() {
361        let _data = crate::core::data_dir::isolated_data_dir();
362        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy(
363            "[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
364        )));
365        let project = tempfile::tempdir().unwrap();
366        let root = project.path().to_string_lossy().into_owned();
367        let clean = project.path().join("clean.rs");
368        let blocked = project.path().join("blocked.rs");
369        std::fs::write(&clean, "pub fn clean() {}\n").unwrap();
370        std::fs::write(&blocked, "CONFIDENTIAL\npub fn hidden_marker() {}\n").unwrap();
371
372        let mut remaining = crate::core::limits::max_read_bytes();
373        let read = read(
374            &clean.to_string_lossy(),
375            &root,
376            "ctx_outline",
377            &mut remaining,
378        );
379        assert_eq!(read.text(), Some("pub fn clean() {}\n"));
380        assert_eq!(read.storable(), Some("pub fn clean() {}\n"));
381
382        let read = super::read(
383            &blocked.to_string_lossy(),
384            &root,
385            "ctx_outline",
386            &mut remaining,
387        );
388        let reason = read.withheld().expect("a blocked source is withheld");
389        assert!(!reason.contains("hidden_marker"), "{reason}");
390        assert!(read.text().is_none() && read.storable().is_none());
391
392        let missing = project.path().join("missing.rs");
393        let read = super::read(
394            &missing.to_string_lossy(),
395            &root,
396            "ctx_outline",
397            &mut remaining,
398        );
399        assert!(read.withheld().is_none() && read.text().is_none());
400    }
401
402    #[test]
403    fn a_restricted_original_is_delivered_but_never_storable() {
404        let _data = crate::core::data_dir::isolated_data_dir();
405        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
406        let project = tempfile::tempdir().unwrap();
407        let root = project.path().to_string_lossy().into_owned();
408        let marked = project.path().join("marked.rs");
409        std::fs::write(&marked, "// TOP SECRET\npub fn plan() {}\n").unwrap();
410        let mut remaining = crate::core::limits::max_read_bytes();
411        let read = read(
412            &marked.to_string_lossy(),
413            &root,
414            "ctx_preload",
415            &mut remaining,
416        );
417        assert!(read.text().is_some_and(|text| text.contains("pub fn plan")));
418        assert!(
419            read.storable().is_none(),
420            "a restricted original must not enter a derived store"
421        );
422    }
423
424    #[test]
425    fn remote_text_is_admitted_with_its_origin_and_clean_text_keeps_its_bytes() {
426        let _data = crate::core::data_dir::isolated_data_dir();
427        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
428
429        let clean = "# Title\n\nplain remote text\n";
430        let admitted =
431            admit_remote_file(clean.as_bytes().to_vec(), "README.md", "ctx_git_read").unwrap();
432        assert_eq!(admitted.text, clean);
433        assert!(admitted.storable);
434        assert_eq!(
435            admit_remote_text(clean, "ctx_url_read").unwrap().text,
436            clean
437        );
438
439        let keyed = format!("aws_access_key_id = {AWS_KEY}\n");
440        let admitted = admit_remote_text(&keyed, "ctx_url_read").unwrap();
441        assert!(!admitted.text.contains(AWS_KEY), "{}", admitted.text);
442
443        // A secret-like origin path restricts the content like a local file.
444        let admitted = admit_remote_file(b"PORT=8080\n".to_vec(), ".env", "ctx_git_read").unwrap();
445        assert!(!admitted.storable);
446
447        // A body that is not text is refused, never passed uninspected.
448        let binary = admit_remote_file(vec![0x7f, b'E', b'L', b'F', 0, 0, 0, 0], "tool", "x");
449        assert!(binary.is_err());
450    }
451
452    #[test]
453    fn remote_text_blocked_by_the_project_policy_is_refused_without_content() {
454        let _data = crate::core::data_dir::isolated_data_dir();
455        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy(
456            "[filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
457        )));
458        let reason = admit_remote_text("CONFIDENTIAL\nroadmap-marker\n", "ctx_url_read")
459            .expect_err("blocked content is refused");
460        assert!(!reason.contains("roadmap-marker"), "{reason}");
461        let reason = admit_remote_file(
462            b"CONFIDENTIAL\nroadmap-marker\n".to_vec(),
463            "docs/plan.md",
464            "ctx_git_read",
465        )
466        .expect_err("blocked content is refused");
467        assert!(!reason.contains("roadmap-marker"), "{reason}");
468    }
469
470    #[test]
471    fn an_agent_acquisition_binds_the_project_and_keeps_one_already_bound() {
472        let bound = || {
473            crate::core::policy::runtime::REQUEST_PROJECT
474                .try_with(|slot| slot.borrow().clone())
475                .ok()
476                .flatten()
477        };
478        assert_eq!(bound(), None, "no request scope outside an acquisition");
479        assert_eq!(
480            as_agent_acquisition("/project/a", bound),
481            Some(PathBuf::from("/project/a"))
482        );
483        let nested =
484            as_agent_acquisition("/project/a", || as_agent_acquisition("/project/b", bound));
485        assert_eq!(nested, Some(PathBuf::from("/project/a")));
486    }
487
488    #[test]
489    fn a_path_is_read_under_the_root_that_contains_it() {
490        let project = tempfile::tempdir().unwrap();
491        let other = tempfile::tempdir().unwrap();
492        let root = project.path().to_string_lossy().into_owned();
493        let extra = other.path().to_string_lossy().into_owned();
494        let roots = vec![root.clone(), extra.clone()];
495        let inside = project.path().join("a.rs");
496        let in_extra = other.path().join("b.rs");
497        std::fs::write(&inside, "fn a() {}\n").unwrap();
498        std::fs::write(&in_extra, "fn b() {}\n").unwrap();
499
500        assert_eq!(root_for(&inside.to_string_lossy(), &roots), root);
501        assert_eq!(root_for(&in_extra.to_string_lossy(), &roots), extra);
502        assert_eq!(root_for("src/lib.rs", &roots), root);
503    }
504
505    /// A root is never derived from the path. An absolute path outside every
506    /// named root is read under the project root — where the rooted reader
507    /// refuses it — and with no named root nothing is read at all.
508    #[test]
509    fn a_path_outside_every_root_is_not_read() {
510        let _data = crate::core::data_dir::isolated_data_dir();
511        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
512        let project = tempfile::tempdir().unwrap();
513        let elsewhere = tempfile::tempdir().unwrap();
514        let root = project.path().to_string_lossy().into_owned();
515        let outside = elsewhere.path().join("notes.txt");
516        std::fs::write(&outside, "outside-marker\n").unwrap();
517        let outside = outside.to_string_lossy().into_owned();
518
519        assert_eq!(root_for(&outside, std::slice::from_ref(&root)), root);
520        let mut remaining = crate::core::limits::max_read_bytes();
521        let refused = read(&outside, &root, "ctx_analyze", &mut remaining);
522        assert!(refused.text().is_none(), "a path outside the root is read");
523        let (SourceRead::Withheld(reason) | SourceRead::Unavailable(reason)) = &refused else {
524            panic!("a path outside the root is read");
525        };
526        assert!(!reason.contains("outside-marker"), "{reason}");
527
528        assert_eq!(root_for(&outside, &[]), "");
529        assert_eq!(root_for(&outside, &[String::new()]), "");
530        let SourceRead::Unavailable(reason) = read(&outside, "", "ctx_analyze", &mut remaining)
531        else {
532            panic!("an absent project root must withhold");
533        };
534        assert_eq!(reason, NO_PROJECT_ROOT);
535    }
536}