Skip to main content

ai_session_search/
search_scope.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Component, Path, PathBuf};
7
8use anyhow::{anyhow, bail, Context, Result};
9use serde::Serialize;
10
11use crate::config::{SearchScopeConfig, SearchScopeMode};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum AccessRootOrigin {
16    HarnessRoots,
17    ExplicitConfig,
18    InvocationDirectory,
19}
20
21impl AccessRootOrigin {
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::HarnessRoots => "harness-roots",
25            Self::ExplicitConfig => "explicit-config",
26            Self::InvocationDirectory => "invocation-directory",
27        }
28    }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
32pub struct AccessRootSource {
33    configured_path: PathBuf,
34    canonicalized_at_startup: bool,
35    origin: AccessRootOrigin,
36}
37
38impl AccessRootSource {
39    pub fn configured_path(&self) -> &Path {
40        &self.configured_path
41    }
42
43    pub const fn origin(&self) -> AccessRootOrigin {
44        self.origin
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
49pub struct AccessRoot {
50    canonical_path: PathBuf,
51    match_paths: Vec<PathBuf>,
52    sources: Vec<AccessRootSource>,
53}
54
55impl AccessRoot {
56    pub fn path(&self) -> &Path {
57        &self.canonical_path
58    }
59
60    pub fn configured_path(&self) -> &Path {
61        self.sources[0].configured_path()
62    }
63
64    pub fn origin(&self) -> AccessRootOrigin {
65        self.sources[0].origin()
66    }
67
68    pub fn sources(&self) -> &[AccessRootSource] {
69        &self.sources
70    }
71
72    fn database_prefixes(&self) -> impl Iterator<Item = &str> {
73        self.match_paths
74            .iter()
75            .map(|path| path.to_str().expect("validated access roots are UTF-8"))
76    }
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct TrustedAccessInputs {
81    pub harness_roots: Vec<PathBuf>,
82    pub invocation_directory: Option<PathBuf>,
83}
84
85impl TrustedAccessInputs {
86    pub(crate) fn capture(config: &SearchScopeConfig, harness_roots: Vec<PathBuf>) -> Result<Self> {
87        let invocation_directory = if config.include_invocation_directory {
88            Some(std::env::current_dir().map_err(|error| {
89                anyhow!("cannot resolve invocation directory for allowed-roots scope: {error}")
90            })?)
91        } else {
92            None
93        };
94        Ok(Self {
95            harness_roots,
96            invocation_directory,
97        })
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102#[serde(tag = "mode", rename_all = "kebab-case")]
103pub enum EffectiveAccessScope {
104    All,
105    AllowedRoots { roots: Vec<AccessRoot> },
106}
107
108impl EffectiveAccessScope {
109    pub fn resolve(config: &SearchScopeConfig, inputs: TrustedAccessInputs) -> Result<Self> {
110        if config.mode == SearchScopeMode::All {
111            return Ok(Self::All);
112        }
113
114        let mut candidates = Vec::new();
115        candidates.extend(
116            inputs
117                .harness_roots
118                .into_iter()
119                .map(|path| (path, AccessRootOrigin::HarnessRoots)),
120        );
121        candidates.extend(
122            config
123                .roots
124                .iter()
125                .map(|path| (PathBuf::from(path), AccessRootOrigin::ExplicitConfig)),
126        );
127        if config.include_invocation_directory {
128            if let Some(path) = inputs.invocation_directory {
129                candidates.push((path, AccessRootOrigin::InvocationDirectory));
130            }
131        }
132
133        let mut roots = Vec::new();
134        for (path, origin) in candidates {
135            let candidate = normalize_authority_root(&path, origin).with_context(|| {
136                format!("invalid {} access root {:?}", origin_name(origin), path)
137            })?;
138            if let Some(existing) = roots
139                .iter_mut()
140                .find(|root: &&mut AccessRoot| root.canonical_path == candidate.canonical_path)
141            {
142                for path in candidate.match_paths {
143                    if !existing.match_paths.contains(&path) {
144                        existing.match_paths.push(path);
145                    }
146                }
147                existing.sources.extend(candidate.sources);
148            } else {
149                roots.push(candidate);
150            }
151        }
152
153        if roots.is_empty() {
154            bail!(
155                "search scope mode allowed-roots resolved no authoritative roots; configure search.scope.roots, enable include_invocation_directory, or supply trusted harness roots"
156            );
157        }
158        Ok(Self::AllowedRoots { roots })
159    }
160
161    pub const fn is_unrestricted(&self) -> bool {
162        matches!(self, Self::All)
163    }
164
165    pub fn roots(&self) -> &[AccessRoot] {
166        match self {
167            Self::All => &[],
168            Self::AllowedRoots { roots } => roots,
169        }
170    }
171
172    pub fn workspace_prefixes(&self) -> impl Iterator<Item = &str> {
173        self.roots().iter().flat_map(AccessRoot::database_prefixes)
174    }
175
176    pub fn allows_workspace_path(&self, path: &Path) -> bool {
177        match self {
178            Self::All => true,
179            Self::AllowedRoots { roots } => lexical_absolute(path).is_ok_and(|path| {
180                roots
181                    .iter()
182                    .flat_map(|root| &root.match_paths)
183                    .any(|root| path.starts_with(root))
184            }),
185        }
186    }
187
188    pub fn validate_stable(&self) -> Result<()> {
189        for root in self.roots() {
190            for source in &root.sources {
191                let current = match fs::canonicalize(&source.configured_path) {
192                    Ok(current) => current,
193                    Err(error)
194                        if error.kind() == ErrorKind::NotFound
195                            && !source.canonicalized_at_startup =>
196                    {
197                        continue;
198                    }
199                    Err(error) => {
200                        return Err(error).with_context(|| {
201                            format!(
202                                "allowed root {:?} disappeared after scope resolution",
203                                source.configured_path
204                            )
205                        });
206                    }
207                };
208                if !current.is_dir() {
209                    bail!(
210                        "allowed root {:?} is no longer a directory; refusing to widen access",
211                        source.configured_path
212                    );
213                }
214                if current != root.canonical_path {
215                    bail!(
216                        "allowed root {:?} changed target after scope resolution; refusing to widen access",
217                        source.configured_path
218                    );
219                }
220            }
221        }
222        Ok(())
223    }
224}
225
226fn origin_name(origin: AccessRootOrigin) -> &'static str {
227    origin.as_str()
228}
229
230fn normalize_authority_root(path: &Path, origin: AccessRootOrigin) -> Result<AccessRoot> {
231    let lexical = lexical_absolute(path)?;
232    let (canonical, canonicalized_at_startup) = match fs::canonicalize(&lexical) {
233        Ok(path) => {
234            if !path.is_dir() {
235                bail!("access root exists but is not a directory");
236            }
237            (lexical_absolute(&path)?, true)
238        }
239        Err(error)
240            if error.kind() == ErrorKind::NotFound
241                && origin == AccessRootOrigin::ExplicitConfig =>
242        {
243            (lexical.clone(), false)
244        }
245        Err(error) if error.kind() == ErrorKind::NotFound => {
246            bail!("trusted runtime access root does not exist")
247        }
248        Err(error) => {
249            return Err(error).with_context(|| format!("cannot resolve access root {lexical:?}"));
250        }
251    };
252    if canonical.parent().is_none() {
253        bail!("filesystem roots are not valid allowed-roots entries");
254    }
255    if lexical.to_str().is_none() || canonical.to_str().is_none() {
256        bail!("access roots must be valid UTF-8 for the SQLite path model");
257    }
258    let mut match_paths = vec![canonical.clone()];
259    if lexical != canonical {
260        match_paths.push(lexical.clone());
261    }
262    Ok(AccessRoot {
263        canonical_path: canonical,
264        match_paths,
265        sources: vec![AccessRootSource {
266            configured_path: lexical,
267            canonicalized_at_startup,
268            origin,
269        }],
270    })
271}
272
273pub(crate) fn validate_configured_root(path: &Path) -> Result<()> {
274    normalize_authority_root(path, AccessRootOrigin::ExplicitConfig).map(|_| ())
275}
276
277pub(crate) fn ensure_raw_sql_allowed(config: &SearchScopeConfig, operation: &str) -> Result<()> {
278    if config.mode == SearchScopeMode::AllowedRoots {
279        bail!(
280            "{operation} is unavailable while search.scope.mode is allowed-roots because arbitrary SQL cannot enforce workspace authority; use typed search, session, message, analysis, file, or export operations"
281        );
282    }
283    Ok(())
284}
285
286fn lexical_absolute(path: &Path) -> Result<PathBuf> {
287    if !path.is_absolute() {
288        bail!("access roots must be absolute paths");
289    }
290    let mut normalized = PathBuf::new();
291    for component in path.components() {
292        match component {
293            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
294                normalized.push(component.as_os_str());
295            }
296            Component::CurDir => {}
297            Component::ParentDir => {
298                if !normalized.pop() || !normalized.has_root() {
299                    return Err(anyhow!("access root escapes its filesystem root"));
300                }
301            }
302        }
303    }
304    Ok(normalized)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn restricted(roots: &[&Path]) -> SearchScopeConfig {
312        SearchScopeConfig {
313            mode: SearchScopeMode::AllowedRoots,
314            roots: roots
315                .iter()
316                .map(|path| path.to_string_lossy().into_owned())
317                .collect(),
318            include_invocation_directory: false,
319        }
320    }
321
322    #[test]
323    fn unrestricted_scope_preserves_every_path_and_ignores_unused_inputs() {
324        let scope = EffectiveAccessScope::resolve(
325            &SearchScopeConfig::default(),
326            TrustedAccessInputs {
327                harness_roots: vec![PathBuf::from("relative-is-unused")],
328                invocation_directory: None,
329            },
330        )
331        .unwrap();
332        assert!(scope.is_unrestricted());
333        assert!(scope.allows_workspace_path(Path::new("relative-is-still-visible")));
334    }
335
336    #[test]
337    fn restricted_scope_unions_deduplicates_and_records_origins() {
338        let root = tempfile::tempdir().unwrap();
339        let configured = root.path().join("configured");
340        let harness = root.path().join("harness");
341        let invocation = root.path().join("invocation");
342        fs::create_dir_all(&configured).unwrap();
343        fs::create_dir_all(&harness).unwrap();
344        fs::create_dir_all(&invocation).unwrap();
345        let mut config = restricted(&[&configured]);
346        config.include_invocation_directory = true;
347
348        let scope = EffectiveAccessScope::resolve(
349            &config,
350            TrustedAccessInputs {
351                harness_roots: vec![harness.clone(), configured.clone()],
352                invocation_directory: Some(invocation.clone()),
353            },
354        )
355        .unwrap();
356
357        assert_eq!(scope.roots().len(), 3);
358        assert_eq!(scope.roots()[0].origin(), AccessRootOrigin::HarnessRoots);
359        assert_eq!(scope.roots()[1].origin(), AccessRootOrigin::HarnessRoots);
360        assert_eq!(scope.roots()[1].sources().len(), 2);
361        assert_eq!(
362            scope.roots()[1].sources()[1].origin(),
363            AccessRootOrigin::ExplicitConfig
364        );
365        assert_eq!(
366            scope.roots()[2].origin(),
367            AccessRootOrigin::InvocationDirectory
368        );
369        assert!(scope.allows_workspace_path(&harness.join("child")));
370        assert!(scope.allows_workspace_path(&configured));
371        assert!(scope.allows_workspace_path(&invocation.join("child")));
372        assert!(!scope.allows_workspace_path(root.path().join("outside").as_path()));
373    }
374
375    #[test]
376    fn restricted_scope_fails_closed_without_authoritative_roots() {
377        let error = EffectiveAccessScope::resolve(&restricted(&[]), TrustedAccessInputs::default())
378            .unwrap_err()
379            .to_string();
380        assert!(error.contains("resolved no authoritative roots"), "{error}");
381    }
382
383    #[test]
384    fn roots_require_absolute_non_root_directory_paths() {
385        for value in [Path::new("relative"), Path::new("/")] {
386            let error = EffectiveAccessScope::resolve(
387                &restricted(&[value]),
388                TrustedAccessInputs::default(),
389            )
390            .unwrap_err()
391            .to_string();
392            assert!(
393                error.contains("invalid explicit-config access root"),
394                "{error}"
395            );
396        }
397    }
398
399    #[test]
400    fn arbitrary_sql_is_allowed_only_for_unrestricted_scope() {
401        ensure_raw_sql_allowed(&SearchScopeConfig::default(), "test query").unwrap();
402        let error = ensure_raw_sql_allowed(&restricted(&[Path::new("/configured")]), "test query")
403            .unwrap_err()
404            .to_string();
405        assert!(error.contains("test query is unavailable"));
406        assert!(error.contains("arbitrary SQL cannot enforce workspace authority"));
407    }
408
409    #[test]
410    fn lexical_normalization_preserves_component_boundaries() {
411        let root = tempfile::tempdir().unwrap();
412        let allowed = root.path().join("a/inside/../allowed");
413        fs::create_dir_all(root.path().join("a/allowed")).unwrap();
414        let scope =
415            EffectiveAccessScope::resolve(&restricted(&[&allowed]), TrustedAccessInputs::default())
416                .unwrap();
417        assert!(scope.allows_workspace_path(&root.path().join("a/allowed/child")));
418        assert!(!scope.allows_workspace_path(&root.path().join("a/allowed-sibling")));
419    }
420
421    #[cfg(unix)]
422    #[test]
423    fn existing_symlink_keeps_lexical_alias_and_fails_closed_after_retarget() {
424        use std::os::unix::fs::symlink;
425
426        let root = tempfile::tempdir().unwrap();
427        let target = root.path().join("target");
428        let alias = root.path().join("alias");
429        fs::create_dir(&target).unwrap();
430        symlink(&target, &alias).unwrap();
431
432        let scope =
433            EffectiveAccessScope::resolve(&restricted(&[&alias]), TrustedAccessInputs::default())
434                .unwrap();
435        assert_eq!(scope.roots()[0].path(), target.canonicalize().unwrap());
436        assert!(scope.allows_workspace_path(&scope.roots()[0].path().join("child")));
437        assert!(scope.allows_workspace_path(&alias.join("child")));
438        scope.validate_stable().unwrap();
439
440        let other = root.path().join("other");
441        fs::create_dir(&other).unwrap();
442        fs::remove_file(&alias).unwrap();
443        symlink(&other, &alias).unwrap();
444        let error = scope.validate_stable().unwrap_err().to_string();
445        assert!(error.contains("changed target"), "{error}");
446    }
447
448    #[cfg(unix)]
449    #[test]
450    fn every_deduplicated_symlink_alias_is_revalidated() {
451        use std::os::unix::fs::symlink;
452
453        let root = tempfile::tempdir().unwrap();
454        let target = root.path().join("target");
455        let other = root.path().join("other");
456        let configured_alias = root.path().join("configured-alias");
457        let harness_alias = root.path().join("harness-alias");
458        fs::create_dir(&target).unwrap();
459        fs::create_dir(&other).unwrap();
460        symlink(&target, &configured_alias).unwrap();
461        symlink(&target, &harness_alias).unwrap();
462
463        let scope = EffectiveAccessScope::resolve(
464            &restricted(&[&configured_alias]),
465            TrustedAccessInputs {
466                harness_roots: vec![harness_alias],
467                invocation_directory: None,
468            },
469        )
470        .unwrap();
471        assert_eq!(scope.roots().len(), 1);
472        assert_eq!(scope.roots()[0].sources().len(), 2);
473
474        fs::remove_file(&configured_alias).unwrap();
475        symlink(&other, &configured_alias).unwrap();
476        let error = scope.validate_stable().unwrap_err().to_string();
477        assert!(error.contains("changed target"), "{error}");
478    }
479
480    #[cfg(unix)]
481    #[test]
482    fn configured_root_missing_at_startup_rejects_later_symlink_target() {
483        use std::os::unix::fs::symlink;
484
485        let root = tempfile::tempdir().unwrap();
486        let configured = root.path().join("future-root");
487        let outside = root.path().join("outside");
488        fs::create_dir(&outside).unwrap();
489
490        let scope = EffectiveAccessScope::resolve(
491            &restricted(&[&configured]),
492            TrustedAccessInputs::default(),
493        )
494        .unwrap();
495        scope.validate_stable().unwrap();
496
497        symlink(&outside, &configured).unwrap();
498        let error = scope.validate_stable().unwrap_err().to_string();
499        assert!(error.contains("changed target"), "{error}");
500    }
501}