1use std::fmt::Write as _;
5use std::future::Future;
6use std::pin::Pin;
7
8use tracing::Instrument as _;
9use zeph_commands::{CommandError, IntegrationAccess};
10
11#[cfg(feature = "cocoon")]
12use super::command_macros::delegate_cmd;
13use super::{Agent, error::AgentError};
14use crate::channel::Channel;
15
16async fn semantic_scan_plugin_add(
28 scanner: &zeph_skills::semantic_scanner::SkillSemanticScanner,
29 source: &str,
30 managed_dir: Option<std::path::PathBuf>,
31 mcp_allowed: Vec<String>,
32 base_shell_allowed: Vec<String>,
33) -> Result<Option<String>, CommandError> {
34 use futures::stream::StreamExt as _;
35 use zeph_skills::semantic_scanner::ScanVerdict;
36
37 let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
38 let mgr_dir =
39 managed_dir.unwrap_or_else(|| zeph_config::defaults::default_vault_dir().join("skills"));
40 let mgr =
41 zeph_plugins::PluginManager::new(plugins_dir, mgr_dir, mcp_allowed, base_shell_allowed);
42
43 let source_owned = source.to_owned();
44 let scan_inputs = tokio::task::spawn_blocking(move || mgr.scan_targets(&source_owned))
45 .await
46 .map_err(|e| CommandError(format!("plugin scan_targets panicked: {e}")))?
47 .map_err(|e| CommandError(format!("plugin add failed: {e}")))?;
48
49 tracing::info!(
50 plugin.source = %source,
51 skills_count = scan_inputs.len(),
52 "plugins.add: running Stage-2 semantic scan"
53 );
54
55 let scan_futs: Vec<_> = scan_inputs
60 .iter()
61 .map(|input| {
62 let name = input.skill_name.clone();
63 let purpose = input.declared_purpose.clone();
64 let md = input.skill_md.clone();
65 async move {
66 let verdict = scanner.scan(&name, &purpose, &md).await;
67 (name, verdict)
68 }
69 })
70 .collect();
71
72 let verdicts: Vec<_> = tokio::time::timeout(
73 std::time::Duration::from_mins(5),
74 futures::stream::iter(scan_futs)
75 .buffer_unordered(4)
76 .collect::<Vec<_>>(),
77 )
78 .await
79 .map_err(|_| CommandError("plugin scan timed out after 300s".to_owned()))?;
80
81 for (skill_name, verdict_result) in verdicts {
82 let verdict = verdict_result.map_err(|e| {
83 CommandError(format!(
84 "plugin add failed: semantic scan error for skill {skill_name:?}: {e}"
85 ))
86 })?;
87 match verdict {
88 ScanVerdict::Allow => {
89 tracing::debug!(
90 skill = %skill_name,
91 "plugins.add: skill passed semantic scan"
92 );
93 }
94 ScanVerdict::Warn(ref reason) => {
95 tracing::warn!(
96 skill = %skill_name,
97 reason = %reason,
98 "plugins.add: skill passed with warning"
99 );
100 }
101 ScanVerdict::Block(reason) => {
102 return Ok(Some(format!(
103 "plugin add failed: skill {skill_name:?} rejected by semantic scan: {reason}"
104 )));
105 }
106 _ => {}
107 }
108 }
109 Ok(None)
110}
111
112pub(super) fn format_acp_dirs(cfg: &zeph_config::AcpConfig) -> String {
114 let mut out = String::new();
115 let _ = writeln!(out, "ACP additional_directories allowlist:");
116 if cfg.additional_directories.is_empty() {
117 let _ = writeln!(out, " (none configured)");
118 } else {
119 for dir in &cfg.additional_directories {
120 let _ = writeln!(out, " {dir}");
121 }
122 }
123 out.trim_end().to_owned()
124}
125
126pub(super) fn format_acp_auth_methods(cfg: &zeph_config::AcpConfig) -> String {
128 let mut out = String::new();
129 let _ = writeln!(out, "ACP auth_methods:");
130 if cfg.auth_methods.is_empty() {
131 let _ = writeln!(out, " (none configured)");
132 } else {
133 for method in &cfg.auth_methods {
134 let _ = writeln!(out, " {method}");
135 }
136 }
137 out.trim_end().to_owned()
138}
139
140pub(super) fn format_acp_status(cfg: &zeph_config::AcpConfig, is_acp_session: bool) -> String {
142 let mut out = String::new();
143 let enabled = if cfg.enabled { "enabled" } else { "disabled" };
144 let _ = writeln!(out, "ACP: {enabled}");
145 let _ = writeln!(out, "transport: {:?}", cfg.transport);
146 let _ = writeln!(out, "agent_name: {}", cfg.agent_name);
147 let _ = writeln!(out, "agent_version: {}", cfg.agent_version);
148 let _ = writeln!(out, "max_sessions: {}", cfg.max_sessions);
149 let _ = writeln!(out, "http_bind: {}", cfg.http_bind);
150 let _ = writeln!(out, "discovery: {}", cfg.discovery_enabled);
151 let _ = writeln!(out, "message_ids: {}", cfg.message_ids_enabled);
152 let _ = writeln!(
153 out,
154 "this session: {}",
155 if is_acp_session {
156 "ACP client"
157 } else {
158 "non-ACP"
159 }
160 );
161 out.trim_end().to_owned()
162}
163
164pub(super) fn dispatch_acp(
166 cfg: &zeph_config::AcpConfig,
167 is_acp_session: bool,
168 args: &str,
169) -> Result<String, AgentError> {
170 match args.trim() {
171 "dirs" => Ok(format_acp_dirs(cfg)),
172 "auth-methods" => Ok(format_acp_auth_methods(cfg)),
173 "status" => Ok(format_acp_status(cfg, is_acp_session)),
174 "" => Ok(
175 "Usage: /acp <subcommand>\n\nSubcommands:\n dirs List additional_directories allowlist\n auth-methods List advertised auth methods\n status Show ACP server configuration summary"
176 .to_owned(),
177 ),
178 other => Err(AgentError::UnknownCommand(format!(
179 "Unknown /acp subcommand: {other}. Valid subcommands: dirs, auth-methods, status"
180 ))),
181 }
182}
183
184impl<C: Channel> Agent<C> {
185 pub(super) fn handle_acp_as_string(&mut self, args: &str) -> Result<String, AgentError> {
187 dispatch_acp(
188 &self.runtime.config.acp_config,
189 self.services.security.is_acp_session,
190 args,
191 )
192 }
193}
194
195impl<C: Channel + Send + 'static> IntegrationAccess for Agent<C> {
196 fn handle_plugins<'a>(
199 &'a mut self,
200 args: &'a str,
201 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
202 let args_owned = args.to_owned();
203 let managed_dir = self.services.skill.managed_dir.clone();
206 let mcp_allowed = self.services.mcp.allowed_commands.clone();
207 let base_shell_allowed = self.runtime.lifecycle.startup_shell_overlay.allowed.clone();
208 let reputation_cfg = self.runtime.config.plugins_reputation.clone();
211 let ephemeral_manifest_paths: Vec<std::path::PathBuf> = self
214 .runtime
215 .ephemeral_plugins
216 .iter()
217 .map(|tmp| tmp.path().join("plugin.toml"))
218 .collect();
219
220 let semantic_scan_enabled = self.services.skill.semantic_scan;
224 let maybe_scanner: Option<zeph_skills::semantic_scanner::SkillSemanticScanner> =
225 if semantic_scan_enabled {
226 let provider_name = self.services.skill.semantic_scan_provider.as_str();
227 if provider_name.trim().is_empty() {
228 return Box::pin(async move {
229 Err(CommandError::new(
230 "semantic_scan is enabled but semantic_scan_provider is not set; \
231 refusing plugin add to maintain fail-closed security posture",
232 ))
233 });
234 }
235 let provider_known = self
236 .runtime
237 .providers
238 .provider_pool
239 .iter()
240 .any(|e| e.effective_name().eq_ignore_ascii_case(provider_name));
241 if !provider_known {
242 let name = provider_name.to_owned();
243 return Box::pin(async move {
244 Err(CommandError::new(format!(
245 "semantic_scan is enabled but semantic_scan_provider '{name}' \
246 is not configured in [[llm.providers]]; \
247 refusing plugin add to maintain fail-closed security posture",
248 )))
249 });
250 }
251 let provider = self.resolve_background_provider(provider_name);
252 Some(zeph_skills::semantic_scanner::SkillSemanticScanner::new(
253 provider,
254 ))
255 } else {
256 None
257 };
258
259 Box::pin(async move {
260 let (subcmd, source) = args_owned
261 .trim()
262 .split_once(' ')
263 .unwrap_or((args_owned.trim(), ""));
264
265 if subcmd == "add"
267 && !source.trim().is_empty()
268 && let Some(ref scanner) = maybe_scanner
269 && let Some(err) = semantic_scan_plugin_add(
270 scanner,
271 source.trim(),
272 managed_dir.clone(),
273 mcp_allowed.clone(),
274 base_shell_allowed.clone(),
275 )
276 .instrument(tracing::info_span!("core.agent.scan_plugin", plugin = %source.trim()))
277 .await?
278 {
279 return Ok(err);
280 }
281
282 let ephemeral_names: Vec<String> = {
284 use futures::future::join_all;
285 let futs = ephemeral_manifest_paths.into_iter().map(|p| async move {
286 tokio::fs::read_to_string(&p)
287 .await
288 .ok()
289 .and_then(|s| toml::from_str::<zeph_plugins::PluginManifest>(&s).ok())
290 .map(|m| m.plugin.name.to_string())
291 });
292 join_all(futs).await.into_iter().flatten().collect()
293 };
294
295 tokio::task::spawn_blocking(move || {
298 Self::run_plugin_command(
299 &args_owned,
300 managed_dir,
301 mcp_allowed,
302 base_shell_allowed,
303 ephemeral_names,
304 &reputation_cfg,
305 )
306 })
307 .await
308 .map_err(|e| CommandError(format!("plugin task panicked: {e}")))
309 })
310 }
311
312 fn handle_acp<'a>(
315 &'a mut self,
316 args: &'a str,
317 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
318 Box::pin(async move {
319 self.handle_acp_as_string(args)
320 .map_err(|e| CommandError::new(e.to_string()))
321 })
322 }
323
324 #[cfg(feature = "cocoon")]
327 delegate_cmd!(handle_cocoon, handle_cocoon_as_string, args: &'a str => String);
328
329 #[cfg(not(feature = "cocoon"))]
330 fn handle_cocoon<'a>(
331 &'a mut self,
332 _args: &'a str,
333 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
334 Box::pin(async {
335 Ok("Cocoon support is not compiled in. Rebuild with `--features cocoon`.".to_owned())
336 })
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::super::agent_tests::{
343 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
344 };
345 use super::*;
346
347 fn cfg_default() -> zeph_config::AcpConfig {
348 zeph_config::AcpConfig::default()
349 }
350
351 fn cfg_with_dirs(dirs: &[&str]) -> zeph_config::AcpConfig {
352 let mut cfg = cfg_default();
353 cfg.additional_directories = dirs
354 .iter()
355 .map(|p| {
356 zeph_config::AdditionalDir::parse(
357 std::path::Path::new(p)
358 .canonicalize()
359 .unwrap_or_else(|_| std::path::PathBuf::from(p)),
360 )
361 .unwrap_or_else(|_| panic!("failed to parse {p}"))
362 })
363 .collect();
364 cfg
365 }
366
367 #[test]
368 fn dirs_empty() {
369 let out = format_acp_dirs(&cfg_default());
370 assert!(out.contains("(none configured)"), "got: {out}");
371 }
372
373 #[test]
374 fn dirs_populated() {
375 let tmp_dir = tempfile::tempdir().expect("tempdir");
379 let canonical =
380 std::fs::canonicalize(tmp_dir.path()).unwrap_or_else(|_| tmp_dir.path().to_owned());
381 let canonical_str = canonical.to_string_lossy();
382 let cfg = cfg_with_dirs(&[canonical_str.as_ref()]);
383 let out = format_acp_dirs(&cfg);
384 assert!(out.contains(canonical_str.as_ref()), "got: {out}");
385 assert!(!out.contains("(none configured)"), "got: {out}");
386 }
387
388 #[test]
389 fn auth_methods_default() {
390 let out = format_acp_auth_methods(&cfg_default());
391 assert!(out.contains("agent"), "got: {out}");
392 assert!(!out.contains("Agent"), "got: {out}");
393 }
394
395 #[test]
396 fn auth_methods_empty() {
397 let mut cfg = cfg_default();
398 cfg.auth_methods.clear();
399 let out = format_acp_auth_methods(&cfg);
400 assert!(out.contains("(none configured)"), "got: {out}");
401 }
402
403 #[test]
404 fn status_disabled() {
405 let out = format_acp_status(&cfg_default(), false);
406 assert!(out.contains("ACP: disabled"), "got: {out}");
407 assert!(out.contains("non-ACP"), "got: {out}");
408 }
409
410 #[test]
411 fn status_enabled_acp_session() {
412 let mut cfg = cfg_default();
413 cfg.enabled = true;
414 let out = format_acp_status(&cfg, true);
415 assert!(out.contains("ACP: enabled"), "got: {out}");
416 assert!(out.contains("ACP client"), "got: {out}");
417 }
418
419 #[test]
420 fn empty_args_returns_help() {
421 let out = dispatch_acp(&cfg_default(), false, "").unwrap();
422 assert!(out.contains("Usage: /acp"), "got: {out}");
423 assert!(out.contains("dirs"), "got: {out}");
424 assert!(out.contains("auth-methods"), "got: {out}");
425 assert!(out.contains("status"), "got: {out}");
426 }
427
428 #[test]
429 fn unknown_subcommand_returns_err() {
430 let err = dispatch_acp(&cfg_default(), false, "bogus").unwrap_err();
431 let msg = err.to_string();
432 assert!(msg.contains("bogus"), "got: {msg}");
433 assert!(
434 !msg.contains("\"bogus\""),
435 "should not quote arg, got: {msg}"
436 );
437 assert!(
438 msg.contains("dirs"),
439 "should list valid subcommands, got: {msg}"
440 );
441 }
442
443 #[test]
444 fn whitespace_args_returns_help() {
445 let out = dispatch_acp(&cfg_default(), false, " ").unwrap();
446 assert!(out.contains("Usage: /acp"), "got: {out}");
447 }
448
449 #[tokio::test]
454 async fn plugin_add_semantic_scan_enabled_empty_provider_returns_error() {
455 let mut agent = Agent::new(
456 mock_provider(vec![]),
457 MockChannel::new(vec![]),
458 create_test_registry(),
459 None,
460 5,
461 MockToolExecutor::no_tools(),
462 )
463 .with_semantic_scan(true, "");
464
465 let result = agent.handle_plugins("add some-plugin").await;
466 assert!(
467 result.is_err(),
468 "expected CommandError for missing semantic_scan_provider, got: {result:?}"
469 );
470 let msg = result.unwrap_err().to_string();
471 assert!(
472 msg.contains("semantic_scan_provider"),
473 "error message must mention semantic_scan_provider, got: {msg}"
474 );
475 }
476
477 #[tokio::test]
480 async fn plugin_list_semantic_scan_disabled_succeeds() {
481 let mut agent = Agent::new(
482 mock_provider(vec![]),
483 MockChannel::new(vec![]),
484 create_test_registry(),
485 None,
486 5,
487 MockToolExecutor::no_tools(),
488 )
489 .with_semantic_scan(false, "");
490
491 let result = agent.handle_plugins("list").await;
493 assert!(
494 result.is_ok(),
495 "plugin list must succeed when semantic_scan is disabled, got: {result:?}"
496 );
497 }
498
499 #[tokio::test]
503 async fn plugin_add_semantic_scan_disabled_no_scan_error() {
504 let mut agent = Agent::new(
505 mock_provider(vec![]),
506 MockChannel::new(vec![]),
507 create_test_registry(),
508 None,
509 5,
510 MockToolExecutor::no_tools(),
511 )
512 .with_semantic_scan(false, "");
513
514 let result = agent.handle_plugins("add some-plugin").await;
515 if let Err(ref e) = result {
518 assert!(
519 !e.to_string().contains("semantic_scan_provider"),
520 "must not fail with scan error when semantic_scan is disabled, got: {e}"
521 );
522 }
523 }
524
525 #[tokio::test]
529 async fn semantic_scan_plugin_add_concurrent_all_allow_returns_none() {
530 use zeph_llm::any::AnyProvider;
531 use zeph_llm::mock::MockProvider;
532 use zeph_skills::semantic_scanner::SkillSemanticScanner;
533
534 let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
536 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
537 allow_json.clone(),
538 allow_json,
539 ]));
540 let scanner = SkillSemanticScanner::new(provider);
541
542 let tmp = tempfile::tempdir().unwrap();
545 let plugin_toml = r#"
546[plugin]
547name = "test-plugin"
548version = "0.1.0"
549description = "test"
550
551[[skills]]
552path = "skill-a"
553
554[[skills]]
555path = "skill-b"
556"#;
557 std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
558 for name in ["skill-a", "skill-b"] {
559 let skill_dir = tmp.path().join(name);
560 std::fs::create_dir_all(&skill_dir).unwrap();
561 std::fs::write(
562 skill_dir.join("SKILL.md"),
563 format!("# {name}\n\n## Purpose\nTest skill.\n"),
564 )
565 .unwrap();
566 }
567
568 let result =
569 semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
570 .await;
571
572 assert!(result.is_ok(), "expected Ok, got: {result:?}");
574 assert!(
575 result.unwrap().is_none(),
576 "expected None (all passed) but got Some(err)"
577 );
578 }
579
580 #[tokio::test]
585 async fn semantic_scan_plugin_add_block_names_correct_skill() {
586 use zeph_llm::any::AnyProvider;
587 use zeph_llm::mock::MockProvider;
588 use zeph_skills::semantic_scanner::SkillSemanticScanner;
589
590 let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
592 let block_json = r#"{"verdict":"block","reason":"malicious"}"#.to_owned();
593 let provider =
594 AnyProvider::Mock(MockProvider::with_responses(vec![allow_json, block_json]));
595 let scanner = SkillSemanticScanner::new(provider);
596
597 let tmp = tempfile::tempdir().unwrap();
598 let plugin_toml = r#"
599[plugin]
600name = "test-plugin-block"
601version = "0.1.0"
602description = "test"
603
604[[skills]]
605path = "skill-first"
606
607[[skills]]
608path = "skill-second"
609"#;
610 std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
611 for name in ["skill-first", "skill-second"] {
612 let skill_dir = tmp.path().join(name);
613 std::fs::create_dir_all(&skill_dir).unwrap();
614 std::fs::write(
615 skill_dir.join("SKILL.md"),
616 format!("# {name}\n\n## Purpose\nTest skill.\n"),
617 )
618 .unwrap();
619 }
620
621 let result =
622 semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
623 .await;
624
625 assert!(result.is_ok(), "expected Ok(_), got: {result:?}");
626 let msg = result
627 .unwrap()
628 .expect("expected Some(err) for blocked skill");
629 assert!(
630 msg.contains("skill-second"),
631 "rejection must name the blocked skill 'skill-second', got: {msg}"
632 );
633 assert!(
634 !msg.contains("skill-first"),
635 "rejection must NOT name the allowed skill 'skill-first', got: {msg}"
636 );
637 }
638
639 #[tokio::test]
642 async fn plugin_add_semantic_scan_unknown_provider_returns_error() {
643 let mut agent = Agent::new(
644 mock_provider(vec![]),
645 MockChannel::new(vec![]),
646 create_test_registry(),
647 None,
648 5,
649 MockToolExecutor::no_tools(),
650 )
651 .with_semantic_scan(true, "nonexistent_provider");
652
653 let result = agent.handle_plugins("add some-plugin").await;
654 assert!(
655 result.is_err(),
656 "expected CommandError for unknown semantic_scan_provider, got: {result:?}"
657 );
658 let msg = result.unwrap_err().to_string();
659 assert!(
660 msg.contains("semantic_scan_provider"),
661 "error message must mention semantic_scan_provider, got: {msg}"
662 );
663 }
664}