返回 CodeWhale
ask_rules.rs
根目录 / crates / tui / src / tui / approval / ask_rules.rs
1 //! Persistent permission-rule construction and save-preview formatting.
2 //!
3 //! This module owns the policy for turning an already-classified approval
4 //! request into exact ask/allow rules. It deliberately has no modal state or
5 //! rendering code: views consume the validated rules and their bounded text
6 //! preview without re-parsing tool inputs.
7
8 use std::path::Path;
9
10 use codewhale_config::ToolAskRule;
11 use codewhale_execpolicy::PermissionAction;
12 use serde_json::Value;
13
14 use crate::tools::canonical_action::canonical_action_alias;
15
16 /// Human-readable preview of rules an approval action would append.
17 ///
18 /// This is intentionally derived from the already validated persistent-rule
19 /// candidates; the approval UI must not re-parse tool inputs such as patches.
20 #[derive(Debug, Clone, PartialEq, Eq)]
21 pub struct PermissionRuleSavePreview {
22 pub action: PermissionAction,
23 pub rule_count: usize,
24 pub entries: Vec<String>,
25 pub omitted: usize,
26 }
27
28 impl PermissionRuleSavePreview {
29 /// What saving these rules does, in the words the person reads on the
30 /// card. The entries below it name each command or file, and `omitted`
31 /// counts the rest, so the summary does not repeat a rule count (#6566).
32 #[must_use]
33 pub fn summary(&self) -> String {
34 match self.action {
35 PermissionAction::Allow => "always allow",
36 PermissionAction::Ask => "always ask first",
37 PermissionAction::Deny => "never allow",
38 }
39 .to_string()
40 }
41 }
42
43 pub(super) const SAVE_PREVIEW_MAX_ENTRIES: usize = 4;
44
45 #[must_use]
46 pub(super) fn build_save_preview(
47 rules: &[ToolAskRule],
48 max_entries: usize,
49 ) -> Option<PermissionRuleSavePreview> {
50 if rules.is_empty() {
51 return None;
52 }
53
54 let entries = rules
55 .iter()
56 .take(max_entries)
57 .map(format_save_entry)
58 .collect();
59 Some(PermissionRuleSavePreview {
60 action: rules[0].action,
61 rule_count: rules.len(),
62 entries,
63 omitted: rules.len().saturating_sub(max_entries),
64 })
65 }
66
67 /// One saved rule as a plain phrase: what it covers ("run cargo test",
68 /// "edit src/lib.rs") and where. The card used to print the rule's config
69 /// syntax (`tool=exec_shell command=… command_exact=true`), which is the
70 /// storage format, not something a person decides on (#6566).
71 #[must_use]
72 fn format_save_entry(rule: &ToolAskRule) -> String {
73 let verb = match rule.tool.as_str() {
74 "exec_shell" if rule.command_exact => Some("run exactly"),
75 "exec_shell" => Some("run"),
76 "write_file" => Some("write"),
77 "edit_file" => Some("edit"),
78 "apply_patch" => Some("change"),
79 _ => None,
80 };
81 let target = rule
82 .command
83 .as_deref()
84 .or(rule.path.as_deref())
85 .map(sanitize_preview_value);
86 let mut entry = match (verb, target) {
87 (Some(verb), Some(target)) => format!("{verb} {target}"),
88 (Some(verb), None) => format!("{verb} ({})", sanitize_preview_value(&rule.tool)),
89 (None, Some(target)) => format!("{} {target}", sanitize_preview_value(&rule.tool)),
90 (None, None) => sanitize_preview_value(&rule.tool),
91 };
92 if let Some(workspace) = &rule.workspace {
93 entry.push_str(" in ");
94 entry.push_str(&sanitize_preview_value(workspace));
95 }
96 entry
97 }
98
99 #[must_use]
100 fn sanitize_preview_value(value: &str) -> String {
101 value
102 .replace('\\', "\\\\")
103 .replace('\r', "\\r")
104 .replace('\n', "\\n")
105 .replace('\t', "\\t")
106 }
107
108 #[must_use]
109 pub(super) fn build_persistent_ask_rules(
110 tool_name: &str,
111 params: &Value,
112 workspace: &Path,
113 ) -> Vec<ToolAskRule> {
114 let semantic = canonical_action_alias(tool_name, params);
115 match semantic {
116 "exec_shell" => build_exec_shell_ask_rules(params),
117 // File writes save an exact, workspace-relative path so a later
118 // edit/write of the same file is matched. read_file stays out: this
119 // boundary is about persisting *write* approvals only.
120 "write_file" | "edit_file" => build_file_write_ask_rules(semantic, params, workspace),
121 "apply_patch" => build_apply_patch_ask_rules(params, workspace),
122 _ => Vec::new(),
123 }
124 }
125
126 #[must_use]
127 pub(super) fn build_persistent_allow_rules(
128 tool_name: &str,
129 params: &Value,
130 workspace: &Path,
131 exact_rules: &[ToolAskRule],
132 ) -> Vec<ToolAskRule> {
133 if exact_rules.is_empty() {
134 return Vec::new();
135 }
136
137 if tool_name == "exec_shell" {
138 let Some(command) = params.get("command").and_then(Value::as_str) else {
139 return Vec::new();
140 };
141 if !matches!(
142 codewhale_execpolicy::command_safety::analyze_command(command).level,
143 codewhale_execpolicy::command_safety::SafetyLevel::Safe
144 | codewhale_execpolicy::command_safety::SafetyLevel::WorkspaceSafe
145 ) {
146 return Vec::new();
147 }
148 }
149
150 let workspace = workspace.to_string_lossy();
151 let Some(workspace) = codewhale_execpolicy::normalize_workspace_scope(workspace.as_ref())
152 else {
153 return Vec::new();
154 };
155
156 exact_rules
157 .iter()
158 .cloned()
159 .map(|rule| rule.into_exact_workspace_allow(workspace.clone()))
160 .collect()
161 }
162
163 #[must_use]
164 fn build_exec_shell_ask_rules(params: &Value) -> Vec<ToolAskRule> {
165 let Some(command) = params
166 .get("command")
167 .and_then(Value::as_str)
168 .map(str::trim)
169 .filter(|command| !command.is_empty())
170 else {
171 return Vec::new();
172 };
173 vec![ToolAskRule::exec_shell(command)]
174 }
175
176 #[must_use]
177 fn build_file_write_ask_rules(
178 tool_name: &str,
179 params: &Value,
180 workspace: &Path,
181 ) -> Vec<ToolAskRule> {
182 let Some(path) = params
183 .get("path")
184 .and_then(Value::as_str)
185 .map(str::trim)
186 .filter(|path| !path.is_empty())
187 else {
188 return Vec::new();
189 };
190 // Reuse the canonical matcher normalization so the saved rule equals what
191 // runtime matching compares against. `None` (and the degenerate
192 // workspace-root case) means the path is empty, traversing, drive-relative,
193 // or outside the workspace, so we save nothing and the `S` shortcut and
194 // preview stay disabled.
195 let workspace = workspace.to_string_lossy();
196 let Some(relative) =
197 codewhale_execpolicy::normalize_workspace_relative_path(path, workspace.as_ref())
198 .filter(|relative| !relative.is_empty())
199 else {
200 return Vec::new();
201 };
202 vec![ToolAskRule::file_path(tool_name, relative)]
203 }
204
205 #[must_use]
206 fn build_apply_patch_ask_rules(params: &Value, workspace: &Path) -> Vec<ToolAskRule> {
207 let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(params) else {
208 return Vec::new();
209 };
210 let workspace = workspace.to_string_lossy();
211 let mut rules = Vec::new();
212
213 for path in preflight.touched_files {
214 let Some(relative) =
215 codewhale_execpolicy::normalize_workspace_relative_path(&path, workspace.as_ref())
216 .filter(|relative| !relative.is_empty())
217 else {
218 return Vec::new();
219 };
220 let rule = ToolAskRule::file_path("apply_patch", relative);
221 if !rules.contains(&rule) {
222 rules.push(rule);
223 }
224 }
225
226 rules
227 }
228
228 lines RUST