Skip to main content

tandem_core/
plugins.rs

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        // L-3: Log active plugins at startup so the plugin set is always visible
38        // in structured logs. Aids debugging and makes prompt injection harder to hide.
39        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            // Deny always wins regardless of allow entries.
88            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
136/// Maximum allowed length for plugin-supplied prompt text.
137/// Plugins exceeding this limit have their prompt fields cleared and are flagged.
138/// Prevents prompt injection via oversized plugin manifests loaded from the workspace.
139const MAX_PLUGIN_PROMPT_LENGTH: usize = 8_192;
140
141/// Validate and sanitize a plugin manifest. Returns the manifest with prompt fields
142/// capped/cleared if they exceed the length limit, and `enabled` set to false if
143/// the violation is significant enough to warrant disabling the plugin.
144fn 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}