1use 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
17pub 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
54pub struct SshAgentClientProtocol;
56
57impl SshAgentClientProtocol {
58 pub fn new() -> Self {
59 Self
60 }
61}
62
63impl 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 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 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 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 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
298impl 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 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}