Skip to main content

telos_agent/tools/
permissions.rs

1//! Rule-based permission engine.
2//!
3//! [`PermissionEngine`] evaluates an ordered list of [`PermissionRule`]s
4//! against a tool call. Rules are matched on tool name (with optional `*`
5//! wildcard suffix), a command prefix (for shell-style tools), and a cwd
6//! prefix. The result of the **last matching rule wins** — order rules from
7//! general to specific.
8
9use crate::tools::command_security::bash::extract_command_prefix;
10use crate::tools::command_security::bash::prefix::PrefixResult;
11
12/// Shell dialect used for command-prefix extraction and matching.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ShellKind {
15    Bash,
16    PowerShell,
17}
18
19/// Outcome of a permission check for a tool call.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum RuleDecision {
22    /// Tool may run without further approval.
23    Allow,
24    /// Tool is forbidden; result is delivered to the model as an error.
25    Deny,
26    /// Defer to the host (typically a human approval prompt).
27    Ask,
28}
29
30/// A single permission rule. Use the constructor / builder methods to build one.
31#[derive(Debug, Clone)]
32pub struct PermissionRule {
33    /// Tool name to match. Trailing `*` makes it a prefix pattern; `*` alone matches any tool.
34    pub tool_name: String,
35    /// Decision applied when this rule matches.
36    pub decision: RuleDecision,
37    /// If set, the rule only matches when the call's `command` argument starts with this prefix.
38    pub command_prefix: Option<String>,
39    /// If set, the rule only matches when the runtime cwd is inside this directory.
40    pub cwd_prefix: Option<std::path::PathBuf>,
41}
42
43impl PermissionRule {
44    /// Build an `Allow` rule for the given tool name.
45    pub fn allow_tool(name: impl Into<String>) -> Self {
46        Self {
47            tool_name: name.into(),
48            decision: RuleDecision::Allow,
49            command_prefix: None,
50            cwd_prefix: None,
51        }
52    }
53
54    /// Build a `Deny` rule for the given tool name.
55    pub fn deny_tool(name: impl Into<String>) -> Self {
56        Self {
57            tool_name: name.into(),
58            decision: RuleDecision::Deny,
59            command_prefix: None,
60            cwd_prefix: None,
61        }
62    }
63
64    /// Build an `Ask` rule for the given tool name.
65    pub fn ask_tool(name: impl Into<String>) -> Self {
66        Self {
67            tool_name: name.into(),
68            decision: RuleDecision::Ask,
69            command_prefix: None,
70            cwd_prefix: None,
71        }
72    }
73
74    /// Narrow the rule to calls whose `command` argument starts with `prefix`.
75    pub fn command_prefix(mut self, prefix: impl Into<String>) -> Self {
76        self.command_prefix = Some(prefix.into());
77        self
78    }
79
80    /// Narrow the rule to calls executed inside (or below) `prefix`.
81    pub fn cwd_prefix(mut self, prefix: impl Into<std::path::PathBuf>) -> Self {
82        self.cwd_prefix = Some(prefix.into());
83        self
84    }
85}
86
87/// Ordered list of permission rules consulted by the executor before every tool call.
88#[derive(Debug, Clone)]
89pub struct PermissionEngine {
90    rules: Vec<PermissionRule>,
91}
92
93impl Default for PermissionEngine {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl PermissionEngine {
100    pub fn new() -> Self {
101        Self { rules: Vec::new() }
102    }
103
104    /// Append a rule; later rules override earlier ones when both match.
105    pub fn add_rule(&mut self, rule: PermissionRule) {
106        self.rules.push(rule);
107    }
108
109    /// Evaluate a tool by name only — ignores command/cwd filters.
110    ///
111    /// Returns `None` if no rule matches, leaving the decision to the tool itself.
112    pub fn evaluate(&self, tool_name: &str) -> Option<RuleDecision> {
113        self.evaluate_call(tool_name, &serde_json::Value::Null, std::path::Path::new("."))
114    }
115
116    /// Evaluate a tool call against all rules. Later rules override earlier ones.
117    pub fn evaluate_call(
118        &self,
119        tool_name: &str,
120        arguments: &serde_json::Value,
121        cwd: &std::path::Path,
122    ) -> Option<RuleDecision> {
123        self.evaluate_call_any(&[tool_name], arguments, cwd)
124    }
125
126    /// Evaluate a tool call against any accepted tool name for that tool.
127    ///
128    /// This preserves the usual "last matching rule wins" behavior across
129    /// canonical names and legacy aliases.
130    pub fn evaluate_call_any(
131        &self,
132        tool_names: &[&str],
133        arguments: &serde_json::Value,
134        cwd: &std::path::Path,
135    ) -> Option<RuleDecision> {
136        let mut result = None;
137        for rule in &self.rules {
138            if tool_names.iter().any(|tool_name| Self::match_name(&rule.tool_name, tool_name))
139                && Self::match_command_prefix(rule, arguments)
140                && Self::match_cwd_prefix(rule, cwd)
141            {
142                result = Some(rule.decision.clone());
143            }
144        }
145        result
146    }
147
148    /// Evaluate a shell-style tool call using an extracted command prefix.
149    ///
150    /// For rules with a [`command_prefix`](PermissionRule::command_prefix), the
151    /// prefix is matched against the *normalized* command prefix produced by
152    /// [`extract_command_prefix`]. This is stricter than raw
153    /// string matching: it strips trailing arguments and redirects, but also
154    /// refuses to match when the command contains injection-like constructs.
155    ///
156    /// Rules without a `command_prefix` still match on tool name and cwd as
157    /// usual, so a blanket `deny_tool("Bash")` continues to work.
158    pub fn evaluate_shell_call(
159        &self,
160        shell_kind: ShellKind,
161        tool_names: &[&str],
162        command: &str,
163        _arguments: &serde_json::Value,
164        cwd: &std::path::Path,
165    ) -> Option<RuleDecision> {
166        // `_arguments` is reserved for future use (e.g. argument-based rules).
167        let _ = _arguments;
168
169        let extracted = match shell_kind {
170            ShellKind::Bash => match extract_command_prefix(command) {
171                PrefixResult::Prefix(p) => Some(p),
172                PrefixResult::None => None,
173                PrefixResult::NeedsReview => {
174                    return self.evaluate_needs_review_shell(tool_names, cwd);
175                }
176            },
177            ShellKind::PowerShell => {
178                match crate::tools::command_security::powershell::extract_command_prefix(command) {
179                    crate::tools::command_security::powershell::PrefixResult::Prefix(p) => Some(p),
180                    crate::tools::command_security::powershell::PrefixResult::None => None,
181                    crate::tools::command_security::powershell::PrefixResult::NeedsReview => {
182                        return self.evaluate_needs_review_shell(tool_names, cwd);
183                    }
184                }
185            }
186        };
187
188        let mut result = None;
189        for rule in &self.rules {
190            if !tool_names.iter().any(|tool_name| Self::match_name(&rule.tool_name, tool_name)) {
191                continue;
192            }
193            if !Self::match_cwd_prefix(rule, cwd) {
194                continue;
195            }
196            if let Some(prefix) = &rule.command_prefix {
197                let haystack = extracted.as_deref().unwrap_or_else(|| command.trim_start());
198                let matches_prefix = match shell_kind {
199                    ShellKind::Bash => haystack.starts_with(prefix),
200                    ShellKind::PowerShell => {
201                        haystack.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase())
202                    }
203                };
204                if !matches_prefix {
205                    continue;
206                }
207            }
208            result = Some(rule.decision.clone());
209        }
210        result
211    }
212
213    fn evaluate_needs_review_shell(
214        &self,
215        tool_names: &[&str],
216        cwd: &std::path::Path,
217    ) -> Option<RuleDecision> {
218        // When the command cannot be safely analyzed (contains injections,
219        // parse errors, etc.), prefix rules must not auto-allow it. Only
220        // general tool-level rules without a command_prefix are considered,
221        // and the engine defaults to Deny if nothing matches so injection-like
222        // commands fail closed.
223        let mut result = None;
224        for rule in &self.rules {
225            if tool_names.iter().any(|tool_name| Self::match_name(&rule.tool_name, tool_name))
226                && rule.command_prefix.is_none()
227                && Self::match_cwd_prefix(rule, cwd)
228            {
229                result = Some(rule.decision.clone());
230            }
231        }
232        result.or(Some(RuleDecision::Deny))
233    }
234
235    /// Match `pattern` against `name` with `*` wildcard support (trailing or solo).
236    fn match_name(pattern: &str, name: &str) -> bool {
237        if pattern == "*" {
238            return true;
239        }
240        if let Some(prefix) = pattern.strip_suffix('*') {
241            return name.starts_with(prefix);
242        }
243        pattern == name
244    }
245
246    /// True if no prefix is configured, or if `arguments.command` (trimmed) starts with it.
247    fn match_command_prefix(rule: &PermissionRule, arguments: &serde_json::Value) -> bool {
248        let Some(prefix) = &rule.command_prefix else {
249            return true;
250        };
251        arguments
252            .get("command")
253            .and_then(|value| value.as_str())
254            .map(|command| command.trim_start().starts_with(prefix))
255            .unwrap_or(false)
256    }
257
258    /// True if no prefix is configured, or if `cwd` lies under it.
259    fn match_cwd_prefix(rule: &PermissionRule, cwd: &std::path::Path) -> bool {
260        let Some(prefix) = &rule.cwd_prefix else {
261            return true;
262        };
263        cwd.starts_with(prefix)
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use serde_json::json;
271
272    // --- match_name ---
273
274    #[test]
275    fn wildcard_star_matches_any_name() {
276        assert!(PermissionEngine::match_name("*", "anything"));
277        assert!(PermissionEngine::match_name("*", ""));
278    }
279
280    #[test]
281    fn prefix_wildcard_matches() {
282        assert!(PermissionEngine::match_name("Bash*", "Bash"));
283        assert!(PermissionEngine::match_name("Bash*", "BashExtended"));
284        assert!(!PermissionEngine::match_name("Bash*", "bash_lowercase"));
285        assert!(!PermissionEngine::match_name("Bash*", "XxxBash"));
286    }
287
288    #[test]
289    fn exact_match_no_wildcard() {
290        assert!(PermissionEngine::match_name("Write", "Write"));
291        assert!(!PermissionEngine::match_name("Write", "write"));
292        assert!(!PermissionEngine::match_name("Write", "WriteTool"));
293    }
294
295    // --- evaluate (name-only) ---
296
297    #[test]
298    fn evaluate_returns_none_when_no_rules() {
299        let engine = PermissionEngine::new();
300        assert_eq!(engine.evaluate("Read"), None);
301    }
302
303    #[test]
304    fn evaluate_returns_last_matching_rule() {
305        let mut engine = PermissionEngine::new();
306        engine.add_rule(PermissionRule::deny_tool("*"));
307        engine.add_rule(PermissionRule::allow_tool("Read"));
308        assert_eq!(engine.evaluate("Read"), Some(RuleDecision::Allow));
309        assert_eq!(engine.evaluate("Write"), Some(RuleDecision::Deny));
310    }
311
312    #[test]
313    fn evaluate_falls_through_when_no_match() {
314        let mut engine = PermissionEngine::new();
315        engine.add_rule(PermissionRule::allow_tool("Read"));
316        assert_eq!(engine.evaluate("Write"), None);
317    }
318
319    // --- command_prefix ---
320
321    #[test]
322    fn command_prefix_matches_when_set() {
323        let mut engine = PermissionEngine::new();
324        engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git "));
325        assert_eq!(
326            engine.evaluate_call(
327                "Bash",
328                &json!({"command": "git status"}),
329                std::path::Path::new(".")
330            ),
331            Some(RuleDecision::Allow)
332        );
333    }
334
335    #[test]
336    fn command_prefix_no_match_when_wrong_command() {
337        let mut engine = PermissionEngine::new();
338        engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("ls "));
339        assert_eq!(
340            engine.evaluate_call(
341                "Bash",
342                &json!({"command": "rm -rf /"}),
343                std::path::Path::new(".")
344            ),
345            None
346        );
347    }
348
349    #[test]
350    fn command_prefix_does_not_match_without_command_key() {
351        let mut engine = PermissionEngine::new();
352        engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("ls "));
353        assert_eq!(engine.evaluate_call("Bash", &json!({}), std::path::Path::new(".")), None);
354    }
355
356    // --- cwd_prefix ---
357
358    #[test]
359    fn cwd_prefix_matches_when_cwd_under_prefix() {
360        let mut engine = PermissionEngine::new();
361        engine.add_rule(
362            PermissionRule::allow_tool("Write").cwd_prefix(std::path::PathBuf::from("/safe")),
363        );
364        assert_eq!(
365            engine.evaluate_call("Write", &json!({}), std::path::Path::new("/safe/sub/dir")),
366            Some(RuleDecision::Allow)
367        );
368    }
369
370    #[test]
371    fn cwd_prefix_rejects_cwd_outside_prefix() {
372        let mut engine = PermissionEngine::new();
373        engine.add_rule(
374            PermissionRule::allow_tool("Write").cwd_prefix(std::path::PathBuf::from("/safe")),
375        );
376        assert_eq!(
377            engine.evaluate_call("Write", &json!({}), std::path::Path::new("/unsafe")),
378            None
379        );
380    }
381
382    // --- combined filters ---
383
384    #[test]
385    fn command_and_cwd_both_applied() {
386        let mut engine = PermissionEngine::new();
387        engine.add_rule(
388            PermissionRule::deny_tool("Bash")
389                .command_prefix("rm ")
390                .cwd_prefix(std::path::PathBuf::from("/protected")),
391        );
392        // correct command, wrong dir — no match
393        assert_eq!(
394            engine.evaluate_call(
395                "Bash",
396                &json!({"command": "rm file"}),
397                std::path::Path::new("/tmp")
398            ),
399            None
400        );
401        // wrong command, correct dir — no match
402        assert_eq!(
403            engine.evaluate_call(
404                "Bash",
405                &json!({"command": "ls"}),
406                std::path::Path::new("/protected/dir")
407            ),
408            None
409        );
410        // both match — rule fires
411        assert_eq!(
412            engine.evaluate_call(
413                "Bash",
414                &json!({"command": "rm file"}),
415                std::path::Path::new("/protected/dir")
416            ),
417            Some(RuleDecision::Deny)
418        );
419    }
420
421    // --- evaluate_shell_call ---
422
423    #[test]
424    fn shell_call_matches_extracted_prefix() {
425        let mut engine = PermissionEngine::new();
426        engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git status"));
427        assert_eq!(
428            engine.evaluate_shell_call(
429                ShellKind::Bash,
430                &["Bash"],
431                "git status --short",
432                &json!({"command": "git status --short"}),
433                std::path::Path::new(".")
434            ),
435            Some(RuleDecision::Allow)
436        );
437    }
438
439    #[test]
440    fn shell_call_strips_redirects_for_prefix() {
441        let mut engine = PermissionEngine::new();
442        engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git status"));
443        assert_eq!(
444            engine.evaluate_shell_call(
445                ShellKind::Bash,
446                &["Bash"],
447                "git status 2>&1",
448                &json!({"command": "git status 2>&1"}),
449                std::path::Path::new(".")
450            ),
451            Some(RuleDecision::Allow)
452        );
453    }
454
455    #[test]
456    fn shell_call_rejects_injection_for_prefix_rules() {
457        let mut engine = PermissionEngine::new();
458        engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git status"));
459        // A command with a compound operator cannot be safely prefixed; the
460        // allow-prefix rule must not auto-allow it. With no other rule present,
461        // the engine defaults to Deny for unanalyzable commands.
462        assert_eq!(
463            engine.evaluate_shell_call(
464                ShellKind::Bash,
465                &["Bash"],
466                "git status; rm -rf /",
467                &json!({"command": "git status; rm -rf /"}),
468                std::path::Path::new(".")
469            ),
470            Some(RuleDecision::Deny)
471        );
472    }
473
474    #[test]
475    fn shell_call_general_tool_rule_still_applies_to_injection() {
476        let mut engine = PermissionEngine::new();
477        engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("git status"));
478        engine.add_rule(PermissionRule::deny_tool("Bash"));
479        assert_eq!(
480            engine.evaluate_shell_call(
481                ShellKind::Bash,
482                &["Bash"],
483                "git status; rm -rf /",
484                &json!({"command": "git status; rm -rf /"}),
485                std::path::Path::new(".")
486            ),
487            Some(RuleDecision::Deny)
488        );
489    }
490
491    #[test]
492    fn powershell_shell_call_matches_prefix_case_insensitively() {
493        let mut engine = PermissionEngine::new();
494        engine.add_rule(PermissionRule::allow_tool("PowerShell").command_prefix("get-process"));
495        assert_eq!(
496            engine.evaluate_shell_call(
497                ShellKind::PowerShell,
498                &["PowerShell"],
499                "Get-Process -Name pwsh",
500                &json!({"command": "Get-Process -Name pwsh"}),
501                std::path::Path::new(".")
502            ),
503            Some(RuleDecision::Allow)
504        );
505    }
506
507    #[test]
508    fn powershell_alias_matches_canonical_deny_rule() {
509        let mut engine = PermissionEngine::new();
510        engine.add_rule(PermissionRule::deny_tool("PowerShell").command_prefix("Remove-Item"));
511        assert_eq!(
512            engine.evaluate_shell_call(
513                ShellKind::PowerShell,
514                &["PowerShell"],
515                "rm ./file.txt",
516                &json!({"command": "rm ./file.txt"}),
517                std::path::Path::new(".")
518            ),
519            Some(RuleDecision::Deny)
520        );
521    }
522
523    #[test]
524    fn bash_and_powershell_rules_are_separate() {
525        let mut engine = PermissionEngine::new();
526        engine.add_rule(PermissionRule::allow_tool("Bash").command_prefix("Get-Process"));
527        assert_eq!(
528            engine.evaluate_shell_call(
529                ShellKind::PowerShell,
530                &["PowerShell"],
531                "Get-Process",
532                &json!({"command": "Get-Process"}),
533                std::path::Path::new(".")
534            ),
535            None
536        );
537    }
538}