Skip to main content

lonca_daemon/
security.rs

1//! Security events outside the operation log (issue #56).
2//!
3//! The operation log records every decided operation, denials included.
4//! Much of what an attacker tries never reaches a decision: a refused peer, a
5//! bad token, an oversized or malformed request, a request over its rate, a
6//! hook refusal, an error while reconciling refs. Those land here, as one JSON
7//! object per line in `<home>/security.log` (mode 0600), together with the
8//! events a human should hear of at once: freezes and integrity alarms.
9//!
10//! An event carries what happened and to whom, never a token, a request body
11//! or file contents: its fields are fixed, its free text is short, has no
12//! control characters, and anything shaped like a Lonca token is masked. The
13//! daemon writes events around decisions, never inside `decide()`.
14
15use std::fs;
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use std::sync::Mutex;
19use std::time::Duration;
20
21use serde::{Deserialize, Serialize};
22
23/// Past this size the log moves to `security.log.1` (replacing the one
24/// there) and a new one starts: at most twice this much is kept.
25pub const ROTATE_BYTES: u64 = 8 << 20;
26
27/// The longest free text of an event, in characters.
28const MAX_DETAIL: usize = 300;
29
30/// How long a notification command may run.
31const NOTIFY_TIMEOUT: Duration = Duration::from_secs(10);
32
33/// What kind of thing happened. The names are the `kind` field.
34pub mod kind {
35    /// A connection from a uid the socket does not serve.
36    pub const REFUSED_PEER: &str = "refused_peer";
37    /// `unauthenticated` or `forbidden`.
38    pub const AUTH_FAILURE: &str = "auth_failure";
39    /// A line past its method's limit.
40    pub const OVERSIZED: &str = "oversized_request";
41    /// A line that is not a request.
42    pub const MALFORMED: &str = "malformed_request";
43    pub const RATE_LIMITED: &str = "rate_limited";
44    /// An isolated session's token used outside its sandbox.
45    pub const OUTSIDE_SANDBOX: &str = "outside_sandbox";
46    /// A harness hook refused a tool call.
47    pub const HOOK_DENIED: &str = "hook_denied";
48    /// `reconcile()` failed for a repository.
49    pub const RECONCILE_ERROR: &str = "reconcile_error";
50    /// A ref changed behind the daemon's back (`import.unmediated`).
51    pub const UNMEDIATED: &str = "unmediated_import";
52    /// A repository froze.
53    pub const FREEZE: &str = "freeze";
54    /// The auditor asked for a freeze (`--freeze-flag`).
55    pub const INTEGRITY_ALARM: &str = "integrity_alarm";
56    /// A session was tainted by the daemon.
57    pub const TAINT: &str = "taint";
58    /// A bind whose hook reports sudo without a password.
59    pub const SUDO_WITHOUT_PASSWORD: &str = "sudo_without_password";
60    /// A session got a new token (`session.rotate`).
61    pub const TOKEN_ROTATED: &str = "token_rotated";
62    /// A call to `lonca mcp` came with no before-tool event: the session's
63    /// hooks are not running (#190).
64    pub const UNHOOKED: &str = "unhooked";
65
66    /// Every kind, for `docs/decisions.md` and the tests.
67    pub const ALL: [&str; 15] = [
68        REFUSED_PEER,
69        AUTH_FAILURE,
70        OVERSIZED,
71        MALFORMED,
72        RATE_LIMITED,
73        OUTSIDE_SANDBOX,
74        HOOK_DENIED,
75        RECONCILE_ERROR,
76        UNMEDIATED,
77        FREEZE,
78        INTEGRITY_ALARM,
79        TAINT,
80        SUDO_WITHOUT_PASSWORD,
81        TOKEN_ROTATED,
82        UNHOOKED,
83    ];
84
85    /// The kinds a human is told of as they happen.
86    pub fn alarming(k: &str) -> bool {
87        matches!(k, FREEZE | INTEGRITY_ALARM)
88    }
89}
90
91/// One security event.
92#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
93pub struct Event {
94    pub time_ms: i64,
95    pub kind: String,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub repo: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub session: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub method: Option<String>,
102    /// The uid of the peer, where the socket told it.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub uid: Option<u32>,
105    /// The error code answered, where there was one.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub code: Option<String>,
108    /// Short words on what happened: never a request, a token or content.
109    #[serde(default, skip_serializing_if = "String::is_empty")]
110    pub detail: String,
111}
112
113impl Event {
114    pub fn new(time_ms: i64, kind: &str) -> Self {
115        Self {
116            time_ms,
117            kind: kind.into(),
118            ..Self::default()
119        }
120    }
121
122    pub fn repo(mut self, repo: &str) -> Self {
123        self.repo = Some(clean(repo, 64));
124        self
125    }
126
127    pub fn session(mut self, session: &str) -> Self {
128        self.session = Some(clean(session, 64));
129        self
130    }
131
132    pub fn method(mut self, method: &str) -> Self {
133        self.method = Some(clean(method, 64));
134        self
135    }
136
137    pub fn uid(mut self, uid: Option<u32>) -> Self {
138        self.uid = uid;
139        self
140    }
141
142    pub fn code(mut self, code: &str) -> Self {
143        self.code = Some(clean(code, 64));
144        self
145    }
146
147    pub fn detail(mut self, detail: &str) -> Self {
148        self.detail = clean(detail, MAX_DETAIL);
149        self
150    }
151}
152
153/// Text fit for an event: no control characters, at most `max`
154/// characters, and every `lonca_…` token masked, in case a message ever
155/// quotes one.
156pub fn clean(text: &str, max: usize) -> String {
157    let kept: String = text
158        .chars()
159        .map(|c| if c.is_control() { ' ' } else { c })
160        .take(max)
161        .collect();
162    mask_tokens(&kept)
163}
164
165fn mask_tokens(text: &str) -> String {
166    let mut out = String::with_capacity(text.len());
167    let mut rest = text;
168    while let Some(at) = rest.find("lonca_") {
169        out.push_str(&rest[..at]);
170        let tail = &rest[at + "lonca_".len()..];
171        let tail = tail.strip_prefix("h_").unwrap_or(tail);
172        let hex = tail.bytes().take_while(u8::is_ascii_hexdigit).count();
173        if hex >= 16 {
174            out.push_str("lonca_[token]");
175            rest = &tail[hex..];
176        } else {
177            out.push_str("lonca_");
178            rest = &rest[at + "lonca_".len()..];
179        }
180    }
181    out.push_str(rest);
182    out
183}
184
185/// What happens when a human must hear of an event at once.
186#[derive(Clone, Debug)]
187pub enum Notify {
188    /// Nothing: the event is in the log and `lonca status` shows a freeze.
189    Off,
190    /// The desktop's notification (macOS `osascript`, Linux `notify-send`),
191    /// where there is one.
192    Desktop,
193    /// This program, given the event as one JSON line on its stdin (a
194    /// webhook, a pager: outside the core).
195    Command(PathBuf),
196}
197
198/// The security log of one daemon.
199pub struct SecurityLog {
200    path: PathBuf,
201    rotate_bytes: u64,
202    notify: Notify,
203    write: Mutex<()>,
204}
205
206impl SecurityLog {
207    pub fn new(home: &Path, notify: Notify) -> Self {
208        Self {
209            path: home.join("security.log"),
210            rotate_bytes: ROTATE_BYTES,
211            notify,
212            write: Mutex::new(()),
213        }
214    }
215
216    pub fn path(&self) -> &Path {
217        &self.path
218    }
219
220    /// Records `event`. Failing to write is said on stderr, the daemon's only
221    /// other voice; it never stops the request that caused it.
222    pub fn record(&self, event: Event) {
223        let alarming = kind::alarming(&event.kind);
224        if let Err(e) = self.append(&event) {
225            eprintln!("lonca: security log {}: {e}", self.path.display());
226        }
227        if alarming {
228            self.tell(&event);
229        }
230    }
231
232    fn append(&self, event: &Event) -> std::io::Result<()> {
233        let _held = self.write.lock().unwrap_or_else(|e| e.into_inner());
234        if fs::metadata(&self.path).is_ok_and(|m| m.len() >= self.rotate_bytes) {
235            fs::rename(&self.path, self.path.with_extension("log.1"))?;
236        }
237        let mut opts = fs::OpenOptions::new();
238        opts.create(true).append(true);
239        #[cfg(unix)]
240        {
241            use std::os::unix::fs::OpenOptionsExt;
242            opts.mode(0o600);
243        }
244        let mut f = opts.open(&self.path)?;
245        let mut line = serde_json::to_vec(event).map_err(std::io::Error::other)?;
246        line.push(b'\n');
247        f.write_all(&line)
248    }
249
250    /// The events at `since_ms` or later, oldest first, from both files.
251    pub fn read_since(&self, since_ms: i64) -> Vec<Event> {
252        let _held = self.write.lock().unwrap_or_else(|e| e.into_inner());
253        let mut out = Vec::new();
254        for p in [self.path.with_extension("log.1"), self.path.clone()] {
255            let Ok(text) = fs::read_to_string(&p) else {
256                continue;
257            };
258            let events = text
259                .lines()
260                .filter_map(|l| serde_json::from_str::<Event>(l).ok());
261            out.extend(events.filter(|e| e.time_ms >= since_ms));
262        }
263        out
264    }
265
266    /// Tells a human, without waiting for it: a notification is best effort
267    /// and runs on a thread of its own, killed after a few seconds.
268    fn tell(&self, event: &Event) {
269        let message = format!(
270            "{}{}: {}",
271            event.kind,
272            event
273                .repo
274                .as_ref()
275                .map(|r| format!(" in {r}"))
276                .unwrap_or_default(),
277            event.detail
278        );
279        let line = serde_json::to_string(event).unwrap_or_default();
280        let notify = self.notify.clone();
281        std::thread::spawn(move || {
282            let child = match notify {
283                Notify::Off => return,
284                Notify::Desktop => desktop(&message),
285                Notify::Command(p) => command(&p, &line),
286            };
287            if let Some(mut child) = child {
288                let started = std::time::Instant::now();
289                while started.elapsed() < NOTIFY_TIMEOUT {
290                    if child.try_wait().ok().flatten().is_some() {
291                        return;
292                    }
293                    std::thread::sleep(Duration::from_millis(50));
294                }
295                let _ = child.kill();
296                let _ = child.wait();
297            }
298        });
299    }
300}
301
302fn desktop(message: &str) -> Option<std::process::Child> {
303    use std::process::{Command, Stdio};
304    let mut cmd = if cfg!(target_os = "macos") {
305        let mut c = Command::new("/usr/bin/osascript");
306        // The text goes in as an argument, never into the script.
307        c.args([
308            "-e",
309            "on run argv",
310            "-e",
311            "display notification (item 1 of argv) with title \"Lonca\"",
312            "-e",
313            "end run",
314            message,
315        ]);
316        c
317    } else {
318        let mut c = Command::new("notify-send");
319        c.args(["--app-name=Lonca", "Lonca", message]);
320        c
321    };
322    cmd.stdin(Stdio::null())
323        .stdout(Stdio::null())
324        .stderr(Stdio::null());
325    cmd.spawn().ok()
326}
327
328fn command(program: &Path, line: &str) -> Option<std::process::Child> {
329    use std::process::{Command, Stdio};
330    let mut child = Command::new(program)
331        .stdin(Stdio::piped())
332        .stdout(Stdio::null())
333        .stderr(Stdio::null())
334        .spawn()
335        .ok()?;
336    if let Some(mut stdin) = child.stdin.take() {
337        let _ = writeln!(stdin, "{line}");
338    }
339    Some(child)
340}
341
342/// Counts of `events` by kind.
343pub fn summary(events: &[Event]) -> std::collections::BTreeMap<String, usize> {
344    let mut out = std::collections::BTreeMap::new();
345    for e in events {
346        *out.entry(e.kind.clone()).or_insert(0) += 1;
347    }
348    out
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn free_text_holds_no_token_and_no_control() {
357        let token = format!("lonca_{}", "ab".repeat(32));
358        let human = format!("lonca_h_{}", "cd".repeat(32));
359        let said = clean(&format!("a {token} b {human}\x1b[2J\nc lonca_x"), 300);
360        assert_eq!(said, "a lonca_[token] b lonca_[token] [2J c lonca_x");
361        assert_eq!(clean("abcdef", 3), "abc");
362    }
363
364    #[test]
365    fn events_go_to_a_private_file_that_rotates() {
366        let dir = tempfile::tempdir().unwrap();
367        let mut log = SecurityLog::new(dir.path(), Notify::Off);
368        log.rotate_bytes = 200;
369        for i in 0..10 {
370            log.record(
371                Event::new(i, kind::AUTH_FAILURE)
372                    .method("status")
373                    .code("unauthenticated"),
374            );
375        }
376        #[cfg(unix)]
377        {
378            use std::os::unix::fs::PermissionsExt;
379            let mode = fs::metadata(log.path()).unwrap().permissions().mode();
380            assert_eq!(mode & 0o777, 0o600);
381        }
382        assert!(dir.path().join("security.log.1").exists());
383        let all = log.read_since(0);
384        assert!(!all.is_empty() && all.len() <= 10);
385        assert!(
386            all.windows(2).all(|w| w[0].time_ms < w[1].time_ms),
387            "oldest first"
388        );
389        assert_eq!(log.read_since(9).len(), 1);
390        assert_eq!(summary(&all)[kind::AUTH_FAILURE], all.len());
391    }
392
393    #[test]
394    fn an_alarm_runs_the_notify_command_with_the_event() {
395        let dir = tempfile::tempdir().unwrap();
396        let out = dir.path().join("told");
397        let script = dir.path().join("notify.sh");
398        fs::write(&script, format!("#!/bin/sh\ncat > {}\n", out.display())).unwrap();
399        #[cfg(unix)]
400        {
401            use std::os::unix::fs::PermissionsExt;
402            fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
403        }
404        let log = SecurityLog::new(dir.path(), Notify::Command(script));
405        log.record(Event::new(1, kind::AUTH_FAILURE));
406        log.record(
407            Event::new(2, kind::FREEZE)
408                .repo("demo")
409                .detail("refs/heads/main moved"),
410        );
411        let mut told = String::new();
412        for _ in 0..200 {
413            told = fs::read_to_string(&out).unwrap_or_default();
414            if told.ends_with('\n') {
415                break;
416            }
417            std::thread::sleep(Duration::from_millis(10));
418        }
419        let event: Event = serde_json::from_str(told.trim()).unwrap();
420        assert_eq!(event.kind, kind::FREEZE);
421        assert_eq!(event.repo.as_deref(), Some("demo"));
422    }
423}