Skip to main content

aegis_tools/
remote.rs

1//! Remote server access over SSH (stage 1 of multi-machine support).
2//!
3//! Lets the agent operate a remote server given `host` + `user` + (`password`
4//! or an SSH key/agent). Actions: `run` a command, `upload` a file, `check`
5//! connectivity. Password auth uses the system `sshpass` with the password
6//! passed via the `SSHPASS` env var — never on the command line (so it doesn't
7//! leak into `ps`/argv) and never echoed back in output.
8//!
9//! High-risk by nature (remote code execution): every call goes through the
10//! approval gate (`ctx.approve`), which also honours the permission policy.
11
12use crate::registry::{Tool, ToolContext};
13use aegis_security::sanitize_credentials;
14use anyhow::Result;
15use async_trait::async_trait;
16use serde_json::{json, Value};
17use std::process::Stdio;
18use std::time::Duration;
19
20pub struct RemoteTool;
21
22impl RemoteTool {
23    fn common_ssh_opts(port: u64) -> Vec<String> {
24        vec![
25            "-o".into(),
26            "StrictHostKeyChecking=accept-new".into(),
27            "-o".into(),
28            "ConnectTimeout=10".into(),
29            "-o".into(),
30            "BatchMode=no".into(),
31            "-p".into(),
32            port.to_string(),
33        ]
34    }
35    fn common_scp_opts(port: u64) -> Vec<String> {
36        vec![
37            "-o".into(),
38            "StrictHostKeyChecking=accept-new".into(),
39            "-o".into(),
40            "ConnectTimeout=10".into(),
41            "-P".into(), // scp uses uppercase -P for port
42            port.to_string(),
43        ]
44    }
45}
46
47#[async_trait]
48impl Tool for RemoteTool {
49    fn name(&self) -> &str {
50        "remote"
51    }
52    fn description(&self) -> &str {
53        "Operate a remote server over SSH. Prefer a saved `server` handle (e.g. \
54         server=\"srv1\") so host/user/password are resolved locally and never sent \
55         to the model; otherwise pass host + user + (password OR SSH key). Actions: \
56         run (command), upload (local→remote file), check (connectivity + OS). \
57         Password auth needs `sshpass` locally. High-risk: each call is gated by \
58         approval. Saved servers are added by the user via the `/server` command."
59    }
60    fn parameters(&self) -> Value {
61        json!({
62            "type": "object",
63            "properties": {
64                "action": { "type": "string", "enum": ["run", "upload", "check"] },
65                "server": { "type": "string", "description": "Saved server handle (resolved locally; preferred over host/user/password)." },
66                "host": { "type": "string", "description": "Server hostname or IP (if no `server`)" },
67                "user": { "type": "string", "description": "SSH username (if no `server`)" },
68                "password": { "type": "string", "description": "SSH password (optional; needs sshpass)" },
69                "port": { "type": "integer", "description": "SSH port (default 22)" },
70                "command": { "type": "string", "description": "Shell command to run (action=run)" },
71                "local": { "type": "string", "description": "Local file path (action=upload)" },
72                "remote": { "type": "string", "description": "Remote destination path (action=upload)" }
73            },
74            "required": ["action"]
75        })
76    }
77    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
78        let action = args["action"].as_str().unwrap_or("run");
79
80        // A `server` handle resolves host/user/password locally from the saved
81        // credential store — so the real values never appear in the model's
82        // tool-call args (they never reach the provider). Otherwise fall back to
83        // inline host/user/password.
84        let (host, user, port, password_owned): (String, String, u64, Option<String>) =
85            if let Some(name) = args["server"].as_str().filter(|s| !s.is_empty()) {
86                match crate::remotes::get(name) {
87                    Some(c) => (c.host, c.user, c.port, c.password),
88                    None => {
89                        return Ok(format!(
90                            "No saved server '{name}'. Add it locally with the `/server add` command first."
91                        ))
92                    }
93                }
94            } else {
95                (
96                    args["host"].as_str().unwrap_or("").trim().to_string(),
97                    args["user"].as_str().unwrap_or("").trim().to_string(),
98                    args["port"].as_u64().unwrap_or(22),
99                    args["password"].as_str().filter(|p| !p.is_empty()).map(|s| s.to_string()),
100                )
101            };
102        let host = host.as_str();
103        let user = user.as_str();
104        if host.is_empty() || user.is_empty() {
105            return Ok("Error: provide `server` (saved handle) or `host`+`user`.".to_string());
106        }
107        let password = password_owned.as_deref();
108        let target = format!("{user}@{host}");
109
110        // Approval (never include the password in the prompt).
111        let what = match action {
112            "upload" => format!(
113                "upload {} → {host}:{}",
114                args["local"].as_str().unwrap_or("?"),
115                args["remote"].as_str().unwrap_or("?")
116            ),
117            "check" => format!("check connectivity to {target}"),
118            _ => format!("run on {target}: {}", args["command"].as_str().unwrap_or("")),
119        };
120        if !ctx.approve(&format!("⚠️ REMOTE SSH ({host}): {what}")) {
121            return Ok("Remote action blocked: user denied.".to_string());
122        }
123
124        let mut cmd = match action {
125            "upload" => {
126                let local = args["local"].as_str().unwrap_or("");
127                let remote = args["remote"].as_str().unwrap_or("");
128                if local.is_empty() || remote.is_empty() {
129                    return Ok("Error: 'local' and 'remote' are required for upload.".to_string());
130                }
131                let mut c = if password.is_some() {
132                    let mut c = tokio::process::Command::new("sshpass");
133                    c.arg("-e").arg("scp");
134                    c
135                } else {
136                    tokio::process::Command::new("scp")
137                };
138                c.args(Self::common_scp_opts(port))
139                    .arg(local)
140                    .arg(format!("{target}:{remote}"));
141                c
142            }
143            _ => {
144                let remote_cmd = if action == "check" {
145                    "echo aegis-remote-ok && uname -a && (command -v aegis >/dev/null && echo 'aegis: present' || echo 'aegis: not installed')"
146                } else {
147                    args["command"].as_str().unwrap_or("")
148                };
149                if remote_cmd.is_empty() {
150                    return Ok("Error: 'command' is required for action=run.".to_string());
151                }
152                let mut c = if password.is_some() {
153                    let mut c = tokio::process::Command::new("sshpass");
154                    c.arg("-e").arg("ssh");
155                    c
156                } else {
157                    tokio::process::Command::new("ssh")
158                };
159                c.args(Self::common_ssh_opts(port))
160                    .arg(&target)
161                    .arg(remote_cmd);
162                c
163            }
164        };
165
166        // Password via env only — never argv.
167        if let Some(pw) = password {
168            cmd.env("SSHPASS", pw);
169        }
170        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
171
172        let output = tokio::time::timeout(Duration::from_secs(120), cmd.output())
173            .await
174            .map_err(|_| anyhow::anyhow!("remote action timed out after 120s"))?;
175        let output = match output {
176            Ok(o) => o,
177            Err(e) => {
178                if e.kind() == std::io::ErrorKind::NotFound {
179                    return Ok(format!(
180                        "Error: required program not found ({e}). Password auth needs `sshpass` installed locally (e.g. `apt install sshpass`); or use an SSH key and omit `password`."
181                    ));
182                }
183                return Ok(format!("Error launching remote command: {e}"));
184            }
185        };
186
187        let stdout = String::from_utf8_lossy(&output.stdout);
188        let stderr = String::from_utf8_lossy(&output.stderr);
189        let code = output.status.code().unwrap_or(-1);
190        let mut result = String::new();
191        if !stdout.is_empty() {
192            result.push_str(&stdout);
193        }
194        if !stderr.is_empty() {
195            if !result.is_empty() {
196                result.push('\n');
197            }
198            result.push_str("[stderr]\n");
199            result.push_str(&stderr);
200        }
201        if code != 0 {
202            result.push_str(&format!("\n[exit code: {code}]"));
203        }
204        if result.len() > 50_000 {
205            result.truncate(result.floor_char_boundary(50_000));
206            result.push_str("\n... [output truncated at 50KB]");
207        }
208        Ok(sanitize_credentials(&result))
209    }
210}