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            .kill_on_drop(true)
119            .spawn()
120            .map_err(|e| {
121                anyhow::anyhow!("host plugin tool '{}' failed to start: {e}", self.name)
122            })?;
123
124        if let Some(mut stdin) = child.stdin.take() {
125            let _ = stdin.write_all(arguments.as_bytes()).await;
126            let _ = stdin.shutdown().await;
127        }
128
129        let output =
130            match crate::tools::child_proc::wait_with_timeout(&mut child, EXEC_TIMEOUT).await? {
131                Some(out) => out,
132                None => {
133                    return Ok(ToolResult::error(format!(
134                        "host plugin tool '{}' exceeded {}s and was killed",
135                        self.name,
136                        EXEC_TIMEOUT.as_secs()
137                    )))
138                }
139            };
140
141        let mut text = String::from_utf8_lossy(&output.stdout).to_string();
142        if text.is_empty() {
143            text = String::from_utf8_lossy(&output.stderr).to_string();
144        }
145        // Truncate by character so a multi-byte boundary cannot panic.
146        if text.chars().count() > MAX_OUTPUT {
147            text = text.chars().take(MAX_OUTPUT).collect::<String>() + "\n… output truncated";
148        }
149
150        if output.status.success() {
151            Ok(ToolResult::success(text))
152        } else {
153            Ok(ToolResult::error(format!(
154                "host plugin tool '{}' exited with {}: {text}",
155                self.name, output.status
156            )))
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::plugin_hosts::{HostPluginKind, HostPluginTool};
165
166    fn entry_with_tool(id: &str, command: &str, args: &[&str]) -> HostPluginEntry {
167        HostPluginEntry {
168            id: id.to_string(),
169            kind: HostPluginKind::Hermes,
170            path: std::env::temp_dir().join("plugin.json"),
171            name: Some("t".into()),
172            description: None,
173            tools: vec![HostPluginTool {
174                name: "echoer".into(),
175                description: "echoes".into(),
176                command: command.into(),
177                args: args.iter().map(|s| s.to_string()).collect(),
178            }],
179        }
180    }
181
182    #[test]
183    fn discovery_alone_grants_nothing() {
184        let entry = entry_with_tool("hermes:evil", "/bin/sh", &["-c", "echo pwned"]);
185        // The trust list is empty — the normal case for anything merely found.
186        assert!(HostPluginToolAdapter::build(&entry, &[]).is_empty());
187        // Trusting a *different* plugin must not help.
188        assert!(HostPluginToolAdapter::build(&entry, &["hermes:other".into()]).is_empty());
189    }
190
191    #[test]
192    fn a_named_plugin_is_built() {
193        let entry = entry_with_tool("hermes:good", "/bin/echo", &[]);
194        let tools = HostPluginToolAdapter::build(&entry, &["hermes:good".into()]);
195        assert_eq!(tools.len(), 1);
196        assert_eq!(tools[0].name(), "echoer");
197    }
198
199    #[tokio::test]
200    async fn arguments_go_to_stdin_not_argv() {
201        // `cat` proves the payload arrived on stdin: if arguments were passed
202        // as argv this would print nothing.
203        let entry = entry_with_tool("hermes:cat", "cat", &[]);
204        let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
205        let result = tools[0].execute(r#"{"k":"v"}"#).await.unwrap();
206        assert!(!result.is_error, "{}", result.output);
207        assert!(result.output.contains("\"k\":\"v\""), "{}", result.output);
208    }
209
210    #[tokio::test]
211    async fn a_hostile_argument_is_not_interpreted() {
212        // Executed directly, so shell metacharacters are inert data. If this
213        // ever went through a shell, the `;` would run a second command.
214        let entry = entry_with_tool("hermes:cat", "cat", &[]);
215        let tools = HostPluginToolAdapter::build(&entry, &["hermes:cat".into()]);
216        let result = tools[0]
217            .execute("; touch /tmp/apollo-should-not-exist; echo")
218            .await
219            .unwrap();
220        assert!(!result.is_error);
221        assert!(
222            !std::path::Path::new("/tmp/apollo-should-not-exist").exists(),
223            "argument was interpreted by a shell"
224        );
225    }
226
227    #[tokio::test]
228    async fn a_failing_command_reports_rather_than_panics() {
229        let entry = entry_with_tool("hermes:missing", "/nonexistent/apollo-binary", &[]);
230        let tools = HostPluginToolAdapter::build(&entry, &["hermes:missing".into()]);
231        let err = tools[0].execute("{}").await.unwrap_err().to_string();
232        assert!(err.contains("failed to start"), "{err}");
233    }
234}