Skip to main content

agentsight_capture/runners/
common.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4use super::{EventStream, Runner, RunnerError};
5use crate::analyzers::Analyzer;
6use crate::event::Event;
7use async_trait::async_trait;
8use futures::stream::{Stream, StreamExt};
9use log::debug;
10use std::path::Path;
11use std::pin::Pin;
12use std::process::Stdio;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use tokio::io::{AsyncBufReadExt, BufReader};
16use tokio::process::Command as TokioCommand;
17
18/// Type alias for JSON stream
19pub type JsonStream = Pin<Box<dyn Stream<Item = serde_json::Value> + Send>>;
20const RUNNER_ERROR_TYPE: &str = "runner_error";
21
22fn preview_line(line: &str, max_chars: usize) -> String {
23    let mut chars = line.chars();
24    let preview: String = chars.by_ref().take(max_chars).collect();
25    if chars.next().is_some() {
26        format!("{preview}...")
27    } else {
28        preview
29    }
30}
31
32fn runner_label(runner_name: Option<&str>, binary_path: &str) -> String {
33    runner_name.map(str::to_string).unwrap_or_else(|| {
34        Path::new(binary_path)
35            .file_name()
36            .and_then(|n| n.to_str())
37            .unwrap_or("binary")
38            .to_string()
39    })
40}
41
42fn runner_startup_exit_message(
43    label: &str,
44    status: impl std::fmt::Display,
45    needs_sudo: bool,
46) -> String {
47    let mut message = format!("{label} exited during startup with {status}");
48    if needs_sudo {
49        message.push_str(
50            "; probe sudo is non-interactive, so run AgentSight with sudo or authenticate first with `sudo -v`",
51        );
52    }
53    message
54}
55
56fn runner_error_json(runner: &str, message: String) -> serde_json::Value {
57    let timestamp = current_boot_time_ns();
58    serde_json::json!({
59        "timestamp": timestamp,
60        "timestamp_ns": timestamp,
61        "pid": 0,
62        "comm": runner,
63        "type": RUNNER_ERROR_TYPE,
64        "message": message,
65    })
66}
67
68pub fn runner_error_from_event(event: &Event) -> Option<RunnerError> {
69    (event.data.get("type").and_then(|v| v.as_str()) == Some(RUNNER_ERROR_TYPE)).then(|| {
70        RunnerError::from(
71            event
72                .data
73                .get("message")
74                .and_then(|v| v.as_str())
75                .unwrap_or("runner failed")
76                .to_string(),
77        )
78    })
79}
80
81struct ProbeProcessGuard {
82    pgid: Option<u32>,
83    needs_sudo: bool,
84}
85
86impl ProbeProcessGuard {
87    fn new(pid: Option<u32>, needs_sudo: bool) -> Self {
88        Self {
89            pgid: pid,
90            needs_sudo,
91        }
92    }
93
94    fn disarm(&mut self) {
95        self.pgid = None;
96    }
97
98    fn terminate(&mut self) {
99        let Some(pgid) = self.pgid.take() else {
100            return;
101        };
102        if self.needs_sudo {
103            let _ = std::process::Command::new("sudo")
104                .args(["-n", "kill", "-TERM", "--", &format!("-{pgid}")])
105                .status();
106        } else {
107            terminate_process_group(pgid);
108        }
109    }
110}
111
112#[cfg(unix)]
113fn terminate_process_group(pgid: u32) {
114    unsafe {
115        libc::killpg(pgid as libc::pid_t, libc::SIGTERM);
116    }
117}
118
119#[cfg(not(unix))]
120fn terminate_process_group(_pgid: u32) {}
121
122#[cfg(unix)]
123fn needs_sudo() -> bool {
124    unsafe { libc::geteuid() != 0 }
125}
126
127#[cfg(not(unix))]
128fn needs_sudo() -> bool {
129    false
130}
131
132impl Drop for ProbeProcessGuard {
133    fn drop(&mut self) {
134        self.terminate();
135    }
136}
137
138pub fn current_boot_time_ns() -> u64 {
139    std::fs::read_to_string("/proc/uptime")
140        .ok()
141        .and_then(|uptime| uptime.split_whitespace().next()?.parse::<f64>().ok())
142        .map(|secs| (secs * 1_000_000_000.0) as u64)
143        .unwrap_or(0)
144}
145
146pub fn parse_error_event(
147    runner: &'static str,
148    raw: serde_json::Value,
149    reason: impl Into<String>,
150    errors: &AtomicU64,
151) -> Event {
152    let timestamp = raw
153        .get("timestamp_ns")
154        .or_else(|| raw.get("timestamp"))
155        .and_then(|v| v.as_u64())
156        .unwrap_or_else(current_boot_time_ns);
157    let pid = raw
158        .get("pid")
159        .and_then(|v| v.as_u64())
160        .map(|v| v as u32)
161        .unwrap_or(0);
162    let comm = raw
163        .get("comm")
164        .and_then(|v| v.as_str())
165        .unwrap_or(runner)
166        .to_string();
167    let count = errors.fetch_add(1, Ordering::Relaxed) + 1;
168
169    Event::new_with_timestamp(
170        timestamp,
171        "diagnostic".to_string(),
172        pid,
173        comm,
174        serde_json::json!({
175            "type": "runner_parse_error",
176            "runner": runner,
177            "reason": reason.into(),
178            "parse_error_count": count,
179            "raw": raw,
180        }),
181    )
182}
183
184pub fn parse_json_event(
185    runner: &'static str,
186    timestamp_field: &'static str,
187    raw: serde_json::Value,
188    errors: &AtomicU64,
189) -> Event {
190    let Some(timestamp) = raw.get(timestamp_field).and_then(|v| v.as_u64()) else {
191        return parse_error_event(runner, raw, format!("missing {timestamp_field}"), errors);
192    };
193    let Some(pid) = raw.get("pid").and_then(|v| v.as_u64()).map(|v| v as u32) else {
194        return parse_error_event(runner, raw, "missing pid", errors);
195    };
196    let Some(comm) = raw.get("comm").and_then(|v| v.as_str()).map(str::to_string) else {
197        return parse_error_event(runner, raw, "missing comm", errors);
198    };
199
200    Event::new_with_timestamp(timestamp, runner.to_string(), pid, comm, raw)
201}
202
203/// Common binary executor for runners - now supports streaming
204pub struct BinaryExecutor {
205    binary_path: String,
206    additional_args: Vec<String>,
207    runner_name: Option<String>,
208}
209
210impl BinaryExecutor {
211    pub fn new(binary_path: String) -> Self {
212        Self {
213            binary_path,
214            additional_args: Vec::new(),
215            runner_name: None,
216        }
217    }
218
219    pub fn with_args(mut self, args: &[String]) -> Self {
220        self.additional_args = args.to_vec();
221        self
222    }
223
224    pub fn set_args(&mut self, args: &[String]) {
225        self.additional_args = args.to_vec();
226    }
227
228    pub fn with_runner_name(mut self, name: String) -> Self {
229        self.runner_name = Some(name);
230        self
231    }
232
233    /// Execute binary and get raw JSON stream.
234    /// When not running as root, automatically wraps the command with `sudo` so
235    /// eBPF programs get the privileges they need while the parent process
236    /// (and the user's agent) stay unprivileged.
237    pub async fn get_json_stream(&self) -> Result<JsonStream, RunnerError> {
238        let needs_sudo = needs_sudo();
239
240        if needs_sudo {
241            log::info!(
242                "Executing binary (via sudo): {} {}",
243                self.binary_path,
244                self.additional_args.join(" ")
245            );
246        } else if self.additional_args.is_empty() {
247            log::info!("Executing binary: {}", self.binary_path);
248        } else {
249            log::info!(
250                "Executing binary: {} {}",
251                self.binary_path,
252                self.additional_args.join(" ")
253            );
254        }
255
256        let mut cmd = if needs_sudo {
257            let mut c = TokioCommand::new("sudo");
258            c.arg("-n").arg(&self.binary_path);
259            c
260        } else {
261            TokioCommand::new(&self.binary_path)
262        };
263        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
264        cmd.kill_on_drop(true);
265        #[cfg(unix)]
266        cmd.process_group(0);
267
268        // Add additional arguments if any
269        if !self.additional_args.is_empty() {
270            cmd.args(&self.additional_args);
271            debug!("Added arguments: {:?}", self.additional_args);
272        }
273
274        let mut child = cmd.spawn().map_err(|e| {
275            Box::new(std::io::Error::other(format!(
276                "Failed to start binary: {}",
277                e
278            ))) as RunnerError
279        })?;
280
281        let stdout = child.stdout.take().ok_or_else(|| {
282            Box::new(std::io::Error::other("Failed to get stdout")) as RunnerError
283        })?;
284
285        let stderr = child.stderr.take().ok_or_else(|| {
286            Box::new(std::io::Error::other("Failed to get stderr")) as RunnerError
287        })?;
288
289        let child_pid = child.id();
290        if let Some(pid) = child_pid {
291            debug!("Binary started with PID: Some({})", pid);
292        }
293
294        // Clone needed data for the stream
295        let runner_name = self.runner_name.clone();
296        let binary_path = self.binary_path.clone();
297        let label = runner_label(runner_name.as_deref(), &binary_path);
298
299        // Spawn a task to read and log stderr
300        let stderr_label = label.clone();
301        tokio::spawn(async move {
302            let mut stderr_reader = BufReader::new(stderr);
303            let mut stderr_line = String::new();
304
305            loop {
306                stderr_line.clear();
307                match stderr_reader.read_line(&mut stderr_line).await {
308                    Ok(0) => {
309                        // EOF reached
310                        break;
311                    }
312                    Ok(_) => {
313                        let trimmed = stderr_line.trim();
314                        if !trimmed.is_empty() {
315                            log::warn!("[{}] STDERR: {}", stderr_label, trimmed);
316                        }
317                    }
318                    Err(e) => {
319                        if e.kind() != std::io::ErrorKind::UnexpectedEof {
320                            log::warn!("Error reading stderr: {}", e);
321                        }
322                        break;
323                    }
324                }
325            }
326        });
327
328        let startup_delay_ms = if self
329            .additional_args
330            .iter()
331            .any(|arg| arg == "--binary-path")
332        {
333            Some(1500)
334        } else if needs_sudo {
335            Some(200)
336        } else {
337            None
338        };
339        if let Some(delay_ms) = startup_delay_ms {
340            tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
341            if let Some(status) = child.try_wait()? {
342                let label = runner_name.as_deref().unwrap_or("binary");
343                return Err(RunnerError::from(runner_startup_exit_message(
344                    label, status, needs_sudo,
345                )));
346            }
347        }
348
349        let stream = async_stream::stream! {
350            let mut guard = ProbeProcessGuard::new(child_pid, needs_sudo);
351            let mut reader = BufReader::new(stdout);
352            let mut line = Vec::new();
353            let mut line_count = 0;
354
355            debug!("Reading from binary stdout");
356
357            loop {
358                line.clear();
359
360                match reader.read_until(b'\n', &mut line).await {
361                    Ok(0) => {
362                        debug!("Binary stdout closed (EOF)");
363                        break;
364                    }
365                    Ok(_) => {
366                        line_count += 1;
367                        let decoded = String::from_utf8_lossy(&line);
368                        let trimmed = decoded.trim();
369
370                        if !trimmed.is_empty() {
371                            debug!("Line {}: {}", line_count, preview_line(trimmed, 100));
372
373                            // Try to parse as JSON
374                            if trimmed.starts_with('{') && trimmed.ends_with('}') {
375                                match serde_json::from_str::<serde_json::Value>(trimmed) {
376                                    Ok(json_value) => {
377                                        debug!("Parsed JSON value");
378                                        yield json_value;
379                                    }
380                                    Err(e) => {
381                                        log::warn!("Failed to parse JSON from line {}: {} - Line: {}",
382                                            line_count, e,
383                                            preview_line(trimmed, 200)
384                                        );
385                                    }
386                                }
387                            } else {
388                                // Check if this might be a stderr message or debug output
389                                if trimmed.contains("error") || trimmed.contains("warn") ||
390                                   trimmed.contains("failed") || trimmed.contains("Error:") {
391                                    log::warn!("Possible error message from binary at line {}: {}",
392                                        line_count, trimmed);
393                                } else {
394                                    log::warn!("Skipping non-JSON line {} from binary: {}",
395                                        line_count,
396                                        preview_line(trimmed, 100)
397                                    );
398                                }
399                            }
400                        }
401                    }
402                    Err(e) => {
403                        if e.kind() == std::io::ErrorKind::Interrupted {
404                            // Retry on interrupted system calls
405                            log::debug!("Read interrupted, retrying...");
406                            continue;
407                        } else {
408                            log::warn!("Error reading from binary: {} (kind: {:?})", e, e.kind());
409                            break;
410                        }
411                    }
412                }
413            }
414
415            log::info!("Terminating binary process");
416
417            // Terminate the child process
418            guard.terminate();
419            if let Err(e) = child.kill().await {
420                log::warn!("Failed to kill binary process: {}", e);
421            }
422
423            // Wait for process to finish
424            match child.wait().await {
425                Ok(status) => {
426                    debug!("Binary process terminated with status: {}", status);
427                    guard.disarm();
428                    if !status.success() {
429                        yield runner_error_json(&label, format!("{label} exited with {status}"));
430                    }
431                }
432                Err(e) => {
433                    yield runner_error_json(&label, format!("failed to wait for {label}: {e}"));
434                }
435            }
436        };
437
438        Ok(Box::pin(stream))
439    }
440}
441
442/// Common analyzer processor for runners
443pub struct AnalyzerProcessor;
444
445impl AnalyzerProcessor {
446    /// Process events through a chain of analyzers
447    pub async fn process_through_analyzers(
448        mut stream: EventStream,
449        analyzers: &mut [Box<dyn Analyzer>],
450    ) -> Result<EventStream, RunnerError> {
451        for analyzer in analyzers.iter_mut() {
452            stream = analyzer.process(stream).await?;
453        }
454        Ok(stream)
455    }
456}
457
458pub struct BinaryRunner {
459    analyzers: Vec<Box<dyn Analyzer>>,
460    executor: BinaryExecutor,
461    source: &'static str,
462    timestamp_field: &'static str,
463}
464
465impl BinaryRunner {
466    pub fn new(
467        runner_name: &str,
468        source: &'static str,
469        timestamp_field: &'static str,
470        binary_path: impl AsRef<Path>,
471    ) -> Self {
472        Self {
473            analyzers: Vec::new(),
474            executor: BinaryExecutor::new(binary_path.as_ref().to_string_lossy().into_owned())
475                .with_runner_name(runner_name.to_string()),
476            source,
477            timestamp_field,
478        }
479    }
480
481    pub fn ssl(binary_path: impl AsRef<Path>) -> Self {
482        Self::new("SSL", "ssl", "timestamp_ns", binary_path)
483    }
484
485    pub fn stdio(binary_path: impl AsRef<Path>) -> Self {
486        Self::new("Stdio", "stdio", "timestamp_ns", binary_path)
487    }
488
489    pub fn with_args<I, S>(mut self, args: I) -> Self
490    where
491        I: IntoIterator<Item = S>,
492        S: AsRef<str>,
493    {
494        let args: Vec<_> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
495        self.executor = self.executor.with_args(&args);
496        self
497    }
498}
499
500#[async_trait]
501impl Runner for BinaryRunner {
502    async fn run(&mut self) -> Result<EventStream, RunnerError> {
503        let json_stream = self.executor.get_json_stream().await?;
504        let errors = Arc::new(AtomicU64::new(0));
505        let source = self.source;
506        let ts_field = self.timestamp_field;
507        let stream = json_stream.map(move |v| parse_json_event(source, ts_field, v, &errors));
508        AnalyzerProcessor::process_through_analyzers(Box::pin(stream), &mut self.analyzers).await
509    }
510
511    fn add_analyzer(mut self, analyzer: Box<dyn Analyzer>) -> Self {
512        self.analyzers.push(analyzer);
513        self
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn sudo_startup_exit_message_names_noninteractive_sudo() {
523        let message = runner_startup_exit_message("Process", "exit status: 1", true);
524        assert!(message.contains("Process exited during startup with exit status: 1"));
525        assert!(message.contains("sudo -v"));
526        assert!(message.contains("non-interactive"));
527    }
528
529    #[test]
530    fn non_sudo_startup_exit_message_stays_short() {
531        let message = runner_startup_exit_message("Process", "exit status: 1", false);
532        assert_eq!(message, "Process exited during startup with exit status: 1");
533    }
534}