Skip to main content

netget/client/ssh_agent/
actions.rs

1//! SSH Agent client protocol actions
2
3use crate::llm::actions::client_trait::{Client, ClientActionResult};
4use crate::llm::actions::{
5    protocol_trait::Protocol, ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
8use crate::protocol::{ConnectContext, EventType};
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::{json, Value};
12use std::future::Future;
13use std::net::SocketAddr;
14use std::pin::Pin;
15use std::sync::LazyLock;
16
17// Event type constants
18pub static SSH_AGENT_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
19    EventType::new(
20        "ssh_agent_client_connected",
21        "SSH Agent client connected to agent socket",
22        json!({"type": "request_identities"}),
23    )
24    .with_parameter(Parameter {
25        name: "socket_path".to_string(),
26        type_hint: "string".to_string(),
27        description: "Path to agent socket".to_string(),
28        required: true,
29    })
30});
31
32pub static SSH_AGENT_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
33    EventType::new(
34        "ssh_agent_client_response_received",
35        "SSH Agent client received response from agent",
36        json!({"type": "sign_request", "public_key_blob_hex": "...", "data_hex": "...", "flags": 0})
37    )
38    .with_parameters(vec![
39        Parameter {
40            name: "response_type".to_string(),
41            type_hint: "string".to_string(),
42            description: "Type of response (identities, signature, success, failure)".to_string(),
43            required: true,
44        },
45        Parameter {
46            name: "response_data".to_string(),
47            type_hint: "object".to_string(),
48            description: "Response data".to_string(),
49            required: true,
50        },
51    ])
52});
53
54/// SSH Agent client protocol implementation
55pub struct SshAgentClientProtocol;
56
57impl SshAgentClientProtocol {
58    pub fn new() -> Self {
59        Self
60    }
61}
62
63// Implement Protocol trait (common functionality)
64impl Protocol for SshAgentClientProtocol {
65    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
66        vec![ParameterDefinition {
67            name: "socket_path".to_string(),
68            type_hint: "string".to_string(),
69            description: "Path to the SSH Agent Unix socket. Defaults to ./netget-ssh-agent.sock, \
70                 which is NetGet's own agent server. It deliberately does NOT default to \
71                 $SSH_AUTH_SOCK: that would attach this client to the operator's real \
72                 running agent and let it sign with their real private keys. Give the path \
73                 explicitly if that is genuinely what you want."
74                .to_string(),
75            required: false,
76            example: json!("./netget-ssh-agent.sock"),
77            default: None,
78        }]
79    }
80
81    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
82        // `modify_instruction` used to be advertised here. Nothing could run it: this
83        // protocol's `execute_action` rejects the name, `handle_custom_action` has no branch
84        // for it, and no generic client-side instruction plumbing exists — clients read
85        // `instruction` once at connect and never re-read it. So the model was shown a tool
86        // that always came back "Unknown action type: modify_instruction", burning a retry.
87        // The server's SSH-Agent protocol does implement it; the client never did.
88        vec![ActionDefinition {
89            name: "disconnect".to_string(),
90            description: "Disconnect from SSH Agent".to_string(),
91            parameters: vec![],
92            example: json!({"type": "disconnect"}),
93            log_template: None,
94        }]
95    }
96
97    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
98        vec![
99            ActionDefinition {
100                name: "request_identities".to_string(),
101                description: "Request list of identities from agent".to_string(),
102                parameters: vec![],
103                example: json!({"type": "request_identities"}),
104                log_template: None,
105            },
106            ActionDefinition {
107                name: "sign_request".to_string(),
108                description: "Request to sign data with a key".to_string(),
109                parameters: vec![
110                    Parameter {
111                        name: "public_key_blob_hex".to_string(),
112                        type_hint: "string".to_string(),
113                        description: "Hex-encoded public key blob to sign with".to_string(),
114                        required: true,
115                    },
116                    Parameter {
117                        name: "data_hex".to_string(),
118                        type_hint: "string".to_string(),
119                        description: "Hex-encoded data to sign".to_string(),
120                        required: true,
121                    },
122                    Parameter {
123                        name: "flags".to_string(),
124                        type_hint: "integer".to_string(),
125                        description: "Signature flags".to_string(),
126                        required: false,
127                    },
128                ],
129                example: json!({"type": "sign_request", "public_key_blob_hex": "...", "data_hex": "...", "flags": 0}),
130                log_template: None,
131            },
132            ActionDefinition {
133                name: "add_identity".to_string(),
134                description: "Add an identity to the agent".to_string(),
135                parameters: vec![
136                    Parameter {
137                        name: "key_type".to_string(),
138                        type_hint: "string".to_string(),
139                        description: "SSH key type".to_string(),
140                        required: true,
141                    },
142                    Parameter {
143                        name: "public_key_blob_hex".to_string(),
144                        type_hint: "string".to_string(),
145                        description: "Hex-encoded public key blob".to_string(),
146                        required: true,
147                    },
148                    Parameter {
149                        name: "private_key_blob_hex".to_string(),
150                        type_hint: "string".to_string(),
151                        description: "Hex-encoded private key blob".to_string(),
152                        required: true,
153                    },
154                    Parameter {
155                        name: "comment".to_string(),
156                        type_hint: "string".to_string(),
157                        description: "Key comment".to_string(),
158                        required: false,
159                    },
160                ],
161                example: json!({"type": "add_identity", "key_type": "ssh-ed25519", "public_key_blob_hex": "...", "private_key_blob_hex": "...", "comment": "my-key"}),
162                log_template: None,
163            },
164            ActionDefinition {
165                name: "remove_identity".to_string(),
166                description: "Remove an identity from the agent".to_string(),
167                parameters: vec![Parameter {
168                    name: "public_key_blob_hex".to_string(),
169                    type_hint: "string".to_string(),
170                    description: "Hex-encoded public key blob to remove".to_string(),
171                    required: true,
172                }],
173                example: json!({"type": "remove_identity", "public_key_blob_hex": "..."}),
174                log_template: None,
175            },
176            ActionDefinition {
177                name: "remove_all_identities".to_string(),
178                description: "Remove all identities from the agent".to_string(),
179                parameters: vec![],
180                example: json!({"type": "remove_all_identities"}),
181                log_template: None,
182            },
183            ActionDefinition {
184                name: "wait_for_more".to_string(),
185                description: "Wait for more data".to_string(),
186                parameters: vec![],
187                example: json!({"type": "wait_for_more"}),
188                log_template: None,
189            },
190        ]
191    }
192
193    fn protocol_name(&self) -> &'static str {
194        "SSH Agent"
195    }
196
197    fn get_event_types(&self) -> Vec<EventType> {
198        vec![
199            (*SSH_AGENT_CLIENT_CONNECTED_EVENT).clone(),
200            (*SSH_AGENT_CLIENT_RESPONSE_RECEIVED_EVENT).clone(),
201        ]
202    }
203
204    fn stack_name(&self) -> &'static str {
205        "UNIX Socket > SSH Agent"
206    }
207
208    fn keywords(&self) -> Vec<&'static str> {
209        vec!["ssh-agent", "agent", "key-agent", "ssh keys"]
210    }
211
212    fn metadata(&self) -> ProtocolMetadataV2 {
213        ProtocolMetadataV2::builder()
214            .state(DevelopmentState::Experimental)
215            .implementation("SSH Agent client using custom protocol implementation")
216            .llm_control("Full control over agent operations and key management")
217            .e2e_testing(
218                "tests/client/ssh_agent/command_channel_test.rs runs; the e2e suite in \
219                 tests/client/ssh_agent/CLAUDE.md is aspirational and no third-party agent \
220                 drives this client in an automated test.",
221            )
222            .notes(
223                "Speaks to any agent on a Unix socket. Defaults to ./netget-ssh-agent.sock, \
224                 NOT $SSH_AUTH_SOCK: pointed at a real agent this client can enumerate the \
225                 operator's identities and have them sign model-chosen bytes, so reaching a \
226                 live agent has to be asked for by path.",
227            )
228            .build()
229    }
230
231    fn description(&self) -> &'static str {
232        "SSH Agent client for connecting to and managing SSH keys via agents"
233    }
234
235    fn example_prompt(&self) -> &'static str {
236        "Connect to the SSH Agent on ./netget-ssh-agent.sock; list all identities; use the first key to sign 'Hello World'"
237    }
238
239    fn group_name(&self) -> &'static str {
240        "Security"
241    }
242    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
243        use crate::llm::actions::StartupExamples;
244        use serde_json::json;
245
246        StartupExamples::new(
247            // LLM mode: LLM controls SSH agent operations
248            json!({
249                "type": "open_client",
250                "remote_addr": "./netget-ssh-agent.sock",
251                "base_stack": "ssh-agent",
252                "instruction": "List all identities and sign 'Hello World' with the first key"
253            }),
254            // Script mode: Code-based agent operations
255            json!({
256                "type": "open_client",
257                "remote_addr": "./netget-ssh-agent.sock",
258                "base_stack": "ssh-agent",
259                "event_handlers": [{
260                    "event_pattern": "ssh_agent_client_response_received",
261                    "handler": {
262                        "type": "script",
263                        "language": "python",
264                        "code": "<ssh_agent_client_handler>"
265                    }
266                }]
267            }),
268            // Static mode: Fixed identity request
269            json!({
270                "type": "open_client",
271                "remote_addr": "./netget-ssh-agent.sock",
272                "base_stack": "ssh-agent",
273                "event_handlers": [
274                    {
275                        "event_pattern": "ssh_agent_client_connected",
276                        "handler": {
277                            "type": "static",
278                            "actions": [{
279                                "type": "request_identities"
280                            }]
281                        }
282                    },
283                    {
284                        "event_pattern": "ssh_agent_client_response_received",
285                        "handler": {
286                            "type": "static",
287                            "actions": [{
288                                "type": "disconnect"
289                            }]
290                        }
291                    }
292                ]
293            }),
294        )
295    }
296}
297
298// Implement Client trait (client-specific functionality)
299impl Client for SshAgentClientProtocol {
300    fn connect(
301        &self,
302        ctx: ConnectContext,
303    ) -> Pin<Box<dyn Future<Output = Result<SocketAddr>> + Send>> {
304        Box::pin(async move {
305            // `socket_path` was declared here and read by nothing: `connect` used
306            // `remote_addr` alone, so a caller that put the path in the parameter it was
307            // told to use had it silently dropped and connected somewhere else entirely.
308            // An advertised knob that does nothing when turned is worse than no knob.
309            let socket_path = ctx
310                .startup_params
311                .as_ref()
312                .map(|p| p.get_optional_string("socket_path"))
313                .transpose()?
314                .flatten()
315                .filter(|s| !s.is_empty());
316
317            let target = match socket_path {
318                Some(path) => path,
319                None => ctx.remote_addr,
320            };
321
322            crate::client::ssh_agent::SshAgentClient::connect_with_llm_actions(
323                target,
324                ctx.llm_client,
325                ctx.state,
326                ctx.status_tx,
327                ctx.client_id,
328            )
329            .await
330        })
331    }
332
333    fn execute_action(&self, action: Value) -> Result<ClientActionResult> {
334        let action_type = action["type"]
335            .as_str()
336            .context("Missing 'type' field in action")?;
337
338        match action_type {
339            "request_identities" => Ok(ClientActionResult::Custom {
340                name: "request_identities".to_string(),
341                data: json!({}),
342            }),
343            "sign_request" => {
344                let public_key_blob_hex = action["public_key_blob_hex"]
345                    .as_str()
346                    .context("Missing 'public_key_blob_hex' field")?;
347                let data_hex = action["data_hex"]
348                    .as_str()
349                    .context("Missing 'data_hex' field")?;
350                let flags = match action.get("flags") {
351                    None => 0,
352                    Some(value) => u32::try_from(
353                        value
354                            .as_u64()
355                            .context("flags must be an unsigned integer")?,
356                    )
357                    .context("flags exceed u32")?,
358                };
359
360                Ok(ClientActionResult::Custom {
361                    name: "sign_request".to_string(),
362                    data: json!({
363                        "public_key_blob_hex": public_key_blob_hex,
364                        "data_hex": data_hex,
365                        "flags": flags,
366                    }),
367                })
368            }
369            "add_identity" => {
370                let key_type = action["key_type"]
371                    .as_str()
372                    .context("Missing 'key_type' field")?;
373                let public_key_blob_hex = action["public_key_blob_hex"]
374                    .as_str()
375                    .context("Missing 'public_key_blob_hex' field")?;
376                let private_key_blob_hex = action["private_key_blob_hex"]
377                    .as_str()
378                    .context("Missing 'private_key_blob_hex' field")?;
379                let comment = action["comment"].as_str().unwrap_or("");
380
381                Ok(ClientActionResult::Custom {
382                    name: "add_identity".to_string(),
383                    data: json!({
384                        "key_type": key_type,
385                        "public_key_blob_hex": public_key_blob_hex,
386                        "private_key_blob_hex": private_key_blob_hex,
387                        "comment": comment,
388                    }),
389                })
390            }
391            "remove_identity" => {
392                let public_key_blob_hex = action["public_key_blob_hex"]
393                    .as_str()
394                    .context("Missing 'public_key_blob_hex' field")?;
395
396                Ok(ClientActionResult::Custom {
397                    name: "remove_identity".to_string(),
398                    data: json!({ "public_key_blob_hex": public_key_blob_hex }),
399                })
400            }
401            "remove_all_identities" => Ok(ClientActionResult::Custom {
402                name: "remove_all_identities".to_string(),
403                data: json!({}),
404            }),
405            "disconnect" => Ok(ClientActionResult::Disconnect),
406            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
407            _ => anyhow::bail!("Unknown action type: {}", action_type),
408        }
409    }
410}