返回 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 #[must_use]
30 pub fn summary(&self) -> String {
31 let action = match self.action {
32 PermissionAction::Allow => "allow",
33 PermissionAction::Ask => "ask",
34 PermissionAction::Deny => "deny",
35 };
36 let noun = if self.rule_count == 1 {
37 "rule"
38 } else {
39 "rules"
40 };
41 format!("{} {action} {noun}", self.rule_count)
42 }
43 }
44
45 pub(super) const SAVE_PREVIEW_MAX_ENTRIES: usize = 4;
46
47 #[must_use]
48 pub(super) fn build_save_preview(
49 rules: &[ToolAskRule],
50 max_entries: usize,
51 ) -> Option<PermissionRuleSavePreview> {
52 if rules.is_empty() {
53 return None;
54 }
55
56 let entries = rules
57 .iter()
58 .take(max_entries)
59 .map(format_save_entry)
60 .collect();
61 Some(PermissionRuleSavePreview {
62 action: rules[0].action,
63 rule_count: rules.len(),
64 entries,
65 omitted: rules.len().saturating_sub(max_entries),
66 })
67 }
68
69 #[must_use]
70 fn format_save_entry(rule: &ToolAskRule) -> String {
71 let mut parts = vec![format!("tool={}", sanitize_preview_value(&rule.tool))];
72 if let Some(command) = &rule.command {
73 parts.push(format!("command={}", sanitize_preview_value(command)));
74 }
75 if let Some(path) = &rule.path {
76 parts.push(format!("path={}", sanitize_preview_value(path)));
77 }
78 if rule.command_exact {
79 parts.push("command_exact=true".to_string());
80 }
81 if let Some(workspace) = &rule.workspace {
82 parts.push(format!("workspace={}", sanitize_preview_value(workspace)));
83 }
84 parts.join(" ")
85 }
86
87 #[must_use]
88 fn sanitize_preview_value(value: &str) -> String {
89 value
90 .replace('\\', "\\\\")
91 .replace('\r', "\\r")
92 .replace('\n', "\\n")
93 .replace('\t', "\\t")
94 }
95
96 #[must_use]
97 pub(super) fn build_persistent_ask_rules(
98 tool_name: &str,
99 params: &Value,
100 workspace: &Path,
101 ) -> Vec<ToolAskRule> {
102 let semantic = canonical_action_alias(tool_name, params);
103 match semantic {
104 "exec_shell" => build_exec_shell_ask_rules(params),
105 // File writes save an exact, workspace-relative path so a later
106 // edit/write of the same file is matched. read_file stays out: this
107 // boundary is about persisting *write* approvals only.
108 "write_file" | "edit_file" => build_file_write_ask_rules(semantic, params, workspace),
109 "apply_patch" => build_apply_patch_ask_rules(params, workspace),
110 _ => Vec::new(),
111 }
112 }
113
114 #[must_use]
115 pub(super) fn build_persistent_allow_rules(
116 tool_name: &str,
117 params: &Value,
118 workspace: &Path,
119 exact_rules: &[ToolAskRule],
120 ) -> Vec<ToolAskRule> {
121 if exact_rules.is_empty() {
122 return Vec::new();
123 }
124
125 if tool_name == "exec_shell" {
126 let Some(command) = params.get("command").and_then(Value::as_str) else {
127 return Vec::new();
128 };
129 if !matches!(
130 codewhale_execpolicy::command_safety::analyze_command(command).level,
131 codewhale_execpolicy::command_safety::SafetyLevel::Safe
132 | codewhale_execpolicy::command_safety::SafetyLevel::WorkspaceSafe
133 ) {
134 return Vec::new();
135 }
136 }
137
138 let workspace = workspace.to_string_lossy();
139 let Some(workspace) = codewhale_execpolicy::normalize_workspace_scope(workspace.as_ref())
140 else {
141 return Vec::new();
142 };
143
144 exact_rules
145 .iter()
146 .cloned()
147 .map(|rule| rule.into_exact_workspace_allow(workspace.clone()))
148 .collect()
149 }
150
151 #[must_use]
152 fn build_exec_shell_ask_rules(params: &Value) -> Vec<ToolAskRule> {
153 let Some(command) = params
154 .get("command")
155 .and_then(Value::as_str)
156 .map(str::trim)
157 .filter(|command| !command.is_empty())
158 else {
159 return Vec::new();
160 };
161 vec![ToolAskRule::exec_shell(command)]
162 }
163
164 #[must_use]
165 fn build_file_write_ask_rules(
166 tool_name: &str,
167 params: &Value,
168 workspace: &Path,
169 ) -> Vec<ToolAskRule> {
170 let Some(path) = params
171 .get("path")
172 .and_then(Value::as_str)
173 .map(str::trim)
174 .filter(|path| !path.is_empty())
175 else {
176 return Vec::new();
177 };
178 // Reuse the canonical matcher normalization so the saved rule equals what
179 // runtime matching compares against. `None` (and the degenerate
180 // workspace-root case) means the path is empty, traversing, drive-relative,
181 // or outside the workspace, so we save nothing and the `S` shortcut and
182 // preview stay disabled.
183 let workspace = workspace.to_string_lossy();
184 let Some(relative) =
185 codewhale_execpolicy::normalize_workspace_relative_path(path, workspace.as_ref())
186 .filter(|relative| !relative.is_empty())
187 else {
188 return Vec::new();
189 };
190 vec![ToolAskRule::file_path(tool_name, relative)]
191 }
192
193 #[must_use]
194 fn build_apply_patch_ask_rules(params: &Value, workspace: &Path) -> Vec<ToolAskRule> {
195 let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(params) else {
196 return Vec::new();
197 };
198 let workspace = workspace.to_string_lossy();
199 let mut rules = Vec::new();
200
201 for path in preflight.touched_files {
202 let Some(relative) =
203 codewhale_execpolicy::normalize_workspace_relative_path(&path, workspace.as_ref())
204 .filter(|relative| !relative.is_empty())
205 else {
206 return Vec::new();
207 };
208 let rule = ToolAskRule::file_path("apply_patch", relative);
209 if !rules.contains(&rule) {
210 rules.push(rule);
211 }
212 }
213
214 rules
215 }
216
216 lines RUST