agent_config/scope.rs
1//! Where a hook should be installed: globally for the user, or scoped to a
2//! single project directory.
3
4use std::path::{Path, PathBuf};
5
6/// Install location for a hook.
7#[derive(Debug, Clone)]
8#[non_exhaustive]
9pub enum Scope {
10 /// User-global: writes to the harness's per-user config dir
11 /// (e.g., `~/.claude/`, `~/.cursor/`).
12 Global,
13
14 /// Project-local: writes to a specific project directory
15 /// (e.g., `./CLAUDE.md`, `./.clinerules`).
16 Local(PathBuf),
17}
18
19impl Scope {
20 /// Returns the [`ScopeKind`] discriminant.
21 pub fn kind(&self) -> ScopeKind {
22 match self {
23 Scope::Global => ScopeKind::Global,
24 Scope::Local(_) => ScopeKind::Local,
25 }
26 }
27
28 /// Returns the local project root for [`Scope::Local`].
29 pub fn local_root(&self) -> Option<&Path> {
30 match self {
31 Scope::Local(p) => Some(p),
32 Scope::Global => None,
33 }
34 }
35
36 /// Verify that `path` is contained within the local project root.
37 ///
38 /// For [`Scope::Global`], rejects symlinks anywhere along the path so a
39 /// symlinked `~/.claude`, `~/.cursor`, etc. cannot redirect writes outside
40 /// the user's intended config tree. For [`Scope::Local`], rejects symlink
41 /// components under the project root, canonicalizes the deepest existing
42 /// path component, then checks it stays within the canonical project root.
43 ///
44 /// Missing tail components are allowed when every existing ancestor stays
45 /// inside the root and is not a symlink.
46 ///
47 /// # Errors
48 ///
49 /// Returns [`crate::AgentConfigError::PathResolution`] when:
50 /// - any path component is a symlink (Global scope) or a symlink under the
51 /// local root (Local scope) — `"refusing to write through symlink"`,
52 /// - the canonicalized path escapes the local project root —
53 /// `"refusing to write outside scope root"`,
54 /// - an unexpected I/O error occurs while inspecting an existing component.
55 pub fn ensure_contained(&self, path: &Path) -> Result<(), crate::error::AgentConfigError> {
56 match self {
57 Scope::Global => crate::util::fs_atomic::reject_symlink_components(path),
58 Scope::Local(root) => crate::util::fs_atomic::ensure_contained(path, root),
59 }
60 }
61}
62
63/// Discriminant of [`Scope`] without payload, used in the
64/// [`Integration::supported_scopes`](crate::integration::Integration::supported_scopes)
65/// trait method.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67#[non_exhaustive]
68pub enum ScopeKind {
69 /// User-global scope.
70 Global,
71 /// Project-local scope.
72 Local,
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 #[test]
80 fn scope_accessors() {
81 assert_eq!(Scope::Global.kind(), ScopeKind::Global);
82 assert_eq!(Scope::Local(PathBuf::from("/tmp")).kind(), ScopeKind::Local);
83 assert!(Scope::Global.local_root().is_none());
84 assert_eq!(
85 Scope::Local(PathBuf::from("/project")).local_root(),
86 Some(Path::new("/project"))
87 );
88 assert_ne!(ScopeKind::Global, ScopeKind::Local);
89 }
90}