1use 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(), 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 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 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 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}