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::{OutputCompleteness, SourceAdmission};
29use crate::core::rooted_read::AdmittedRootedRead;
30
31/// Outcome of one rooted source acquisition.
32#[derive(Debug)]
33pub enum SourceRead {
34    /// The delivered text and the outcome of admitting the original.
35    Admitted(AdmittedRootedRead),
36    /// Refused by a path rule, the project policy or the gateway. The reason
37    /// is content-free.
38    Withheld(String),
39    /// Not a readable text source: missing, too large, binary, or the call's
40    /// read budget is used up. Nothing was inspected and nothing is delivered.
41    Unavailable(String),
42}
43
44impl SourceRead {
45    /// The admitted text, when the source was delivered.
46    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    /// The admitted text, when the ORIGINAL may enter a cache, an index or
54    /// any other derived store: delivered, completely decoded, not restricted.
55    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    /// The content-free reason of a refusal.
63    #[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
72/// Read `path` under `root` for `tool` through the common rooted admitted
73/// reader. `remaining` is the call's byte budget; it is charged on the
74/// original bytes, also for a source that is then withheld.
75///
76/// An absent root withholds: with no project root there is no authority to
77/// read under, and none is derived from the path (no ancestor, no directory
78/// of the file itself).
79pub fn read(path: &str, root: &str, tool: &str, remaining: &mut usize) -> SourceRead {
80    read_under(path, root, tool, remaining, None)
81}
82
83/// [`read`] under the gateway snapshot the CALLER captured
84/// (`context_admission::carrier::gateway_snapshot()`, or any
85/// `StoreAdmission`): the same rooted admitted read in every respect — path
86/// jail, no-follow open, project policy on the original, source publication —
87/// except that the gateway rules are those of `admission`, not the
88/// configuration of this moment. A caller that judged its payload under a
89/// snapshot has its sources judged under that same snapshot, so a
90/// configuration that changes and changes back in between cannot admit a
91/// source the snapshot refuses. The pack side is pinned by the caller's
92/// source view, as for every read.
93///
94/// NOTE the argument order (root first) and that there is no call budget:
95/// one source is bounded by the read cap (`limits::max_read_bytes`), as a
96/// fresh budget of [`read`] would bound it.
97pub 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
128/// Run `operation` as an agent acquisition for `root`.
129///
130/// The unrooted read path (`tools::ctx_read::handle` and friends) admits a
131/// source only inside a request scope; outside one it is a plain local read.
132/// A tool that delegates a delivery to that path binds the scope here, so the
133/// delivery is admitted whoever called the tool — MCP dispatch, the CLI or a
134/// test. A project the request already bound is kept; `root` is used only
135/// when none is bound (the rule the rooted reader applies).
136pub 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
146/// The project the running request is bound to; empty when there is none.
147/// For readers whose callers pass no root: the request's project is the only
148/// authority they may read under, and without one [`read`] withholds.
149pub 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
158/// Serve text that lean-ctx stored earlier without a source binding (a
159/// package description, a shared-context record, another project's fact) as
160/// a new delivery. This is the existing originless rule of
161/// `context_admission::stored::serve_stored`, not a second accessor: withheld
162/// while protection is active in any layer, re-admitted under today's content
163/// rules in Community developer mode. `Err` is the content-free reason.
164pub 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
169/// The content of one session-cache entry as the cache's current-rights
170/// accessor releases it NOW (`SessionCache::current_full_content`): the
171/// source still exists and is permitted under today's policy, an entry
172/// without a source follows the originless rule, and a copy whose original
173/// may not be kept is not served. `None`: no entry, or not released. Tools
174/// that derive from cached entries (checkpoints, dedup analysis) use this
175/// instead of the raw entry.
176pub 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
186/// One line that stands where the content of a withheld source would have
187/// been. Stable and content-free (#498): the label the tool already shows and
188/// the typed reason.
189pub fn withheld_line(label: &str, reason: &str) -> String {
190    format!("[withheld] {label}: {reason}")
191}
192
193/// The content-free reason of a read that has no project root to run under.
194pub const NO_PROJECT_ROOT: &str = "project root unavailable";
195
196/// The root a caller-named `path` is read under.
197///
198/// The first of `roots` (project root, then session roots) that contains it.
199/// Every other path — relative, or outside every named root — is read under
200/// the FIRST named root, so the rooted admitted reader and the path jail
201/// decide, and refuse, exactly as they do for `ctx_read`. A root is never
202/// derived from the path: neither a project detected above it nor its own
203/// directory is an authority the caller may choose. With no named root the
204/// result is empty and [`read`] withholds.
205pub 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/// The explicit root of a unit test's fixture: the directory itself, or the
221/// directory of the file. Test-only — production code always names the
222/// call's project root.
223#[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
238/// The project policy judges the ORIGINAL first and records its privacy-safe
239/// audit, exactly as the rooted reader does; a lossy decode under an active
240/// pack is refused there too.
241fn 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
252/// Admit text that did not come from a file at all: a fetched page, a
253/// transcript. Nothing is known about its origin beyond the destination the
254/// caller named, so it is admitted as a context source without a path. The
255/// result is the delivered text and whether the original may be kept; `Err`
256/// is the content-free reason.
257pub 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
262/// Admit the bytes of a file that is not a file of the project (a file of a
263/// remote repository clone). `origin_path` is the path it has at its origin:
264/// a secret-like name restricts it as it would a local file. The bytes are
265/// decoded like every other source; what the decoder could not represent
266/// makes the source incomplete, and the mode decides — delivered with the
267/// "not fully inspected" note in developer mode, refused in governed and
268/// sovereign mode and under a project pack. A binary body is not inspectable
269/// as text and is refused in every mode.
270pub 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        // A secret-like origin path restricts the content like a local file.
394        let admitted = admit_remote_file(b"PORT=8080\n".to_vec(), ".env", "ctx_git_read").unwrap();
395        assert!(!admitted.storable);
396
397        // A body that is not text is refused, never passed uninspected.
398        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    /// A root is never derived from the path. An absolute path outside every
456    /// named root is read under the project root — where the rooted reader
457    /// refuses it — and with no named root nothing is read at all.
458    #[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}