返回 CodeWhale
render.rs
根目录 / crates / tui / src / commands / groups / plugins / render.rs
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
377 lines RUST