返回 CodeWhale
approval.rs
根目录 / crates / tui / src / tui / approval.rs
1 //! Tool approval system for `DeepSeek` CLI.
2 //!
3 //! Hosts the [`ApprovalRequest`] / [`ApprovalView`] pair the engine asks
4 //! the TUI to present whenever a tool needs human approval, plus the
5 //! sandbox elevation flow ([`ElevationRequest`] / [`ElevationView`]) that
6 //! follows a sandbox denial.
7 //!
8 //! ## v0.6.7: Codex-style takeover with stakes-based variants (#129)
9 //!
10 //! The modal renders as a compact bottom-anchored approval card that preserves
11 //! transcript context and routes each request to one of two
12 //! stakes-based variants:
13 //!
14 //! - **Benign** (`RiskLevel::Benign`) — read-only ops, MCP discovery,
15 //! query-only network. A single `Enter` / `1` / `y` approves once;
16 //! `2` / `a` approves for the session.
17 //! - **Destructive** (`RiskLevel::Destructive`) — file writes, shell
18 //! commands that are not proven read-only, patches, MCP actions,
19 //! unclassified tools, and any "fetch arbitrary content" surface.
20 //! The approval card keeps the destructive badge and
21 //! impact summary visible, then lets `Enter` commit the highlighted
22 //! option or `y` / `a` / `d` commit directly.
23 //!
24 //! The decision events emitted upstream are unchanged
25 //! (`ViewEvent::ApprovalDecision`), so `ui.rs` and the engine handle
26 //! both variants without modification. Auto-approve / YOLO bypasses
27 //! happen *before* the view is constructed (see `tui/ui.rs`); this
28 //! module always assumes the user is being asked.
29
30 #[cfg(test)]
31 use crate::config::ApprovalDefaultSelection;
32 use crate::tools::canonical_action::canonical_action_alias;
33 use codewhale_config::ToolAskRule;
34 use codewhale_localization::{Locale, MessageId, tr};
35 use serde_json::Value;
36 use std::path::Path;
37 #[cfg(test)]
38 use std::path::PathBuf;
39
40 #[cfg(test)]
41 use crate::sandbox::SandboxPolicy;
42 #[cfg(test)]
43 use crate::tui::views::{ModalView, ViewAction, ViewEvent};
44 #[cfg(test)]
45 use crossterm::event::KeyEvent;
46
47 mod ask_rules;
48 mod elevation;
49 pub mod policy;
50 mod previews;
51 mod view;
52
53 pub use ask_rules::PermissionRuleSavePreview;
54 #[cfg(test)]
55 use ask_rules::build_save_preview as build_permission_rule_save_preview;
56 use ask_rules::{
57 SAVE_PREVIEW_MAX_ENTRIES, build_persistent_allow_rules, build_persistent_ask_rules,
58 build_save_preview,
59 };
60 pub use elevation::{ElevationOption, ElevationRequest, ElevationView};
61 #[cfg(test)]
62 use previews::apply_patch_preview_lines;
63 pub(crate) use previews::format_shell_command_for_approval;
64 use previews::{
65 file_write_preview_lines, localize_detail_label, localize_preview_shell_line, param_text,
66 };
67 // Keep the existing module path even though production callers only construct
68 // `ApprovalView`; approval characterization tests inspect its typed options.
69 #[allow(unused_imports)]
70 pub use view::ApprovalOption;
71 pub use view::ApprovalView;
72
73 pub use policy::{
74 ApprovalStakes, RiskLevel, ToolCategory, classify_risk, classify_stakes,
75 get_tool_category_for_call,
76 };
77
78 /// User's decision for a pending approval
79 #[derive(Debug, Clone, PartialEq, Eq)]
80 pub enum ReviewDecision {
81 /// Execute this tool once
82 Approved,
83 /// Approve and don't ask again for this tool type this session
84 ApprovedForSession,
85 /// Reject the tool execution
86 Denied,
87 /// Abort the entire turn
88 Abort,
89 }
90
91 /// Request for user approval of a tool execution
92 #[derive(Debug, Clone)]
93 pub struct ApprovalRequest {
94 /// Unique ID for this tool use
95 pub id: String,
96 /// Tool being executed
97 pub tool_name: String,
98 /// Human-readable tool description from the engine
99 pub description: String,
100 /// Tool category
101 pub category: ToolCategory,
102 /// Stakes-based routing for the compact approval card
103 pub risk: RiskLevel,
104 /// Derived impact summary for the approval prompt
105 pub impacts: Vec<String>,
106 /// Tool parameters (for display)
107 pub params: Value,
108 /// Exact-argument fingerprint, used to scope *denials* (#1617).
109 pub approval_key: String,
110 /// Lossy / arity-aware fingerprint, used to scope *approvals* so an
111 /// "approve for session" covers later flag variants (v0.8.37).
112 pub approval_grouping_key: String,
113 /// The model's explanation of intent before invoking write tools (#2381).
114 /// Displayed in the approval view so users understand *why* the change
115 /// is being made before reviewing *what* will change.
116 pub intent_summary: Option<String>,
117 /// Ask-only persistent rules that can be saved with the approval.
118 pub persistent_ask_rules: Vec<ToolAskRule>,
119 /// Exact repo-scoped allow rules available for safe approval requests.
120 pub persistent_allow_rules: Vec<ToolAskRule>,
121 }
122
123 /// Key approval details rendered prominently in the approval card.
124 #[derive(Debug, Clone, PartialEq, Eq)]
125 pub struct ApprovalDetail {
126 pub label: String,
127 pub value: String,
128 /// Preformatted shell lines for commands that benefit from safe wrapping
129 /// or a compact write-file preview. `value` remains the original command.
130 pub shell_lines: Option<Vec<String>>,
131 }
132
133 impl ApprovalRequest {
134 /// Mechanical repo-law asks are a distinct authority boundary, not an
135 /// ordinary risk prompt. The engine stamps this stable prefix when a
136 /// `.codewhale/constitution.json` ask rule forces review.
137 #[must_use]
138 pub fn is_repo_law_prompt(&self) -> bool {
139 description_is_repo_law_prompt(&self.description)
140 }
141
142 /// Presentation stakes for this request (see [`ApprovalStakes`]).
143 #[must_use]
144 pub fn stakes(&self) -> ApprovalStakes {
145 classify_stakes(&self.tool_name, self.category, self.risk, &self.params)
146 }
147
148 #[cfg(test)]
149 pub fn new(
150 id: &str,
151 tool_name: &str,
152 description: &str,
153 params: &Value,
154 approval_key: &str,
155 ) -> Self {
156 Self::new_with_intent(
157 id,
158 tool_name,
159 description,
160 params,
161 approval_key,
162 None,
163 Path::new("/workspace"),
164 )
165 }
166
167 pub fn new_with_intent(
168 id: &str,
169 tool_name: &str,
170 description: &str,
171 params: &Value,
172 approval_key: &str,
173 intent_summary: Option<&str>,
174 workspace: &Path,
175 ) -> Self {
176 let semantic_tool_name = canonical_action_alias(tool_name, params);
177 let category = get_tool_category_for_call(tool_name, params);
178 let risk = classify_risk(tool_name, category, params);
179 let approval_grouping_key =
180 crate::tools::approval_cache::build_approval_grouping_key(tool_name, params).0;
181 let persistent_ask_rules =
182 build_persistent_ask_rules(semantic_tool_name, params, workspace);
183 let persistent_allow_rules = if classify_stakes(tool_name, category, risk, params)
184 == ApprovalStakes::Critical
185 || description_is_repo_law_prompt(description)
186 {
187 Vec::new()
188 } else {
189 build_persistent_allow_rules(
190 semantic_tool_name,
191 params,
192 workspace,
193 &persistent_ask_rules,
194 )
195 };
196
197 Self {
198 id: id.to_string(),
199 tool_name: tool_name.to_string(),
200 description: description.to_string(),
201 category,
202 risk,
203 impacts: build_impact_summary(semantic_tool_name, category, params),
204 params: params.clone(),
205 approval_key: approval_key.to_string(),
206 approval_grouping_key,
207 intent_summary: intent_summary.and_then(|summary| {
208 let summary = summary.trim();
209 if summary.is_empty() {
210 None
211 } else {
212 Some(summary.to_string())
213 }
214 }),
215 persistent_ask_rules,
216 persistent_allow_rules,
217 }
218 }
219
220 /// Format parameters for display (truncated)
221 pub fn params_display(&self) -> String {
222 let truncated = truncate_params_value(&self.params, 200);
223 serde_json::to_string(&truncated).unwrap_or_else(|_| truncated.to_string())
224 }
225
226 pub fn description_for_locale(&self, locale: Locale) -> String {
227 match locale {
228 Locale::ZhHans => localized_description_zh_hans(self.category),
229 _ if self.category == ToolCategory::Shell => {
230 "Review the Bash command before it runs.".to_string()
231 }
232 _ => self.description.clone(),
233 }
234 }
235
236 pub fn impacts_for_locale(&self, locale: Locale) -> Vec<String> {
237 let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params);
238 match locale {
239 Locale::ZhHans => {
240 build_impact_summary_zh_hans(semantic_tool_name, self.category, &self.params)
241 }
242 _ => self.impacts.clone(),
243 }
244 }
245
246 #[must_use]
247 pub fn can_save_ask_rule(&self) -> bool {
248 !self.persistent_ask_rules.is_empty()
249 }
250
251 #[must_use]
252 pub fn can_save_allow_rule(&self) -> bool {
253 !self.persistent_allow_rules.is_empty()
254 && self.stakes() != ApprovalStakes::Critical
255 && !self.is_repo_law_prompt()
256 }
257
258 #[must_use]
259 pub fn ask_rule_save_preview(&self) -> Option<PermissionRuleSavePreview> {
260 build_save_preview(&self.persistent_ask_rules, SAVE_PREVIEW_MAX_ENTRIES)
261 }
262
263 #[must_use]
264 pub fn allow_rule_save_preview(&self) -> Option<PermissionRuleSavePreview> {
265 self.can_save_allow_rule().then(|| {
266 build_save_preview(&self.persistent_allow_rules, SAVE_PREVIEW_MAX_ENTRIES)
267 .expect("eligible allow rules are non-empty")
268 })
269 }
270
271 #[must_use]
272 #[cfg(test)]
273 pub fn ask_rule_preview(&self) -> Option<String> {
274 if self.persistent_ask_rules.is_empty() {
275 return None;
276 }
277 let permissions = codewhale_config::PermissionsToml {
278 rules: self.persistent_ask_rules.clone(),
279 };
280 toml::to_string_pretty(&permissions).ok()
281 }
282
283 /// Extract the most important params for the approval card.
284 #[must_use]
285 pub fn prominent_detail_items(&self, locale: Locale) -> Vec<ApprovalDetail> {
286 let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params);
287 build_prominent_details(semantic_tool_name, self.category, &self.params)
288 .into_iter()
289 .map(|mut detail| {
290 let is_preview = detail.label == "Preview";
291 detail.label = localize_detail_label(&detail.label, locale).to_string();
292 if is_preview && let Some(lines) = detail.shell_lines.as_mut() {
293 for line in lines.iter_mut() {
294 *line = localize_preview_shell_line(semantic_tool_name, line, locale)
295 .to_string();
296 }
297 detail.value = lines.join("\n");
298 }
299 detail
300 })
301 .collect()
302 }
303 }
304
305 fn description_is_repo_law_prompt(description: &str) -> bool {
306 description.starts_with("Repo law holds this write:")
307 && description.contains(".codewhale/constitution.json")
308 }
309
310 fn param_preview(params: &Value, keys: &[&str], max_len: usize) -> Option<String> {
311 let Value::Object(map) = params else {
312 return None;
313 };
314
315 for key in keys {
316 let Some(value) = map.get(*key) else {
317 continue;
318 };
319 match value {
320 Value::String(text) => return Some(truncate_string_value(text, max_len)),
321 Value::Number(number) => return Some(number.to_string()),
322 Value::Bool(flag) => return Some(flag.to_string()),
323 Value::Array(items) if !items.is_empty() => {
324 let preview = items
325 .iter()
326 .take(3)
327 .map(|item| match item {
328 Value::String(text) => truncate_string_value(text, max_len / 2),
329 other => truncate_string_value(&other.to_string(), max_len / 2),
330 })
331 .collect::<Vec<_>>()
332 .join(", ");
333 return Some(truncate_string_value(&preview, max_len));
334 }
335 other => return Some(truncate_string_value(&other.to_string(), max_len)),
336 }
337 }
338
339 None
340 }
341
342 fn mcp_target_hint(tool_name: &str) -> Option<String> {
343 let remainder = tool_name.strip_prefix("mcp_")?;
344 if remainder.is_empty() {
345 None
346 } else {
347 Some(remainder.to_string())
348 }
349 }
350
351 fn build_impact_summary(tool_name: &str, category: ToolCategory, params: &Value) -> Vec<String> {
352 match category {
353 ToolCategory::Safe => {
354 let mut impacts = vec!["Read-only operation.".to_string()];
355 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
356 impacts.push(format!("Reads: {path}"));
357 }
358 impacts
359 }
360 ToolCategory::FileWrite => {
361 let mut impacts =
362 vec!["Writes files in the workspace or an approved write scope.".to_string()];
363 if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
364 impacts.push(format!("Writes: {path}"));
365 }
366 impacts
367 }
368 ToolCategory::Shell => {
369 vec!["Executes a Bash command in your workspace.".to_string()]
370 }
371 ToolCategory::Network => {
372 let mut impacts = vec!["May reach network services or remote content.".to_string()];
373 if let Some(target) =
374 param_preview(params, &["url", "q", "query", "location", "repo"], 96)
375 {
376 impacts.push(format!("Target: {target}"));
377 }
378 impacts
379 }
380 ToolCategory::McpRead => {
381 let mut impacts =
382 vec!["Reads from an MCP server without an obvious local write.".to_string()];
383 if let Some(target) = mcp_target_hint(tool_name) {
384 impacts.push(format!("MCP target: {target}"));
385 }
386 impacts
387 }
388 ToolCategory::McpAction => {
389 let mut impacts =
390 vec!["Calls an MCP server action that may have side effects.".to_string()];
391 if let Some(target) = mcp_target_hint(tool_name) {
392 impacts.push(format!("MCP target: {target}"));
393 }
394 impacts
395 }
396 ToolCategory::Agent if tool_name == "workflow" => {
397 // #4126: elevated Workflow plan card — goal, children, capability flags, budget.
398 crate::tools::workflow_plan_approval::analyze_workflow_plan_approval(params)
399 .approval_impacts()
400 }
401 ToolCategory::Agent => {
402 let mut impacts = vec![
403 "Starts or inspects a child agent task; the child's own tool gates still apply."
404 .to_string(),
405 ];
406 if let Some(kind) = param_preview(params, &["type"], 40) {
407 impacts.push(format!("Child type: {kind}"));
408 }
409 impacts
410 }
411 ToolCategory::Unknown => {
412 let mut impacts = vec![
413 "Tool is not classified. Review params carefully before approving.".to_string(),
414 ];
415 if let Some(target) = param_preview(
416 params,
417 &["path", "cmd", "command", "url", "q", "query", "ref_id"],
418 96,
419 ) {
420 impacts.push(format!("Primary input: {target}"));
421 }
422 impacts
423 }
424 }
425 }
426
427 fn localized_description_zh_hans(category: ToolCategory) -> String {
428 let locale = Locale::ZhHans;
429 match category {
430 ToolCategory::Safe => tr(locale, MessageId::ApprovalDescSafe).to_string(),
431 ToolCategory::FileWrite => tr(locale, MessageId::ApprovalDescFileWrite).to_string(),
432 ToolCategory::Shell => tr(locale, MessageId::ApprovalDescShell).to_string(),
433 ToolCategory::Network => tr(locale, MessageId::ApprovalDescNetwork).to_string(),
434 ToolCategory::McpRead => tr(locale, MessageId::ApprovalDescMcpRead).to_string(),
435 ToolCategory::McpAction => tr(locale, MessageId::ApprovalDescMcpAction).to_string(),
436 ToolCategory::Agent => tr(locale, MessageId::ApprovalDescAgent).to_string(),
437 ToolCategory::Unknown => tr(locale, MessageId::ApprovalDescUnknown).to_string(),
438 }
439 }
440
441 fn build_impact_summary_zh_hans(
442 tool_name: &str,
443 category: ToolCategory,
444 params: &Value,
445 ) -> Vec<String> {
446 let locale = Locale::ZhHans;
447 match category {
448 ToolCategory::Safe => {
449 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactSafe).to_string()];
450 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
451 impacts.push(format!("读取:{path}"));
452 }
453 impacts
454 }
455 ToolCategory::FileWrite => {
456 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactFileWrite).to_string()];
457 if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
458 impacts.push(format!("写入:{path}"));
459 }
460 impacts
461 }
462 ToolCategory::Shell => {
463 vec![tr(locale, MessageId::ApprovalImpactShell).to_string()]
464 }
465 ToolCategory::Network => {
466 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactNetwork).to_string()];
467 if let Some(target) =
468 param_preview(params, &["url", "q", "query", "location", "repo"], 96)
469 {
470 impacts.push(format!("目标:{target}"));
471 }
472 impacts
473 }
474 ToolCategory::McpRead => {
475 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactMcpRead).to_string()];
476 if let Some(target) = mcp_target_hint(tool_name) {
477 impacts.push(format!("MCP 目标:{target}"));
478 }
479 impacts
480 }
481 ToolCategory::McpAction => {
482 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactMcpAction).to_string()];
483 if let Some(target) = mcp_target_hint(tool_name) {
484 impacts.push(format!("MCP 目标:{target}"));
485 }
486 impacts
487 }
488 ToolCategory::Agent => {
489 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactAgent).to_string()];
490 if let Some(kind) = param_preview(params, &["type"], 40) {
491 impacts.push(format!("子代理类型:{kind}"));
492 }
493 impacts
494 }
495 ToolCategory::Unknown => {
496 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactUnknown).to_string()];
497 if let Some(target) = param_preview(
498 params,
499 &["path", "cmd", "command", "url", "q", "query", "ref_id"],
500 96,
501 ) {
502 impacts.push(format!("主要输入:{target}"));
503 }
504 impacts
505 }
506 }
507 }
508
509 fn build_prominent_details(
510 tool_name: &str,
511 category: ToolCategory,
512 params: &Value,
513 ) -> Vec<ApprovalDetail> {
514 let mut details = Vec::new();
515 match category {
516 ToolCategory::Shell => {
517 if let Some(command) = param_text(params, &["command", "cmd"]) {
518 details.push(ApprovalDetail {
519 label: "Command".to_string(),
520 shell_lines: Some(format_shell_command_for_approval(&command)),
521 value: command,
522 });
523 }
524 if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 96) {
525 details.push(ApprovalDetail {
526 label: "Dir".to_string(),
527 value: workdir,
528 shell_lines: None,
529 });
530 }
531 }
532 ToolCategory::FileWrite => {
533 if let Some(path) = param_preview(params, &["path", "target", "destination"], 200) {
534 details.push(ApprovalDetail {
535 label: "File".to_string(),
536 value: path,
537 shell_lines: None,
538 });
539 }
540 if let Some(preview_lines) = file_write_preview_lines(tool_name, params) {
541 details.push(ApprovalDetail {
542 label: "Preview".to_string(),
543 value: preview_lines.join("\n"),
544 shell_lines: Some(preview_lines),
545 });
546 }
547 }
548 ToolCategory::Safe => {
549 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 200) {
550 details.push(ApprovalDetail {
551 label: "Path".to_string(),
552 value: path,
553 shell_lines: None,
554 });
555 }
556 }
557 ToolCategory::Network => {
558 if let Some(target) =
559 param_preview(params, &["url", "q", "query", "location", "repo"], 200)
560 {
561 details.push(ApprovalDetail {
562 label: "Target".to_string(),
563 value: target,
564 shell_lines: None,
565 });
566 }
567 }
568 ToolCategory::Agent if tool_name == "workflow" => {
569 // #4126: elevated Workflow plan card fields.
570 let summary =
571 crate::tools::workflow_plan_approval::analyze_workflow_plan_approval(params);
572 for (label, value) in summary.card_fields() {
573 details.push(ApprovalDetail {
574 label: label.to_string(),
575 value,
576 shell_lines: None,
577 });
578 }
579 }
580 ToolCategory::Agent => {
581 if let Some(action) = param_preview(params, &["action"], 40) {
582 details.push(ApprovalDetail {
583 label: "Action".to_string(),
584 value: action,
585 shell_lines: None,
586 });
587 }
588 if let Some(kind) = param_preview(params, &["type"], 40) {
589 details.push(ApprovalDetail {
590 label: "Type".to_string(),
591 value: kind,
592 shell_lines: None,
593 });
594 }
595 if let Some(prompt) = param_preview(params, &["prompt", "task", "message"], 200) {
596 details.push(ApprovalDetail {
597 label: "Prompt".to_string(),
598 value: prompt,
599 shell_lines: None,
600 });
601 }
602 }
603 ToolCategory::McpRead | ToolCategory::McpAction | ToolCategory::Unknown => {
604 if let Some(input) = param_preview(
605 params,
606 &["command", "cmd", "path", "url", "q", "query", "ref_id"],
607 200,
608 ) {
609 details.push(ApprovalDetail {
610 label: "Input".to_string(),
611 value: input,
612 shell_lines: None,
613 });
614 }
615 }
616 }
617 details
618 }
619
620 fn truncate_params_value(value: &Value, max_len: usize) -> Value {
621 match value {
622 Value::Object(map) => {
623 let truncated = map
624 .iter()
625 .map(|(key, val)| (key.clone(), truncate_params_value(val, max_len)))
626 .collect();
627 Value::Object(truncated)
628 }
629 Value::Array(items) => {
630 let truncated_items = items
631 .iter()
632 .map(|val| truncate_params_value(val, max_len))
633 .collect();
634 Value::Array(truncated_items)
635 }
636 Value::String(text) => Value::String(truncate_string_value(text, max_len)),
637 other => {
638 let rendered = other.to_string();
639 if rendered.chars().count() > max_len {
640 Value::String(truncate_string_value(&rendered, max_len))
641 } else {
642 other.clone()
643 }
644 }
645 }
646 }
647
648 fn truncate_string_value(value: &str, max_len: usize) -> String {
649 if value.chars().count() <= max_len {
650 return value.to_string();
651 }
652 let truncated: String = value.chars().take(max_len).collect();
653 format!("{truncated}...")
654 }
655
656 // ============================================================================
657 // Tests
658 // ============================================================================
659
660 #[cfg(test)]
661 mod tests;
662
662 lines RUST