1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15pub static SEND_IDENTITIES_LIST_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
26 ActionDefinition {
27 name: "send_identities_list".to_string(),
28 description: "Answer ssh_agent_request_identities with the keys this agent \
29 holds. This is the ONLY valid answer to that event - send_success does not \
30 satisfy it. An empty 'identities' array is valid and means the agent holds \
31 no keys, which is what `ssh-add -l` reports as \"no identities\"."
32 .to_string(),
33 parameters: vec![Parameter {
34 name: "identities".to_string(),
35 type_hint: "array".to_string(),
36 description: "Array of objects, one per key. Give each key as 'public_key': an \
37 OpenSSH public key exactly as it appears in authorized_keys or a .pub file, \
38 \"<algorithm> <base64 blob> [comment]\". 'comment' is the label \
39 `ssh-add -l` prints and overrides any comment in the line. \
40 'public_key_blob_hex' is the escape hatch for a blob with no OpenSSH text \
41 form - give that or 'public_key', never both. A key whose blob will not \
42 decode, or whose length prefixes do not span it exactly, fails the whole \
43 request rather than sending a broken identity"
44 .to_string(),
45 required: true,
46 }],
47 example: json!({"type": "send_identities_list", "identities": [{"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH98ewNgR4yzG9S6UrA3P6sN2mFMedO/XrRqPcibUAfr", "comment": "deploy-key"}]}),
52 log_template: Some(
53 LogTemplate::new()
54 .with_info("-> SSH Agent {identities_len} identities")
55 .with_debug("SSH Agent send_identities_list: count={identities_len}"),
56 ),
57 }
58});
59
60pub static SEND_SIGN_RESPONSE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
61 ActionDefinition {
62 name: "send_sign_response".to_string(),
63 description: "Answer ssh_agent_sign_request with a signature over the data the \
64 client sent. NOTE: no private key exists here - you are fabricating the \
65 signature bytes, so a real client will fail to verify them against the \
66 public key it holds. Useful for honeypots and for exercising the protocol; \
67 reply with send_failure to refuse the signature instead."
68 .to_string(),
69 parameters: vec![
70 Parameter {
71 name: "algorithm".to_string(),
72 type_hint: "string".to_string(),
73 description: "Signature algorithm name - \"ssh-ed25519\", \"ssh-rsa\", \
74 \"rsa-sha2-256\" or \"rsa-sha2-512\" - matching the key in the event's \
75 'key_type' and its 'flags'. The server builds the framing (algorithm \
76 name and signature, each length-prefixed) and, unless you give \
77 'signature_bytes_hex', fabricates a signature of the size that \
78 algorithm requires. This is the field to use"
79 .to_string(),
80 required: false,
81 },
82 Parameter {
83 name: "signature_bytes_hex".to_string(),
84 type_hint: "string".to_string(),
85 description: "Optional: the raw signature bytes to put inside the framing \
86 'algorithm' builds - 64 for ssh-ed25519. NOT the framed blob; that is \
87 'signature_hex'"
88 .to_string(),
89 required: false,
90 },
91 Parameter {
92 name: "signature_hex".to_string(),
93 type_hint: "string".to_string(),
94 description: "Escape hatch: the whole framed signature blob as hex - the \
95 algorithm name and the signature, each length-prefixed, as in the \
96 event's 'public_key_blob_hex' encoding. Give this or 'algorithm', never \
97 both. Invalid hex, or hex decoding to zero bytes, fails the request \
98 instead of sending an empty signature"
99 .to_string(),
100 required: false,
101 },
102 ],
103 example: json!({"type": "send_sign_response", "algorithm": "ssh-ed25519"}),
108 log_template: Some(
109 LogTemplate::new()
110 .with_info("-> SSH Agent signature")
111 .with_debug("SSH Agent send_sign_response"),
112 ),
113 }
114});
115
116pub static SEND_SUCCESS_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| ActionDefinition {
117 name: "send_success".to_string(),
118 description: "Send SSH_AGENT_SUCCESS: the operation was accepted. This is the \
119 expected answer to ssh_agent_add_identity, ssh_agent_remove_identity, \
120 ssh_agent_remove_all_identities, ssh_agent_lock and ssh_agent_unlock. It is \
121 NOT a valid answer to a request for identities or a signature."
122 .to_string(),
123 parameters: vec![],
124 example: json!({"type": "send_success"}),
125 log_template: Some(
126 LogTemplate::new()
127 .with_info("-> SSH Agent SUCCESS")
128 .with_debug("SSH Agent send_success"),
129 ),
130});
131
132pub static SEND_FAILURE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| ActionDefinition {
133 name: "send_failure".to_string(),
134 description: "Send SSH_AGENT_FAILURE: refuse the operation. Use it to deny a \
135 signature, reject a key, or refuse an unlock whose passphrase does not \
136 match. Every event accepts this. If you return no action at all the server \
137 sends nothing and the client blocks, so refuse explicitly."
138 .to_string(),
139 parameters: vec![],
140 example: json!({"type": "send_failure"}),
141 log_template: Some(
142 LogTemplate::new()
143 .with_info("-> SSH Agent FAILURE")
144 .with_debug("SSH Agent send_failure"),
145 ),
146});
147
148pub static CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
149 LazyLock::new(|| ActionDefinition {
150 name: "close_connection".to_string(),
151 description: "Close the agent connection that raised this event, without \
152 replying to the request. The client sees the socket drop. Use send_failure \
153 instead when you want to refuse an operation but keep the session."
154 .to_string(),
155 parameters: vec![],
156 example: json!({"type": "close_connection"}),
157 log_template: Some(
158 LogTemplate::new()
159 .with_info("-> SSH Agent close connection")
160 .with_debug("SSH Agent close_connection"),
161 ),
162 });
163
164pub static WAIT_FOR_MORE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| ActionDefinition {
165 name: "wait_for_more".to_string(),
166 description: "Send no reply and wait for the next message. Rarely correct: the \
167 agent protocol is strict request/response and a client blocks until it gets \
168 an answer, so prefer send_success or send_failure."
169 .to_string(),
170 parameters: vec![],
171 example: json!({"type": "wait_for_more"}),
172 log_template: Some(
173 LogTemplate::new()
174 .with_info("-> SSH Agent wait")
175 .with_debug("SSH Agent wait_for_more"),
176 ),
177});
178
179pub static SSH_AGENT_CONNECTION_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
181 EventType::new(
182 "ssh_agent_connection_opened",
183 "A client connected to the agent socket and has not sent a request yet. Nothing is \
184 expected of you here - it exists so you can set up state before the first request. \
185 Returning no action is normal; do NOT send send_success, which would put an \
186 unrequested reply on the wire and desynchronise the client. Close the connection if \
187 you want to refuse the client outright.",
188 json!({
189 "type": "close_connection"
190 }),
191 )
192 .with_parameter(Parameter {
193 name: "connection_id".to_string(),
194 type_hint: "string".to_string(),
195 description: "Unique connection identifier".to_string(),
196 required: true,
197 })
198 .with_actions(vec![CLOSE_CONNECTION_ACTION.clone()])
201 .with_log_template(
202 LogTemplate::new()
203 .with_info("SSH Agent connection opened")
204 .with_debug("SSH Agent conn={connection_id}")
205 .with_trace("SSH Agent connection: {json_pretty(.)}"),
206 )
207});
208
209pub static SSH_AGENT_REQUEST_IDENTITIES_EVENT: LazyLock<EventType> = LazyLock::new(|| {
210 EventType::new(
211 "ssh_agent_request_identities",
212 "The client asked which keys this agent holds (`ssh-add -l`, or ssh choosing a key for a \
213 login). Answer with send_identities_list - an empty list is valid.",
214 json!({
215 "type": "send_identities_list",
216 "identities": []
217 }),
218 )
219 .with_actions(vec![
220 SEND_IDENTITIES_LIST_ACTION.clone(),
221 SEND_FAILURE_ACTION.clone(),
222 CLOSE_CONNECTION_ACTION.clone(),
223 ])
224 .with_log_template(
225 LogTemplate::new()
226 .with_info("SSH Agent REQUEST_IDENTITIES")
227 .with_debug("SSH Agent REQUEST_IDENTITIES")
228 .with_trace("SSH Agent identities request: {json_pretty(.)}"),
229 )
230});
231
232pub static SSH_AGENT_SIGN_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
233 EventType::new(
234 "ssh_agent_sign_request",
235 "The client asked the agent to sign a challenge with one of its keys, which is how an \
236 SSH login using agent authentication proves key possession. Answer with \
237 send_sign_response to sign, or send_failure to refuse. No private key exists here, so \
238 any signature you return is fabricated and will not verify.",
239 json!({
240 "type": "send_sign_response",
241 "algorithm": "ssh-ed25519"
242 }),
243 )
244 .with_parameters(vec![
245 Parameter {
246 name: "key_type".to_string(),
247 type_hint: "string".to_string(),
248 description: "Algorithm name decoded from the key blob, e.g. \"ssh-ed25519\" or \
249 \"ssh-rsa\". Use this to identify which key is being asked for rather than \
250 comparing hex blobs. Empty if the blob could not be decoded"
251 .to_string(),
252 required: true,
253 },
254 Parameter {
255 name: "public_key_blob_hex".to_string(),
256 type_hint: "string".to_string(),
257 description: "The public key the client wants to sign with, as the hex-encoded SSH \
258 wire blob. Compare it against the blobs you returned from send_identities_list \
259 to tell which of your keys is meant"
260 .to_string(),
261 required: true,
262 },
263 Parameter {
264 name: "data_hex".to_string(),
265 type_hint: "string".to_string(),
266 description: "The challenge to be signed, hex-encoded. It is an SSH session \
267 identifier and login request, not human-readable text"
268 .to_string(),
269 required: true,
270 },
271 Parameter {
272 name: "flags".to_string(),
273 type_hint: "integer".to_string(),
274 description: "Signature flags from the client: 0 for the key's default algorithm, \
275 2 requests rsa-sha2-256 and 4 rsa-sha2-512 for RSA keys"
276 .to_string(),
277 required: true,
278 },
279 ])
280 .with_actions(vec![
281 SEND_SIGN_RESPONSE_ACTION.clone(),
282 SEND_FAILURE_ACTION.clone(),
283 CLOSE_CONNECTION_ACTION.clone(),
284 ])
285 .with_log_template(
286 LogTemplate::new()
287 .with_info("SSH Agent SIGN_REQUEST flags={flags}")
288 .with_debug("SSH Agent SIGN_REQUEST flags={flags}")
289 .with_trace("SSH Agent sign request: {json_pretty(.)}"),
290 )
291});
292
293pub static SSH_AGENT_ADD_IDENTITY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
294 EventType::new(
295 "ssh_agent_add_identity",
296 "The client is loading a private key into the agent (`ssh-add`). The private key material \
297 is parsed off the wire and discarded - it is never given to you and never stored. \
298 Answer send_success to accept the key or send_failure to refuse it; remember accepted \
299 keys in memory if you want them to appear in later identity listings.",
300 json!({
301 "type": "send_success"
302 })
303 )
304 .with_parameters(vec![
305 Parameter {
306 name: "key_type".to_string(),
307 type_hint: "string".to_string(),
308 description: "Algorithm name the client sent, e.g. \"ssh-ed25519\" or \"ssh-rsa\""
309 .to_string(),
310 required: true,
311 },
312 Parameter {
313 name: "public_key_blob_hex".to_string(),
314 type_hint: "string".to_string(),
315 description: "Hex-encoded public part of the key. NOTE: this is parsed assuming the \
316 Ed25519 layout, so for RSA and other multi-field key types this value and \
317 'comment' may be wrong or the message may fail to parse entirely"
318 .to_string(),
319 required: true,
320 },
321 Parameter {
322 name: "comment".to_string(),
323 type_hint: "string".to_string(),
324 description: "The label the client attached to the key, usually the path or an \
325 email address. Subject to the same Ed25519-layout caveat as \
326 'public_key_blob_hex'"
327 .to_string(),
328 required: true,
329 },
330 Parameter {
331 name: "constrained".to_string(),
332 type_hint: "boolean".to_string(),
333 description: "True if the client sent the key with constraints such as a lifetime \
334 (`ssh-add -t`) or confirmation requirement (`ssh-add -c`). The constraints \
335 themselves are not parsed and nothing enforces them - honour them yourself if \
336 you care"
337 .to_string(),
338 required: true,
339 },
340 ])
341 .with_actions(vec![
342 SEND_SUCCESS_ACTION.clone(),
343 SEND_FAILURE_ACTION.clone(),
344 CLOSE_CONNECTION_ACTION.clone(),
345 ])
346 .with_log_template(
347 LogTemplate::new()
348 .with_info("SSH Agent ADD_IDENTITY {key_type} ({comment})")
349 .with_debug("SSH Agent ADD_IDENTITY key_type={key_type} comment={comment} constrained={constrained}")
350 .with_trace("SSH Agent add identity: {json_pretty(.)}"),
351 )
352});
353
354pub static SSH_AGENT_REMOVE_IDENTITY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
355 EventType::new(
356 "ssh_agent_remove_identity",
357 "The client asked the agent to forget one key (`ssh-add -d`). Answer send_success if you \
358 drop it from memory, or send_failure if you do not hold it.",
359 json!({
360 "type": "send_success"
361 }),
362 )
363 .with_parameters(vec![Parameter {
364 name: "public_key_blob_hex".to_string(),
365 type_hint: "string".to_string(),
366 description: "Hex-encoded SSH wire blob of the key to forget. Match it against the \
367 blobs you return from send_identities_list"
368 .to_string(),
369 required: true,
370 }])
371 .with_actions(vec![
372 SEND_SUCCESS_ACTION.clone(),
373 SEND_FAILURE_ACTION.clone(),
374 CLOSE_CONNECTION_ACTION.clone(),
375 ])
376 .with_log_template(
377 LogTemplate::new()
378 .with_info("SSH Agent REMOVE_IDENTITY")
379 .with_debug("SSH Agent REMOVE_IDENTITY")
380 .with_trace("SSH Agent remove identity: {json_pretty(.)}"),
381 )
382});
383
384pub static SSH_AGENT_REMOVE_ALL_IDENTITIES_EVENT: LazyLock<EventType> = LazyLock::new(|| {
385 EventType::new(
386 "ssh_agent_remove_all_identities",
387 "The client asked the agent to forget every key (`ssh-add -D`). Answer send_success after \
388 clearing them from memory.",
389 json!({
390 "type": "send_success"
391 }),
392 )
393 .with_actions(vec![
394 SEND_SUCCESS_ACTION.clone(),
395 SEND_FAILURE_ACTION.clone(),
396 CLOSE_CONNECTION_ACTION.clone(),
397 ])
398 .with_log_template(
399 LogTemplate::new()
400 .with_info("SSH Agent REMOVE_ALL_IDENTITIES")
401 .with_debug("SSH Agent REMOVE_ALL_IDENTITIES")
402 .with_trace("SSH Agent remove all: {json_pretty(.)}"),
403 )
404});
405
406pub static SSH_AGENT_LOCK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
407 EventType::new(
408 "ssh_agent_lock",
409 "The client asked to lock the agent with a passphrase (`ssh-add -x`). While locked, a \
410 real agent hides its identities and refuses to sign until unlocked. Nothing here \
411 enforces that: record the passphrase and the locked state in memory and honour it \
412 yourself on later events. Answer send_success to accept.",
413 json!({
414 "type": "send_success"
415 }),
416 )
417 .with_parameter(Parameter {
418 name: "passphrase".to_string(),
419 type_hint: "string".to_string(),
420 description: "The passphrase the client sent, in the clear".to_string(),
421 required: true,
422 })
423 .with_actions(vec![
424 SEND_SUCCESS_ACTION.clone(),
425 SEND_FAILURE_ACTION.clone(),
426 CLOSE_CONNECTION_ACTION.clone(),
427 ])
428 .with_log_template(
429 LogTemplate::new()
430 .with_info("SSH Agent LOCK")
431 .with_debug("SSH Agent LOCK")
432 .with_trace("SSH Agent lock: {json_pretty(.)}"),
433 )
434});
435
436pub static SSH_AGENT_UNLOCK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
437 EventType::new(
438 "ssh_agent_unlock",
439 "The client asked to unlock the agent (`ssh-add -X`). Compare 'passphrase' with the one \
440 from the lock request you remembered: answer send_success if it matches and \
441 send_failure if it does not.",
442 json!({
443 "type": "send_success"
444 }),
445 )
446 .with_parameter(Parameter {
447 name: "passphrase".to_string(),
448 type_hint: "string".to_string(),
449 description: "The passphrase the client sent, in the clear".to_string(),
450 required: true,
451 })
452 .with_actions(vec![
453 SEND_SUCCESS_ACTION.clone(),
454 SEND_FAILURE_ACTION.clone(),
455 CLOSE_CONNECTION_ACTION.clone(),
456 ])
457 .with_log_template(
458 LogTemplate::new()
459 .with_info("SSH Agent UNLOCK")
460 .with_debug("SSH Agent UNLOCK")
461 .with_trace("SSH Agent unlock: {json_pretty(.)}"),
462 )
463});
464
465fn ssh_fields_span_exactly(blob: &[u8]) -> bool {
484 let mut at = 0usize;
485 while at < blob.len() {
486 let Some(header) = blob.get(at..at + 4) else {
487 return false;
488 };
489 let len = u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as usize;
490 at += 4;
491 let Some(end) = at.checked_add(len) else {
492 return false;
493 };
494 if end > blob.len() {
495 return false;
496 }
497 at = end;
498 }
499 at == blob.len() && !blob.is_empty()
500}
501
502fn ssh_string(out: &mut Vec<u8>, bytes: &[u8]) {
504 out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
505 out.extend_from_slice(bytes);
506}
507
508fn normalise_identity(identity: &serde_json::Value) -> Result<serde_json::Value> {
511 use base64::Engine as _;
512
513 let text = identity
514 .get("public_key")
515 .and_then(|v| v.as_str())
516 .filter(|s| !s.trim().is_empty());
517 let blob_hex = identity
518 .get("public_key_blob_hex")
519 .and_then(|v| v.as_str())
520 .filter(|s| !s.is_empty());
521 let stated_comment = identity.get("comment").and_then(|v| v.as_str());
522
523 match (text, blob_hex) {
524 (Some(line), None) => {
525 let mut fields = line.split_whitespace();
526 let algorithm = fields
527 .next()
528 .context("'public_key' is empty; it is an authorized_keys line")?;
529 let encoded = fields.next().with_context(|| {
530 format!(
531 "'public_key' must be \"<algorithm> <base64 blob> [comment]\", e.g. \
532 \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...\"; got {line:?}"
533 )
534 })?;
535 let line_comment = fields.next();
536
537 let blob = base64::engine::general_purpose::STANDARD
538 .decode(encoded)
539 .with_context(|| {
540 format!("the base64 blob in 'public_key' does not decode ({encoded:?})")
541 })?;
542 if !ssh_fields_span_exactly(&blob) {
543 anyhow::bail!(
544 "the key blob in 'public_key' is truncated: its length prefixes do not \
545 span its {} bytes. Copy a whole line from a .pub file rather than \
546 shortening it",
547 blob.len()
548 );
549 }
550 let named: &[u8] = {
553 let len = u32::from_be_bytes([blob[0], blob[1], blob[2], blob[3]]) as usize;
554 &blob[4..4 + len]
555 };
556 if named != algorithm.as_bytes() {
557 anyhow::bail!(
558 "'public_key' says {algorithm:?} but its blob names {:?}",
559 String::from_utf8_lossy(named)
560 );
561 }
562
563 Ok(json!({
564 "public_key_blob_hex": hex::encode(&blob),
565 "comment": stated_comment.or(line_comment).unwrap_or(""),
566 }))
567 }
568 (None, Some(blob_hex)) => Ok(json!({
569 "public_key_blob_hex": blob_hex,
570 "comment": stated_comment.unwrap_or(""),
571 })),
572 (Some(_), Some(_)) => anyhow::bail!(
573 "an identity has both 'public_key' and 'public_key_blob_hex'; they are two \
574 spellings of the same key and nothing can tell which one you meant"
575 ),
576 (None, None) => anyhow::bail!(
577 "an identity has neither 'public_key' (an authorized_keys line, e.g. \
578 \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... deploy-key\") nor \
579 'public_key_blob_hex'"
580 ),
581 }
582}
583
584fn fabricated_signature_len(algorithm: &str) -> Option<usize> {
589 match algorithm {
590 "ssh-ed25519" => Some(64),
591 "ssh-rsa" | "rsa-sha2-256" | "rsa-sha2-512" => Some(256),
592 _ => None,
593 }
594}
595
596fn sign_response_blob(action: &serde_json::Value) -> Result<Vec<u8>> {
598 let algorithm = action
599 .get("algorithm")
600 .and_then(|v| v.as_str())
601 .filter(|s| !s.is_empty());
602 let framed_hex = action
603 .get("signature_hex")
604 .and_then(|v| v.as_str())
605 .filter(|s| !s.is_empty());
606
607 match (algorithm, framed_hex) {
608 (Some(algorithm), None) => {
609 let signature = match action.get("signature_bytes_hex").and_then(|v| v.as_str()) {
610 Some(raw) if !raw.is_empty() => hex::decode(raw)
611 .context("Invalid hex in 'signature_bytes_hex'")
612 .and_then(|bytes| {
613 if bytes.is_empty() {
614 anyhow::bail!("'signature_bytes_hex' decoded to zero bytes");
615 }
616 Ok(bytes)
617 })?,
618 _ => {
619 let len = fabricated_signature_len(algorithm).with_context(|| {
620 format!(
621 "no signature size is known for {algorithm:?}, so there is \
622 nothing to fabricate - give 'signature_bytes_hex' as well, or \
623 the whole framed blob as 'signature_hex'"
624 )
625 })?;
626 const MARKER: &[u8] = b"netget-fabricated-signature-";
629 MARKER.iter().copied().cycle().take(len).collect()
630 }
631 };
632
633 let mut blob = Vec::with_capacity(8 + algorithm.len() + signature.len());
634 ssh_string(&mut blob, algorithm.as_bytes());
635 ssh_string(&mut blob, &signature);
636 Ok(blob)
637 }
638 (None, Some(framed_hex)) => {
639 let blob = hex::decode(framed_hex).context("Invalid hex in 'signature_hex'")?;
640 if blob.is_empty() {
641 anyhow::bail!("'signature_hex' decoded to zero bytes");
642 }
643 Ok(blob)
644 }
645 (Some(_), Some(_)) => anyhow::bail!(
646 "give 'algorithm' or 'signature_hex', not both - the first builds the framing and \
647 the second already contains it"
648 ),
649 (None, None) => anyhow::bail!(
650 "Missing 'algorithm'. Name the signature algorithm (e.g. \"ssh-ed25519\") and the \
651 server frames the blob for you, or pass a whole framed blob as 'signature_hex'"
652 ),
653 }
654}
655
656pub struct SshAgentProtocol;
658
659impl SshAgentProtocol {
660 pub fn new() -> Self {
661 Self
662 }
663}
664
665impl Protocol for SshAgentProtocol {
667 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
675 Some(crate::protocol::BindingDefaults {
676 mac_address: None,
677 interface: None,
678 host: None,
679 port: None,
680 })
681 }
682
683 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
684 vec![
685 ParameterDefinition {
686 name: "socket_path".to_string(),
687 type_hint: "string".to_string(),
688 description: "Path to Unix domain socket (default: ./netget-ssh-agent.sock)"
689 .to_string(),
690 required: false,
691 example: json!("./netget-ssh-agent.sock"),
692 default: None,
693 },
694 ParameterDefinition {
695 name: "first_byte_timeout_secs".to_string(),
696 type_hint: "number".to_string(),
697 description: "Seconds a connected peer may send no request at all before the \
698 server closes it. Default 300: ssh-add and ssh ask at once, but \
699 NetGet's own ssh_agent client connects and waits for a person."
700 .to_string(),
701 required: false,
702 example: json!(300),
703 default: Some(serde_json::json!(super::FIRST_BYTE_READ_TIMEOUT.as_secs())),
704 },
705 ParameterDefinition {
706 name: "idle_timeout_secs".to_string(),
707 type_hint: "number".to_string(),
708 description: "Seconds an established connection may be silent between requests \
709 before the server closes it. Default 900. A request still being \
710 answered never counts as silence."
711 .to_string(),
712 required: false,
713 example: json!(900),
714 default: Some(serde_json::json!(
715 super::IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
716 )),
717 },
718 ]
719 }
720
721 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
722 Vec::new()
730 }
731
732 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
733 vec![
734 SEND_IDENTITIES_LIST_ACTION.clone(),
735 SEND_SIGN_RESPONSE_ACTION.clone(),
736 SEND_SUCCESS_ACTION.clone(),
737 SEND_FAILURE_ACTION.clone(),
738 CLOSE_CONNECTION_ACTION.clone(),
739 WAIT_FOR_MORE_ACTION.clone(),
740 ]
741 }
742
743 fn protocol_name(&self) -> &'static str {
744 "SSH Agent"
745 }
746
747 fn get_event_types(&self) -> Vec<EventType> {
748 vec![
749 (*SSH_AGENT_CONNECTION_OPENED_EVENT).clone(),
750 (*SSH_AGENT_REQUEST_IDENTITIES_EVENT).clone(),
751 (*SSH_AGENT_SIGN_REQUEST_EVENT).clone(),
752 (*SSH_AGENT_ADD_IDENTITY_EVENT).clone(),
753 (*SSH_AGENT_REMOVE_IDENTITY_EVENT).clone(),
754 (*SSH_AGENT_REMOVE_ALL_IDENTITIES_EVENT).clone(),
755 (*SSH_AGENT_LOCK_EVENT).clone(),
756 (*SSH_AGENT_UNLOCK_EVENT).clone(),
757 ]
758 }
759
760 fn stack_name(&self) -> &'static str {
761 "UNIX Socket > SSH Agent"
762 }
763
764 fn keywords(&self) -> Vec<&'static str> {
765 vec!["ssh-agent", "agent", "key-agent", "ssh keys"]
766 }
767
768 fn metadata(&self) -> ProtocolMetadataV2 {
769 ProtocolMetadataV2::builder()
770 .state(DevelopmentState::Experimental)
771 .implementation("Custom SSH Agent wire parser over a Unix domain socket")
773 .llm_control("Identity listings, signing decisions, key lifecycle, lock/unlock")
774 .e2e_testing(
775 "tests/server/ssh_agent drives the real binary over its Unix socket with a \
776 hand-written agent client and a mocked model, covering REQUEST_IDENTITIES, \
777 SIGN_REQUEST, ADD_IDENTITY and a pipelined multi-operation session. That \
778 client is written from the wire format inside the test, so it is an \
779 independent reading of the protocol, not an independent implementation - \
780 the same class of evidence as dhcp's in-test RFC 2131 decoder. No \
781 third-party agent client drives it, and none usefully could: see notes.",
782 )
783 .notes(
784 "FAILS CLOSED: an LLM error, an answer with no usable action, and an action \
785 whose fields will not decode all send SSH_AGENT_FAILURE, logged as \
786 decision=fail_closed_*. Saying nothing is not an option - the client blocks \
787 on the read - and it is never SSH_AGENT_SUCCESS. Virtual agent: no private \
788 keys exist, so signatures are fabricated and will not verify, which is why \
789 a real `ssh` cannot complete a login through this agent and why its rating \
790 cannot rest on one. ADD_IDENTITY parsing assumes the Ed25519 key layout. \
791 Lock/unlock and key constraints are reported but never enforced.",
792 )
793 .max_inbound_bytes(crate::server::ssh_agent::MAX_AGENT_MESSAGE_LEN)
794 .build()
795 }
796
797 fn description(&self) -> &'static str {
798 "SSH Agent protocol server for managing SSH keys and signing operations"
799 }
800
801 fn example_prompt(&self) -> &'static str {
802 "Start SSH Agent on ./netget-ssh-agent.sock; provide 2 Ed25519 keys (admin-key, deploy-key); sign any requests automatically"
803 }
804
805 fn group_name(&self) -> &'static str {
806 "Security"
807 }
808
809 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
810 use crate::llm::actions::StartupExamples;
811 use serde_json::json;
812
813 let script = r#"import json, sys
816data = json.load(sys.stdin)
817event = data["event"]
818if data["event_type_id"] == "ssh_agent_request_identities":
819 actions = [{"type": "send_identities_list", "identities": []}]
820else:
821 actions = []
822print(json.dumps({"actions": actions}))"#;
823
824 StartupExamples::new(
825 json!({
827 "type": "open_server",
828 "port": 0,
829 "base_stack": "ssh-agent",
830 "instruction": "SSH Agent managing keys and signing operations"
831 }),
832 json!({
834 "type": "open_server",
835 "port": 0,
836 "base_stack": "ssh-agent",
837 "event_handlers": [{
838 "event_pattern": "ssh_agent_request_identities",
839 "handler": {
840 "type": "script",
841 "language": "python",
842 "code": script
843 }
844 }]
845 }),
846 json!({
848 "type": "open_server",
849 "port": 0,
850 "base_stack": "ssh-agent",
851 "event_handlers": [{
852 "event_pattern": "ssh_agent_request_identities",
853 "handler": {
854 "type": "static",
855 "actions": [{
856 "type": "send_identities_list",
857 "identities": []
858 }]
859 }
860 }]
861 }),
862 )
863 }
864}
865
866impl Server for SshAgentProtocol {
868 fn spawn(
869 &self,
870 ctx: crate::protocol::SpawnContext,
871 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
872 {
873 Box::pin(async move {
874 let socket_path = ctx
877 .startup_params
878 .as_ref()
879 .map(|p| p.get_optional_string("socket_path"))
880 .transpose()?
881 .flatten()
882 .unwrap_or_else(|| "./netget-ssh-agent.sock".to_string());
883
884 let socket_path_buf = std::path::PathBuf::from(socket_path);
885
886 let secs = |name: &str| -> Result<Option<u64>> {
889 Ok(ctx
890 .startup_params
891 .as_ref()
892 .map(|p| p.get_optional_u64(name))
893 .transpose()?
894 .flatten())
895 };
896 let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
897 let idle_timeout_secs = secs("idle_timeout_secs")?;
898
899 use crate::server::ssh_agent::SshAgentServer;
900 let _actual_path = SshAgentServer::spawn_with_llm_actions(
901 socket_path_buf,
902 ctx.llm_client,
903 ctx.state,
904 ctx.status_tx,
905 ctx.server_id,
906 first_byte_timeout_secs,
907 idle_timeout_secs,
908 )
909 .await?;
910
911 Ok("127.0.0.1:0".parse().unwrap())
913 })
914 }
915
916 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
917 let action_type = action["type"]
918 .as_str()
919 .ok_or_else(|| anyhow::anyhow!("Missing 'type' field in action"))?;
920
921 match action_type {
922 "send_identities_list" => {
923 let identities = action["identities"]
924 .as_array()
925 .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'identities' field"))?;
926
927 let normalised = identities
931 .iter()
932 .map(normalise_identity)
933 .collect::<Result<Vec<_>>>()?;
934
935 Ok(ActionResult::Custom {
936 name: "send_identities_list".to_string(),
937 data: json!({ "identities": normalised }),
938 })
939 }
940 "send_sign_response" => Ok(ActionResult::Custom {
941 name: "send_sign_response".to_string(),
942 data: json!({ "signature_hex": hex::encode(sign_response_blob(&action)?) }),
943 }),
944 "send_success" => Ok(ActionResult::Custom {
945 name: "send_success".to_string(),
946 data: json!({}),
947 }),
948 "send_failure" => Ok(ActionResult::Custom {
949 name: "send_failure".to_string(),
950 data: json!({}),
951 }),
952 "wait_for_more" => Ok(ActionResult::WaitForMore),
953 "close_connection" => {
959 Ok(ActionResult::CloseConnection)
961 }
962 _ => anyhow::bail!("Unknown action type: {}", action_type),
963 }
964 }
965}