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