Skip to main content

ljos_cli/
rewrite.rs

1//! A guard that fixes a command instead of only warning about it.
2//!
3//! ljos-policyd answers `allow\trewrite\tLINE` when the line should run as
4//! LINE. The guard reads LINE again. A runner whose `PreToolUse` hook can
5//! replace the tool input gets LINE back as that input. A runner without
6//! that field, or a LINE the guard does not take as it stands, runs the
7//! original line with a warning that names LINE.
8//!
9//! The local table names a fix in a policyd warning it knows. That call
10//! runs as written, and the warning names the fix.
11
12use std::cell::RefCell;
13
14use serde_json::{json, Value};
15
16use crate::{HookCall, HookShape, Rule};
17
18/// Set on the inner `ljos hook` a runner relay starts. The relay renders
19/// a decision and a reason but no updated input, so an allow that carries
20/// one would run the line as written.
21pub const RELAY_VAR: &str = "LJOS_HOOK_RELAY";
22
23/// The tool names a shell call arrives under: The terminal harness and the command harness
24/// `Bash`, the editor harness `Shell`, the build harness `run_terminal_command`.
25const SHELL_TOOLS: &[&str] = &["Bash", "Shell", "run_terminal_command"];
26
27const POLICYD_REASON: &str = "ljos-policyd rewrote this line";
28
29/// Characters that make a line more than one simple command. The local
30/// table leaves such a line to the warning as it came.
31const SHELL_META: &[char] = &[
32    ';', '&', '|', '<', '>', '$', '`', '\\', '(', ')', '{', '}', '\n', '\r',
33];
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Rewrite {
37    pub command: String,
38    pub reason: String,
39}
40
41/// What the hook does with a call that has a rewrite.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Outcome {
44    /// Run this line, which the guard judged and accepted, in place of
45    /// the call's line.
46    Run(Rewrite),
47    /// Run the call's own line, with this rule's warning.
48    Warn(Rule),
49}
50
51/// The new line in a policyd rewrite answer, `allow\trewrite\tLINE`.
52/// This is the one place that format is read.
53///
54/// ljos asks about `bash -c SCRIPT`, and policyd 0.6.0 answers with that
55/// argv rewritten and quoted: `bash -c 'uv run x.py'`. That one wrapper
56/// is ljos's own and is taken off. A LINE in any other shape is the line.
57#[must_use]
58pub fn from_policyd(text: &str) -> Option<String> {
59    let mut parts = text.trim().lines().next()?.splitn(3, '\t');
60    if parts.next()? != "allow" || parts.next()? != "rewrite" {
61        return None;
62    }
63    let line = parts.next()?.trim();
64    let line = match crate::shell_words(line).as_slice() {
65        [shell, flag, script] if shell == "bash" && flag == "-c" => script.trim().to_string(),
66        _ => line.to_string(),
67    };
68    (!line.is_empty()).then_some(line)
69}
70
71/// Hold policyd's rewrite of `line`, which it judged as `script`. The
72/// script can differ from the line: a here-document body is taken out, or
73/// a continuation is joined. Then the rewrite is inexact, so the line
74/// runs as written with a warning that names the rewrite.
75pub(crate) fn hold_exact(line: &str, script: &str, command: String) -> Option<Rule> {
76    if script.trim() == line.trim() {
77        hold(line, command);
78        return None;
79    }
80    Some(Rule {
81        pattern: "ljos-policyd".into(),
82        verdict: "warn".into(),
83        reason: format!(
84            "{POLICYD_REASON} to `{command}`, but it judged a copy with its here-documents \
85             or continuations changed, so the new line is not exact. The line runs as written"
86        ),
87    })
88}
89
90thread_local! {
91    static HELD: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
92}
93
94/// Keep policyd's rewrite of `line` for the hook that asked about it.
95pub(crate) fn hold(line: &str, command: String) {
96    HELD.with(|held| *held.borrow_mut() = Some((line.to_string(), command)));
97}
98
99fn take_held(line: &str) -> Option<String> {
100    HELD.with(|held| held.borrow_mut().take())
101        .filter(|(held, _)| held == line)
102        .map(|(_, command)| command)
103}
104
105struct Fix {
106    /// The policyd codes this row answers.
107    codes: &'static [&'static str],
108    /// The interpreter, with or without a version suffix or a directory.
109    program: &'static str,
110    /// What the first argument ends with.
111    script: &'static str,
112    /// What runs the script instead of the interpreter.
113    run: &'static str,
114    reason: &'static str,
115}
116
117const LOCAL: &[Fix] = &[Fix {
118    codes: &["python-requires-uv", "python-requires-uv-run"],
119    program: "python",
120    script: ".py",
121    run: "uv run",
122    reason: "a Python script runs with `uv run`, which reads its PEP 723 block",
123}];
124
125fn is_program(word: &str, program: &str) -> bool {
126    let name = word.rsplit('/').next().unwrap_or(word);
127    name.strip_prefix(program)
128        .is_some_and(|version| version.chars().all(|c| c.is_ascii_digit() || c == '.'))
129}
130
131/// The local table's fix for `line`, warned on with `code`: `python x.py
132/// ARGS` becomes `uv run x.py ARGS`, the arguments kept as written.
133#[must_use]
134pub fn local(code: &str, line: &str) -> Option<Rewrite> {
135    let line = line.trim();
136    if line.contains(SHELL_META) {
137        return None;
138    }
139    let (program, rest) = line.split_once(char::is_whitespace)?;
140    let rest = rest.trim_start();
141    let script = rest.split_whitespace().next()?;
142    if script.starts_with('-') || script.contains(['\'', '"']) {
143        return None;
144    }
145    LOCAL
146        .iter()
147        .find(|fix| {
148            fix.codes.contains(&code)
149                && is_program(program, fix.program)
150                && script.ends_with(fix.script)
151        })
152        .map(|fix| Rewrite {
153            command: format!("{} {rest}", fix.run),
154            reason: fix.reason.to_string(),
155        })
156}
157
158/// Why the guard does not take `line` as it stands, or None when it
159/// does. `judge` reads a line as the guard would and holds a rewrite as
160/// [`crate::tcb_verdict_in`] does, so a line rewritten again is not taken.
161fn doubt_on(line: &str, judge: impl Fn(&str) -> Option<Rule>) -> Option<String> {
162    let _ = take_held(line);
163    if let Some(rule) = judge(line) {
164        return Some(format!("the guard warns on it: {}", rule.reason));
165    }
166    take_held(line).map(|again| format!("the guard rewrites it again, to `{again}`"))
167}
168
169/// What the hook does with a tool call that has a rewrite, or None when
170/// it has none and the usual answer stands.
171///
172/// A policyd rewrite runs only when the guard takes the new line as it
173/// stands. Otherwise the call's own line runs with a warning. A local fix
174/// is named in the policyd warning when the guard takes that line.
175pub fn pick(
176    call: &HookCall,
177    verdict: Option<&Rule>,
178    judge: impl Fn(&str) -> Option<Rule>,
179) -> Option<Outcome> {
180    if call.event != "PreToolUse" {
181        return None;
182    }
183    let held = take_held(&call.cue);
184    let line = call.cue.trim();
185    match verdict {
186        None => {
187            let command = held.filter(|command| command != line)?;
188            Some(match doubt_on(&command, judge) {
189                None => Outcome::Run(Rewrite {
190                    command,
191                    reason: POLICYD_REASON.to_string(),
192                }),
193                Some(why) => Outcome::Warn(Rule {
194                    pattern: "ljos-policyd".into(),
195                    verdict: "warn".into(),
196                    reason: format!(
197                        "{POLICYD_REASON} to `{command}`, and {why}. The line runs as written"
198                    ),
199                }),
200            })
201        }
202        Some(rule) if rule.pattern == "ljos-policyd" => {
203            let code = rule.reason.split(':').next().unwrap_or("").trim();
204            let fix = local(code, line).filter(|fix| fix.command != line)?;
205            doubt_on(&fix.command, judge).is_none().then(|| {
206                let mut warned = rule.clone();
207                warned.reason = format!(
208                    "{}. Run `{}` instead: {}",
209                    rule.reason, fix.command, fix.reason
210                );
211                Outcome::Warn(warned)
212            })
213        }
214        Some(_) => None,
215    }
216}
217
218/// The hook's answer when the call has a rewrite, else None and the
219/// usual answer stands.
220pub fn answer(
221    input: &str,
222    call: &HookCall,
223    context: &str,
224    verdict: Option<&Rule>,
225    judge: impl Fn(&str) -> Option<Rule>,
226) -> Option<String> {
227    let outcome = pick(call, verdict, judge)?;
228    let relayed = std::env::var_os(RELAY_VAR).is_some();
229    Some(answer_with(input, call, context, &outcome, relayed))
230}
231
232/// [`answer`] for a known outcome. A warning is the usual allow with that
233/// line. A line to run is the updated input where the runner takes one. A
234/// runner that cannot take one runs the original line, with a warning
235/// that names the new one.
236#[must_use]
237pub fn answer_with(
238    input: &str,
239    call: &HookCall,
240    context: &str,
241    outcome: &Outcome,
242    relayed: bool,
243) -> String {
244    let warn = |rule: &Rule| crate::hook_output_ruled(call, context, Some(rule));
245    match outcome {
246        Outcome::Warn(rule) => warn(rule),
247        Outcome::Run(fix) => (!relayed)
248            .then(|| updated_input(input, call, context, fix))
249            .flatten()
250            .unwrap_or_else(|| warn(&unchangeable(fix))),
251    }
252}
253
254/// The warning for a runner whose hook cannot replace the command.
255fn unchangeable(fix: &Rewrite) -> Rule {
256    Rule {
257        pattern: "ljos-policyd".into(),
258        verdict: "warn".into(),
259        reason: format!(
260            "{}. Run `{}` instead. This runner cannot take a new command, so the original \
261             line runs as written",
262            fix.reason, fix.command
263        ),
264    }
265}
266
267fn joined(context: &str, line: &str) -> String {
268    if context.is_empty() {
269        line.to_string()
270    } else {
271        format!("{context}\n{line}")
272    }
273}
274
275/// The terminal, command and build harnesses read `hookSpecificOutput.updatedInput`
276/// beside `permissionDecision: allow`; the editor harness's `preToolUse` reads
277/// `updated_input` beside `permission: allow`. Each replaces the whole
278/// input, so every other field the runner sent goes back unchanged.
279fn updated_input(input: &str, call: &HookCall, context: &str, fix: &Rewrite) -> Option<String> {
280    let v: Value = serde_json::from_str(input.trim()).ok()?;
281    // the build harness cut the input short, so it cannot be sent back whole.
282    if v["toolInputTruncated"].as_bool() == Some(true) {
283        return None;
284    }
285    let tool = v["tool_name"].as_str().or_else(|| v["toolName"].as_str())?;
286    if !SHELL_TOOLS.contains(&tool) {
287        return None;
288    }
289    let mut given = if v["tool_input"].is_object() {
290        v["tool_input"].clone()
291    } else {
292        v["toolInput"].clone()
293    };
294    let fields = given.as_object_mut()?;
295    if fields.get("command").and_then(Value::as_str).map(str::trim) != Some(call.cue.trim()) {
296        return None;
297    }
298    fields.insert("command".into(), json!(fix.command));
299    let said = format!(
300        "ljos ran `{}` in place of `{}`: {}",
301        fix.command,
302        call.cue.trim(),
303        fix.reason
304    );
305    let out = match call.shape {
306        HookShape::Cursor => json!({
307            "permission": "allow",
308            "updated_input": given,
309            "agent_message": said,
310        }),
311        HookShape::Asks | HookShape::DenyOnly | HookShape::CamelCase => {
312            let mut specific = json!({
313                "hookEventName": "PreToolUse",
314                "permissionDecision": "allow",
315                "updatedInput": given,
316                "additionalContext": joined(context, &said),
317            });
318            // the command harness fails a hook on a field it does not take, and then
319            // runs the line as written.
320            if call.shape != HookShape::DenyOnly {
321                specific["permissionDecisionReason"] = json!(said);
322            }
323            let mut out = json!({ "hookSpecificOutput": specific });
324            if call.shape == HookShape::CamelCase {
325                out["decision"] = json!("allow");
326            }
327            out
328        }
329        _ => return None,
330    };
331    Some(out.to_string() + "\n")
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::hook_call;
338
339    fn uv_warning() -> Rule {
340        Rule {
341            pattern: "ljos-policyd".into(),
342            verdict: "warn".into(),
343            reason: crate::policyd_reason("python-requires-uv"),
344        }
345    }
346
347    fn allow_all(_: &str) -> Option<Rule> {
348        None
349    }
350
351    fn answer_json(input: &str, outcome: &Outcome, relayed: bool) -> Value {
352        let out = answer_with(input, &hook_call(input), "", outcome, relayed);
353        serde_json::from_str(out.trim()).unwrap()
354    }
355
356    /// The answer when policyd rewrote the call's line to `uv run x.py --n
357    /// 3` and the guard accepts that line.
358    fn policyd_run(input: &str, relayed: bool) -> Value {
359        let call = hook_call(input);
360        hold(&call.cue, "uv run x.py --n 3".into());
361        let outcome = pick(&call, None, allow_all).expect("a rewrite");
362        assert!(matches!(outcome, Outcome::Run(_)), "{outcome:?}");
363        answer_json(input, &outcome, relayed)
364    }
365
366    /// The answer when policyd warned on the call for want of uv.
367    fn local_warning(input: &str) -> Value {
368        let call = hook_call(input);
369        let outcome = pick(&call, Some(&uv_warning()), allow_all).expect("a fix");
370        assert!(matches!(outcome, Outcome::Warn(_)), "{outcome:?}");
371        answer_json(input, &outcome, false)
372    }
373
374    /// Whether a runner reads the answer as holding the call, whichever
375    /// field carries the decision.
376    fn holds(v: &Value) -> bool {
377        let decision = v["hookSpecificOutput"]["permissionDecision"]
378            .as_str()
379            .or_else(|| v["permission"].as_str())
380            .or_else(|| v["decision"].as_str());
381        matches!(decision, Some("deny" | "ask" | "block"))
382    }
383
384    const CLAUDE: &str = r#"{"session_id":"s-1","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"python x.py --n 3","description":"run it","timeout":60000},"cwd":"/w"}"#;
385    const CODEX: &str = r#"{"session_id":"s-1","turn_id":"t-1","hook_event_name":"PreToolUse","tool_name":"Bash","tool_use_id":"c-1","tool_input":{"command":"python x.py --n 3"},"cwd":"/w"}"#;
386    const CURSOR_TOOL: &str = r#"{"cursor_version":"3.2.0","conversation_id":"conv-1","hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"python x.py --n 3","working_directory":"/w"},"tool_use_id":"u-1","cwd":"/w"}"#;
387    const CURSOR_SHELL: &str = r#"{"cursor_version":"3.2.0","conversation_id":"conv-1","hook_event_name":"beforeShellExecution","command":"python x.py --n 3","cwd":"/w","sandbox":false}"#;
388    const GROK: &str = r#"{"hookEventName":"pre_tool_use","hook_event_name":"PreToolUse","sessionId":"g-1","cwd":"/w","workspaceRoot":"/w","permissionMode":"default","toolName":"run_terminal_command","toolInput":{"command":"python x.py --n 3"},"toolUseId":"u-1","toolInputTruncated":false}"#;
389    const READ: &str = r#"{"session_id":"s-1","hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/w/x.py","command":"python x.py --n 3"},"cwd":"/w"}"#;
390    const RUNNERS: &[&str] = &[CLAUDE, CODEX, CURSOR_TOOL, CURSOR_SHELL, GROK];
391
392    #[test]
393    fn the_policyd_rewrite_line_is_read_and_nothing_else_is() {
394        assert_eq!(
395            from_policyd("allow\trewrite\tuv run x.py\n").as_deref(),
396            Some("uv run x.py")
397        );
398        assert_eq!(
399            from_policyd("allow\trewrite\tuv run x.py --n 3").as_deref(),
400            Some("uv run x.py --n 3")
401        );
402        // policyd 0.6.0 answers with the `bash -c` argv ljos sent, quoted.
403        for (text, line) in [
404            ("allow\trewrite\tbash -c 'uv run x.py'\n", "uv run x.py"),
405            (
406                "allow\trewrite\tbash -c 'uv run x.py --n 3'",
407                "uv run x.py --n 3",
408            ),
409            (
410                "allow\trewrite\tbash -c 'uv run '\\''a b.py'\\'''",
411                "uv run 'a b.py'",
412            ),
413            (
414                "allow\trewrite\tbash -c \"uv run x.py > out\"",
415                "uv run x.py > out",
416            ),
417            ("allow\trewrite\tbash -c 'x' extra", "bash -c 'x' extra"),
418            ("allow\trewrite\tsh -c 'uv run x.py'", "sh -c 'uv run x.py'"),
419        ] {
420            assert_eq!(from_policyd(text).as_deref(), Some(line), "{text:?}");
421        }
422        for text in [
423            "allow\trewrite\tbash -c ''",
424            "allow",
425            "allow\trewrite\t",
426            "allow\trewrite",
427            "deny\trewrite\tuv run x.py",
428            "allow\tfail-open\tthe ljos guard is unhealthy",
429            "allow\tthe ljos guard is unhealthy (did not start)",
430            "deny\tpython-requires-uv",
431            r#"{"decision":"rewrite","command":"uv run x.py"}"#,
432        ] {
433            assert_eq!(from_policyd(text), None, "{text:?}");
434        }
435    }
436
437    #[test]
438    fn the_local_table_runs_a_python_script_with_uv() {
439        let fix = |line: &str| local("python-requires-uv", line).map(|f| f.command);
440        assert_eq!(fix("python x.py").as_deref(), Some("uv run x.py"));
441        assert_eq!(fix("python3 x.py").as_deref(), Some("uv run x.py"));
442        assert_eq!(
443            fix("python3.12 scripts/x.py").as_deref(),
444            Some("uv run scripts/x.py")
445        );
446        assert_eq!(fix("/usr/bin/python3 x.py").as_deref(), Some("uv run x.py"));
447        assert_eq!(
448            fix("python x.py --name 'a b'").as_deref(),
449            Some("uv run x.py --name 'a b'")
450        );
451        assert_eq!(
452            local("python-requires-uv-run", "python x.py")
453                .map(|f| f.command)
454                .as_deref(),
455            Some("uv run x.py")
456        );
457        for line in [
458            "python",
459            "python -c 'print(1)'",
460            "python -m pytest",
461            "python x.txt",
462            "pythonista x.py",
463            "python x.py && rm -rf /",
464            "python x.py | tee out",
465            "python x.py > out",
466            "python $(echo x.py)",
467            "FOO=1 python x.py",
468            "uv run x.py",
469        ] {
470            assert_eq!(fix(line), None, "{line}");
471        }
472        assert_eq!(local("rm-rf-outside-tmp", "python x.py"), None);
473    }
474
475    #[test]
476    fn claude_code_gets_an_accepted_rewrite_as_updated_input() {
477        let v = policyd_run(CLAUDE, false);
478        let specific = &v["hookSpecificOutput"];
479        assert_eq!(specific["hookEventName"], "PreToolUse");
480        assert_eq!(specific["permissionDecision"], "allow");
481        assert_eq!(
482            specific["updatedInput"],
483            json!({"command":"uv run x.py --n 3","description":"run it","timeout":60000})
484        );
485        let said = specific["additionalContext"].as_str().unwrap();
486        assert!(
487            said.contains("`uv run x.py --n 3` in place of `python x.py --n 3`"),
488            "{said}"
489        );
490        assert!(v.get("decision").is_none());
491    }
492
493    #[test]
494    fn codex_gets_an_accepted_rewrite_and_no_field_it_would_fail_on() {
495        let v = policyd_run(CODEX, false);
496        let specific = &v["hookSpecificOutput"];
497        assert_eq!(specific["permissionDecision"], "allow");
498        assert_eq!(
499            specific["updatedInput"],
500            json!({"command":"uv run x.py --n 3"})
501        );
502        assert!(specific.get("permissionDecisionReason").is_none());
503        let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
504        assert_eq!(keys, ["hookSpecificOutput"]);
505    }
506
507    #[test]
508    fn cursor_pre_tool_use_gets_an_accepted_rewrite_as_updated_input() {
509        let v = policyd_run(CURSOR_TOOL, false);
510        assert_eq!(v["permission"], "allow");
511        assert_eq!(
512            v["updated_input"],
513            json!({"command":"uv run x.py --n 3","working_directory":"/w"})
514        );
515        assert!(v.get("hookSpecificOutput").is_none());
516    }
517
518    #[test]
519    fn grok_build_gets_updated_input_unless_its_input_was_cut() {
520        let v = policyd_run(GROK, false);
521        assert_eq!(v["decision"], "allow");
522        let specific = &v["hookSpecificOutput"];
523        assert_eq!(specific["permissionDecision"], "allow");
524        assert_eq!(
525            specific["updatedInput"],
526            json!({"command":"uv run x.py --n 3"})
527        );
528        let cut = GROK.replace(
529            r#""toolInputTruncated":false"#,
530            r#""toolInputTruncated":true"#,
531        );
532        let v = policyd_run(&cut, false);
533        assert!(!holds(&v), "{v}");
534        assert!(v["hookSpecificOutput"].get("updatedInput").is_none());
535        assert!(
536            v.to_string().contains("Run `uv run x.py --n 3` instead"),
537            "{v}"
538        );
539    }
540
541    /// A runner that cannot take a new command runs the original line,
542    /// with a warning that names the new one: the editor harness's
543    /// `beforeShellExecution`, and every runner behind the relay.
544    #[test]
545    fn an_accepted_rewrite_is_a_warning_where_input_cannot_change() {
546        let cases = [
547            (CURSOR_SHELL, false),
548            (CLAUDE, true),
549            (CODEX, true),
550            (GROK, true),
551            (CURSOR_TOOL, true),
552        ];
553        for (input, relayed) in cases {
554            let v = policyd_run(input, relayed);
555            assert!(!holds(&v), "{input} relayed={relayed}\n{v}");
556            assert!(v["hookSpecificOutput"].get("updatedInput").is_none(), "{v}");
557            assert!(v.get("updated_input").is_none(), "{v}");
558            let said = v.to_string();
559            assert!(said.contains("Run `uv run x.py --n 3` instead"), "{said}");
560            assert!(said.contains("line runs as written"), "{said}");
561        }
562    }
563
564    /// A rewrite of a copy that is not the line cannot be applied exactly,
565    /// so the line runs as written with a warning and nothing is held.
566    #[test]
567    fn a_rewrite_of_a_changed_copy_warns_and_runs_the_line() {
568        let line = "python x.py <<EOF\nbody\nEOF";
569        let rule = hold_exact(line, "python x.py <<EOF\nEOF", "uv run x.py".into())
570            .expect("a changed copy warns");
571        assert!(
572            rule.reason.contains("The line runs as written"),
573            "{}",
574            rule.reason
575        );
576        assert!(take_held(line).is_none());
577        assert!(hold_exact("python x.py", "python x.py", "uv run x.py".into()).is_none());
578        assert_eq!(take_held("python x.py").as_deref(), Some("uv run x.py"));
579    }
580
581    /// A local fix is named in the warning, and the line runs as written:
582    /// no runner gets an updated input it might drop.
583    #[test]
584    fn a_local_fix_is_a_warning_on_every_runner() {
585        for input in RUNNERS {
586            let v = local_warning(input);
587            assert!(!holds(&v), "{input}\n{v}");
588            assert!(v["hookSpecificOutput"].get("updatedInput").is_none(), "{v}");
589            assert!(v.get("updated_input").is_none(), "{v}");
590            let said = v.to_string();
591            assert!(said.contains("Run `uv run x.py --n 3` instead"), "{said}");
592            assert!(said.contains("python-requires-uv: "), "{said}");
593        }
594        let v = local_warning(CODEX);
595        let specific = &v["hookSpecificOutput"];
596        assert!(specific.get("permissionDecision").is_none(), "{v}");
597        assert!(specific["additionalContext"]
598            .as_str()
599            .unwrap()
600            .contains("Run `uv run x.py --n 3` instead"));
601    }
602
603    /// A local fix the guard would warn on is not named. The policyd
604    /// warning stands as it came.
605    #[test]
606    fn a_local_fix_the_guard_warns_on_leaves_the_warning_as_it_came() {
607        let call = hook_call(CLAUDE);
608        let warns = |_: &str| Some(uv_warning());
609        assert_eq!(pick(&call, Some(&uv_warning()), warns), None);
610        let again = |line: &str| {
611            hold(line, "echo again".into());
612            None
613        };
614        assert_eq!(pick(&call, Some(&uv_warning()), again), None);
615    }
616
617    /// Review repro 1: the recheck of policyd's new line rewrites it
618    /// again. The guard's last word was another line, so the new line
619    /// does not run; the original runs with a warning that names both.
620    #[test]
621    fn a_rewrite_the_recheck_rewrites_again_runs_the_line_with_a_warning() {
622        let call = hook_call(CLAUDE);
623        hold(&call.cue, "python evil.py".into());
624        let judge = |line: &str| {
625            if line == "python evil.py" {
626                hold(line, "echo safe".into());
627            }
628            None
629        };
630        let Some(Outcome::Warn(rule)) = pick(&call, None, judge) else {
631            panic!("a rewrite rewritten again must warn");
632        };
633        assert!(rule.reason.contains("`python evil.py`"), "{}", rule.reason);
634        assert!(rule.reason.contains("`echo safe`"), "{}", rule.reason);
635        for input in RUNNERS {
636            let call = hook_call(input);
637            hold(&call.cue, "python evil.py".into());
638            let outcome = pick(&call, None, judge).unwrap();
639            let v = answer_json(input, &outcome, false);
640            assert!(!holds(&v), "{input}\n{v}");
641            assert!(!v.to_string().contains("updatedInput"), "{v}");
642            assert!(!v.to_string().contains("updated_input"), "{v}");
643        }
644    }
645
646    /// Review repro 2: the recheck warns on policyd's new line. The
647    /// original runs, and its warning names the new line and the rule.
648    #[test]
649    fn a_rewrite_the_recheck_warns_on_runs_the_line_with_a_warning() {
650        let warns = |_: &str| Some(uv_warning());
651        for input in RUNNERS {
652            let call = hook_call(input);
653            hold(&call.cue, "uv run evil.py".into());
654            let outcome = pick(&call, None, warns).expect("a warning, not a silent allow");
655            let Outcome::Warn(rule) = &outcome else {
656                panic!("{outcome:?}");
657            };
658            assert!(rule.reason.contains("`uv run evil.py`"), "{}", rule.reason);
659            assert!(
660                rule.reason.contains("python-requires-uv"),
661                "{}",
662                rule.reason
663            );
664            let v = answer_json(input, &outcome, false);
665            assert!(!holds(&v), "{input}\n{v}");
666            assert!(v.to_string().contains("`uv run evil.py`"), "{v}");
667            let v = answer_json(input, &outcome, true);
668            assert!(!holds(&v), "relayed {input}\n{v}");
669        }
670    }
671
672    #[test]
673    fn no_rewrite_without_a_held_line_or_a_known_warning() {
674        let call = hook_call(CLAUDE);
675        assert!(pick(&call, None, allow_all).is_none(), "nothing held");
676        hold("some other line", "uv run x.py".into());
677        assert!(
678            pick(&call, None, allow_all).is_none(),
679            "held for another line"
680        );
681        hold(&call.cue, call.cue.clone());
682        assert!(pick(&call, None, allow_all).is_none(), "the same line");
683        let ask = Rule {
684            pattern: "git-force-push".into(),
685            verdict: "ask".into(),
686            reason: "a force push".into(),
687        };
688        hold(&call.cue, "uv run x.py --n 3".into());
689        assert!(pick(&call, Some(&ask), allow_all).is_none());
690        assert!(
691            take_held(&call.cue).is_none(),
692            "a warning drops the held line"
693        );
694        let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"python x.py"}"#);
695        assert!(pick(&prompt, Some(&uv_warning()), allow_all).is_none());
696    }
697
698    /// A Read call is not a shell call, so an accepted rewrite of its cue
699    /// is never sent back as its input.
700    #[test]
701    fn a_read_call_never_gets_updated_input() {
702        let call = hook_call(READ);
703        assert_eq!(call.event, "PreToolUse");
704        hold(&call.cue, "uv run x.py --n 3".into());
705        let outcome = pick(&call, None, allow_all).expect("a rewrite of the Read cue");
706        assert!(matches!(outcome, Outcome::Run(_)), "{outcome:?}");
707        let v = answer_json(READ, &outcome, false);
708        assert!(v["hookSpecificOutput"].get("updatedInput").is_none(), "{v}");
709        assert!(!holds(&v), "{v}");
710    }
711
712    /// The fake policyd, answering in 0.6.0's `bash -c` form: `python a.py`
713    /// is rewritten to an accepted line, `python b.py` to one a rule warns
714    /// on, and `python c.py` to one that is rewritten again.
715    const FAKE_POLICYD: &str = r#"#!/bin/sh
716case "$5" in
717  "python a.py") printf "allow\trewrite\tbash -c 'uv run a.py'\n" ;;
718  "python b.py") printf "allow\trewrite\tbash -c 'uv run b.py'\n" ;;
719  "python c.py") printf "allow\trewrite\tbash -c 'uv run c.py'\n" ;;
720  "uv run b.py") printf 'allow\twarn\tpython-requires-uv\tuse uv\n' ;;
721  "uv run c.py") printf "allow\trewrite\tbash -c 'echo c'\n" ;;
722  *) echo allow ;;
723esac
724"#;
725
726    /// The guard holds a policyd rewrite for the hook, and the real guard
727    /// as the recheck swaps in only an accepted line.
728    #[test]
729    fn the_real_guard_lets_only_an_accepted_rewrite_run() {
730        use std::os::unix::fs::PermissionsExt as _;
731        let _g = crate::tests::env_guard();
732        let home = tempfile::tempdir().unwrap();
733        let bin = home.path().join("policyd");
734        std::fs::write(&bin, FAKE_POLICYD).unwrap();
735        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
736        let keys = [
737            "HOME",
738            "POLICYD_BIN",
739            "PACKSET_URL",
740            "LJOS_OFF",
741            "LJOS_GUARD",
742            "POLICYD_REQUIRED",
743            "LJOS_GUARD_FAIL_OPEN",
744        ];
745        let saved: Vec<_> = keys.iter().map(|k| (*k, std::env::var_os(k))).collect();
746        unsafe {
747            std::env::set_var("HOME", home.path());
748            std::env::set_var("POLICYD_BIN", &bin);
749            std::env::set_var("PACKSET_URL", "off");
750            std::env::set_var("LJOS_OFF", "0");
751            std::env::set_var("LJOS_GUARD", "on");
752            std::env::remove_var("POLICYD_REQUIRED");
753            std::env::remove_var("LJOS_GUARD_FAIL_OPEN");
754        }
755        let dir = Some(home.path());
756        let judge = |line: &str| crate::tcb_verdict_in(line, dir);
757        let outcome = |script: &str| {
758            let input = CLAUDE.replace("python x.py --n 3", &format!("python {script}"));
759            let call = hook_call(&input);
760            let verdict = crate::tcb_verdict_in(&call.cue, dir);
761            assert!(verdict.is_none(), "{script}: {verdict:?}");
762            pick(&call, verdict.as_ref(), judge)
763        };
764        let accepted = outcome("a.py");
765        let warned = outcome("b.py");
766        let again = outcome("c.py");
767        unsafe {
768            for (k, v) in saved {
769                match v {
770                    Some(v) => std::env::set_var(k, v),
771                    None => std::env::remove_var(k),
772                }
773            }
774        }
775        assert_eq!(
776            accepted,
777            Some(Outcome::Run(Rewrite {
778                command: "uv run a.py".into(),
779                reason: POLICYD_REASON.into(),
780            }))
781        );
782        let Some(Outcome::Warn(rule)) = warned else {
783            panic!("a substitute a rule warns on must warn: {warned:?}");
784        };
785        assert!(
786            rule.reason.contains("python-requires-uv"),
787            "{}",
788            rule.reason
789        );
790        let Some(Outcome::Warn(rule)) = again else {
791            panic!("a substitute rewritten again must warn: {again:?}");
792        };
793        assert!(rule.reason.contains("`echo c`"), "{}", rule.reason);
794    }
795
796    /// A policyd rewrite the guard accepts reaches the runner as its new
797    /// input, and the same swap is said and kept in the rewrite log. With
798    /// the repository's `rewrite = false` the line runs as written, and
799    /// the declined swap is still kept.
800    #[test]
801    fn a_policyd_rewrite_is_passed_on_and_also_noted_and_logged() {
802        use std::os::unix::fs::PermissionsExt as _;
803        let _g = crate::tests::env_guard();
804        let home = tempfile::tempdir().unwrap();
805        let repo = home.path().join("proj");
806        std::fs::create_dir_all(&repo).unwrap();
807        let init = crate::git_cmd()
808            .args(["init", "-q"])
809            .current_dir(&repo)
810            .output()
811            .unwrap();
812        assert!(init.status.success(), "{init:?}");
813        let bin = home.path().join("policyd");
814        std::fs::write(
815            &bin,
816            "#!/bin/sh\ncase \"$5\" in\n  \"python x.py --n 3\") printf \"allow\\trewrite\\tbash -c 'uv run x.py --n 3'\\n\" ;;\n  *) echo allow ;;\nesac\n",
817        )
818        .unwrap();
819        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
820        // SAFETY: under the lock every environment-reading test takes,
821        // which puts each name back when it drops.
822        unsafe {
823            std::env::set_var("HOME", home.path());
824            std::env::set_var("XDG_DATA_HOME", home.path().join("data"));
825            std::env::set_var("XDG_CONFIG_HOME", home.path().join("config"));
826            std::env::set_var("POLICYD_BIN", &bin);
827            std::env::set_var("PACKSET_URL", "off");
828            std::env::set_var("LJOS_OFF", "0");
829            std::env::set_var("LJOS_GUARD", "on");
830            for key in ["POLICYD_REQUIRED", "LJOS_GUARD_FAIL_OPEN", "LJOS_CONFIG"] {
831                std::env::remove_var(key);
832            }
833        }
834        let dir = Some(repo.as_path());
835        let judge = |line: &str| crate::tcb_verdict_in(line, dir);
836        let call = hook_call(CLAUDE);
837
838        let _ = crate::take_guard_note();
839        let verdict = crate::tcb_verdict_in(&call.cue, dir);
840        assert!(verdict.is_none(), "{verdict:?}");
841        let note = crate::take_guard_note().expect("the swap is said");
842        let outcome = pick(&call, None, judge).expect("the rewrite is passed on");
843        let v = answer_json(CLAUDE, &outcome, false);
844        assert_eq!(
845            v["hookSpecificOutput"]["updatedInput"]["command"], "uv run x.py --n 3",
846            "{v}"
847        );
848        assert!(note.contains("`uv run x.py --n 3`"), "{note}");
849        assert!(note.contains("`python x.py --n 3`"), "{note}");
850        let kept = crate::swap_log::rows();
851        assert_eq!(kept.len(), 1, "{kept:?}");
852        assert!(kept[0].applied, "{kept:?}");
853        assert_eq!(kept[0].ran, "uv run x.py --n 3");
854
855        std::fs::write(repo.join("ljos.toml"), "rewrite = false\n").unwrap();
856        let _ = crate::take_guard_note();
857        let verdict = crate::tcb_verdict_in(&call.cue, dir);
858        assert!(verdict.is_none(), "{verdict:?}");
859        assert!(crate::take_guard_note().is_none(), "nothing was swapped");
860        assert_eq!(pick(&call, None, judge), None, "the line runs as written");
861        let kept = crate::swap_log::rows();
862        assert_eq!(kept.len(), 2, "{kept:?}");
863        assert!(!kept[1].applied, "the switch declined it: {kept:?}");
864    }
865}