| 1 | //! Presentation for `/plugin`: bundle detail, the capability review body, |
| 2 | //! and diagnostics. |
| 3 | //! |
| 4 | //! Everything here is a pure `&LoadedPlugin -> String` transform — no |
| 5 | //! registry mutation, no disk access. [`escape_review_text`] is the |
| 6 | //! security-relevant part: manifest fields are attacker-controlled, so they |
| 7 | //! are escaped before they reach a review the user is about to approve. |
| 8 | |
| 9 | use std::fmt::Write as _; |
| 10 | use std::path::Path; |
| 11 | |
| 12 | use crate::localization::{MessageId, tr}; |
| 13 | use crate::plugins::types::{LoadedPlugin, PluginDiagnosticLevel}; |
| 14 | use crate::tui::app::App; |
| 15 | |
| 16 | pub(super) fn render_bundle_detail( |
| 17 | app: &App, |
| 18 | plugin: &LoadedPlugin, |
| 19 | include_hashes: bool, |
| 20 | ) -> String { |
| 21 | let unsupported = plugin.inventory.unsupported_labels(); |
| 22 | let unsupported = if unsupported.is_empty() { |
| 23 | "none".to_string() |
| 24 | } else { |
| 25 | unsupported.join(", ") |
| 26 | }; |
| 27 | let (content_hash, capability_hash) = if include_hashes { |
| 28 | ( |
| 29 | plugin.content_hash.as_str(), |
| 30 | plugin.capability_hash.as_str(), |
| 31 | ) |
| 32 | } else { |
| 33 | ("hidden", "hidden") |
| 34 | }; |
| 35 | let mut output = tr(app.ui_locale, MessageId::CmdPluginBundleDetail) |
| 36 | .replace("{name}", &escape_review_text(plugin.name())) |
| 37 | .replace("{id}", &escape_review_text(plugin.id.as_str())) |
| 38 | .replace( |
| 39 | "{version}", |
| 40 | &escape_review_text(&plugin.manifest.plugin.version), |
| 41 | ) |
| 42 | .replace("{origin}", plugin.origin.as_str()) |
| 43 | .replace("{scope}", plugin.scope.as_str()) |
| 44 | .replace("{state}", plugin.state_label()) |
| 45 | .replace("{trust}", plugin.trust_status.as_str()) |
| 46 | .replace("{inventory}", &plugin.inventory.summary()) |
| 47 | .replace("{permissions}", &render_permissions(plugin)) |
| 48 | .replace("{mcp}", &render_mcp_inventory(plugin)) |
| 49 | .replace("{unsupported}", &unsupported) |
| 50 | .replace("{content_hash}", content_hash) |
| 51 | .replace("{capability_hash}", capability_hash) |
| 52 | .replace("{path}", &escape_review_path(&plugin.canonical_root)); |
| 53 | let skills = plugin |
| 54 | .skill_snapshots |
| 55 | .iter() |
| 56 | .map(|skill| escape_review_text(&format!("{}:{}", plugin.name(), skill.name))) |
| 57 | .collect::<Vec<_>>(); |
| 58 | let _ = write!( |
| 59 | output, |
| 60 | "\nQualified skills: [{}]\nActivation boundary: trust stages the exact reviewed content but does not activate it; enable rebuilds this workspace's Skill/MCP catalog immediately; disable or revoke cancels in-flight plugin MCP operations and denies queued Skills.", |
| 61 | if skills.is_empty() { |
| 62 | "none".to_string() |
| 63 | } else { |
| 64 | skills.join(", ") |
| 65 | } |
| 66 | ); |
| 67 | append_diagnostics(app, &mut output, &plugin.diagnostics); |
| 68 | output |
| 69 | } |
| 70 | |
| 71 | fn render_permissions(plugin: &LoadedPlugin) -> String { |
| 72 | let filesystem = if plugin.inventory.filesystem_roots.is_empty() { |
| 73 | "none".to_string() |
| 74 | } else { |
| 75 | plugin |
| 76 | .inventory |
| 77 | .filesystem_roots |
| 78 | .iter() |
| 79 | .map(|value| escape_review_text(value)) |
| 80 | .collect::<Vec<_>>() |
| 81 | .join(", ") |
| 82 | }; |
| 83 | let network = if plugin.inventory.network_hosts.is_empty() { |
| 84 | "none".to_string() |
| 85 | } else { |
| 86 | plugin |
| 87 | .inventory |
| 88 | .network_hosts |
| 89 | .iter() |
| 90 | .map(|value| escape_review_text(value)) |
| 91 | .collect::<Vec<_>>() |
| 92 | .join(", ") |
| 93 | }; |
| 94 | let stdio_authority = if plugin.inventory.stdio_mcp_servers == 0 { |
| 95 | "none".to_string() |
| 96 | } else { |
| 97 | format!( |
| 98 | "{} local child process(es) with host-user filesystem/network authority; MCP tool approvals still apply", |
| 99 | plugin.inventory.stdio_mcp_servers |
| 100 | ) |
| 101 | }; |
| 102 | format!( |
| 103 | "filesystem_roots=[{filesystem}] network_hosts=[{network}] (exact allowlist for Codewhale-managed remote requests; redirects stay same-origin) lifecycle_mutation={} stdio_runtime=[{stdio_authority}]", |
| 104 | plugin.inventory.lifecycle_mutation |
| 105 | ) |
| 106 | } |
| 107 | |
| 108 | fn render_mcp_inventory(plugin: &LoadedPlugin) -> String { |
| 109 | let Some(servers) = plugin.manifest.mcp_servers.as_ref() else { |
| 110 | return "none".to_string(); |
| 111 | }; |
| 112 | let mut servers = servers.iter().collect::<Vec<_>>(); |
| 113 | servers.sort_by_key(|(name, _)| *name); |
| 114 | servers |
| 115 | .into_iter() |
| 116 | .map(|(name, server)| { |
| 117 | let enabled = if server.is_enabled() { |
| 118 | "configured-on" |
| 119 | } else { |
| 120 | "configured-off" |
| 121 | }; |
| 122 | if let Some(command) = server.command.as_deref() { |
| 123 | let mut env_provenance = server |
| 124 | .env |
| 125 | .iter() |
| 126 | .map(|(destination, source)| { |
| 127 | let source = source |
| 128 | .strip_prefix("${") |
| 129 | .and_then(|source| source.strip_suffix('}')) |
| 130 | .unwrap_or("invalid"); |
| 131 | format!( |
| 132 | "{} <- {}", |
| 133 | escape_review_text(destination), |
| 134 | escape_review_text(source) |
| 135 | ) |
| 136 | }) |
| 137 | .collect::<Vec<_>>(); |
| 138 | env_provenance.sort_unstable(); |
| 139 | let cwd = server |
| 140 | .cwd |
| 141 | .as_deref() |
| 142 | .map(escape_review_path) |
| 143 | .unwrap_or_else(|| "plugin-root".to_string()); |
| 144 | let argv = render_review_argv(plugin, &server.args); |
| 145 | format!( |
| 146 | "{}: transport=stdio command={} argv=[{}] cwd={cwd} env=[{}] timeouts={} required={} enabled_tools=[{}] disabled_tools=[{}] host-user-filesystem/network-authority {enabled}", |
| 147 | escape_review_text(name), |
| 148 | escape_review_text(command), |
| 149 | argv.join(", "), |
| 150 | if env_provenance.is_empty() { "none".to_string() } else { env_provenance.join(", ") }, |
| 151 | render_mcp_timeouts(server), |
| 152 | server.required, |
| 153 | render_review_values(&server.enabled_tools), |
| 154 | render_review_values(&server.disabled_tools), |
| 155 | ) |
| 156 | } else if let Some(url) = server.url.as_deref() { |
| 157 | let endpoint = reqwest::Url::parse(url) |
| 158 | .ok() |
| 159 | .map(|url| escape_review_text(url.as_str())) |
| 160 | .unwrap_or_else(|| "invalid-url".to_string()); |
| 161 | let mut env_headers = server |
| 162 | .env_headers |
| 163 | .iter() |
| 164 | .map(|(header, source)| { |
| 165 | format!( |
| 166 | "{} <- {}", |
| 167 | escape_review_text(header), |
| 168 | escape_review_text(source) |
| 169 | ) |
| 170 | }) |
| 171 | .collect::<Vec<_>>(); |
| 172 | env_headers.sort_unstable(); |
| 173 | let bearer = server |
| 174 | .bearer_token_env_var |
| 175 | .as_deref() |
| 176 | .map(escape_review_text) |
| 177 | .unwrap_or_else(|| "none".to_string()); |
| 178 | let transport = server.transport.as_deref().unwrap_or( |
| 179 | "streamable-http with same-origin SSE fallback", |
| 180 | ); |
| 181 | format!( |
| 182 | "{}: transport={} endpoint={} redirects=same-origin-only env_headers=[{}] bearer_env={} oauth=disabled-v0.9.1 timeouts={} required={} enabled_tools=[{}] disabled_tools=[{}] {enabled}", |
| 183 | escape_review_text(name), |
| 184 | escape_review_text(transport), |
| 185 | endpoint, |
| 186 | if env_headers.is_empty() { "none".to_string() } else { env_headers.join(", ") }, |
| 187 | bearer, |
| 188 | render_mcp_timeouts(server), |
| 189 | server.required, |
| 190 | render_review_values(&server.enabled_tools), |
| 191 | render_review_values(&server.disabled_tools), |
| 192 | ) |
| 193 | } else { |
| 194 | format!("{name}: invalid") |
| 195 | } |
| 196 | }) |
| 197 | .collect::<Vec<_>>() |
| 198 | .join("; ") |
| 199 | } |
| 200 | |
| 201 | fn render_review_argv(plugin: &LoadedPlugin, arguments: &[String]) -> Vec<String> { |
| 202 | arguments |
| 203 | .iter() |
| 204 | .enumerate() |
| 205 | .map(|(index, argument)| { |
| 206 | let position = index + 1; |
| 207 | let candidate = plugin.canonical_root.join(argument); |
| 208 | if candidate.exists() |
| 209 | && candidate |
| 210 | .canonicalize() |
| 211 | .is_ok_and(|path| path.starts_with(&plugin.canonical_root)) |
| 212 | { |
| 213 | return format!( |
| 214 | "#{position} plugin-path={}", |
| 215 | render_review_argv_value(argument) |
| 216 | ); |
| 217 | } |
| 218 | format!("#{position} value={}", render_review_argv_value(argument)) |
| 219 | }) |
| 220 | .collect() |
| 221 | } |
| 222 | |
| 223 | fn render_review_argv_value(value: &str) -> String { |
| 224 | // JSON string syntax is a lossless, unambiguous terminal representation: |
| 225 | // whitespace, quotes, backslashes, and punctuation retain their exact |
| 226 | // argv semantics without hiding arbitrary values behind redaction. |
| 227 | serde_json::to_string(value).expect("serializing a Rust string cannot fail") |
| 228 | } |
| 229 | |
| 230 | fn render_review_values(values: &[String]) -> String { |
| 231 | if values.is_empty() { |
| 232 | return "none".to_string(); |
| 233 | } |
| 234 | values |
| 235 | .iter() |
| 236 | .map(|value| escape_review_text(value)) |
| 237 | .collect::<Vec<_>>() |
| 238 | .join(", ") |
| 239 | } |
| 240 | |
| 241 | fn render_mcp_timeouts(server: &crate::mcp::McpServerConfig) -> String { |
| 242 | format!( |
| 243 | "connect={}/execute={}/read={}", |
| 244 | server |
| 245 | .connect_timeout |
| 246 | .map_or_else(|| "default".to_string(), |value| format!("{value}s")), |
| 247 | server |
| 248 | .execute_timeout |
| 249 | .map_or_else(|| "default".to_string(), |value| format!("{value}s")), |
| 250 | server |
| 251 | .read_timeout |
| 252 | .map_or_else(|| "default".to_string(), |value| format!("{value}s")), |
| 253 | ) |
| 254 | } |
| 255 | |
| 256 | pub(super) fn escape_review_path(path: &Path) -> String { |
| 257 | escape_review_text(&path.to_string_lossy()) |
| 258 | } |
| 259 | |
| 260 | pub(super) fn escape_review_text(value: &str) -> String { |
| 261 | let mut escaped = String::with_capacity(value.len()); |
| 262 | for ch in value.chars() { |
| 263 | if ch.is_control() |
| 264 | || matches!( |
| 265 | ch, |
| 266 | '\u{061c}' |
| 267 | | '\u{200e}' |
| 268 | | '\u{200f}' |
| 269 | | '\u{202a}'..='\u{202e}' |
| 270 | | '\u{2066}'..='\u{2069}' |
| 271 | ) |
| 272 | { |
| 273 | let _ = write!(escaped, "\\u{{{:x}}}", ch as u32); |
| 274 | } else if matches!( |
| 275 | ch, |
| 276 | '\\' | '`' |
| 277 | | '*' |
| 278 | | '_' |
| 279 | | '{' |
| 280 | | '}' |
| 281 | | '[' |
| 282 | | ']' |
| 283 | | '<' |
| 284 | | '>' |
| 285 | | '(' |
| 286 | | ')' |
| 287 | | '#' |
| 288 | | '+' |
| 289 | | '-' |
| 290 | | '.' |
| 291 | | '!' |
| 292 | | '|' |
| 293 | ) { |
| 294 | escaped.push('\\'); |
| 295 | escaped.push(ch); |
| 296 | } else { |
| 297 | escaped.push(ch); |
| 298 | } |
| 299 | } |
| 300 | escaped |
| 301 | } |
| 302 | |
| 303 | pub(super) fn review_token(plugin: &LoadedPlugin) -> String { |
| 304 | // This is an explicit user confirmation, not cosmetic display text. Bind |
| 305 | // the command to both complete SHA-256 receipts so a same-inventory bundle |
| 306 | // cannot collide through the former 48-bit content prefix. |
| 307 | format!("{}.{}", plugin.content_hash, plugin.capability_hash) |
| 308 | } |
| 309 | |
| 310 | pub(super) fn append_diagnostics( |
| 311 | app: &App, |
| 312 | output: &mut String, |
| 313 | diagnostics: &[crate::plugins::types::PluginDiagnostic], |
| 314 | ) { |
| 315 | if diagnostics.is_empty() { |
| 316 | return; |
| 317 | } |
| 318 | if !output.ends_with('\n') { |
| 319 | output.push('\n'); |
| 320 | } |
| 321 | output.push_str( |
| 322 | &tr(app.ui_locale, MessageId::CmdPluginBundleDiagnosticsHeader) |
| 323 | .replace("{count}", &diagnostics.len().to_string()), |
| 324 | ); |
| 325 | output.push('\n'); |
| 326 | for diagnostic in diagnostics { |
| 327 | let level = match diagnostic.level { |
| 328 | PluginDiagnosticLevel::Warning => "warning", |
| 329 | PluginDiagnosticLevel::Error => "error", |
| 330 | }; |
| 331 | let path = diagnostic |
| 332 | .path |
| 333 | .as_deref() |
| 334 | .map(|path| format!(" ({})", escape_review_path(path))) |
| 335 | .unwrap_or_default(); |
| 336 | let _ = writeln!( |
| 337 | output, |
| 338 | "• {level} [{}]: {}{path}", |
| 339 | diagnostic.code, |
| 340 | escape_review_text(&diagnostic.message) |
| 341 | ); |
| 342 | } |
| 343 | } |
| 344 |