Skip to main content

aegis_tools/
service.rs

1//! # ServiceTool
2//!
3//! Manage/inspect systemd services and read their journal. Read actions
4//! (status/is-active/is-enabled/journal) run freely; mutating actions
5//! (start/stop/restart/reload/enable/disable) are approval-gated because they
6//! have a large blast radius on a live server (see capability-gaps §5.6).
7//!
8//! Shells out to `systemctl`/`journalctl` with an argv array (no shell → no
9//! injection). Zero new dependencies. Linux/systemd only; degrades gracefully
10//! elsewhere.
11
12use crate::registry::{Tool, ToolContext};
13use anyhow::Result;
14use async_trait::async_trait;
15use serde_json::{json, Value};
16use std::process::Stdio;
17
18/// systemd service management + journal reading.
19pub struct ServiceTool;
20
21impl ServiceTool {
22    /// Create a new `ServiceTool`.
23    pub fn new() -> Self {
24        ServiceTool
25    }
26}
27
28impl Default for ServiceTool {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34const READ_ACTIONS: &[&str] = &["status", "is-active", "is-enabled", "journal", "list"];
35const WRITE_ACTIONS: &[&str] = &[
36    "start", "stop", "restart", "reload", "enable", "disable",
37];
38
39/// Validate a systemd unit name to prevent argument/command injection.
40fn valid_unit(unit: &str) -> bool {
41    !unit.is_empty()
42        && unit.len() <= 128
43        && unit
44            .chars()
45            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@' | ':' | '\\'))
46}
47
48#[async_trait]
49impl Tool for ServiceTool {
50    fn name(&self) -> &str {
51        "service"
52    }
53
54    fn description(&self) -> &str {
55        "Inspect or manage systemd services. Read actions: status, is-active, is-enabled, journal, list. Managing actions (start/stop/restart/reload/enable/disable) require approval. Linux/systemd only."
56    }
57
58    fn parameters(&self) -> Value {
59        json!({
60            "type": "object",
61            "properties": {
62                "action": {
63                    "type": "string",
64                    "enum": ["status", "is-active", "is-enabled", "journal", "list",
65                             "start", "stop", "restart", "reload", "enable", "disable"],
66                    "description": "What to do"
67                },
68                "unit": { "type": "string", "description": "Service unit name, e.g. 'nginx' or 'aegis.service' (not needed for 'list')" },
69                "lines": { "type": "integer", "description": "journal only: number of recent log lines (default 50)" }
70            },
71            "required": ["action"]
72        })
73    }
74
75    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
76        let action = args["action"].as_str().unwrap_or("").trim();
77        if action.is_empty() {
78            return Ok("Error: action is required".to_string());
79        }
80        let is_read = READ_ACTIONS.contains(&action);
81        let is_write = WRITE_ACTIONS.contains(&action);
82        if !is_read && !is_write {
83            return Ok(format!("Error: unknown action '{action}'"));
84        }
85
86        // 'list' needs no unit; everything else does.
87        let unit = args["unit"].as_str().unwrap_or("").trim().to_string();
88        if action != "list" {
89            if unit.is_empty() {
90                return Ok("Error: 'unit' is required for this action".to_string());
91            }
92            if !valid_unit(&unit) {
93                return Ok("Error: invalid unit name (allowed: alphanumerics . _ - @ :)".to_string());
94            }
95        }
96
97        // Approval gate for mutating actions (high blast radius).
98        if is_write
99            && !ctx.approve(&format!("systemctl {action} {unit} — this changes a live service"))
100        {
101            return Ok(format!("Service '{action} {unit}' cancelled: user did not approve."));
102        }
103
104        // Build argv (no shell).
105        let (program, cmd_args): (&str, Vec<String>) = match action {
106            "journal" => {
107                let lines = args["lines"].as_u64().unwrap_or(50).min(2000);
108                (
109                    "journalctl",
110                    vec![
111                        "-u".into(),
112                        unit.clone(),
113                        "-n".into(),
114                        lines.to_string(),
115                        "--no-pager".into(),
116                        "--output".into(),
117                        "short-iso".into(),
118                    ],
119                )
120            }
121            "list" => (
122                "systemctl",
123                vec![
124                    "list-units".into(),
125                    "--type=service".into(),
126                    "--no-pager".into(),
127                    "--no-legend".into(),
128                ],
129            ),
130            other => ("systemctl", vec![other.into(), unit.clone(), "--no-pager".into()]),
131        };
132
133        let output = tokio::time::timeout(
134            std::time::Duration::from_secs(30),
135            tokio::process::Command::new(program)
136                .args(&cmd_args)
137                .stdout(Stdio::piped())
138                .stderr(Stdio::piped())
139                .output(),
140        )
141        .await
142        .map_err(|_| anyhow::anyhow!("{program} timed out"))?;
143
144        let output = match output {
145            Ok(o) => o,
146            Err(e) => {
147                return Ok(format!(
148                    "Failed to run {program}: {e}. (This tool needs systemd; it only works on Linux servers using systemctl.)"
149                ));
150            }
151        };
152
153        let stdout = String::from_utf8_lossy(&output.stdout);
154        let stderr = String::from_utf8_lossy(&output.stderr);
155        let code = output.status.code().unwrap_or(-1);
156
157        // systemctl uses non-zero exit for is-active/is-enabled when inactive;
158        // that's informative, not an error, so surface stdout regardless.
159        let mut result = String::new();
160        if !stdout.trim().is_empty() {
161            result.push_str(stdout.trim_end());
162        }
163        if !stderr.trim().is_empty() {
164            if !result.is_empty() {
165                result.push('\n');
166            }
167            result.push_str(&format!("[stderr] {}", stderr.trim_end()));
168        }
169        if result.is_empty() {
170            result = format!("(exit {code}, no output)");
171        }
172        // Cap very long journals.
173        Ok(truncate(&result, 20_000))
174    }
175}
176
177fn truncate(s: &str, max: usize) -> String {
178    if s.len() <= max {
179        s.to_string()
180    } else {
181        let cut: String = s.chars().take(max).collect();
182        format!("{cut}\n... [truncated]")
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn valid_unit_accepts_and_rejects() {
192        assert!(valid_unit("nginx"));
193        assert!(valid_unit("aegis.service"));
194        assert!(valid_unit("getty@tty1.service"));
195        assert!(!valid_unit(""));
196        assert!(!valid_unit("nginx; rm -rf /"));
197        assert!(!valid_unit("a b"));
198        assert!(!valid_unit("$(whoami)"));
199    }
200}