Skip to main content

netget/server/ssh/
sftp_handler.rs

1//! SFTP handler with LLM integration
2//!
3//! This module implements an SFTP server handler that delegates all
4//! filesystem operations to the LLM, creating a virtual filesystem
5//! entirely controlled by the AI.
6
7use super::actions::SFTP_OPERATION_EVENT;
8use crate::llm::action_helper::call_llm;
9use crate::llm::ollama_client::OllamaClient;
10use crate::protocol::Event;
11use crate::server::connection::ConnectionId;
12use crate::server::SshProtocol;
13use crate::state::app_state::AppState;
14use crate::utils::WireFailure;
15use anyhow::Result;
16use russh_sftp::protocol::*;
17use std::collections::HashMap;
18use std::sync::Arc;
19use tokio::sync::{mpsc, Mutex};
20use tokio_util::sync::CancellationToken;
21use tracing::{debug, error, info, trace};
22
23/// LLM-controlled SFTP handler
24///
25/// This handler implements the russh_sftp::server::Handler trait
26/// but delegates all filesystem decisions to the LLM instead of
27/// using a real filesystem.
28pub struct LlmSftpHandler {
29    connection_id: ConnectionId,
30    server_id: crate::state::ServerId,
31    llm_client: OllamaClient,
32    app_state: Arc<AppState>,
33    protocol: Arc<SshProtocol>,
34    status_tx: mpsc::UnboundedSender<String>,
35    /// Track handles that the LLM creates
36    handles: Arc<Mutex<HashMap<String, HandleInfo>>>,
37    /// SFTP protocol version
38    version: Option<u32>,
39    /// The SSH connection's owner; see `super::until_owner_drops`.
40    owner: CancellationToken,
41}
42
43/// The action names that count as an answer to an `sftp_operation` event.
44fn is_sftp_reply_action(name: &str) -> bool {
45    matches!(
46        name,
47        "sftp_handle"
48            | "sftp_directory_listing"
49            | "sftp_file_content"
50            | "sftp_file_attributes"
51            | "sftp_error"
52    )
53}
54
55/// If the handler answered with `sftp_error`, map its `code` to an SFTP status code.
56///
57/// Returns `None` when the response is not an error, so callers can carry on.
58fn sftp_error_status(response: &serde_json::Value) -> Option<StatusCode> {
59    if response.get("type").and_then(|v| v.as_str()) != Some("sftp_error") {
60        return None;
61    }
62    let code = response
63        .get("code")
64        .and_then(|v| v.as_str())
65        .unwrap_or("no_such_file");
66    Some(match code {
67        "no_such_file" => StatusCode::NoSuchFile,
68        "permission_denied" => StatusCode::PermissionDenied,
69        "op_unsupported" => StatusCode::OpUnsupported,
70        "eof" => StatusCode::Eof,
71        _ => StatusCode::Failure,
72    })
73}
74
75/// What a backend failure is reported to the SFTP client as.
76///
77/// SFTP v3 defines only OK, EOF, NO_SUCH_FILE, PERMISSION_DENIED, FAILURE, BAD_MESSAGE,
78/// NO_CONNECTION, CONNECTION_LOST and OP_UNSUPPORTED — there is no "busy, try again". So both
79/// [`WireFailure`] categories land on FAILURE and the distinction is kept in the log, where
80/// `llm_sftp_operation` records it alongside the operation name.
81///
82/// FAILURE rather than NO_SUCH_FILE, which several of these paths used to return: "the handler
83/// that answers questions about this tree is unreachable" is a different statement from "this
84/// path does not exist", and the second one is a permanent lie about the filesystem that a
85/// client may cache and a script will branch on.
86const BACKEND_FAILURE_STATUS: StatusCode = StatusCode::Failure;
87
88/// True when the handler produced nothing that could be an SFTP reply.
89///
90/// `llm_sftp_operation` falls back to `{}` when the response carried zero actions. Every reader
91/// below then finds its field missing and substitutes a default — a handle equal to the
92/// requested path, `0o100644` attributes, empty content — so silence *invented* a directory, a
93/// file or a stat for any path asked about, and a model that said nothing "opened" everything.
94/// Silence is not an answer: fail closed, exactly as an unreachable backend does.
95///
96/// A named action that is not one of the SFTP replies counts as silence too — the fallback in
97/// `llm_sftp_operation` hands back the first action when none of them is an SFTP reply, so a
98/// lone `show_message` would otherwise be read as an answer and defaulted in exactly the same
99/// way. A bare object with no `type` is still accepted: that is the shape a script handler
100/// returns, and its fields are the answer.
101fn sftp_no_answer(response: &serde_json::Value) -> bool {
102    let Some(obj) = response.as_object() else {
103        return true;
104    };
105    if obj.is_empty() {
106        return true;
107    }
108    match obj.get("type").and_then(|v| v.as_str()) {
109        Some(name) => !is_sftp_reply_action(name),
110        None => false,
111    }
112}
113
114/// Information about an open handle
115#[derive(Debug, Clone)]
116struct HandleInfo {
117    path: String,
118    #[allow(dead_code)] // Tracked for correctness but not currently used in logic
119    is_directory: bool,
120    /// For directories, track if we've completed reading
121    dir_read_done: bool,
122}
123
124impl LlmSftpHandler {
125    /// Create a new LLM-controlled SFTP handler
126    pub fn new(
127        connection_id: ConnectionId,
128        server_id: crate::state::ServerId,
129        llm_client: OllamaClient,
130        app_state: Arc<AppState>,
131        protocol: Arc<SshProtocol>,
132        status_tx: mpsc::UnboundedSender<String>,
133        owner: CancellationToken,
134    ) -> Self {
135        Self {
136            connection_id,
137            server_id,
138            llm_client,
139            app_state,
140            protocol,
141            status_tx,
142            handles: Arc::new(Mutex::new(HashMap::new())),
143            version: None,
144            owner,
145        }
146    }
147
148    /// Ask the handler/LLM to answer an SFTP operation.
149    ///
150    /// `params` carries structured fields (`path`, `handle`, `offset`, `length`) that are
151    /// merged into the event alongside `operation`. They used to be flattened into a single
152    /// `params` string like `"path='/x', id=3"`, which a script handler had to re-parse and
153    /// which violated the structured-data rule for event payloads.
154    async fn llm_sftp_operation(
155        &self,
156        operation: &str,
157        params: serde_json::Value,
158    ) -> Result<serde_json::Value> {
159        // DEBUG: LLM request summary
160        debug!(
161            "SFTP LLM request: operation={}, params={}",
162            operation, params
163        );
164        let _ = self.status_tx.send(format!(
165            "[DEBUG] SFTP LLM request: operation={}, params={}",
166            operation, params
167        ));
168
169        // Create SFTP operation event
170        let mut event_data = serde_json::json!({ "operation": operation });
171        if let Some(fields) = params.as_object() {
172            for (key, value) in fields {
173                event_data[key] = value.clone();
174            }
175        }
176        let event = Event::new(&SFTP_OPERATION_EVENT, event_data);
177
178        // TRACE: Event details
179        trace!("SFTP calling LLM for operation: {}", operation);
180        let _ = self.status_tx.send(format!(
181            "[TRACE] SFTP calling LLM for operation: {}",
182            operation
183        ));
184
185        // Call LLM with Event-based approach
186        match super::until_owner_drops(
187            &self.owner,
188            call_llm(
189                &self.llm_client,
190                &self.app_state,
191                self.server_id,
192                Some(self.connection_id),
193                &event,
194                self.protocol.as_ref(),
195            ),
196        )
197        .await
198        {
199            Ok(execution_result) => {
200                // Display messages from LLM
201                for message in &execution_result.messages {
202                    info!("{}", message);
203                    let _ = self.status_tx.send(format!("[INFO] {}", message));
204                }
205
206                // DEBUG: LLM response summary
207                debug!(
208                    "SFTP LLM returned {} actions for operation: {}",
209                    execution_result.raw_actions.len(),
210                    operation
211                );
212                let _ = self.status_tx.send(format!(
213                    "[DEBUG] SFTP LLM returned {} actions for operation: {}",
214                    execution_result.raw_actions.len(),
215                    operation
216                ));
217
218                // TRACE: Full response
219                if !execution_result.raw_actions.is_empty() {
220                    let pretty = serde_json::to_string_pretty(&execution_result.raw_actions[0])
221                        .unwrap_or_else(|_| format!("{:?}", execution_result.raw_actions[0]));
222                    trace!("SFTP LLM response ({}) JSON:\n{}", operation, pretty);
223                    let _ = self.status_tx.send(format!(
224                        "[TRACE] SFTP LLM response ({}) JSON:\r\n{}",
225                        operation,
226                        pretty.replace('\n', "\r\n")
227                    ));
228                }
229
230                // SFTP expects exactly one reply per request. Prefer the first action that is
231                // actually an SFTP reply, so a leading show_message/set_memory does not get
232                // mistaken for the answer. Fall back to the first action for handlers that
233                // return a bare object with no "type".
234                let reply = execution_result
235                    .raw_actions
236                    .iter()
237                    .find(|a| {
238                        a.get("type")
239                            .and_then(|v| v.as_str())
240                            .is_some_and(is_sftp_reply_action)
241                    })
242                    .or_else(|| execution_result.raw_actions.first())
243                    .cloned()
244                    .unwrap_or_else(|| serde_json::json!({}));
245
246                Ok(reply)
247            }
248            Err(e) => {
249                // Return the error unwrapped. It used to be re-created with
250                // `anyhow!("LLM error: {}", e)`, which discards the concrete error type —
251                // and `is_overload_error` works by downcasting, so every SFTP failure was
252                // classified as a generic one and a saturated backend was indistinguishable
253                // from a broken one. `context()`/plain propagation keep the chain intact.
254                let failure = WireFailure::classify(&e);
255                error!(
256                    "SFTP {}: decision=fail_closed_backend_error category={:?}: {}",
257                    operation, failure, e
258                );
259                let _ = self.status_tx.send(format!(
260                    "[ERROR] SFTP {}: decision=fail_closed_backend_error category={:?}",
261                    operation, failure
262                ));
263                Err(e)
264            }
265        }
266    }
267}
268
269impl russh_sftp::server::Handler for LlmSftpHandler {
270    type Error = StatusCode;
271
272    async fn init(
273        &mut self,
274        version: u32,
275        extensions: HashMap<String, String>,
276    ) -> Result<Version, Self::Error> {
277        info!(
278            "SFTP init: version={}, extensions={:?}",
279            version, extensions
280        );
281        self.version = Some(version);
282
283        // Send status update
284        let _ = self
285            .status_tx
286            .send(format!("SFTP session initialized (v{})", version));
287
288        // Return version 3 (most widely supported)
289        Ok(Version::new())
290    }
291
292    async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> {
293        // DEBUG: SFTP request summary
294        debug!("SFTP request: SSH_FXP_OPENDIR id={}, path={}", id, path);
295        let _ = self.status_tx.send(format!(
296            "[DEBUG] SFTP request: SSH_FXP_OPENDIR id={}, path={}",
297            id, path
298        ));
299
300        // TRACE: Full SFTP request
301        trace!("SFTP SSH_FXP_OPENDIR request: id={}, path='{}'", id, path);
302        let _ = self.status_tx.send(format!(
303            "[TRACE] SFTP SSH_FXP_OPENDIR request: id={}, path='{}'",
304            id, path
305        ));
306
307        let params = serde_json::json!({ "path": path });
308        match self.llm_sftp_operation("opendir", params).await {
309            Ok(response) => {
310                if let Some(status) = sftp_error_status(&response) {
311                    debug!("SFTP opendir refused by handler for {}: {:?}", path, status);
312                    let _ = self.status_tx.send(format!(
313                        "[DEBUG] SFTP opendir for {path}: decision=model_reject"
314                    ));
315                    return Err(status);
316                }
317
318                if sftp_no_answer(&response) {
319                    error!("SFTP opendir for {}: decision=fail_closed_no_answer", path);
320                    let _ = self.status_tx.send(format!(
321                        "[ERROR] SFTP opendir for {path}: decision=fail_closed_no_answer"
322                    ));
323                    return Err(BACKEND_FAILURE_STATUS);
324                }
325
326                // LLM should return a handle for this directory
327                let handle_str = response
328                    .get("handle")
329                    .and_then(|v| v.as_str())
330                    .unwrap_or(&path)
331                    .to_string();
332
333                // Store handle info
334                self.handles.lock().await.insert(
335                    handle_str.clone(),
336                    HandleInfo {
337                        path: path.clone(),
338                        is_directory: true,
339                        dir_read_done: false,
340                    },
341                );
342
343                let _ = self
344                    .status_tx
345                    .send(format!("→ SFTP opened directory: {}", path));
346
347                // DEBUG: SFTP response summary
348                debug!(
349                    "SFTP response: SSH_FXP_HANDLE id={}, handle_len={} bytes",
350                    id,
351                    handle_str.len()
352                );
353                let _ = self.status_tx.send(format!(
354                    "[DEBUG] SFTP response: SSH_FXP_HANDLE id={}, handle_len={} bytes",
355                    id,
356                    handle_str.len()
357                ));
358
359                // TRACE: Full SFTP response
360                trace!(
361                    "SFTP SSH_FXP_HANDLE response: id={}, handle='{}'",
362                    id,
363                    handle_str
364                );
365                let _ = self.status_tx.send(format!(
366                    "[TRACE] SFTP SSH_FXP_HANDLE response: id={}, handle='{}'",
367                    id, handle_str
368                ));
369
370                Ok(Handle {
371                    id,
372                    handle: handle_str,
373                })
374            }
375            Err(_) => {
376                // Already logged with its category by `llm_sftp_operation`.
377                debug!("SFTP response: SSH_FXP_STATUS id={}, status=FAILURE", id);
378                let _ = self.status_tx.send(format!(
379                    "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=FAILURE (opendir '{}')",
380                    id, path
381                ));
382
383                Err(BACKEND_FAILURE_STATUS)
384            }
385        }
386    }
387
388    async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> {
389        // DEBUG: SFTP request summary
390        debug!(
391            "SFTP request: SSH_FXP_READDIR id={}, handle_len={} bytes",
392            id,
393            handle.len()
394        );
395        let _ = self.status_tx.send(format!(
396            "[DEBUG] SFTP request: SSH_FXP_READDIR id={}, handle_len={} bytes",
397            id,
398            handle.len()
399        ));
400
401        // TRACE: Full SFTP request
402        trace!(
403            "SFTP SSH_FXP_READDIR request: id={}, handle='{}'",
404            id,
405            handle
406        );
407        let _ = self.status_tx.send(format!(
408            "[TRACE] SFTP SSH_FXP_READDIR request: id={}, handle='{}'",
409            id, handle
410        ));
411
412        // Check if we've already read this directory
413        let mut handles = self.handles.lock().await;
414        if let Some(handle_info) = handles.get_mut(&handle) {
415            if handle_info.dir_read_done {
416                // DEBUG: SFTP EOF response
417                debug!(
418                    "SFTP response: SSH_FXP_STATUS id={}, status=EOF (directory already read)",
419                    id
420                );
421                let _ = self.status_tx.send(format!("[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=EOF (directory already read)", id));
422
423                return Err(StatusCode::Eof);
424            }
425
426            let path = handle_info.path.clone();
427            drop(handles); // Release lock before async operation
428
429            let params = serde_json::json!({ "path": path, "handle": handle });
430            match self.llm_sftp_operation("readdir", params).await {
431                Ok(response) => {
432                    if let Some(status) = sftp_error_status(&response) {
433                        debug!(
434                            "SFTP readdir for {}: decision=model_reject ({:?})",
435                            path, status
436                        );
437                        return Err(status);
438                    }
439
440                    if sftp_no_answer(&response) {
441                        // An empty listing is a legitimate answer and looks nothing like this:
442                        // it arrives as `sftp_directory_listing` with no `entries`. A reply
443                        // with no fields at all means the handler said nothing.
444                        error!("SFTP readdir for {}: decision=fail_closed_no_answer", path);
445                        let _ = self.status_tx.send(format!(
446                            "[ERROR] SFTP readdir for {path}: decision=fail_closed_no_answer"
447                        ));
448                        return Err(BACKEND_FAILURE_STATUS);
449                    }
450
451                    // Parse file list from LLM
452                    let files = response
453                        .get("entries")
454                        .and_then(|v| v.as_array())
455                        .map(|arr| {
456                            arr.iter()
457                                .filter_map(|entry| {
458                                    // Only 'name' is load-bearing. is_dir/size used to be
459                                    // fetched with `?`, so an entry that omitted either was
460                                    // silently dropped from the listing.
461                                    let name = entry.get("name")?.as_str()?.to_string();
462                                    let is_dir = entry
463                                        .get("is_dir")
464                                        .and_then(|v| v.as_bool())
465                                        .unwrap_or(false);
466                                    let size =
467                                        entry.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
468
469                                    // Set permissions: 0755 for dirs, 0644 for files
470                                    let attrs = FileAttributes {
471                                        size: Some(size),
472                                        permissions: Some(if is_dir { 0o40755 } else { 0o100644 }),
473                                        ..Default::default()
474                                    };
475
476                                    Some(File::new(name, attrs))
477                                })
478                                .collect::<Vec<_>>()
479                        })
480                        .unwrap_or_default();
481
482                    // Mark directory as read
483                    if let Some(h) = self.handles.lock().await.get_mut(&handle) {
484                        h.dir_read_done = true;
485                    }
486
487                    let _ = self.status_tx.send(format!(
488                        "→ SFTP listed {} items in {}",
489                        files.len(),
490                        path
491                    ));
492
493                    // DEBUG: SFTP response summary
494                    debug!(
495                        "SFTP response: SSH_FXP_NAME id={}, file_count={}",
496                        id,
497                        files.len()
498                    );
499                    let _ = self.status_tx.send(format!(
500                        "[DEBUG] SFTP response: SSH_FXP_NAME id={}, file_count={}",
501                        id,
502                        files.len()
503                    ));
504
505                    // TRACE: Full SFTP response
506                    let file_names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
507                    trace!(
508                        "SFTP SSH_FXP_NAME response: id={}, files={:?}",
509                        id,
510                        file_names
511                    );
512                    let _ = self.status_tx.send(format!(
513                        "[TRACE] SFTP SSH_FXP_NAME response: id={}, files={:?}",
514                        id, file_names
515                    ));
516
517                    Ok(Name { id, files })
518                }
519                Err(_) => {
520                    // Already logged with its category by `llm_sftp_operation`.
521                    debug!("SFTP response: SSH_FXP_STATUS id={}, status=FAILURE", id);
522                    let _ = self.status_tx.send(format!(
523                        "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=FAILURE (readdir '{}')",
524                        id, handle
525                    ));
526
527                    Err(BACKEND_FAILURE_STATUS)
528                }
529            }
530        } else {
531            error!("SFTP readdir: invalid handle {}", handle);
532            let _ = self
533                .status_tx
534                .send(format!("[ERROR] SFTP readdir: invalid handle {}", handle));
535
536            // DEBUG: SFTP error response
537            debug!(
538                "SFTP response: SSH_FXP_STATUS id={}, status=BAD_MESSAGE",
539                id
540            );
541            let _ = self.status_tx.send(format!(
542                "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=BAD_MESSAGE",
543                id
544            ));
545
546            Err(StatusCode::BadMessage)
547        }
548    }
549
550    async fn open(
551        &mut self,
552        id: u32,
553        path: String,
554        pflags: OpenFlags,
555        _attrs: FileAttributes,
556    ) -> Result<Handle, Self::Error> {
557        // DEBUG: SFTP request summary
558        debug!(
559            "SFTP request: SSH_FXP_OPEN id={}, path={}, flags={:?}",
560            id, path, pflags
561        );
562        let _ = self.status_tx.send(format!(
563            "[DEBUG] SFTP request: SSH_FXP_OPEN id={}, path={}, flags={:?}",
564            id, path, pflags
565        ));
566
567        // TRACE: Full SFTP request
568        trace!(
569            "SFTP SSH_FXP_OPEN request: id={}, path='{}', flags={:?}",
570            id,
571            path,
572            pflags
573        );
574        let _ = self.status_tx.send(format!(
575            "[TRACE] SFTP SSH_FXP_OPEN request: id={}, path='{}', flags={:?}",
576            id, path, pflags
577        ));
578
579        let params = serde_json::json!({ "path": path });
580        match self.llm_sftp_operation("open", params).await {
581            Ok(response) => {
582                if let Some(status) = sftp_error_status(&response) {
583                    debug!("SFTP open refused by handler for {}: {:?}", path, status);
584                    let _ = self.status_tx.send(format!(
585                        "[DEBUG] SFTP open for {path}: decision=model_reject"
586                    ));
587                    return Err(status);
588                }
589
590                if sftp_no_answer(&response) {
591                    error!("SFTP open for {}: decision=fail_closed_no_answer", path);
592                    let _ = self.status_tx.send(format!(
593                        "[ERROR] SFTP open for {path}: decision=fail_closed_no_answer"
594                    ));
595                    return Err(BACKEND_FAILURE_STATUS);
596                }
597
598                let handle_str = response
599                    .get("handle")
600                    .and_then(|v| v.as_str())
601                    .unwrap_or(&path)
602                    .to_string();
603
604                // Store handle info
605                self.handles.lock().await.insert(
606                    handle_str.clone(),
607                    HandleInfo {
608                        path: path.clone(),
609                        is_directory: false,
610                        dir_read_done: false,
611                    },
612                );
613
614                let _ = self.status_tx.send(format!("→ SFTP opened file: {}", path));
615
616                // DEBUG: SFTP response summary
617                debug!(
618                    "SFTP response: SSH_FXP_HANDLE id={}, handle_len={} bytes",
619                    id,
620                    handle_str.len()
621                );
622                let _ = self.status_tx.send(format!(
623                    "[DEBUG] SFTP response: SSH_FXP_HANDLE id={}, handle_len={} bytes",
624                    id,
625                    handle_str.len()
626                ));
627
628                // TRACE: Full SFTP response
629                trace!(
630                    "SFTP SSH_FXP_HANDLE response: id={}, handle='{}'",
631                    id,
632                    handle_str
633                );
634                let _ = self.status_tx.send(format!(
635                    "[TRACE] SFTP SSH_FXP_HANDLE response: id={}, handle='{}'",
636                    id, handle_str
637                ));
638
639                Ok(Handle {
640                    id,
641                    handle: handle_str,
642                })
643            }
644            Err(_) => {
645                // Already logged with its category by `llm_sftp_operation`.
646                debug!("SFTP response: SSH_FXP_STATUS id={}, status=FAILURE", id);
647                let _ = self.status_tx.send(format!(
648                    "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=FAILURE (open '{}')",
649                    id, path
650                ));
651
652                Err(BACKEND_FAILURE_STATUS)
653            }
654        }
655    }
656
657    async fn read(
658        &mut self,
659        id: u32,
660        handle: String,
661        offset: u64,
662        len: u32,
663    ) -> Result<Data, Self::Error> {
664        // DEBUG: SFTP request summary
665        debug!(
666            "SFTP request: SSH_FXP_READ id={}, offset={}, len={} bytes",
667            id, offset, len
668        );
669        let _ = self.status_tx.send(format!(
670            "[DEBUG] SFTP request: SSH_FXP_READ id={}, offset={}, len={} bytes",
671            id, offset, len
672        ));
673
674        // TRACE: Full SFTP request
675        trace!(
676            "SFTP SSH_FXP_READ request: id={}, handle='{}', offset={}, len={}",
677            id,
678            handle,
679            offset,
680            len
681        );
682        let _ = self.status_tx.send(format!(
683            "[TRACE] SFTP SSH_FXP_READ request: id={}, handle='{}', offset={}, len={}",
684            id, handle, offset, len
685        ));
686
687        let handles = self.handles.lock().await;
688        if let Some(handle_info) = handles.get(&handle) {
689            let path = handle_info.path.clone();
690            drop(handles);
691
692            let params = serde_json::json!({
693                "path": path,
694                "handle": handle,
695                "offset": offset,
696                "length": len,
697            });
698            match self.llm_sftp_operation("read", params).await {
699                Ok(response) => {
700                    if let Some(status) = sftp_error_status(&response) {
701                        debug!(
702                            "SFTP read for {}: decision=model_reject ({:?})",
703                            path, status
704                        );
705                        return Err(status);
706                    }
707
708                    if sftp_no_answer(&response) {
709                        // Without this the missing `content` defaults to "", `offset >= 0` is
710                        // immediately past the end, and the client is told EOF — i.e. it
711                        // downloads a successful zero-byte file and reports no error at all.
712                        error!("SFTP read for {}: decision=fail_closed_no_answer", path);
713                        let _ = self.status_tx.send(format!(
714                            "[ERROR] SFTP read for {path}: decision=fail_closed_no_answer"
715                        ));
716                        return Err(BACKEND_FAILURE_STATUS);
717                    }
718
719                    // Get file content from LLM
720                    let content = response
721                        .get("content")
722                        .and_then(|v| v.as_str())
723                        .unwrap_or("");
724
725                    // The handler returns the WHOLE file; apply the requested window here.
726                    // Returning the full content for every request made a client that reads
727                    // in chunks receive the file repeatedly and never see EOF.
728                    let full = content.as_bytes();
729                    let start = usize::try_from(offset).unwrap_or(usize::MAX);
730                    if start >= full.len() {
731                        debug!(
732                            "SFTP response: SSH_FXP_STATUS id={}, status=EOF (offset {} >= size {})",
733                            id,
734                            offset,
735                            full.len()
736                        );
737                        return Err(StatusCode::Eof);
738                    }
739                    let end = start.saturating_add(len as usize).min(full.len());
740                    let data = full[start..end].to_vec();
741                    let actual_len = data.len();
742
743                    let _ = self
744                        .status_tx
745                        .send(format!("→ SFTP read {} bytes from {}", actual_len, path));
746
747                    // DEBUG: SFTP response summary
748                    debug!(
749                        "SFTP response: SSH_FXP_DATA id={}, data_len={} bytes",
750                        id, actual_len
751                    );
752                    let _ = self.status_tx.send(format!(
753                        "[DEBUG] SFTP response: SSH_FXP_DATA id={}, data_len={} bytes",
754                        id, actual_len
755                    ));
756
757                    // TRACE: Full SFTP response (truncate if too long)
758                    if actual_len <= 256 {
759                        trace!(
760                            "SFTP SSH_FXP_DATA response: id={}, data={:?}",
761                            id,
762                            String::from_utf8_lossy(&data)
763                        );
764                        let _ = self.status_tx.send(format!(
765                            "[TRACE] SFTP SSH_FXP_DATA response: id={}, data={:?}",
766                            id,
767                            String::from_utf8_lossy(&data)
768                        ));
769                    } else {
770                        trace!(
771                            "SFTP SSH_FXP_DATA response: id={}, data_len={} bytes (truncated)",
772                            id,
773                            actual_len
774                        );
775                        let _ = self.status_tx.send(format!("[TRACE] SFTP SSH_FXP_DATA response: id={}, data_len={} bytes (truncated)", id, actual_len));
776                    }
777
778                    Ok(Data { id, data })
779                }
780                Err(_) => {
781                    // FAILURE, not EOF. EOF is a *successful* end-of-file: a client that gets
782                    // it at offset 0 writes out a complete zero-byte file and exits 0, so a
783                    // backend outage silently truncated every download. Already logged with
784                    // its category by `llm_sftp_operation`.
785                    debug!("SFTP response: SSH_FXP_STATUS id={}, status=FAILURE", id);
786                    let _ = self.status_tx.send(format!(
787                        "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=FAILURE (read '{}')",
788                        id, handle
789                    ));
790
791                    Err(BACKEND_FAILURE_STATUS)
792                }
793            }
794        } else {
795            error!("SFTP read: invalid handle {}", handle);
796            let _ = self
797                .status_tx
798                .send(format!("[ERROR] SFTP read: invalid handle {}", handle));
799
800            // DEBUG: SFTP error response
801            debug!(
802                "SFTP response: SSH_FXP_STATUS id={}, status=BAD_MESSAGE",
803                id
804            );
805            let _ = self.status_tx.send(format!(
806                "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=BAD_MESSAGE",
807                id
808            ));
809
810            Err(StatusCode::BadMessage)
811        }
812    }
813
814    async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
815        // DEBUG: SFTP request summary
816        debug!(
817            "SFTP request: SSH_FXP_CLOSE id={}, handle_len={} bytes",
818            id,
819            handle.len()
820        );
821        let _ = self.status_tx.send(format!(
822            "[DEBUG] SFTP request: SSH_FXP_CLOSE id={}, handle_len={} bytes",
823            id,
824            handle.len()
825        ));
826
827        // TRACE: Full SFTP request
828        trace!("SFTP SSH_FXP_CLOSE request: id={}, handle='{}'", id, handle);
829        let _ = self.status_tx.send(format!(
830            "[TRACE] SFTP SSH_FXP_CLOSE request: id={}, handle='{}'",
831            id, handle
832        ));
833
834        // Remove handle from tracking
835        if let Some(handle_info) = self.handles.lock().await.remove(&handle) {
836            let _ = self
837                .status_tx
838                .send(format!("→ SFTP closed: {}", handle_info.path));
839
840            // DEBUG: SFTP response summary
841            debug!(
842                "SFTP response: SSH_FXP_STATUS id={}, status=OK (closed '{}')",
843                id, handle_info.path
844            );
845            let _ = self.status_tx.send(format!(
846                "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=OK (closed '{}')",
847                id, handle_info.path
848            ));
849
850            // TRACE: Full SFTP response
851            trace!(
852                "SFTP SSH_FXP_STATUS response: id={}, status=OK, path='{}'",
853                id,
854                handle_info.path
855            );
856            let _ = self.status_tx.send(format!(
857                "[TRACE] SFTP SSH_FXP_STATUS response: id={}, status=OK, path='{}'",
858                id, handle_info.path
859            ));
860        } else {
861            // DEBUG: Unknown handle closed (still return OK per SFTP spec)
862            debug!(
863                "SFTP response: SSH_FXP_STATUS id={}, status=OK (unknown handle)",
864                id
865            );
866            let _ = self.status_tx.send(format!(
867                "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=OK (unknown handle)",
868                id
869            ));
870        }
871
872        Ok(Status {
873            id,
874            status_code: StatusCode::Ok,
875            error_message: "".to_string(),
876            language_tag: "en-US".to_string(),
877        })
878    }
879
880    async fn lstat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
881        // DEBUG: SFTP request summary
882        debug!("SFTP request: SSH_FXP_LSTAT id={}, path={}", id, path);
883        let _ = self.status_tx.send(format!(
884            "[DEBUG] SFTP request: SSH_FXP_LSTAT id={}, path={}",
885            id, path
886        ));
887
888        // TRACE: Full SFTP request
889        trace!("SFTP SSH_FXP_LSTAT request: id={}, path='{}'", id, path);
890        let _ = self.status_tx.send(format!(
891            "[TRACE] SFTP SSH_FXP_LSTAT request: id={}, path='{}'",
892            id, path
893        ));
894
895        let params = serde_json::json!({ "path": path });
896        match self.llm_sftp_operation("lstat", params).await {
897            Ok(response) => {
898                if let Some(status) = sftp_error_status(&response) {
899                    debug!("SFTP lstat refused by handler for {}: {:?}", path, status);
900                    let _ = self.status_tx.send(format!(
901                        "[DEBUG] SFTP lstat for {path}: decision=model_reject"
902                    ));
903                    return Err(status);
904                }
905
906                if sftp_no_answer(&response) {
907                    // Otherwise every field below falls back to its default and the client is
908                    // told the path exists as a 0-byte 0o100644 file — so a handler that said
909                    // nothing stats *any* path successfully, including ones it would have
910                    // refused. That is the fail-open shape CLAUDE.md calls the most dangerous
911                    // pattern here.
912                    error!("SFTP lstat for {}: decision=fail_closed_no_answer", path);
913                    let _ = self.status_tx.send(format!(
914                        "[ERROR] SFTP lstat for {path}: decision=fail_closed_no_answer"
915                    ));
916                    return Err(BACKEND_FAILURE_STATUS);
917                }
918
919                let mut attrs = FileAttributes::default();
920
921                // Parse attributes from LLM response
922                if let Some(size) = response.get("size").and_then(|v| v.as_u64()) {
923                    attrs.size = Some(size);
924                }
925
926                // The mode's *type* bits are what a client reads to decide whether an entry is
927                // a file or a directory, so they must always be set explicitly.
928                //
929                // This used to OR in `S_IFDIR` when `is_dir` was true and otherwise leave
930                // `FileAttributes::default()` alone — and russh-sftp's default is `0o40777`,
931                // a directory. So a handler answering `{"size": 23, "is_dir": false}` had its
932                // file reported to the client as a **directory**: `ls -l` showed `d`, and
933                // clients that check the type before downloading refused it. `is_dir: false`
934                // was, in effect, ignored. The defaults below match what `readdir` already
935                // uses for the same entries, so the two views agree.
936                let is_dir = response
937                    .get("is_dir")
938                    .and_then(|v| v.as_bool())
939                    .unwrap_or(false);
940                let type_bit = if is_dir { 0o40000 } else { 0o100000 };
941                attrs.permissions =
942                    Some(match response.get("permissions").and_then(|v| v.as_u64()) {
943                        // A handler that supplied the type bits itself is taken at its word.
944                        Some(perms) if perms & 0o170000 != 0 => perms as u32,
945                        Some(perms) => perms as u32 | type_bit,
946                        None if is_dir => 0o40755,
947                        None => 0o100644,
948                    });
949
950                // DEBUG: SFTP response summary
951                debug!(
952                    "SFTP response: SSH_FXP_ATTRS id={}, size={:?}, perms={:?}",
953                    id, attrs.size, attrs.permissions
954                );
955                let _ = self.status_tx.send(format!(
956                    "[DEBUG] SFTP response: SSH_FXP_ATTRS id={}, size={:?}, perms={:?}",
957                    id, attrs.size, attrs.permissions
958                ));
959
960                // TRACE: Full SFTP response
961                trace!(
962                    "SFTP SSH_FXP_ATTRS response: id={}, path='{}', attrs={:?}",
963                    id,
964                    path,
965                    attrs
966                );
967                let _ = self.status_tx.send(format!(
968                    "[TRACE] SFTP SSH_FXP_ATTRS response: id={}, path='{}', attrs={:?}",
969                    id, path, attrs
970                ));
971
972                Ok(Attrs { id, attrs })
973            }
974            Err(_) => {
975                // Already logged with its category by `llm_sftp_operation`.
976                debug!("SFTP response: SSH_FXP_STATUS id={}, status=FAILURE", id);
977                let _ = self.status_tx.send(format!(
978                    "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=FAILURE (lstat '{}')",
979                    id, path
980                ));
981
982                Err(BACKEND_FAILURE_STATUS)
983            }
984        }
985    }
986
987    async fn fstat(&mut self, id: u32, handle: String) -> Result<Attrs, Self::Error> {
988        // DEBUG: SFTP request summary
989        debug!(
990            "SFTP request: SSH_FXP_FSTAT id={}, handle_len={} bytes",
991            id,
992            handle.len()
993        );
994        let _ = self.status_tx.send(format!(
995            "[DEBUG] SFTP request: SSH_FXP_FSTAT id={}, handle_len={} bytes",
996            id,
997            handle.len()
998        ));
999
1000        // TRACE: Full SFTP request
1001        trace!("SFTP SSH_FXP_FSTAT request: id={}, handle='{}'", id, handle);
1002        let _ = self.status_tx.send(format!(
1003            "[TRACE] SFTP SSH_FXP_FSTAT request: id={}, handle='{}'",
1004            id, handle
1005        ));
1006
1007        let handles = self.handles.lock().await;
1008        if let Some(handle_info) = handles.get(&handle) {
1009            let path = handle_info.path.clone();
1010            drop(handles);
1011
1012            // Delegate to lstat (which will log the lstat request/response)
1013            trace!("SFTP fstat delegating to lstat for path: '{}'", path);
1014            let _ = self.status_tx.send(format!(
1015                "[TRACE] SFTP fstat delegating to lstat for path: '{}'",
1016                path
1017            ));
1018            self.lstat(id, path).await
1019        } else {
1020            error!("SFTP fstat: invalid handle {}", handle);
1021            let _ = self
1022                .status_tx
1023                .send(format!("[ERROR] SFTP fstat: invalid handle {}", handle));
1024
1025            // DEBUG: SFTP error response
1026            debug!(
1027                "SFTP response: SSH_FXP_STATUS id={}, status=BAD_MESSAGE",
1028                id
1029            );
1030            let _ = self.status_tx.send(format!(
1031                "[DEBUG] SFTP response: SSH_FXP_STATUS id={}, status=BAD_MESSAGE",
1032                id
1033            ));
1034
1035            Err(StatusCode::BadMessage)
1036        }
1037    }
1038
1039    /// `SSH_FXP_STAT` — the follow-symlinks form of `lstat`.
1040    ///
1041    /// This used to fall through to `unimplemented()` and answer `SSH_FX_OP_UNSUPPORTED`, so
1042    /// `sftp.stat(path)` — what every client calls to size a file before downloading it, and
1043    /// what `sftp -P … ls -l` uses — failed against a server whose `lstat` worked fine. There
1044    /// is no filesystem behind this handler and therefore no symlink for the two calls to
1045    /// disagree about, so `stat` is `lstat`, exactly as `fstat` already is once its handle is
1046    /// resolved. The handler still sees a single `lstat` operation and needs no new vocabulary.
1047    async fn stat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
1048        debug!("SFTP request: SSH_FXP_STAT id={}, path={}", id, path);
1049        let _ = self.status_tx.send(format!(
1050            "[DEBUG] SFTP request: SSH_FXP_STAT id={}, path={}",
1051            id, path
1052        ));
1053        trace!("SFTP stat delegating to lstat for path: '{}'", path);
1054        self.lstat(id, path).await
1055    }
1056
1057    async fn realpath(&mut self, id: u32, path: String) -> Result<Name, Self::Error> {
1058        // DEBUG: SFTP request summary
1059        debug!("SFTP request: SSH_FXP_REALPATH id={}, path={}", id, path);
1060        let _ = self.status_tx.send(format!(
1061            "[DEBUG] SFTP request: SSH_FXP_REALPATH id={}, path={}",
1062            id, path
1063        ));
1064
1065        // TRACE: Full SFTP request
1066        trace!("SFTP SSH_FXP_REALPATH request: id={}, path='{}'", id, path);
1067        let _ = self.status_tx.send(format!(
1068            "[TRACE] SFTP SSH_FXP_REALPATH request: id={}, path='{}'",
1069            id, path
1070        ));
1071
1072        // Answered locally - the handler is not consulted. OpenSSH opens every session with
1073        // realpath("."), and echoing "." straight back leaves the client with a relative
1074        // working directory that every later path is resolved against; map it to "/".
1075        let path = if path.is_empty() || path == "." {
1076            "/".to_string()
1077        } else {
1078            path
1079        };
1080
1081        let attrs = FileAttributes::default();
1082        let file = File::new(path.clone(), attrs);
1083
1084        // DEBUG: SFTP response summary
1085        debug!(
1086            "SFTP response: SSH_FXP_NAME id={}, resolved_path={}",
1087            id, path
1088        );
1089        let _ = self.status_tx.send(format!(
1090            "[DEBUG] SFTP response: SSH_FXP_NAME id={}, resolved_path={}",
1091            id, path
1092        ));
1093
1094        // TRACE: Full SFTP response
1095        trace!("SFTP SSH_FXP_NAME response: id={}, path='{}'", id, path);
1096        let _ = self.status_tx.send(format!(
1097            "[TRACE] SFTP SSH_FXP_NAME response: id={}, path='{}'",
1098            id, path
1099        ));
1100
1101        Ok(Name {
1102            id,
1103            files: vec![file],
1104        })
1105    }
1106
1107    fn unimplemented(&self) -> Self::Error {
1108        error!("SFTP unimplemented packet received");
1109        let _ = self
1110            .status_tx
1111            .send("[ERROR] SFTP unimplemented packet received".to_string());
1112        StatusCode::OpUnsupported
1113    }
1114}