返回 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 `&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
344 lines RUST