1use crate::{append_locked_row, data_base, now_utc, Durability};
16use serde_json::Value;
17use std::path::{Path, PathBuf};
18
19pub const REWRITE: &str = "rewrite";
22
23pub const SWITCH: &str = "rewrite";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Swap {
31 pub line: String,
33 pub note: String,
35}
36
37#[must_use]
41pub fn rewritten(verdict: &str) -> Option<String> {
42 crate::rewrite::from_policyd(verdict)
43}
44
45#[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#[must_use]
62pub fn log_path() -> Option<PathBuf> {
63 Some(data_base()?.join("ljos").join("rewrite.jsonl"))
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Row {
69 pub at: String,
71 pub asked: String,
73 pub ran: String,
75 pub applied: bool,
78 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#[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#[must_use]
120pub fn rows() -> Vec<Row> {
121 log_path().map(|path| rows_in(&path)).unwrap_or_default()
122}
123
124pub 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#[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
173pub fn clear() {
176 HELD.with(|slot| *slot.borrow_mut() = None);
177}
178
179#[must_use]
182pub fn take_line() -> Option<String> {
183 HELD.with(|slot| slot.borrow_mut().take())
184}
185
186pub fn apply_rewrite(asked: &str, verdict: &str, cwd: Option<&Path>) -> Option<Swap> {
194 let ran = rewritten(verdict)?;
195 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 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 #[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 = std::process::Command::new("git")
289 .args(["init", "-q"])
290 .current_dir(path)
291 .output()
292 .unwrap();
293 assert!(out.status.success(), "{out:?}");
294 }
295
296 #[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 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}