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