#[cfg(test)]
use crate::config::ApprovalDefaultSelection;
use crate::localization::{Locale, MessageId, tr};
use crate::tools::canonical_action::canonical_action_alias;
use codewhale_config::ToolAskRule;
use serde_json::Value;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
#[cfg(test)]
use crate::sandbox::SandboxPolicy;
#[cfg(test)]
use crate::tui::views::{ModalView, ViewAction, ViewEvent};
#[cfg(test)]
use crossterm::event::KeyEvent;
mod ask_rules;
mod elevation;
pub mod policy;
mod previews;
mod view;
pub use ask_rules::PermissionRuleSavePreview;
#[cfg(test)]
use ask_rules::build_save_preview as build_permission_rule_save_preview;
use ask_rules::{
SAVE_PREVIEW_MAX_ENTRIES, build_persistent_allow_rules, build_persistent_ask_rules,
build_save_preview,
};
pub use elevation::{ElevationOption, ElevationRequest, ElevationView};
#[cfg(test)]
use previews::apply_patch_preview_lines;
pub(crate) use previews::format_shell_command_for_approval;
use previews::{
file_write_preview_lines, localize_detail_label, localize_preview_shell_line, param_text,
};
#[allow(unused_imports)]
pub use view::ApprovalOption;
pub use view::ApprovalView;
pub use policy::{
ApprovalStakes, RiskLevel, ToolCategory, classify_risk, classify_stakes,
get_tool_category_for_call,
};
pub use codewhale_execpolicy::ApprovalMode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewDecision {
Approved,
ApprovedForSession,
Denied,
Abort,
}
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
pub id: String,
pub tool_name: String,
pub description: String,
pub category: ToolCategory,
pub risk: RiskLevel,
pub impacts: Vec<String>,
pub params: Value,
pub approval_key: String,
pub approval_grouping_key: String,
pub intent_summary: Option<String>,
pub persistent_ask_rules: Vec<ToolAskRule>,
pub persistent_allow_rules: Vec<ToolAskRule>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalDetail {
pub label: String,
pub value: String,
pub shell_lines: Option<Vec<String>>,
}
impl ApprovalRequest {
#[must_use]
pub fn is_repo_law_prompt(&self) -> bool {
description_is_repo_law_prompt(&self.description)
}
#[must_use]
pub fn stakes(&self) -> ApprovalStakes {
classify_stakes(&self.tool_name, self.category, self.risk, &self.params)
}
#[cfg(test)]
pub fn new(
id: &str,
tool_name: &str,
description: &str,
params: &Value,
approval_key: &str,
) -> Self {
Self::new_with_intent(
id,
tool_name,
description,
params,
approval_key,
None,
Path::new("/workspace"),
)
}
pub fn new_with_intent(
id: &str,
tool_name: &str,
description: &str,
params: &Value,
approval_key: &str,
intent_summary: Option<&str>,
workspace: &Path,
) -> Self {
let semantic_tool_name = canonical_action_alias(tool_name, params);
let category = get_tool_category_for_call(tool_name, params);
let risk = classify_risk(tool_name, category, params);
let approval_grouping_key =
crate::tools::approval_cache::build_approval_grouping_key(tool_name, params).0;
let persistent_ask_rules =
build_persistent_ask_rules(semantic_tool_name, params, workspace);
let persistent_allow_rules = if classify_stakes(tool_name, category, risk, params)
== ApprovalStakes::Critical
|| description_is_repo_law_prompt(description)
{
Vec::new()
} else {
build_persistent_allow_rules(
semantic_tool_name,
params,
workspace,
&persistent_ask_rules,
)
};
Self {
id: id.to_string(),
tool_name: tool_name.to_string(),
description: description.to_string(),
category,
risk,
impacts: build_impact_summary(semantic_tool_name, category, params),
params: params.clone(),
approval_key: approval_key.to_string(),
approval_grouping_key,
intent_summary: intent_summary.and_then(|summary| {
let summary = summary.trim();
if summary.is_empty() {
None
} else {
Some(summary.to_string())
}
}),
persistent_ask_rules,
persistent_allow_rules,
}
}
pub fn params_display(&self) -> String {
let truncated = truncate_params_value(&self.params, 200);
serde_json::to_string(&truncated).unwrap_or_else(|_| truncated.to_string())
}
pub fn description_for_locale(&self, locale: Locale) -> String {
match locale {
Locale::ZhHans => localized_description_zh_hans(self.category),
_ if self.category == ToolCategory::Shell => {
"Review the Bash command before it runs.".to_string()
}
_ => self.description.clone(),
}
}
pub fn impacts_for_locale(&self, locale: Locale) -> Vec<String> {
let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params);
match locale {
Locale::ZhHans => {
build_impact_summary_zh_hans(semantic_tool_name, self.category, &self.params)
}
_ => self.impacts.clone(),
}
}
#[must_use]
pub fn can_save_ask_rule(&self) -> bool {
!self.persistent_ask_rules.is_empty()
}
#[must_use]
pub fn can_save_allow_rule(&self) -> bool {
!self.persistent_allow_rules.is_empty()
&& self.stakes() != ApprovalStakes::Critical
&& !self.is_repo_law_prompt()
}
#[must_use]
pub fn ask_rule_save_preview(&self) -> Option<PermissionRuleSavePreview> {
build_save_preview(&self.persistent_ask_rules, SAVE_PREVIEW_MAX_ENTRIES)
}
#[must_use]
pub fn allow_rule_save_preview(&self) -> Option<PermissionRuleSavePreview> {
self.can_save_allow_rule().then(|| {
build_save_preview(&self.persistent_allow_rules, SAVE_PREVIEW_MAX_ENTRIES)
.expect("eligible allow rules are non-empty")
})
}
#[must_use]
#[cfg(test)]
pub fn ask_rule_preview(&self) -> Option<String> {
if self.persistent_ask_rules.is_empty() {
return None;
}
let permissions = codewhale_config::PermissionsToml {
rules: self.persistent_ask_rules.clone(),
};
toml::to_string_pretty(&permissions).ok()
}
#[must_use]
pub fn prominent_detail_items(&self, locale: Locale) -> Vec<ApprovalDetail> {
let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params);
build_prominent_details(semantic_tool_name, self.category, &self.params)
.into_iter()
.map(|mut detail| {
let is_preview = detail.label == "Preview";
detail.label = localize_detail_label(&detail.label, locale).to_string();
if is_preview && let Some(lines) = detail.shell_lines.as_mut() {
for line in lines.iter_mut() {
*line = localize_preview_shell_line(semantic_tool_name, line, locale)
.to_string();
}
detail.value = lines.join("\n");
}
detail
})
.collect()
}
}
fn description_is_repo_law_prompt(description: &str) -> bool {
description.starts_with("Repo law holds this write:")
&& description.contains(".codewhale/constitution.json")
}
fn param_preview(params: &Value, keys: &[&str], max_len: usize) -> Option<String> {
let Value::Object(map) = params else {
return None;
};
for key in keys {
let Some(value) = map.get(*key) else {
continue;
};
match value {
Value::String(text) => return Some(truncate_string_value(text, max_len)),
Value::Number(number) => return Some(number.to_string()),
Value::Bool(flag) => return Some(flag.to_string()),
Value::Array(items) if !items.is_empty() => {
let preview = items
.iter()
.take(3)
.map(|item| match item {
Value::String(text) => truncate_string_value(text, max_len / 2),
other => truncate_string_value(&other.to_string(), max_len / 2),
})
.collect::<Vec<_>>()
.join(", ");
return Some(truncate_string_value(&preview, max_len));
}
other => return Some(truncate_string_value(&other.to_string(), max_len)),
}
}
None
}
fn mcp_target_hint(tool_name: &str) -> Option<String> {
let remainder = tool_name.strip_prefix("mcp_")?;
if remainder.is_empty() {
None
} else {
Some(remainder.to_string())
}
}
fn build_impact_summary(tool_name: &str, category: ToolCategory, params: &Value) -> Vec<String> {
match category {
ToolCategory::Safe => {
let mut impacts = vec!["Read-only operation.".to_string()];
if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
impacts.push(format!("Reads: {path}"));
}
impacts
}
ToolCategory::FileWrite => {
let mut impacts =
vec!["Writes files in the workspace or an approved write scope.".to_string()];
if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
impacts.push(format!("Writes: {path}"));
}
impacts
}
ToolCategory::Shell => {
vec!["Executes a Bash command in your workspace.".to_string()]
}
ToolCategory::Network => {
let mut impacts = vec!["May reach network services or remote content.".to_string()];
if let Some(target) =
param_preview(params, &["url", "q", "query", "location", "repo"], 96)
{
impacts.push(format!("Target: {target}"));
}
impacts
}
ToolCategory::McpRead => {
let mut impacts =
vec!["Reads from an MCP server without an obvious local write.".to_string()];
if let Some(target) = mcp_target_hint(tool_name) {
impacts.push(format!("MCP target: {target}"));
}
impacts
}
ToolCategory::McpAction => {
let mut impacts =
vec!["Calls an MCP server action that may have side effects.".to_string()];
if let Some(target) = mcp_target_hint(tool_name) {
impacts.push(format!("MCP target: {target}"));
}
impacts
}
ToolCategory::Agent if tool_name == "workflow" => {
crate::tools::workflow_plan_approval::analyze_workflow_plan_approval(params)
.approval_impacts()
}
ToolCategory::Agent => {
let mut impacts = vec![
"Starts or inspects a child agent task; the child's own tool gates still apply."
.to_string(),
];
if let Some(kind) = param_preview(params, &["type"], 40) {
impacts.push(format!("Child type: {kind}"));
}
impacts
}
ToolCategory::Unknown => {
let mut impacts = vec![
"Tool is not classified. Review params carefully before approving.".to_string(),
];
if let Some(target) = param_preview(
params,
&["path", "cmd", "command", "url", "q", "query", "ref_id"],
96,
) {
impacts.push(format!("Primary input: {target}"));
}
impacts
}
}
}
fn localized_description_zh_hans(category: ToolCategory) -> String {
let locale = Locale::ZhHans;
match category {
ToolCategory::Safe => tr(locale, MessageId::ApprovalDescSafe).to_string(),
ToolCategory::FileWrite => tr(locale, MessageId::ApprovalDescFileWrite).to_string(),
ToolCategory::Shell => tr(locale, MessageId::ApprovalDescShell).to_string(),
ToolCategory::Network => tr(locale, MessageId::ApprovalDescNetwork).to_string(),
ToolCategory::McpRead => tr(locale, MessageId::ApprovalDescMcpRead).to_string(),
ToolCategory::McpAction => tr(locale, MessageId::ApprovalDescMcpAction).to_string(),
ToolCategory::Agent => tr(locale, MessageId::ApprovalDescAgent).to_string(),
ToolCategory::Unknown => tr(locale, MessageId::ApprovalDescUnknown).to_string(),
}
}
fn build_impact_summary_zh_hans(
tool_name: &str,
category: ToolCategory,
params: &Value,
) -> Vec<String> {
let locale = Locale::ZhHans;
match category {
ToolCategory::Safe => {
let mut impacts = vec![tr(locale, MessageId::ApprovalImpactSafe).to_string()];
if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
impacts.push(format!("读取:{path}"));
}
impacts
}
ToolCategory::FileWrite => {
let mut impacts = vec![tr(locale, MessageId::ApprovalImpactFileWrite).to_string()];
if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
impacts.push(format!("写入:{path}"));
}
impacts
}
ToolCategory::Shell => {
vec![tr(locale, MessageId::ApprovalImpactShell).to_string()]
}
ToolCategory::Network => {
let mut impacts = vec![tr(locale, MessageId::ApprovalImpactNetwork).to_string()];
if let Some(target) =
param_preview(params, &["url", "q", "query", "location", "repo"], 96)
{
impacts.push(format!("目标:{target}"));
}
impacts
}
ToolCategory::McpRead => {
let mut impacts = vec![tr(locale, MessageId::ApprovalImpactMcpRead).to_string()];
if let Some(target) = mcp_target_hint(tool_name) {
impacts.push(format!("MCP 目标:{target}"));
}
impacts
}
ToolCategory::McpAction => {
let mut impacts = vec![tr(locale, MessageId::ApprovalImpactMcpAction).to_string()];
if let Some(target) = mcp_target_hint(tool_name) {
impacts.push(format!("MCP 目标:{target}"));
}
impacts
}
ToolCategory::Agent => {
let mut impacts = vec![tr(locale, MessageId::ApprovalImpactAgent).to_string()];
if let Some(kind) = param_preview(params, &["type"], 40) {
impacts.push(format!("子代理类型:{kind}"));
}
impacts
}
ToolCategory::Unknown => {
let mut impacts = vec![tr(locale, MessageId::ApprovalImpactUnknown).to_string()];
if let Some(target) = param_preview(
params,
&["path", "cmd", "command", "url", "q", "query", "ref_id"],
96,
) {
impacts.push(format!("主要输入:{target}"));
}
impacts
}
}
}
fn build_prominent_details(
tool_name: &str,
category: ToolCategory,
params: &Value,
) -> Vec<ApprovalDetail> {
let mut details = Vec::new();
match category {
ToolCategory::Shell => {
if let Some(command) = param_text(params, &["command", "cmd"]) {
details.push(ApprovalDetail {
label: "Command".to_string(),
shell_lines: Some(format_shell_command_for_approval(&command)),
value: command,
});
}
if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 96) {
details.push(ApprovalDetail {
label: "Dir".to_string(),
value: workdir,
shell_lines: None,
});
}
}
ToolCategory::FileWrite => {
if let Some(path) = param_preview(params, &["path", "target", "destination"], 200) {
details.push(ApprovalDetail {
label: "File".to_string(),
value: path,
shell_lines: None,
});
}
if let Some(preview_lines) = file_write_preview_lines(tool_name, params) {
details.push(ApprovalDetail {
label: "Preview".to_string(),
value: preview_lines.join("\n"),
shell_lines: Some(preview_lines),
});
}
}
ToolCategory::Safe => {
if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 200) {
details.push(ApprovalDetail {
label: "Path".to_string(),
value: path,
shell_lines: None,
});
}
}
ToolCategory::Network => {
if let Some(target) =
param_preview(params, &["url", "q", "query", "location", "repo"], 200)
{
details.push(ApprovalDetail {
label: "Target".to_string(),
value: target,
shell_lines: None,
});
}
}
ToolCategory::Agent if tool_name == "workflow" => {
let summary =
crate::tools::workflow_plan_approval::analyze_workflow_plan_approval(params);
for (label, value) in summary.card_fields() {
details.push(ApprovalDetail {
label: label.to_string(),
value,
shell_lines: None,
});
}
}
ToolCategory::Agent => {
if let Some(action) = param_preview(params, &["action"], 40) {
details.push(ApprovalDetail {
label: "Action".to_string(),
value: action,
shell_lines: None,
});
}
if let Some(kind) = param_preview(params, &["type"], 40) {
details.push(ApprovalDetail {
label: "Type".to_string(),
value: kind,
shell_lines: None,
});
}
if let Some(prompt) = param_preview(params, &["prompt", "task", "message"], 200) {
details.push(ApprovalDetail {
label: "Prompt".to_string(),
value: prompt,
shell_lines: None,
});
}
}
ToolCategory::McpRead | ToolCategory::McpAction | ToolCategory::Unknown => {
if let Some(input) = param_preview(
params,
&["command", "cmd", "path", "url", "q", "query", "ref_id"],
200,
) {
details.push(ApprovalDetail {
label: "Input".to_string(),
value: input,
shell_lines: None,
});
}
}
}
details
}
fn truncate_params_value(value: &Value, max_len: usize) -> Value {
match value {
Value::Object(map) => {
let truncated = map
.iter()
.map(|(key, val)| (key.clone(), truncate_params_value(val, max_len)))
.collect();
Value::Object(truncated)
}
Value::Array(items) => {
let truncated_items = items
.iter()
.map(|val| truncate_params_value(val, max_len))
.collect();
Value::Array(truncated_items)
}
Value::String(text) => Value::String(truncate_string_value(text, max_len)),
other => {
let rendered = other.to_string();
if rendered.chars().count() > max_len {
Value::String(truncate_string_value(&rendered, max_len))
} else {
other.clone()
}
}
}
}
fn truncate_string_value(value: &str, max_len: usize) -> String {
if value.chars().count() <= max_len {
return value.to_string();
}
let truncated: String = value.chars().take(max_len).collect();
format!("{truncated}...")
}
#[cfg(test)]
mod tests;