1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use tokio::fs;
8use tokio::sync::RwLock;
9
10use crate::permissions::PermissionAction;
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct PluginManifest {
14 pub name: String,
15 #[serde(default = "default_true")]
16 pub enabled: bool,
17 pub system_prompt_prefix: Option<String>,
18 pub system_prompt_suffix: Option<String>,
19 #[serde(default)]
20 pub allow_tools: Vec<String>,
21 #[serde(default)]
22 pub deny_tools: Vec<String>,
23 #[serde(default)]
24 pub shell_env: HashMap<String, String>,
25 pub tool_output_suffix: Option<String>,
26}
27
28#[derive(Clone)]
29pub struct PluginRegistry {
30 plugins: Arc<RwLock<Vec<PluginManifest>>>,
31}
32
33impl PluginRegistry {
34 pub async fn new(workspace_root: impl Into<PathBuf>) -> anyhow::Result<Self> {
35 let root: PathBuf = workspace_root.into();
36 let plugins = load_plugins(root.join(".tandem").join("plugins")).await?;
37 let active: Vec<&str> = plugins
40 .iter()
41 .filter(|p| p.enabled)
42 .map(|p| p.name.as_str())
43 .collect();
44 if !active.is_empty() {
45 tracing::info!(
46 count = %active.len(),
47 names = %active.join(", "),
48 "plugin registry loaded active plugins"
49 );
50 }
51 Ok(Self {
52 plugins: Arc::new(RwLock::new(plugins)),
53 })
54 }
55
56 pub async fn list(&self) -> Vec<PluginManifest> {
57 self.plugins.read().await.clone()
58 }
59
60 pub async fn transform_prompt(&self, prompt: String) -> String {
61 let plugins = self.plugins.read().await;
62 let mut transformed = prompt;
63 for plugin in plugins.iter().filter(|p| p.enabled) {
64 if let Some(prefix) = &plugin.system_prompt_prefix {
65 transformed = format!("{prefix}\n\n{transformed}");
66 }
67 if let Some(suffix) = &plugin.system_prompt_suffix {
68 transformed = format!("{transformed}\n\n{suffix}");
69 }
70 }
71 transformed
72 }
73
74 pub async fn permission_override(&self, tool_name: &str) -> Option<PermissionAction> {
75 let plugins = self.plugins.read().await;
76 let mut denied = false;
77 let mut allowed = false;
78 for plugin in plugins.iter().filter(|p| p.enabled) {
79 if plugin.deny_tools.iter().any(|t| t == tool_name) {
80 denied = true;
81 }
82 if plugin.allow_tools.iter().any(|t| t == tool_name) {
83 allowed = true;
84 }
85 }
86 if denied {
87 if allowed {
89 tracing::warn!(
90 tool = %tool_name,
91 "plugin conflict: tool appears in both deny_tools and allow_tools; deny wins"
92 );
93 }
94 return Some(PermissionAction::Deny);
95 }
96 if allowed {
97 return Some(PermissionAction::Allow);
98 }
99 None
100 }
101
102 pub async fn inject_tool_args(&self, tool_name: &str, mut args: Value) -> Value {
103 if tool_name != "bash" {
104 return args;
105 }
106
107 let plugins = self.plugins.read().await;
108 let mut merged_env = serde_json::Map::new();
109 for plugin in plugins.iter().filter(|p| p.enabled) {
110 for (k, v) in &plugin.shell_env {
111 merged_env.insert(k.clone(), Value::String(v.clone()));
112 }
113 }
114 if !merged_env.is_empty() {
115 args["env"] = Value::Object(merged_env);
116 }
117 args
118 }
119
120 pub async fn transform_tool_output(&self, output: String) -> String {
121 let plugins = self.plugins.read().await;
122 let mut transformed = output;
123 for plugin in plugins.iter().filter(|p| p.enabled) {
124 if let Some(suffix) = &plugin.tool_output_suffix {
125 transformed = format!("{transformed}\n{suffix}");
126 }
127 }
128 transformed
129 }
130}
131
132fn default_true() -> bool {
133 true
134}
135
136const MAX_PLUGIN_PROMPT_LENGTH: usize = 8_192;
140
141fn sanitize_plugin_manifest(mut manifest: PluginManifest, source: &str) -> PluginManifest {
145 let mut oversized = false;
146 if let Some(ref prefix) = manifest.system_prompt_prefix {
147 if prefix.len() > MAX_PLUGIN_PROMPT_LENGTH {
148 tracing::warn!(
149 plugin = %manifest.name,
150 source = %source,
151 field = "system_prompt_prefix",
152 len = %prefix.len(),
153 max = %MAX_PLUGIN_PROMPT_LENGTH,
154 "plugin prompt field exceeds max length; plugin disabled"
155 );
156 manifest.system_prompt_prefix = None;
157 oversized = true;
158 }
159 }
160 if let Some(ref suffix) = manifest.system_prompt_suffix {
161 if suffix.len() > MAX_PLUGIN_PROMPT_LENGTH {
162 tracing::warn!(
163 plugin = %manifest.name,
164 source = %source,
165 field = "system_prompt_suffix",
166 len = %suffix.len(),
167 max = %MAX_PLUGIN_PROMPT_LENGTH,
168 "plugin prompt field exceeds max length; plugin disabled"
169 );
170 manifest.system_prompt_suffix = None;
171 oversized = true;
172 }
173 }
174 if oversized {
175 manifest.enabled = false;
176 }
177 manifest
178}
179
180async fn load_plugins(dir: PathBuf) -> anyhow::Result<Vec<PluginManifest>> {
181 let mut out = Vec::new();
182 let mut entries = match fs::read_dir(&dir).await {
183 Ok(rd) => rd,
184 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(out),
185 Err(err) => return Err(err.into()),
186 };
187
188 while let Some(entry) = entries.next_entry().await? {
189 let path = entry.path();
190 let Some(ext) = path.extension().and_then(|v| v.to_str()) else {
191 continue;
192 };
193 if ext != "json" {
194 continue;
195 }
196 let raw = fs::read_to_string(&path).await?;
197 if let Ok(parsed) = serde_json::from_str::<PluginManifest>(&raw) {
198 let sanitized = sanitize_plugin_manifest(parsed, &path.to_string_lossy());
199 out.push(sanitized);
200 }
201 }
202 out.sort_by(|a, b| a.name.cmp(&b.name));
203 Ok(out)
204}