返回 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 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
355 lines RUST