返回 DeepSeek-TUI-2026
rules.rs
根目录 / crates / tui / src / execpolicy / rules.rs
1 //! Execpolicy rules loaded from TOML configuration.
2
3 use std::collections::BTreeMap;
4 use std::path::{Path, PathBuf};
5
6 use anyhow::{Context, Result};
7 use serde::Deserialize;
8
9 use super::matcher::pattern_matches;
10 use crate::command_safety::prefix_allow_matches;
11
12 #[derive(Debug, Clone, PartialEq, Eq)]
13 pub enum ExecPolicyDecision {
14 Allow,
15 Deny(String),
16 AskUser(String),
17 }
18
19 #[derive(Debug, Deserialize, Default)]
20 pub struct ExecPolicyConfig {
21 #[serde(default)]
22 pub rules: BTreeMap<String, RuleSet>,
23 }
24
25 #[derive(Debug, Deserialize, Default)]
26 pub struct RuleSet {
27 #[serde(default)]
28 pub allow: Vec<String>,
29 #[serde(default)]
30 pub deny: Vec<String>,
31 }
32
33 impl ExecPolicyConfig {
34 pub fn from_str(contents: &str) -> Result<Self> {
35 toml::from_str(contents).context("failed to parse execpolicy.toml")
36 }
37
38 pub fn from_path(path: &Path) -> Result<Self> {
39 let contents = std::fs::read_to_string(path)
40 .with_context(|| format!("failed to read execpolicy file {}", path.display()))?;
41 Self::from_str(&contents)
42 }
43
44 pub fn evaluate(&self, command: &str) -> ExecPolicyDecision {
45 for (group, rules) in &self.rules {
46 for pattern in &rules.deny {
47 if pattern_matches(pattern, command) {
48 return ExecPolicyDecision::Deny(format!(
49 "execpolicy denied by {group}: {pattern}"
50 ));
51 }
52 }
53 }
54
55 for (group, rules) in &self.rules {
56 for pattern in &rules.allow {
57 // Allow rules use arity-aware prefix matching first so that
58 // `allow = ["git status"]` matches `git status -s` but NOT
59 // `git push origin main`. Fall back to regex-style
60 // `pattern_matches` for wildcard patterns (e.g. `cargo *`).
61 if prefix_allow_matches(pattern, command) || pattern_matches(pattern, command) {
62 let _ = group;
63 return ExecPolicyDecision::Allow;
64 }
65 }
66 }
67
68 ExecPolicyDecision::AskUser("execpolicy: no matching allow rule".to_string())
69 }
70 }
71
72 pub fn default_execpolicy_path() -> Option<PathBuf> {
73 dirs::home_dir().map(|home| home.join(".deepseek").join("execpolicy.toml"))
74 }
75
76 pub fn load_default_policy() -> Result<Option<ExecPolicyConfig>> {
77 let Some(path) = default_execpolicy_path() else {
78 return Ok(None);
79 };
80 if !path.exists() {
81 return Ok(None);
82 }
83 ExecPolicyConfig::from_path(&path).map(Some)
84 }
85
86 #[cfg(test)]
87 mod tests {
88 use super::*;
89
90 #[test]
91 fn test_execpolicy_evaluate() {
92 let config = ExecPolicyConfig {
93 rules: BTreeMap::from([
94 (
95 "git".to_string(),
96 RuleSet {
97 allow: vec!["git status".to_string(), "git log *".to_string()],
98 deny: vec!["git push --force".to_string()],
99 },
100 ),
101 (
102 "danger".to_string(),
103 RuleSet {
104 allow: vec![],
105 deny: vec!["rm -rf /".to_string()],
106 },
107 ),
108 ]),
109 };
110
111 assert!(matches!(
112 config.evaluate("git status"),
113 ExecPolicyDecision::Allow
114 ));
115 assert!(matches!(
116 config.evaluate("git log --oneline"),
117 ExecPolicyDecision::Allow
118 ));
119 assert!(matches!(
120 config.evaluate("git push --force"),
121 ExecPolicyDecision::Deny(_)
122 ));
123 assert!(matches!(
124 config.evaluate("unknown command"),
125 ExecPolicyDecision::AskUser(_)
126 ));
127 }
128
129 #[test]
130 fn test_prefix_rule_allows_git_status_with_flags() {
131 // Arity-aware: `allow = ["git status"]` must match `git status -s`.
132 let config = ExecPolicyConfig {
133 rules: BTreeMap::from([(
134 "git".to_string(),
135 RuleSet {
136 allow: vec!["git status".to_string()],
137 deny: vec![],
138 },
139 )]),
140 };
141
142 assert!(matches!(
143 config.evaluate("git status -s"),
144 ExecPolicyDecision::Allow
145 ));
146 assert!(matches!(
147 config.evaluate("git status --porcelain"),
148 ExecPolicyDecision::Allow
149 ));
150 // Push must NOT match the "git status" allow rule.
151 assert!(matches!(
152 config.evaluate("git push origin main"),
153 ExecPolicyDecision::AskUser(_)
154 ));
155 }
156
157 #[test]
158 fn test_prefix_rule_allows_cargo_check_variants() {
159 let config = ExecPolicyConfig {
160 rules: BTreeMap::from([(
161 "cargo".to_string(),
162 RuleSet {
163 allow: vec!["cargo check".to_string()],
164 deny: vec![],
165 },
166 )]),
167 };
168
169 assert!(matches!(
170 config.evaluate("cargo check"),
171 ExecPolicyDecision::Allow
172 ));
173 assert!(matches!(
174 config.evaluate("cargo check --workspace"),
175 ExecPolicyDecision::Allow
176 ));
177 assert!(matches!(
178 config.evaluate("cargo build --release"),
179 ExecPolicyDecision::AskUser(_)
180 ));
181 }
182 }
183
183 lines RUST