Skip to main content

ljos_cli/
swap_log.rs

1//! What a person sees, and what is kept, when the guard swaps a command.
2//!
3//! The guard can answer a call with another call to run in its place:
4//! `uv run pytest` for a bare `pytest`, under a project that pins its
5//! environment. The swap is a good one, and it was also silent. The line
6//! that ran was not the line anyone typed, nothing said so, and no log
7//! held the pair, so a session where a command behaved oddly could not be
8//! read back afterwards.
9//!
10//! This module is the visible half of that answer. It reads the verdict,
11//! says in one line what ran instead of what was asked for, keeps a row
12//! per swap, and reads the `rewrite` switch a repository sets to turn the
13//! whole thing off and run its lines as written.
14
15use crate::{append_locked_row, data_base, now_utc, Durability};
16use serde_json::Value;
17use std::path::{Path, PathBuf};
18
19/// The word the guard's verdict carries when it hands back another call:
20/// `allow`, `rewrite`, and the line to run, tab separated.
21pub const REWRITE: &str = "rewrite";
22
23/// The key a repository sets to `false` to run its lines as written. It
24/// sits at the top of `ljos.toml` or under `[gates]`.
25pub const SWITCH: &str = "rewrite";
26
27/// The line the guard hands back in place of the one asked for, and the
28/// line a person reads about the swap.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Swap {
31    /// The call to run instead.
32    pub line: String,
33    /// The one line that says so.
34    pub note: String,
35}
36
37/// The line a `allow\trewrite\t<line>` verdict hands back, or `None` when
38/// the verdict is an ordinary allow or carries no line. A verdict whose
39/// line is only whitespace is no rewrite: there is nothing to run.
40#[must_use]
41pub fn rewritten(verdict: &str) -> Option<String> {
42    crate::rewrite::from_policyd(verdict)
43}
44
45/// The one line a person reads when a call was swapped. It names both
46/// calls and the switch that turns the swapping off, and it is one line
47/// whatever the calls looked like: a runner shows a note as the reason
48/// text, where a newline reads as a second note.
49#[must_use]
50pub fn note(asked: &str, ran: &str) -> String {
51    format!(
52        "ljos ran `{}` in place of `{}`. The guard asked for the swap. \
53         Set `{SWITCH} = false` in this repository's ljos.toml to run lines as written.",
54        one_line(ran),
55        one_line(asked)
56    )
57}
58
59/// `$XDG_DATA_HOME/ljos/rewrite.jsonl`: one row per swap, beside the
60/// guard's bypass log. See [`rows_in`] for their shape.
61#[must_use]
62pub fn log_path() -> Option<PathBuf> {
63    Some(data_base()?.join("ljos").join("rewrite.jsonl"))
64}
65
66/// One row of the rewrite log.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Row {
69    /// When the guard answered, RFC 3339 UTC.
70    pub at: String,
71    /// The call as it was asked for.
72    pub asked: String,
73    /// The call the guard handed back.
74    pub ran: String,
75    /// Whether it ran: false where the `rewrite` switch is off, and the
76    /// asked-for call ran instead.
77    pub applied: bool,
78    /// The seat the call was made under, where one is set.
79    pub seat: Option<String>,
80}
81
82impl Row {
83    fn of(value: &Value) -> Option<Self> {
84        Some(Self {
85            at: value["ts"].as_str()?.to_string(),
86            asked: value["asked"].as_str()?.to_string(),
87            ran: value["ran"].as_str()?.to_string(),
88            applied: value["applied"].as_bool()?,
89            seat: value["seat"].as_str().map(str::to_string),
90        })
91    }
92
93    fn value(&self) -> Value {
94        serde_json::json!({
95            "ts": self.at,
96            "asked": self.asked,
97            "ran": self.ran,
98            "applied": self.applied,
99            "seat": self.seat,
100        })
101    }
102}
103
104/// The rows in `path`, oldest first. A line that does not parse is
105/// skipped: the log is appended to under a lock by several seats, and one
106/// torn row does not hide the rest.
107#[must_use]
108pub fn rows_in(path: &Path) -> Vec<Row> {
109    let Ok(text) = std::fs::read_to_string(path) else {
110        return Vec::new();
111    };
112    text.lines()
113        .filter_map(|line| serde_json::from_str::<Value>(line).ok())
114        .filter_map(|value| Row::of(&value))
115        .collect()
116}
117
118/// The rows of this seat's rewrite log, oldest first.
119#[must_use]
120pub fn rows() -> Vec<Row> {
121    log_path().map(|path| rows_in(&path)).unwrap_or_default()
122}
123
124/// Keep a row for one swap. Best effort: a row that cannot be written
125/// does not hold up the call, and the note is shown either way.
126pub fn log_swap(asked: &str, ran: &str, applied: bool) {
127    let Some(path) = log_path() else {
128        return;
129    };
130    let row = Row {
131        at: now_utc(),
132        asked: one_line(asked),
133        ran: one_line(ran),
134        applied,
135        seat: std::env::var("LJOS_SEAT").ok(),
136    };
137    let Ok(mut line) = serde_json::to_string(&row.value()) else {
138        return;
139    };
140    line.push('\n');
141    let _ = path.parent().map(std::fs::create_dir_all);
142    let _ = append_locked_row(&path, line.as_bytes(), Durability::BestEffort);
143}
144
145/// Whether the guard may hand back another call for a command run in
146/// `cwd`. True unless a config layer sets `rewrite = false`.
147///
148/// The layers are the ones the gates read: the user's `ljos.toml`, the
149/// seat's, the repository's, `LJOS_CONFIG`, then `.ljos/gates.toml`, a
150/// later file overriding an earlier one. A layer that does not parse
151/// leaves the swapping off, since a config the seat cannot read is not
152/// one that asked for it.
153#[must_use]
154pub fn allowed_in(cwd: Option<&Path>) -> bool {
155    let mut on = true;
156    for path in crate::gates::rewrite_layers(cwd) {
157        if !path.is_file() {
158            continue;
159        }
160        match crate::gates::rewrite_key(&path) {
161            Ok(Some(set)) => on = set,
162            Ok(None) => {}
163            Err(_) => return false,
164        }
165    }
166    on
167}
168
169thread_local! {
170    static HELD: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
171}
172
173/// Forget a swap the last check held, so a call the guard did not rewrite
174/// does not pick up the one before it.
175pub fn clear() {
176    HELD.with(|slot| *slot.borrow_mut() = None);
177}
178
179/// The call the guard handed back, once. The hook gives it to a runner as
180/// `updatedInput`; a runner without that field has the note instead.
181#[must_use]
182pub fn take_line() -> Option<String> {
183    HELD.with(|slot| slot.borrow_mut().take())
184}
185
186/// What the guard's `verdict` on `asked` changes about the call, where it
187/// changes anything: the line to run, which [`take_line`] hands on, and
188/// the note to show. Each swap, and each one the switch declined, leaves
189/// a row in the log.
190///
191/// `cwd` is the directory the call would run in, which is where the
192/// `rewrite` switch is read.
193pub fn apply_rewrite(asked: &str, verdict: &str, cwd: Option<&Path>) -> Option<Swap> {
194    let ran = rewritten(verdict)?;
195    // A verdict that hands back the same line changes nothing, and saying
196    // so would be a note about nothing.
197    if one_line(&ran) == one_line(asked) {
198        return None;
199    }
200    if !allowed_in(cwd) {
201        log_swap(asked, &ran, false);
202        return None;
203    }
204    log_swap(asked, &ran, true);
205    let swap = Swap {
206        note: note(asked, &ran),
207        line: ran,
208    };
209    HELD.with(|slot| *slot.borrow_mut() = Some(swap.line.clone()));
210    Some(swap)
211}
212
213fn one_line(text: &str) -> String {
214    // A backslash before a newline is how a shell line is written over
215    // several lines. It is not part of the call, and leaving it in reads
216    // as a stray argument.
217    text.replace("\\\r\n", " ")
218        .replace("\\\n", " ")
219        .split_whitespace()
220        .collect::<Vec<_>>()
221        .join(" ")
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn a_rewrite_verdict_names_the_call_to_run() {
230        assert_eq!(
231            rewritten("allow\trewrite\tuv run pytest -q").as_deref(),
232            Some("uv run pytest -q")
233        );
234        assert_eq!(rewritten("allow"), None);
235        assert_eq!(rewritten("allow\tthe ljos guard is unhealthy"), None);
236        assert_eq!(rewritten("allow\trewrite\t   "), None);
237        assert_eq!(rewritten("deny\trewrite\tuv run pytest"), None);
238    }
239
240    /// A runner shows the note as one piece of reason text. A command
241    /// spread over several lines must not break it into two notes.
242    #[test]
243    fn the_note_is_one_line_and_names_both_calls() {
244        let line = note("pytest \\\n  -q", "uv run pytest \\\n  -q");
245        assert!(!line.contains('\n'), "{line}");
246        assert!(line.contains("`uv run pytest -q`"), "{line}");
247        assert!(line.contains("`pytest -q`"), "{line}");
248        assert!(line.contains("rewrite = false"), "{line}");
249    }
250
251    #[test]
252    fn a_kept_row_reads_back_as_it_was_written() {
253        let dir = tempfile::TempDir::new().unwrap();
254        let path = dir.path().join("rewrite.jsonl");
255        let row = Row {
256            at: "2026-10-28T10:00:00Z".into(),
257            asked: "pytest".into(),
258            ran: "uv run pytest".into(),
259            applied: true,
260            seat: Some("claude".into()),
261        };
262        let mut text = serde_json::to_string(&row.value()).unwrap();
263        text.push('\n');
264        text.push_str("{not json}\n");
265        std::fs::write(&path, text).unwrap();
266        assert_eq!(rows_in(&path), vec![row]);
267    }
268
269    #[test]
270    fn a_verdict_that_hands_back_the_same_call_is_no_swap() {
271        assert_eq!(
272            apply_rewrite("uv run pytest", "allow\trewrite\tuv run pytest", None),
273            None
274        );
275        assert!(take_line().is_none());
276    }
277
278    fn fake_policyd(dir: &Path, verdict: &str) -> PathBuf {
279        use std::os::unix::fs::PermissionsExt as _;
280        let path = dir.join("policyd");
281        std::fs::write(&path, format!("#!/bin/sh\nprintf '{verdict}\\n'\n")).unwrap();
282        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
283        path
284    }
285
286    fn git_repo(path: &Path) {
287        std::fs::create_dir_all(path).unwrap();
288        let out = crate::git_cmd()
289            .args(["init", "-q"])
290            .current_dir(path)
291            .output()
292            .unwrap();
293        assert!(out.status.success(), "{out:?}");
294    }
295
296    /// The whole seam, as a person meets it: the guard hands back another
297    /// call, the note says which, the log keeps the pair, and the
298    /// repository's `rewrite = false` runs the line as written.
299    #[test]
300    fn a_swapped_call_is_said_once_and_kept_until_the_repo_turns_it_off() {
301        let _lock = crate::test_env_lock();
302        let home = tempfile::TempDir::new().unwrap();
303        let repo = home.path().join("proj");
304        git_repo(&repo);
305        let bin = fake_policyd(home.path(), "allow\\trewrite\\tuv run pytest -q");
306        // SAFETY: under the lock every environment-reading test takes,
307        // which puts each name back when it drops.
308        unsafe {
309            std::env::set_var("HOME", home.path());
310            std::env::set_var("XDG_DATA_HOME", home.path().join("data"));
311            std::env::set_var("XDG_CONFIG_HOME", home.path().join("config"));
312            std::env::set_var("POLICYD_BIN", &bin);
313            std::env::set_var("PACKSET_URL", "off");
314            std::env::set_var("LJOS_GUARD", "on");
315            std::env::set_var("LJOS_OFF", "0");
316            std::env::set_var("LJOS_SEAT", "claude");
317            for key in ["LJOS_GUARD_FAIL_OPEN", "POLICYD_REQUIRED", "LJOS_CONFIG"] {
318                std::env::remove_var(key);
319            }
320        }
321        let _ = crate::take_guard_note();
322        assert!(
323            crate::tcb_verdict_in("pytest -q", Some(&repo)).is_none(),
324            "a rewrite is an allow"
325        );
326        let note = crate::take_guard_note().expect("the swap is said");
327        assert!(note.contains("`uv run pytest -q`"), "{note}");
328        assert!(note.contains("`pytest -q`"), "{note}");
329        assert_eq!(take_line().as_deref(), Some("uv run pytest -q"));
330        let kept = rows();
331        assert_eq!(kept.len(), 1, "{kept:?}");
332        assert!(kept[0].applied, "{kept:?}");
333        assert_eq!(kept[0].seat.as_deref(), Some("claude"));
334
335        std::fs::write(repo.join("ljos.toml"), "rewrite = false\n").unwrap();
336        assert!(
337            crate::tcb_verdict_in("pytest -q", Some(&repo)).is_none(),
338            "the line still runs, as it was written"
339        );
340        assert!(crate::take_guard_note().is_none(), "nothing was swapped");
341        assert!(take_line().is_none());
342        let kept = rows();
343        assert_eq!(kept.len(), 2, "{kept:?}");
344        assert!(!kept[1].applied, "the switch declined it: {kept:?}");
345    }
346}