| 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 | display_component_labels(&detail.unsupported_labels) |
| 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 | display_component_labels(labels) |
| 39 | } |
| 40 | } else { |
| 41 | "none".to_string() |
| 42 | }; |
| 43 | let extension_host = crate::plugins::activation::extension_host_policy_enabled(); |
| 44 | let (content_hash, capability_hash) = if include_hashes { |
| 45 | ( |
| 46 | detail.content_hash.as_str(), |
| 47 | detail.capability_hash.as_str(), |
| 48 | ) |
| 49 | } else { |
| 50 | ("hidden", "hidden") |
| 51 | }; |
| 52 | let mut output = presentation |
| 53 | .translate( |
| 54 | "cmd_plugin_bundle_detail", |
| 55 | &[ |
| 56 | ("name", &escape_review_text(&detail.name)), |
| 57 | ("id", &escape_review_text(&detail.id)), |
| 58 | ("version", &escape_review_text(&detail.version)), |
| 59 | ("origin", &detail.origin), |
| 60 | ("scope", &detail.scope), |
| 61 | ("state", &detail.state_label), |
| 62 | ("trust", &detail.trust_status), |
| 63 | ("inventory", &detail.inventory_summary), |
| 64 | ("permissions", &render_permissions(detail)), |
| 65 | ("mcp", &render_mcp_inventory(detail)), |
| 66 | ("unsupported", &unsupported), |
| 67 | ("content_hash", content_hash), |
| 68 | ("capability_hash", capability_hash), |
| 69 | ("path", &escape_review_path(&detail.canonical_root)), |
| 70 | ], |
| 71 | ) |
| 72 | .unwrap_or_default(); |
| 73 | let skills = detail |
| 74 | .skills |
| 75 | .iter() |
| 76 | .map(|skill| escape_review_text(skill)) |
| 77 | .collect::<Vec<_>>(); |
| 78 | let _ = write!( |
| 79 | output, |
| 80 | "\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. {}", |
| 81 | detail.compatibility, |
| 82 | if skills.is_empty() { |
| 83 | "none".to_string() |
| 84 | } else { |
| 85 | skills.join(", ") |
| 86 | }, |
| 87 | if extension_host { |
| 88 | "Native host code (JavaScript) runs in the experimental extension host with your user permissions (sandboxed where the OS sandbox is available); extension tools are never auto-approved by the plugin itself, and each call needs approval under your approval mode. LSP, filesystem-roots, and lifecycle-mutation stay inventoried and inactive." |
| 89 | } else { |
| 90 | "LSP, native, filesystem-roots, and lifecycle-mutation stay inventoried and inactive." |
| 91 | } |
| 92 | ); |
| 93 | append_diagnostics(presentation, &mut output, &detail.diagnostics); |
| 94 | output |
| 95 | } |
| 96 | |
| 97 | /// Component labels for review text. With the experimental extension host |
| 98 | /// on, `native` is shown by what it is; with it off the text is unchanged. |
| 99 | /// The hashed label itself (`PluginActivationCapability::as_str`) never |
| 100 | /// changes. |
| 101 | fn display_component_labels(labels: &[String]) -> String { |
| 102 | let extension_host = crate::plugins::activation::extension_host_policy_enabled(); |
| 103 | labels |
| 104 | .iter() |
| 105 | .map(|label| match label.as_str() { |
| 106 | "native" if extension_host => "native (host code: JavaScript)", |
| 107 | other => other, |
| 108 | }) |
| 109 | .collect::<Vec<_>>() |
| 110 | .join(", ") |
| 111 | } |
| 112 | |
| 113 | fn render_permissions(detail: &PluginDetail) -> String { |
| 114 | let filesystem = if detail.filesystem_roots.is_empty() { |
| 115 | "none".to_string() |
| 116 | } else { |
| 117 | detail |
| 118 | .filesystem_roots |
| 119 | .iter() |
| 120 | .map(|value| escape_review_text(value)) |
| 121 | .collect::<Vec<_>>() |
| 122 | .join(", ") |
| 123 | }; |
| 124 | let network = if detail.network_hosts.is_empty() { |
| 125 | "none".to_string() |
| 126 | } else { |
| 127 | detail |
| 128 | .network_hosts |
| 129 | .iter() |
| 130 | .map(|value| escape_review_text(value)) |
| 131 | .collect::<Vec<_>>() |
| 132 | .join(", ") |
| 133 | }; |
| 134 | let stdio_authority = if detail.stdio_mcp_servers == 0 { |
| 135 | "none".to_string() |
| 136 | } else { |
| 137 | format!( |
| 138 | "{} local child process(es) with host-user filesystem/network authority; MCP tool approvals still apply", |
| 139 | detail.stdio_mcp_servers |
| 140 | ) |
| 141 | }; |
| 142 | format!( |
| 143 | "filesystem_roots=[{filesystem}] network_hosts=[{network}] (exact allowlist for Codewhale-managed remote requests; redirects stay same-origin) lifecycle_mutation={} stdio_runtime=[{stdio_authority}]", |
| 144 | detail.lifecycle_mutation |
| 145 | ) |
| 146 | } |
| 147 | |
| 148 | fn render_mcp_inventory(detail: &PluginDetail) -> String { |
| 149 | if detail.mcp_servers.is_empty() { |
| 150 | return "none".to_string(); |
| 151 | } |
| 152 | detail |
| 153 | .mcp_servers |
| 154 | .iter() |
| 155 | .map(render_mcp_server) |
| 156 | .collect::<Vec<_>>() |
| 157 | .join("; ") |
| 158 | } |
| 159 | |
| 160 | fn render_mcp_server(server: &PluginMcpServerDetail) -> String { |
| 161 | let enabled = if server.enabled { |
| 162 | "configured-on" |
| 163 | } else { |
| 164 | "configured-off" |
| 165 | }; |
| 166 | if let Some(command) = server.command.as_deref() { |
| 167 | let mut env_provenance = server |
| 168 | .env |
| 169 | .iter() |
| 170 | .map(|(destination, source)| { |
| 171 | let source = source |
| 172 | .strip_prefix("${") |
| 173 | .and_then(|source| source.strip_suffix('}')) |
| 174 | .unwrap_or("invalid"); |
| 175 | format!( |
| 176 | "{} <- {}", |
| 177 | escape_review_text(destination), |
| 178 | escape_review_text(source) |
| 179 | ) |
| 180 | }) |
| 181 | .collect::<Vec<_>>(); |
| 182 | env_provenance.sort_unstable(); |
| 183 | let cwd = server |
| 184 | .cwd |
| 185 | .as_deref() |
| 186 | .map(escape_review_path) |
| 187 | .unwrap_or_else(|| "plugin-root".to_string()); |
| 188 | let argv = render_review_argv(server, &server.argv); |
| 189 | format!( |
| 190 | "{}: transport=stdio command={} argv=[{}] cwd={cwd} env=[{}] timeouts={} required={} enabled_tools=[{}] disabled_tools=[{}] host-user-filesystem/network-authority {enabled}", |
| 191 | escape_review_text(&server.name), |
| 192 | escape_review_text(command), |
| 193 | argv.join(", "), |
| 194 | if env_provenance.is_empty() { |
| 195 | "none".to_string() |
| 196 | } else { |
| 197 | env_provenance.join(", ") |
| 198 | }, |
| 199 | render_mcp_timeouts(server), |
| 200 | server.required, |
| 201 | render_review_values(&server.enabled_tools), |
| 202 | render_review_values(&server.disabled_tools), |
| 203 | ) |
| 204 | } else if let Some(url) = server.url.as_deref() { |
| 205 | let endpoint = reqwest::Url::parse(url) |
| 206 | .ok() |
| 207 | .map(|url| escape_review_text(url.as_str())) |
| 208 | .unwrap_or_else(|| "invalid-url".to_string()); |
| 209 | let mut env_headers = server |
| 210 | .env_headers |
| 211 | .iter() |
| 212 | .map(|(header, source)| { |
| 213 | format!( |
| 214 | "{} <- {}", |
| 215 | escape_review_text(header), |
| 216 | escape_review_text(source) |
| 217 | ) |
| 218 | }) |
| 219 | .collect::<Vec<_>>(); |
| 220 | env_headers.sort_unstable(); |
| 221 | let bearer = server |
| 222 | .bearer_token_env_var |
| 223 | .as_deref() |
| 224 | .map(escape_review_text) |
| 225 | .unwrap_or_else(|| "none".to_string()); |
| 226 | let transport = transport_label(&server.transport); |
| 227 | format!( |
| 228 | "{}: transport={} endpoint={} redirects=same-origin-only env_headers=[{}] bearer_env={} oauth=disabled timeouts={} required={} enabled_tools=[{}] disabled_tools=[{}] {enabled}", |
| 229 | escape_review_text(&server.name), |
| 230 | escape_review_text(transport), |
| 231 | endpoint, |
| 232 | if env_headers.is_empty() { |
| 233 | "none".to_string() |
| 234 | } else { |
| 235 | env_headers.join(", ") |
| 236 | }, |
| 237 | bearer, |
| 238 | render_mcp_timeouts(server), |
| 239 | server.required, |
| 240 | render_review_values(&server.enabled_tools), |
| 241 | render_review_values(&server.disabled_tools), |
| 242 | ) |
| 243 | } else { |
| 244 | format!("{}: invalid", server.name) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | fn transport_label( |
| 249 | transport: &codewhale_command_contract::facets::PluginMcpTransport, |
| 250 | ) -> &'static str { |
| 251 | match transport { |
| 252 | codewhale_command_contract::facets::PluginMcpTransport::Stdio => "stdio", |
| 253 | codewhale_command_contract::facets::PluginMcpTransport::Http => "http", |
| 254 | codewhale_command_contract::facets::PluginMcpTransport::Invalid => "invalid", |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | fn render_review_argv(server: &PluginMcpServerDetail, arguments: &[String]) -> Vec<String> { |
| 259 | // Portable argv rendering: plugin-path classification requires the |
| 260 | // canonical root, which is carried in the detail. Keep the exact |
| 261 | // semantics of the legacy renderer. |
| 262 | let root = &server.cwd.clone().unwrap_or_default(); |
| 263 | arguments |
| 264 | .iter() |
| 265 | .enumerate() |
| 266 | .map(|(index, argument)| { |
| 267 | let position = index + 1; |
| 268 | let candidate = root.join(argument); |
| 269 | if candidate.exists() |
| 270 | && candidate |
| 271 | .canonicalize() |
| 272 | .is_ok_and(|path| path.starts_with(root)) |
| 273 | { |
| 274 | return format!( |
| 275 | "#{position} plugin-path={}", |
| 276 | render_review_argv_value(argument) |
| 277 | ); |
| 278 | } |
| 279 | format!("#{position} value={}", render_review_argv_value(argument)) |
| 280 | }) |
| 281 | .collect() |
| 282 | } |
| 283 | |
| 284 | fn render_review_argv_value(value: &str) -> String { |
| 285 | // JSON string syntax is a lossless, unambiguous terminal representation: |
| 286 | // whitespace, quotes, backslashes, and punctuation retain their exact |
| 287 | // argv semantics without hiding arbitrary values behind redaction. |
| 288 | serde_json::to_string(value).expect("serializing a Rust string cannot fail") |
| 289 | } |
| 290 | |
| 291 | fn render_review_values(values: &[String]) -> String { |
| 292 | if values.is_empty() { |
| 293 | return "none".to_string(); |
| 294 | } |
| 295 | values |
| 296 | .iter() |
| 297 | .map(|value| escape_review_text(value)) |
| 298 | .collect::<Vec<_>>() |
| 299 | .join(", ") |
| 300 | } |
| 301 | |
| 302 | fn render_mcp_timeouts(server: &PluginMcpServerDetail) -> String { |
| 303 | format!( |
| 304 | "connect={}/execute={}/read={}", |
| 305 | server |
| 306 | .connect_timeout_secs |
| 307 | .map_or_else(|| "default".to_string(), |value| format!("{value}s")), |
| 308 | server |
| 309 | .execute_timeout_secs |
| 310 | .map_or_else(|| "default".to_string(), |value| format!("{value}s")), |
| 311 | server |
| 312 | .read_timeout_secs |
| 313 | .map_or_else(|| "default".to_string(), |value| format!("{value}s")), |
| 314 | ) |
| 315 | } |
| 316 | |
| 317 | pub(crate) fn escape_review_path(path: &Path) -> String { |
| 318 | escape_review_text(&path.to_string_lossy()) |
| 319 | } |
| 320 | |
| 321 | pub(crate) fn escape_review_text(value: &str) -> String { |
| 322 | let mut escaped = String::with_capacity(value.len()); |
| 323 | for ch in value.chars() { |
| 324 | if ch.is_control() |
| 325 | || matches!( |
| 326 | ch, |
| 327 | '\u{061c}' |
| 328 | | '\u{200e}' |
| 329 | | '\u{200f}' |
| 330 | | '\u{202a}'..='\u{202e}' |
| 331 | | '\u{2066}'..='\u{2069}' |
| 332 | ) |
| 333 | { |
| 334 | let _ = write!(escaped, "\\u{{{:x}}}", ch as u32); |
| 335 | } else if matches!( |
| 336 | ch, |
| 337 | '\\' | '`' |
| 338 | | '*' |
| 339 | | '_' |
| 340 | | '{' |
| 341 | | '}' |
| 342 | | '[' |
| 343 | | ']' |
| 344 | | '<' |
| 345 | | '>' |
| 346 | | '(' |
| 347 | | ')' |
| 348 | | '#' |
| 349 | | '+' |
| 350 | | '-' |
| 351 | | '.' |
| 352 | | '!' |
| 353 | | '|' |
| 354 | ) { |
| 355 | escaped.push('\\'); |
| 356 | escaped.push(ch); |
| 357 | } else { |
| 358 | escaped.push(ch); |
| 359 | } |
| 360 | } |
| 361 | escaped |
| 362 | } |
| 363 | |
| 364 | fn _diagnostic_level_label(level: PluginDiagnosticLevel) -> &'static str { |
| 365 | match level { |
| 366 | PluginDiagnosticLevel::Warning => "warning", |
| 367 | PluginDiagnosticLevel::Error => "error", |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | fn _diagnostic_path(diagnostic: &PluginDiagnostic) -> Option<String> { |
| 372 | diagnostic |
| 373 | .path |
| 374 | .as_ref() |
| 375 | .map(|path| path.display().to_string()) |
| 376 | } |
| 377 |