返回 DeepSeek-TUI-2026
lib.rs
根目录 / crates / execpolicy / src / lib.rs
1 pub mod bash_arity;
2
3 use std::collections::HashSet;
4
5 use anyhow::Result;
6 use bash_arity::BashArityDict;
7 use deepseek_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction};
8 use serde::{Deserialize, Serialize};
9
10 /// Priority layer for a permission ruleset. Higher ordinal = higher priority.
11 /// On conflict, the highest-priority layer's longest matching prefix wins.
12 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13 #[serde(rename_all = "snake_case")]
14 pub enum RulesetLayer {
15 BuiltinDefault = 0,
16 Agent = 1,
17 User = 2,
18 }
19
20 /// A named set of allow/deny prefix rules at a given priority layer.
21 #[derive(Debug, Clone, Serialize, Deserialize)]
22 pub struct Ruleset {
23 pub layer: RulesetLayer,
24 pub trusted_prefixes: Vec<String>,
25 pub denied_prefixes: Vec<String>,
26 }
27
28 impl Ruleset {
29 pub fn builtin_default() -> Self {
30 Self {
31 layer: RulesetLayer::BuiltinDefault,
32 trusted_prefixes: vec![],
33 denied_prefixes: vec![],
34 }
35 }
36
37 pub fn agent(trusted: Vec<String>, denied: Vec<String>) -> Self {
38 Self {
39 layer: RulesetLayer::Agent,
40 trusted_prefixes: trusted,
41 denied_prefixes: denied,
42 }
43 }
44
45 pub fn user(trusted: Vec<String>, denied: Vec<String>) -> Self {
46 Self {
47 layer: RulesetLayer::User,
48 trusted_prefixes: trusted,
49 denied_prefixes: denied,
50 }
51 }
52 }
53
54 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55 #[serde(rename_all = "snake_case")]
56 pub enum AskForApproval {
57 UnlessTrusted,
58 OnFailure,
59 OnRequest,
60 Reject {
61 sandbox_approval: bool,
62 rules: bool,
63 mcp_elicitations: bool,
64 },
65 Never,
66 }
67
68 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69 pub struct ExecPolicyAmendment {
70 pub prefixes: Vec<String>,
71 }
72
73 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74 pub enum ExecApprovalRequirement {
75 Skip {
76 bypass_sandbox: bool,
77 proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
78 },
79 NeedsApproval {
80 reason: String,
81 proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
82 proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
83 },
84 Forbidden {
85 reason: String,
86 },
87 }
88
89 impl ExecApprovalRequirement {
90 pub fn reason(&self) -> &str {
91 match self {
92 ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
93 ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
94 ExecApprovalRequirement::Forbidden { reason } => reason,
95 }
96 }
97
98 pub fn phase(&self) -> &'static str {
99 match self {
100 ExecApprovalRequirement::Skip { .. } => "allowed",
101 ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
102 ExecApprovalRequirement::Forbidden { .. } => "forbidden",
103 }
104 }
105 }
106
107 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108 pub struct ExecPolicyDecision {
109 pub allow: bool,
110 pub requires_approval: bool,
111 pub requirement: ExecApprovalRequirement,
112 pub matched_rule: Option<String>,
113 }
114
115 impl ExecPolicyDecision {
116 pub fn reason(&self) -> &str {
117 self.requirement.reason()
118 }
119 }
120
121 #[derive(Debug, Clone)]
122 pub struct ExecPolicyContext<'a> {
123 pub command: &'a str,
124 pub cwd: &'a str,
125 pub ask_for_approval: AskForApproval,
126 pub sandbox_mode: Option<&'a str>,
127 }
128
129 #[derive(Debug, Clone, Default)]
130 pub struct ExecPolicyEngine {
131 /// Layered rulesets (builtin → agent → user). When non-empty, takes precedence
132 /// over the legacy flat lists below.
133 rulesets: Vec<Ruleset>,
134 /// Legacy flat lists kept for backward compatibility with `new()`.
135 trusted_prefixes: Vec<String>,
136 denied_prefixes: Vec<String>,
137 approved_for_session: HashSet<String>,
138 /// Arity dictionary for command-prefix allow-rule matching.
139 arity_dict: BashArityDict,
140 }
141
142 impl ExecPolicyEngine {
143 /// Legacy constructor: wraps the two vecs into a User-layer ruleset.
144 pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
145 Self {
146 rulesets: vec![],
147 trusted_prefixes,
148 denied_prefixes,
149 approved_for_session: HashSet::new(),
150 arity_dict: BashArityDict::new(),
151 }
152 }
153
154 /// Build an engine from explicit layered rulesets.
155 /// Rulesets are sorted by layer priority on construction.
156 pub fn with_rulesets(mut rulesets: Vec<Ruleset>) -> Self {
157 rulesets.sort_by_key(|r| r.layer);
158 Self {
159 rulesets,
160 trusted_prefixes: vec![],
161 denied_prefixes: vec![],
162 approved_for_session: HashSet::new(),
163 arity_dict: BashArityDict::new(),
164 }
165 }
166
167 /// Add a ruleset layer (re-sorts internally).
168 pub fn add_ruleset(&mut self, ruleset: Ruleset) {
169 self.rulesets.push(ruleset);
170 self.rulesets.sort_by_key(|r| r.layer);
171 }
172
173 /// Resolve the effective trusted/denied prefix sets by merging all rulesets.
174 ///
175 /// Collects all prefixes from every layer (builtin → agent → user) into flat
176 /// trusted/denied lists. The `check()` method then applies deny-always-wins
177 /// semantics: any matching deny prefix blocks the command regardless of layer.
178 /// Trusted rules are only consulted after deny checks pass.
179 fn resolve_prefixes(&self) -> (Vec<String>, Vec<String>) {
180 if self.rulesets.is_empty() {
181 return (self.trusted_prefixes.clone(), self.denied_prefixes.clone());
182 }
183 // Collect all trusted/denied across all layers, highest-priority last so they
184 // shadow lower-priority entries with the same prefix.
185 let mut trusted: Vec<String> = vec![];
186 let mut denied: Vec<String> = vec![];
187 for rs in &self.rulesets {
188 trusted.extend(rs.trusted_prefixes.iter().cloned());
189 denied.extend(rs.denied_prefixes.iter().cloned());
190 }
191 // Also merge legacy flat lists as user-layer.
192 trusted.extend(self.trusted_prefixes.iter().cloned());
193 denied.extend(self.denied_prefixes.iter().cloned());
194 (trusted, denied)
195 }
196
197 pub fn remember_session_approval(&mut self, approval_key: String) {
198 self.approved_for_session.insert(approval_key);
199 }
200
201 pub fn is_session_approved(&self, approval_key: &str) -> bool {
202 self.approved_for_session.contains(approval_key)
203 }
204
205 pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
206 let normalized = normalize_command(ctx.command);
207 let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
208 // Deny rules use simple prefix matching (no arity semantics needed).
209 if let Some(rule) = denied_prefixes
210 .iter()
211 .find(|rule| normalized.starts_with(&normalize_command(rule)))
212 {
213 return Ok(ExecPolicyDecision {
214 allow: false,
215 requires_approval: false,
216 matched_rule: Some(rule.clone()),
217 requirement: ExecApprovalRequirement::Forbidden {
218 reason: format!("Command blocked by denied prefix rule '{rule}'"),
219 },
220 });
221 }
222
223 // Allow (trusted) rules use arity-aware prefix matching so that
224 // `auto_allow = ["git status"]` matches `git status -s` but NOT
225 // `git push origin main`.
226 let trusted_rule = trusted_prefixes
227 .iter()
228 .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
229 .cloned();
230 let is_trusted = trusted_rule.is_some();
231
232 let requirement = match ctx.ask_for_approval {
233 AskForApproval::Never => ExecApprovalRequirement::Skip {
234 bypass_sandbox: false,
235 proposed_execpolicy_amendment: None,
236 },
237 AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
238 bypass_sandbox: false,
239 proposed_execpolicy_amendment: None,
240 },
241 AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
242 bypass_sandbox: false,
243 proposed_execpolicy_amendment: None,
244 },
245 AskForApproval::Reject { rules, .. } if rules => ExecApprovalRequirement::Forbidden {
246 reason: "Policy is configured to reject rule-exceptions.".to_string(),
247 },
248 _ => ExecApprovalRequirement::NeedsApproval {
249 reason: if is_trusted {
250 "Approval requested by policy mode.".to_string()
251 } else {
252 "Unmatched command prefix requires approval.".to_string()
253 },
254 proposed_execpolicy_amendment: if is_trusted {
255 None
256 } else {
257 Some(ExecPolicyAmendment {
258 prefixes: vec![first_token(ctx.command)],
259 })
260 },
261 proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
262 host: ctx.cwd.to_string(),
263 action: NetworkPolicyRuleAction::Allow,
264 }],
265 },
266 };
267
268 let (allow, requires_approval) = match requirement {
269 ExecApprovalRequirement::Skip { .. } => (true, false),
270 ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
271 ExecApprovalRequirement::Forbidden { .. } => (false, false),
272 };
273
274 Ok(ExecPolicyDecision {
275 allow,
276 requires_approval,
277 matched_rule: trusted_rule,
278 requirement,
279 })
280 }
281 }
282
283 fn normalize_command(value: &str) -> String {
284 value.trim().to_ascii_lowercase()
285 }
286
287 fn first_token(command: &str) -> String {
288 command
289 .split_whitespace()
290 .next()
291 .unwrap_or_default()
292 .to_string()
293 }
294
294 lines RUST