1use crate::llm::actions::{
3 client_trait::{Client, ClientActionResult},
4 protocol_trait::Protocol,
5 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::{ConnectContext, EventType};
9use crate::state::AppState;
10use anyhow::{ensure, Context, Result};
11use serde_json::{json, Value};
12use std::sync::LazyLock;
13fn field(name: &str, hint: &str, description: &str, required: bool) -> Parameter {
14 Parameter {
15 name: name.into(),
16 type_hint: hint.into(),
17 description: description.into(),
18 required,
19 }
20}
21fn action(
22 name: &str,
23 description: &str,
24 parameters: Vec<Parameter>,
25 example: Value,
26 log_template: &str,
27) -> ActionDefinition {
28 ActionDefinition {
29 name: name.into(),
30 description: description.into(),
31 parameters,
32 example,
33 log_template: Some(LogTemplate::new().with_info(log_template)),
34 }
35}
36fn event(id: &str, description: &str, fields: Vec<Parameter>) -> EventType {
37 EventType::new(id, description, json!({"type":"wait_for_more"}))
38 .with_parameters(fields)
39 .with_actions(SshClientProtocol.get_sync_actions())
40}
41pub static SSH_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
42 event(
43 "ssh_connected",
44 "SSH session authenticated",
45 vec![
46 field("remote_addr", "string", "Remote SSH server", true),
47 field("username", "string", "Authenticated username", true),
48 field(
49 "host_key_verified",
50 "boolean",
51 "Whether the configured SHA256 host-key pin matched",
52 true,
53 ),
54 ],
55 )
56});
57pub static SSH_CLIENT_OUTPUT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
58 event(
59 "ssh_output_received",
60 "SSH command completed",
61 vec![
62 field("command", "string", "Executed command", true),
63 field(
64 "output",
65 "string",
66 "Captured standard output from the completed command",
67 true,
68 ),
69 field("stderr", "string", "Command stderr when present", false),
70 field(
71 "exit_code",
72 "number",
73 "Exit status when supplied by the peer",
74 false,
75 ),
76 ],
77 )
78});
79pub static SSH_SFTP_RESULT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
80 event(
81 "ssh_sftp_result",
82 "SFTP v3 operation completed",
83 vec![
84 field(
85 "operation",
86 "string",
87 "stat, list_directory or read_file",
88 true,
89 ),
90 field(
91 "path",
92 "string",
93 "Remote file or directory path selected for SFTP",
94 true,
95 ),
96 field(
97 "attributes",
98 "object",
99 "File size, permissions, type, owners and timestamps when supplied",
100 false,
101 ),
102 field(
103 "entries",
104 "array",
105 "Directory entries with name and typed attributes",
106 false,
107 ),
108 field("offset", "number", "Read window start", false),
109 field("bytes_read", "number", "Bytes in the UTF-8 window", false),
110 field(
111 "eof",
112 "boolean",
113 "Whether the server reported EOF before the window filled",
114 false,
115 ),
116 field("text", "string", "UTF-8 file window", false),
117 ],
118 )
119});
120pub static SSH_OPERATION_FAILED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
121 event(
122 "ssh_operation_failed",
123 "SSH command or SFTP operation failed",
124 vec![
125 field(
126 "action_type",
127 "string",
128 "Name of the rejected SSH or SFTP action",
129 true,
130 ),
131 field("path", "string", "Remote path for SFTP", false),
132 field("command", "string", "Command for exec", false),
133 field(
134 "error",
135 "string",
136 "Transport, status, framing or local validation error",
137 true,
138 ),
139 ],
140 )
141});
142#[derive(Default)]
143pub struct SshClientProtocol;
144impl SshClientProtocol {
145 pub fn new() -> Self {
146 Self
147 }
148}
149impl Protocol for SshClientProtocol {
150 fn protocol_name(&self) -> &'static str {
151 "SSH"
152 }
153 fn stack_name(&self) -> &'static str {
154 "ETH>IP>TCP>SSH"
155 }
156 fn description(&self) -> &'static str {
157 "SSH commands and pinned SFTP v3 stat, directory listing and UTF-8 file reads"
158 }
159 fn keywords(&self) -> Vec<&'static str> {
160 vec!["ssh", "secure shell", "sftp", "connect to ssh"]
161 }
162 fn group_name(&self) -> &'static str {
163 "Network Infrastructure"
164 }
165 fn example_prompt(&self) -> &'static str {
166 "Connect with a trusted SHA256 host-key pin and list /reports over SFTP"
167 }
168 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
169 self.get_sync_actions()
170 }
171 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
172 vec![
173 action(
174 "execute_command",
175 "Execute a shell command; bounded stdout/stderr and exit status become ssh_output_received",
176 vec![field("command", "string", "Shell command, 1..4096 bytes", true)],
177 json!({"type":"execute_command","command":"pwd"}),
178 "Execute SSH command: {command}",
179 ),
180 action(
181 "sftp_stat",
182 "Inspect remote file or directory attributes using pinned SFTP v3",
183 vec![
184 field("path", "string", "Remote path, 1..4096 bytes without NUL", true),
185 field("follow_symlinks", "boolean", "Use STAT instead of LSTAT; default false", false),
186 ],
187 json!({"type":"sftp_stat","path":"/reports"}),
188 "Inspect SFTP path {path}",
189 ),
190 action(
191 "sftp_list_directory",
192 "List a remote directory, at most 1024 entries, with typed attributes",
193 vec![field("path", "string", "Remote directory, 1..4096 bytes without NUL", true)],
194 json!({"type":"sftp_list_directory","path":"/reports"}),
195 "List SFTP directory {path}",
196 ),
197 action(
198 "sftp_read_file",
199 "Read a bounded UTF-8 window from a remote file; no local file is created",
200 vec![
201 field("path", "string", "Remote path, 1..4096 bytes without NUL", true),
202 field("offset", "number", "Unsigned starting byte offset, default 0", false),
203 field("length", "number", "Maximum bytes, 1..1048576, default 65536", false),
204 ],
205 json!({"type":"sftp_read_file","path":"/reports/today.txt","offset":0,"length":65536}),
206 "Read SFTP file window from {path}",
207 ),
208 action(
209 "disconnect",
210 "Disconnect SSH and cancel every command, SFTP operation and response handler",
211 vec![],
212 json!({"type":"disconnect"}),
213 "Disconnect SSH and cancel active operations",
214 ),
215 action(
216 "wait_for_more",
217 "Take no action and wait for another result",
218 vec![],
219 json!({"type":"wait_for_more"}),
220 "Wait for SSH command or SFTP result",
221 ),
222 ]
223 }
224 fn get_event_types(&self) -> Vec<EventType> {
225 vec![
226 SSH_CLIENT_CONNECTED_EVENT.clone(),
227 SSH_CLIENT_OUTPUT_RECEIVED_EVENT.clone(),
228 SSH_SFTP_RESULT_EVENT.clone(),
229 SSH_OPERATION_FAILED_EVENT.clone(),
230 ]
231 }
232 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
233 let p = |name: &str,
234 hint: &str,
235 description: &str,
236 required: bool,
237 example: Value,
238 default: Option<Value>| ParameterDefinition {
239 name: name.into(),
240 type_hint: hint.into(),
241 description: description.into(),
242 required,
243 example,
244 default,
245 };
246 vec![
247 p("username","string","SSH account used to authenticate this connection",true,json!("user"),None),
248 p("password","string","Password for password authentication",false,json!("password"),None),
249 p("private_key_path","string","Operator's OpenSSH private-key file; read only at connect",false,json!("/home/user/.ssh/id_ed25519"),None),
250 p("private_key_passphrase","string","Passphrase for the private key",false,json!("passphrase"),None),
251 p("auth_method","string","password or publickey; defaults to publickey when private_key_path is set",false,json!("publickey"),None),
252 p("host_key_sha256","string","Trusted OpenSSH SHA256: host-key fingerprint. Required for SFTP; when supplied a mismatch fails the SSH handshake",false,json!("SHA256:<trusted fingerprint>"),None),
253 p("handshake_timeout_secs","number","Resolution, TCP, SSH handshake and authentication deadline, 1..60 seconds",false,json!(10),Some(json!(super::HANDSHAKE_TIMEOUT.as_secs()))),
254 p("operation_timeout_secs","number","Whole command or SFTP exchange deadline, 1..300 seconds",false,json!(30),Some(json!(super::OPERATION_TIMEOUT.as_secs()))),
255 p("idle_timeout_secs","number","SSH inactivity deadline, 1..3600 seconds",false,json!(300),Some(json!(super::IDLE_TIMEOUT.as_secs()))),
256 ]
257 }
258 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
259 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
260 ProtocolMetadataV2::builder().state(DevelopmentState::Experimental)
261 .implementation("russh 0.45 SSH transport with owned socket shutdown; bounded SFTP v3 request/response codec without detached library session tasks")
262 .llm_control("Shell command and stdout/stderr/exit status; SFTP stat, list_directory and read_file with structured attributes, directory entries and UTF-8 file windows. Shared handlers, memory, command injection and four follow-up levels")
263 .e2e_testing("Independent OpenSSH sshd command and SFTP sessions, host-key pin negatives and direct NetGet pairing; tests under tests/client/ssh. No tests skip missing peers")
264 .notes("SFTP requires an explicit SHA256 host-key pin. Command-only legacy sessions without a pin accept the peer key and are vulnerable to active attackers. SFTP packets 64 KiB, paths 4 KiB, read windows 1 MiB, directories 1024 entries and 16 empty batches, whole response budget 2 MiB; 16 total active operations/handlers. SSH stdout/stderr 1 MiB combined; channel payload 3 MiB and 4096 messages, at most 16 channels until peer CLOSE. A transport bound closes SSH; callback checks may include one extra upstream packet capped at 256 KiB. No SFTP writes, binary file action, protocol extensions, known_hosts, SSH agent, keyboard-interactive, certificate auth, PTY or forwarding. Experimental: no second independent SFTP server, pcap oracle or fuzz target")
265 .build()
266 }
267 fn get_startup_examples(&self) -> StartupExamples {
268 let base = || json!({"type":"open_client","protocol":"ssh","remote_addr":"localhost:22","startup_params":{"username":"user","private_key_path":"/home/user/.ssh/id_ed25519","host_key_sha256":"SHA256:<trusted fingerprint>"}});
269 let mut llm = base();
270 llm["instruction"] = json!("On ssh_connected, list /reports with sftp_list_directory. Inspect the resulting entries, then disconnect. Disconnect if the operation fails. Use only read-only SFTP actions.");
271 let mut script = base();
272 script["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python","code":r#"import json, sys
273event_type = json.load(sys.stdin)['event_type_id']
274actions = []
275if event_type == 'ssh_connected':
276 actions = [{'type': 'sftp_list_directory', 'path': '/reports'}]
277elif event_type in ('ssh_sftp_result', 'ssh_operation_failed'):
278 actions = [{'type': 'disconnect'}]
279print(json.dumps({'actions': actions}))"#}}]);
280 let mut static_handler = base();
281 static_handler["event_handlers"] = json!([
282 {"event_pattern":"ssh_connected","handler":{"type":"static","actions":[{"type":"sftp_list_directory","path":"/reports"}]}},
283 {"event_pattern":"ssh_sftp_result","handler":{"type":"static","actions":[{"type":"disconnect"}]}},
284 {"event_pattern":"ssh_operation_failed","handler":{"type":"static","actions":[{"type":"disconnect"}]}},
285 {"event_pattern":"*","handler":{"type":"static","actions":[]}}
286 ]);
287 StartupExamples::new(llm, script, static_handler)
288 }
289}
290impl Client for SshClientProtocol {
291 fn connect(
292 &self,
293 ctx: ConnectContext,
294 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
295 {
296 Box::pin(super::SshClient::connect(ctx))
297 }
298 fn execute_action(&self, action: Value) -> Result<ClientActionResult> {
299 match action["type"]
300 .as_str()
301 .context("Missing 'type' field in action")?
302 {
303 "execute_command" => {
304 let command = action["command"]
305 .as_str()
306 .context("Missing 'command' field")?;
307 ensure!(
308 !command.is_empty() && command.len() <= super::sftp::MAX_PATH,
309 "command must contain 1..4096 bytes"
310 );
311 Ok(ClientActionResult::Custom {
312 name: "execute_command".into(),
313 data: json!({"command":command}),
314 })
315 }
316 "sftp_stat" | "sftp_list_directory" | "sftp_read_file" => {
317 super::sftp::validate_action(&action)?;
318 Ok(ClientActionResult::Custom {
319 name: "sftp_operation".into(),
320 data: action,
321 })
322 }
323 "disconnect" => Ok(ClientActionResult::Disconnect),
324 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
325 other => anyhow::bail!("Unknown SSH client action: {other}"),
326 }
327 }
328}