Skip to main content

talos_core/tool/
authorization.rs

1use std::io;
2use std::path::{Component, Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use super::ToolNature;
8
9/// Identifies how a permission resource string should be interpreted.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "lowercase")]
12pub enum ToolResourceKind {
13    /// File or directory path resource.
14    Path,
15    /// URL host or domain resource.
16    Domain,
17    /// External command or executable resource.
18    Command,
19    /// Named remote resource, such as a Git remote.
20    Remote,
21}
22
23/// Lifetime of a concrete tool-execution authorization.
24///
25/// This describes the approval scope that produced an authorization. It does
26/// not itself persist permission policy.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ToolAuthorizationScope {
29    /// Authorization applies only to the current invocation.
30    Once,
31    /// Authorization was produced from a reusable permission rule.
32    Persisted,
33}
34
35/// A concrete, path-bound authorization for one tool operation.
36///
37/// Permission-aware composition roots create this value only after resolving
38/// `Allow`/`Ask`/`Deny`. File tools compare the normalized path and operation
39/// before allowing an invocation to leave the workspace boundary. Calling a
40/// file tool through [`AgentTool::execute`] does not provide this capability.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ToolExecutionAuthorization {
43    tool_name: String,
44    nature: ToolNature,
45    resource_kind: ToolResourceKind,
46    normalized_resource: PathBuf,
47    scope: ToolAuthorizationScope,
48}
49
50impl ToolExecutionAuthorization {
51    /// Creates a path-bound authorization using the workspace as the base for
52    /// relative resources.
53    ///
54    /// Existing paths and their nearest existing ancestor are canonicalized,
55    /// so a later symlink change cannot silently reuse an authorization for a
56    /// different target.
57    pub fn for_path(
58        tool_name: impl Into<String>,
59        nature: ToolNature,
60        workspace_root: &Path,
61        resource: &str,
62        scope: ToolAuthorizationScope,
63    ) -> io::Result<Self> {
64        Ok(Self {
65            tool_name: tool_name.into(),
66            nature,
67            resource_kind: ToolResourceKind::Path,
68            normalized_resource: normalize_authorized_path(workspace_root, resource)?,
69            scope,
70        })
71    }
72
73    /// Returns whether this authorization exactly covers a requested path and
74    /// operation.
75    pub fn authorizes_path(
76        &self,
77        tool_name: &str,
78        nature: ToolNature,
79        workspace_root: &Path,
80        resource: &str,
81    ) -> bool {
82        self.tool_name == tool_name
83            && self.nature == nature
84            && self.resource_kind == ToolResourceKind::Path
85            && normalize_authorized_path(workspace_root, resource)
86                .is_ok_and(|path| path == self.normalized_resource)
87    }
88
89    /// Returns the normalized path carried by this authorization.
90    #[must_use]
91    pub fn normalized_path(&self) -> &Path {
92        &self.normalized_resource
93    }
94
95    /// Returns the approval scope that produced this authorization.
96    #[must_use]
97    pub fn scope(&self) -> ToolAuthorizationScope {
98        self.scope
99    }
100}
101
102fn normalize_authorized_path(workspace_root: &Path, resource: &str) -> io::Result<PathBuf> {
103    let requested = Path::new(resource);
104    let candidate = if requested.is_absolute() {
105        requested.to_path_buf()
106    } else {
107        workspace_root.join(requested)
108    };
109
110    let mut lexical = PathBuf::new();
111    for component in candidate.components() {
112        match component {
113            Component::CurDir => {}
114            Component::ParentDir => {
115                if !lexical.pop() {
116                    return Err(io::Error::new(
117                        io::ErrorKind::InvalidInput,
118                        "path traversal escapes filesystem root",
119                    ));
120                }
121            }
122            other => lexical.push(other.as_os_str()),
123        }
124    }
125
126    let mut existing = lexical.as_path();
127    let mut suffix = Vec::new();
128    while !existing.exists() {
129        let Some(name) = existing.file_name() else {
130            return Err(io::Error::new(
131                io::ErrorKind::NotFound,
132                "path has no existing ancestor",
133            ));
134        };
135        suffix.push(name.to_os_string());
136        existing = existing.parent().ok_or_else(|| {
137            io::Error::new(io::ErrorKind::NotFound, "path has no existing ancestor")
138        })?;
139    }
140
141    let mut normalized = existing.canonicalize()?;
142    for component in suffix.into_iter().rev() {
143        normalized.push(component);
144    }
145    Ok(normalized)
146}
147
148/// One permission facet touched by a tool invocation.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
150pub struct ToolPermissionFacet {
151    /// Risk nature for this facet.
152    pub nature: ToolNature,
153    /// Optional concrete resource touched by this facet.
154    #[serde(default)]
155    pub resource: Option<String>,
156    /// Optional interpretation hint for [`resource`](Self::resource).
157    #[serde(default)]
158    pub resource_kind: Option<ToolResourceKind>,
159    /// Optional human-readable detail used in approval or diagnostics.
160    #[serde(default)]
161    pub description: Option<String>,
162}
163
164impl ToolPermissionFacet {
165    /// Creates a facet with no concrete resource.
166    pub fn new(nature: ToolNature) -> Self {
167        Self {
168            nature,
169            resource: None,
170            resource_kind: None,
171            description: None,
172        }
173    }
174
175    /// Creates a facet with a concrete resource.
176    pub fn with_resource(
177        nature: ToolNature,
178        resource: impl Into<String>,
179        resource_kind: ToolResourceKind,
180    ) -> Self {
181        Self {
182            nature,
183            resource: Some(resource.into()),
184            resource_kind: Some(resource_kind),
185            description: None,
186        }
187    }
188
189    /// Adds display-oriented detail to this facet.
190    pub fn with_description(mut self, description: impl Into<String>) -> Self {
191        self.description = Some(description.into());
192        self
193    }
194}