Skip to main content

apollo/
plugin_exec.rs

1//! Running tools declared by a host plugin manifest.
2//!
3//! # Why this is deny-by-default
4//!
5//! A host plugin is a directory apollo *found*. Turning a manifest in a found
6//! directory into an executable the agent can call means anything that can
7//! write a file into `plugins/` gets code execution — which is the prompt
8//! injection to persistent execution path in AGENTS.md section 10, arrived at
9//! from the other side.
10//!
11//! So discovery grants nothing. A manifest's tools are only built if its
12//! plugin id appears in `plugin_layer.trusted_host_plugins`, which is empty by
13//! default. Enabling the feature at all is not enough; each plugin is named.
14//!
15//! Within that gate the execution is still narrow:
16//! - the command is executed directly, **never** through a shell, so there is
17//!   no quoting or metacharacter surface;
18//! - JSON arguments arrive on **stdin**, not argv, so a crafted argument
19//!   cannot turn into another flag or another command;
20//! - the working directory is the plugin's own directory;
21//! - the run is bounded by a timeout and the captured output is truncated.
22
23use std::path::PathBuf;
24use std::process::Stdio;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use tokio::io::AsyncWriteExt;
29
30use crate::plugin_hosts::{HostPluginEntry, HostPluginTool};
31use crate::tools::{Tool, ToolResult, ToolSpec};
32
33/// How long a host plugin tool may run before it is killed.
34const EXEC_TIMEOUT: Duration = Duration::from_secs(60);
35/// How much of its output is kept.
36const MAX_OUTPUT: usize = 64 * 1024;
37
38/// A manifest-declared command, exposed to the agent as a tool.
39pub struct HostPluginToolAdapter {
40    name: String,
41    description: String,
42    command: String,
43    args: Vec<String>,
44    cwd: PathBuf,
45}
46
47impl HostPluginToolAdapter {
48    /// Build the adapters for a plugin, but only if it is trusted.
49    ///
50    /// `trusted` holds plugin ids exactly as `HostPluginEntry::id` reports them
51    /// (`hermes:foo`, `openclaw:bar`). An untrusted plugin yields nothing —
52    /// no error, because a discovered-but-untrusted plugin is the normal case,
53    /// not a fault.
54    pub fn build(entry: &HostPluginEntry, trusted: &[String]) -> Vec<Self> {
55        if entry.tools.is_empty() {
56            return Vec::new();
57        }
58        if !trusted.iter().any(|t| t == &entry.id) {
59            tracing::info!(
60                "[plugin-host] {} declares {} tool(s) but is not in \
61                 plugin_layer.trusted_host_plugins — not loading them",
62                entry.id,
63                entry.tools.len()
64            );
65            return Vec::new();
66        }
67        let cwd = entry
68            .path
69            .parent()
70            .map(|p| p.to_path_buf())
71            .unwrap_or_else(|| PathBuf::from("."));
72
73        entry
74            .tools
75            .iter()
76            .map(|t| Self::from_declaration(t, &cwd))
77            .collect()
78    }
79
80    fn from_declaration(tool: &HostPluginTool, cwd: &std::path::Path) -> Self {
81        Self {
82            name: tool.name.clone(),
83            description: tool.description.clone(),
84            command: tool.command.clone(),
85            args: tool.args.clone(),
86            cwd: cwd.to_path_buf(),
87        }
88    }
89}
90
91#[async_trait]
92impl Tool for HostPluginToolAdapter {
93    fn name(&self) -> &str {
94        &self.name
95    }
96
97    fn spec(&self) -> ToolSpec {
98        ToolSpec {
99            name: self.name.clone(),
100            description: self.description.clone(),
101            // The manifest does not describe a schema, so the contract is
102            // "whatever JSON you send arrives on stdin".
103            parameters: serde_json::json!({
104                "type": "object",
105                "properties": {},
106                "additionalProperties": true
107            }),
108        }
109    }
110
111    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
112        let mut child = tokio::process::Command::new(&self.command)
113            .args(&self.args)
114            .current_dir(&self.cwd)
115            .stdin(Stdio::piped())
116            .stdout(Stdio::piped())
117            .stderr(Stdio::piped())
118            .spawn()
119            .map_err(|e| {
120                anyhow::anyhow!("host plugin tool '{}' failed to start: {e}", self.name)
121            })?;
122
123        if let Some(mut stdin) = child.stdin.take() {
124            let _ = stdin.write_all(arguments.as_bytes()).await;
125            let _ = stdin.shutdown().await;
126        }
127
128        let output = match tokio::time::timeout(EXEC_TIMEOUT, child.wait_with_output()).await {
129            Ok(out) => out?,
130            Err(_) => {
131                return Ok(ToolResult::error(format!(
132                    "host plugin tool '{}' exceeded {}s and was abandoned",
133                    self.name,
134                    EXEC_TIMEOUT.as_secs()
135                )))
136            }
137        };
138
139        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
140        if text.is_empty() {
141            text = String::from_utf8_lossy(&output.stderr).to_string();
142        }
143        // Truncate by character so a multi-byte boundary cannot panic.
144        if text.chars().count() > MAX_OUTPUT {
145            text = text.chars().take(MAX_OUTPUT).collect::<String>() + "\n… output truncated";
146        }
147
148        if output.status.success() {
149            Ok(ToolResult::success(text))
150        } else {
151            Ok(ToolResult::error(format!(
152                "host plugin tool '{}' exited with {}: {text}",
153                self.name, output.status
154            )))
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::plugin_hosts::{HostPluginKind, HostPluginTool};
163
164    fn entry_with_tool(id: &str, command: &str, args: &[&str]) -> HostPluginEntry {
165        HostPluginEntry {
166            id: id.to_string(),
167            kind: HostPluginKind::Hermes,
168            path: std::env::temp_dir().join("plugin.json"),
169            name: Some("t".into()),
170            description: None,
171            tools: vec![HostPluginTool {
172                name: "echoer".into(),
173                description: "echoes".into(),
174                command: command.into(),
175                args: args.iter().map(|s| s.to_string()).collect(),
176            }],
177        }
178    }
179
180    #[test]
181    fn discovery_alone_grants_nothing() {
182        let entry = entry_with_tool("hermes:evil", "/bin/sh", &["-c", "echo pwned"]);
183        // The trust list is empty — the normal case for anything merely found.
184        assert!(HostPluginToolAdapter::build(&entry, &[]).is_empty());
185        // Trusting a *different* plugin must not help.
186        assert!(HostPluginToolAdapter::build(&entry, &["hermes:other".into()]).is_empty());
187    }
188
189    #[test]
190    fn a_named_plugin_is_built() {
191        let entry = entry_with_tool("hermes:good", "/bin/echo", &[]);
192        let tools = HostPluginToolAdapter::build(&entry, &["hermes:good".into()]);
193        assert_eq!(tools.len(), 1);
194        assert_eq!(tools[0].name(), "echoer");
195    }
196
197    #[tokio::test]
198    async fn arguments_go_to_stdin_not_argv() {
199        // `cat` proves the payload arrived on stdin: if arguments were passed
200        // as argv this would print nothing.
201        let entry = entry_with_tool("hermes:cat", "cat", &[]);
202        let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
203        let result = tools[0].execute(r#"{"k":"v"}"#).await.unwrap();
204        assert!(!result.is_error, "{}", result.output);
205        assert!(result.output.contains("\"k\":\"v\""), "{}", result.output);
206    }
207
208    #[tokio::test]
209    async fn a_hostile_argument_is_not_interpreted() {
210        // Executed directly, so shell metacharacters are inert data. If this
211        // ever went through a shell, the `;` would run a second command.
212        let entry = entry_with_tool("hermes:cat", "cat", &[]);
213        let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
214        let result = tools[0]
215            .execute("; touch /tmp/apollo-should-not-exist; echo")
216            .await
217            .unwrap();
218        assert!(!result.is_error);
219        assert!(
220            !std::path::Path::new("/tmp/apollo-should-not-exist").exists(),
221            "argument was interpreted by a shell"
222        );
223    }
224
225    #[tokio::test]
226    async fn a_failing_command_reports_rather_than_panics() {
227        let entry = entry_with_tool("hermes:missing", "/nonexistent/apollo-binary", &[]);
228        let tools = HostPluginToolAdapter::build(&entry, &["hermes:missing".into()]);
229        let err = tools[0].execute("{}").await.unwrap_err().to_string();
230        assert!(err.contains("failed to start"), "{err}");
231    }
232}