Skip to main content

ljos_cli/
rewrite.rs

1//! A guard that fixes a command instead of only refusing it.
2//!
3//! ljos-policyd answers `allow\trewrite\tLINE` when the line should run as
4//! LINE. The guard judges 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 is refused with LINE named. A LINE the guard does not accept
7//! as it stands refuses the call. The original line never runs.
8//!
9//! The local table names a fix in a policyd refusal it knows. That call
10//! stays a deny, so a runner that drops an updated input still refuses it.
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: Claude Code and Codex
24/// `Bash`, Cursor `Shell`, Grok Build `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 refusal.
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    /// Refuse the call. The original line does not run.
48    Refuse(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 call is
74/// refused and neither line runs.
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: "deny".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. Neither line runs"
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 refusal 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`, refused 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 accept `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 refused.
161fn refusal_of(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 refuses 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 accepts the new line as it
173/// stands. Otherwise the call is refused. A local fix names the new line
174/// in the policyd refusal when the guard accepts that line, and the call
175/// stays refused either way.
176pub fn pick(
177    call: &HookCall,
178    verdict: Option<&Rule>,
179    judge: impl Fn(&str) -> Option<Rule>,
180) -> Option<Outcome> {
181    if call.event != "PreToolUse" {
182        return None;
183    }
184    let held = take_held(&call.cue);
185    let line = call.cue.trim();
186    match verdict {
187        None => {
188            let command = held.filter(|command| command != line)?;
189            Some(match refusal_of(&command, judge) {
190                None => Outcome::Run(Rewrite {
191                    command,
192                    reason: POLICYD_REASON.to_string(),
193                }),
194                Some(why) => Outcome::Refuse(Rule {
195                    pattern: "ljos-policyd".into(),
196                    verdict: "deny".into(),
197                    reason: format!(
198                        "{POLICYD_REASON} to `{command}`, and {why}. Neither line runs"
199                    ),
200                }),
201            })
202        }
203        Some(rule) if rule.verdict == "deny" && rule.pattern == "ljos-policyd" => {
204            let code = rule.reason.split(':').next().unwrap_or("").trim();
205            let fix = local(code, line).filter(|fix| fix.command != line)?;
206            refusal_of(&fix.command, judge).is_none().then(|| {
207                let mut refused = rule.clone();
208                refused.reason = format!(
209                    "{}. Run `{}` instead: {}",
210                    rule.reason, fix.command, fix.reason
211                );
212                Outcome::Refuse(refused)
213            })
214        }
215        Some(_) => None,
216    }
217}
218
219/// The hook's answer when the call has a rewrite, else None and the
220/// usual answer stands.
221pub fn answer(
222    input: &str,
223    call: &HookCall,
224    context: &str,
225    verdict: Option<&Rule>,
226    judge: impl Fn(&str) -> Option<Rule>,
227) -> Option<String> {
228    let outcome = pick(call, verdict, judge)?;
229    let relayed = std::env::var_os(RELAY_VAR).is_some();
230    Some(answer_with(input, call, context, &outcome, relayed))
231}
232
233/// [`answer`] for a known outcome. A refusal is the runner's deny. A line
234/// to run is the updated input where the runner takes one. A runner that
235/// cannot take one is refused with the new line named, so the original
236/// line never runs.
237#[must_use]
238pub fn answer_with(
239    input: &str,
240    call: &HookCall,
241    context: &str,
242    outcome: &Outcome,
243    relayed: bool,
244) -> String {
245    let refuse = |rule: &Rule| crate::approval::hook_output(input, call, context, Some(rule));
246    match outcome {
247        Outcome::Refuse(rule) => refuse(rule),
248        Outcome::Run(fix) => (!relayed)
249            .then(|| updated_input(input, call, context, fix))
250            .flatten()
251            .unwrap_or_else(|| refuse(&unchangeable(fix))),
252    }
253}
254
255/// The deny for a runner whose hook cannot replace the command.
256fn unchangeable(fix: &Rewrite) -> Rule {
257    Rule {
258        pattern: "ljos-policyd".into(),
259        verdict: "deny".into(),
260        reason: format!(
261            "{}. Run `{}` instead. This runner cannot take a new command, so the original \
262             line does not run",
263            fix.reason, fix.command
264        ),
265    }
266}
267
268fn joined(context: &str, line: &str) -> String {
269    if context.is_empty() {
270        line.to_string()
271    } else {
272        format!("{context}\n{line}")
273    }
274}
275
276/// Claude Code, Codex and Grok Build read `hookSpecificOutput.updatedInput`
277/// beside `permissionDecision: allow`; Cursor's `preToolUse` reads
278/// `updated_input` beside `permission: allow`. Each replaces the whole
279/// input, so every other field the runner sent goes back unchanged.
280fn updated_input(input: &str, call: &HookCall, context: &str, fix: &Rewrite) -> Option<String> {
281    let v: Value = serde_json::from_str(input.trim()).ok()?;
282    // Grok Build cut the input short, so it cannot be sent back whole.
283    if v["toolInputTruncated"].as_bool() == Some(true) {
284        return None;
285    }
286    let tool = v["tool_name"].as_str().or_else(|| v["toolName"].as_str())?;
287    if !SHELL_TOOLS.contains(&tool) {
288        return None;
289    }
290    let mut given = if v["tool_input"].is_object() {
291        v["tool_input"].clone()
292    } else {
293        v["toolInput"].clone()
294    };
295    let fields = given.as_object_mut()?;
296    if fields.get("command").and_then(Value::as_str).map(str::trim) != Some(call.cue.trim()) {
297        return None;
298    }
299    fields.insert("command".into(), json!(fix.command));
300    let said = format!(
301        "ljos ran `{}` in place of `{}`: {}",
302        fix.command,
303        call.cue.trim(),
304        fix.reason
305    );
306    let out = match call.shape {
307        HookShape::Cursor => json!({
308            "permission": "allow",
309            "updated_input": given,
310            "agent_message": said,
311        }),
312        HookShape::Asks | HookShape::DenyOnly | HookShape::CamelCase => {
313            let mut specific = json!({
314                "hookEventName": "PreToolUse",
315                "permissionDecision": "allow",
316                "updatedInput": given,
317                "additionalContext": joined(context, &said),
318            });
319            // Codex fails a hook on a field it does not take, and then
320            // runs the line as written.
321            if call.shape != HookShape::DenyOnly {
322                specific["permissionDecisionReason"] = json!(said);
323            }
324            let mut out = json!({ "hookSpecificOutput": specific });
325            if call.shape == HookShape::CamelCase {
326                out["decision"] = json!("allow");
327            }
328            out
329        }
330        _ => return None,
331    };
332    Some(out.to_string() + "\n")
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::hook_call;
339
340    fn uv_refusal() -> Rule {
341        Rule {
342            pattern: "ljos-policyd".into(),
343            verdict: "deny".into(),
344            reason: crate::policyd_reason("python-requires-uv"),
345        }
346    }
347
348    fn allow_all(_: &str) -> Option<Rule> {
349        None
350    }
351
352    fn answer_json(input: &str, outcome: &Outcome, relayed: bool) -> Value {
353        let out = answer_with(input, &hook_call(input), "", outcome, relayed);
354        serde_json::from_str(out.trim()).unwrap()
355    }
356
357    /// The answer when policyd rewrote the call's line to `uv run x.py --n
358    /// 3` and the guard accepts that line.
359    fn policyd_run(input: &str, relayed: bool) -> Value {
360        let call = hook_call(input);
361        hold(&call.cue, "uv run x.py --n 3".into());
362        let outcome = pick(&call, None, allow_all).expect("a rewrite");
363        assert!(matches!(outcome, Outcome::Run(_)), "{outcome:?}");
364        answer_json(input, &outcome, relayed)
365    }
366
367    /// The answer when policyd refused the call for want of uv.
368    fn local_refusal(input: &str) -> Value {
369        let call = hook_call(input);
370        let outcome = pick(&call, Some(&uv_refusal()), allow_all).expect("a fix");
371        assert!(matches!(outcome, Outcome::Refuse(_)), "{outcome:?}");
372        answer_json(input, &outcome, false)
373    }
374
375    /// The decision a runner reads, whichever field carries it.
376    fn decision(v: &Value) -> &str {
377        v["hookSpecificOutput"]["permissionDecision"]
378            .as_str()
379            .or_else(|| v["permission"].as_str())
380            .or_else(|| v["decision"].as_str())
381            .unwrap_or("")
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_eq!(decision(&v), "deny", "{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 is refused with the new
542    /// line named, so the original line never runs: Cursor's
543    /// `beforeShellExecution`, and every runner behind the relay.
544    #[test]
545    fn an_accepted_rewrite_is_a_deny_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_eq!(decision(&v), "deny", "{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("the original line does not run"), "{said}");
561        }
562    }
563
564    /// A rewrite of a copy that is not the line cannot be applied exactly,
565    /// so the call is refused and nothing is held.
566    #[test]
567    fn a_rewrite_of_a_changed_copy_refuses_the_call() {
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 refuses");
571        assert_eq!(rule.verdict, "deny");
572        assert!(rule.reason.contains("Neither line runs"), "{}", rule.reason);
573        assert!(take_held(line).is_none());
574        assert!(hold_exact("python x.py", "python x.py", "uv run x.py".into()).is_none());
575        assert_eq!(take_held("python x.py").as_deref(), Some("uv run x.py"));
576    }
577
578    /// Review repro: a denial sent as allow plus updatedInput runs the
579    /// refused line on a runner that drops updatedInput. Each runner gets
580    /// a deny with no updated input, and the deny names the fix.
581    #[test]
582    fn a_local_fix_is_a_deny_on_every_runner() {
583        for input in RUNNERS {
584            let mut v = local_refusal(input);
585            assert_eq!(decision(&v), "deny", "{input}\n{v}");
586            assert!(v["hookSpecificOutput"].get("updatedInput").is_none(), "{v}");
587            assert!(v.get("updated_input").is_none(), "{v}");
588            let said = v.to_string();
589            assert!(said.contains("Run `uv run x.py --n 3` instead"), "{said}");
590            assert!(said.contains("python-requires-uv: "), "{said}");
591            if let Some(specific) = v["hookSpecificOutput"].as_object_mut() {
592                specific.remove("updatedInput");
593            }
594            v.as_object_mut().unwrap().remove("updated_input");
595            assert_eq!(decision(&v), "deny", "dropping the input still refuses");
596        }
597        let v = local_refusal(CODEX);
598        let specific = &v["hookSpecificOutput"];
599        assert_eq!(specific["permissionDecision"], "deny");
600        assert!(specific["permissionDecisionReason"]
601            .as_str()
602            .unwrap()
603            .contains("Run `uv run x.py --n 3` instead"));
604    }
605
606    /// A local fix the guard would refuse is not named. The policyd
607    /// refusal stands as it came.
608    #[test]
609    fn a_refused_local_fix_leaves_the_refusal_as_it_came() {
610        let call = hook_call(CLAUDE);
611        let refuse = |_: &str| Some(uv_refusal());
612        assert_eq!(pick(&call, Some(&uv_refusal()), refuse), None);
613        let again = |line: &str| {
614            hold(line, "echo again".into());
615            None
616        };
617        assert_eq!(pick(&call, Some(&uv_refusal()), again), None);
618    }
619
620    /// Review repro 1: the recheck of policyd's new line rewrites it
621    /// again. The guard's last word was another line, so the new line
622    /// does not run, and neither does the original.
623    #[test]
624    fn a_rewrite_the_recheck_rewrites_again_refuses_the_call() {
625        let call = hook_call(CLAUDE);
626        hold(&call.cue, "python evil.py".into());
627        let judge = |line: &str| {
628            if line == "python evil.py" {
629                hold(line, "echo safe".into());
630            }
631            None
632        };
633        let Some(Outcome::Refuse(rule)) = pick(&call, None, judge) else {
634            panic!("a rewrite rewritten again must refuse");
635        };
636        assert_eq!(rule.verdict, "deny");
637        assert!(rule.reason.contains("`python evil.py`"), "{}", rule.reason);
638        assert!(rule.reason.contains("`echo safe`"), "{}", rule.reason);
639        for input in RUNNERS {
640            let call = hook_call(input);
641            hold(&call.cue, "python evil.py".into());
642            let outcome = pick(&call, None, judge).unwrap();
643            let v = answer_json(input, &outcome, false);
644            assert_eq!(decision(&v), "deny", "{input}\n{v}");
645            assert!(!v.to_string().contains("updatedInput"), "{v}");
646            assert!(!v.to_string().contains("updated_input"), "{v}");
647        }
648    }
649
650    /// Review repro 2: the recheck denies policyd's new line. The hook
651    /// used to fall back to the original verdict and allow the original.
652    #[test]
653    fn a_rewrite_the_recheck_denies_refuses_the_call() {
654        let refuse = |_: &str| Some(uv_refusal());
655        for input in RUNNERS {
656            let call = hook_call(input);
657            hold(&call.cue, "uv run evil.py".into());
658            let outcome = pick(&call, None, refuse).expect("a refusal, not the original");
659            let Outcome::Refuse(rule) = &outcome else {
660                panic!("{outcome:?}");
661            };
662            assert!(rule.reason.contains("`uv run evil.py`"), "{}", rule.reason);
663            assert!(
664                rule.reason.contains("python-requires-uv"),
665                "{}",
666                rule.reason
667            );
668            let v = answer_json(input, &outcome, false);
669            assert_eq!(decision(&v), "deny", "{input}\n{v}");
670            let v = answer_json(input, &outcome, true);
671            assert_eq!(decision(&v), "deny", "relayed {input}\n{v}");
672        }
673    }
674
675    #[test]
676    fn no_rewrite_without_a_held_line_or_a_known_refusal() {
677        let call = hook_call(CLAUDE);
678        assert!(pick(&call, None, allow_all).is_none(), "nothing held");
679        hold("some other line", "uv run x.py".into());
680        assert!(
681            pick(&call, None, allow_all).is_none(),
682            "held for another line"
683        );
684        hold(&call.cue, call.cue.clone());
685        assert!(pick(&call, None, allow_all).is_none(), "the same line");
686        let ask = Rule {
687            pattern: "git-force-push".into(),
688            verdict: "ask".into(),
689            reason: "a force push".into(),
690        };
691        hold(&call.cue, "uv run x.py --n 3".into());
692        assert!(pick(&call, Some(&ask), allow_all).is_none());
693        assert!(
694            take_held(&call.cue).is_none(),
695            "a refusal drops the held line"
696        );
697        let prompt = hook_call(r#"{"hook_event_name":"UserPromptSubmit","prompt":"python x.py"}"#);
698        assert!(pick(&prompt, Some(&uv_refusal()), allow_all).is_none());
699    }
700
701    /// A Read call is not a shell call, so an accepted rewrite of its cue
702    /// is never sent back as its input.
703    #[test]
704    fn a_read_call_never_gets_updated_input() {
705        let call = hook_call(READ);
706        assert_eq!(call.event, "PreToolUse");
707        hold(&call.cue, "uv run x.py --n 3".into());
708        let outcome = pick(&call, None, allow_all).expect("a rewrite of the Read cue");
709        assert!(matches!(outcome, Outcome::Run(_)), "{outcome:?}");
710        let v = answer_json(READ, &outcome, false);
711        assert!(v["hookSpecificOutput"].get("updatedInput").is_none(), "{v}");
712        assert_eq!(decision(&v), "deny", "{v}");
713    }
714
715    /// The fake policyd, answering in 0.6.0's `bash -c` form: `python a.py`
716    /// is rewritten to an accepted line, `python b.py` to a refused one,
717    /// and `python c.py` to one that is rewritten again.
718    const FAKE_POLICYD: &str = r#"#!/bin/sh
719case "$5" in
720  "python a.py") printf "allow\trewrite\tbash -c 'uv run a.py'\n" ;;
721  "python b.py") printf "allow\trewrite\tbash -c 'uv run b.py'\n" ;;
722  "python c.py") printf "allow\trewrite\tbash -c 'uv run c.py'\n" ;;
723  "uv run b.py") printf 'deny\tpython-requires-uv\n'; exit 2 ;;
724  "uv run c.py") printf "allow\trewrite\tbash -c 'echo c'\n" ;;
725  *) echo allow ;;
726esac
727"#;
728
729    /// The guard holds a policyd rewrite for the hook, and the real guard
730    /// as the recheck lets only an accepted line run.
731    #[test]
732    fn the_real_guard_lets_only_an_accepted_rewrite_run() {
733        use std::os::unix::fs::PermissionsExt as _;
734        let _g = crate::tests::env_guard();
735        let home = tempfile::tempdir().unwrap();
736        let bin = home.path().join("policyd");
737        std::fs::write(&bin, FAKE_POLICYD).unwrap();
738        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
739        let keys = [
740            "HOME",
741            "POLICYD_BIN",
742            "PACKSET_URL",
743            "LJOS_OFF",
744            "LJOS_GUARD",
745            "POLICYD_REQUIRED",
746            "LJOS_GUARD_FAIL_OPEN",
747        ];
748        let saved: Vec<_> = keys.iter().map(|k| (*k, std::env::var_os(k))).collect();
749        unsafe {
750            std::env::set_var("HOME", home.path());
751            std::env::set_var("POLICYD_BIN", &bin);
752            std::env::set_var("PACKSET_URL", "off");
753            std::env::set_var("LJOS_OFF", "0");
754            std::env::set_var("LJOS_GUARD", "on");
755            std::env::remove_var("POLICYD_REQUIRED");
756            std::env::remove_var("LJOS_GUARD_FAIL_OPEN");
757        }
758        let dir = Some(home.path());
759        let judge = |line: &str| crate::tcb_verdict_in(line, dir);
760        let outcome = |script: &str| {
761            let input = CLAUDE.replace("python x.py --n 3", &format!("python {script}"));
762            let call = hook_call(&input);
763            let verdict = crate::tcb_verdict_in(&call.cue, dir);
764            assert!(verdict.is_none(), "{script}: {verdict:?}");
765            pick(&call, verdict.as_ref(), judge)
766        };
767        let accepted = outcome("a.py");
768        let refused = outcome("b.py");
769        let again = outcome("c.py");
770        unsafe {
771            for (k, v) in saved {
772                match v {
773                    Some(v) => std::env::set_var(k, v),
774                    None => std::env::remove_var(k),
775                }
776            }
777        }
778        assert_eq!(
779            accepted,
780            Some(Outcome::Run(Rewrite {
781                command: "uv run a.py".into(),
782                reason: POLICYD_REASON.into(),
783            }))
784        );
785        let Some(Outcome::Refuse(rule)) = refused else {
786            panic!("a refused substitute must refuse the call: {refused:?}");
787        };
788        assert!(
789            rule.reason.contains("python-requires-uv"),
790            "{}",
791            rule.reason
792        );
793        let Some(Outcome::Refuse(rule)) = again else {
794            panic!("a substitute rewritten again must refuse the call: {again:?}");
795        };
796        assert!(rule.reason.contains("`echo c`"), "{}", rule.reason);
797    }
798
799    /// A policyd rewrite the guard accepts reaches the runner as its new
800    /// input, and the same swap is said and kept in the rewrite log. With
801    /// the repository's `rewrite = false` the line runs as written, and
802    /// the declined swap is still kept.
803    #[test]
804    fn a_policyd_rewrite_is_passed_on_and_also_noted_and_logged() {
805        use std::os::unix::fs::PermissionsExt as _;
806        let _g = crate::tests::env_guard();
807        let home = tempfile::tempdir().unwrap();
808        let repo = home.path().join("proj");
809        std::fs::create_dir_all(&repo).unwrap();
810        let init = crate::git_cmd()
811            .args(["init", "-q"])
812            .current_dir(&repo)
813            .output()
814            .unwrap();
815        assert!(init.status.success(), "{init:?}");
816        let bin = home.path().join("policyd");
817        std::fs::write(
818            &bin,
819            "#!/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",
820        )
821        .unwrap();
822        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
823        // SAFETY: under the lock every environment-reading test takes,
824        // which puts each name back when it drops.
825        unsafe {
826            std::env::set_var("HOME", home.path());
827            std::env::set_var("XDG_DATA_HOME", home.path().join("data"));
828            std::env::set_var("XDG_CONFIG_HOME", home.path().join("config"));
829            std::env::set_var("POLICYD_BIN", &bin);
830            std::env::set_var("PACKSET_URL", "off");
831            std::env::set_var("LJOS_OFF", "0");
832            std::env::set_var("LJOS_GUARD", "on");
833            for key in ["POLICYD_REQUIRED", "LJOS_GUARD_FAIL_OPEN", "LJOS_CONFIG"] {
834                std::env::remove_var(key);
835            }
836        }
837        let dir = Some(repo.as_path());
838        let judge = |line: &str| crate::tcb_verdict_in(line, dir);
839        let call = hook_call(CLAUDE);
840
841        let _ = crate::take_guard_note();
842        let verdict = crate::tcb_verdict_in(&call.cue, dir);
843        assert!(verdict.is_none(), "{verdict:?}");
844        let note = crate::take_guard_note().expect("the swap is said");
845        let outcome = pick(&call, None, judge).expect("the rewrite is passed on");
846        let v = answer_json(CLAUDE, &outcome, false);
847        assert_eq!(
848            v["hookSpecificOutput"]["updatedInput"]["command"], "uv run x.py --n 3",
849            "{v}"
850        );
851        assert!(note.contains("`uv run x.py --n 3`"), "{note}");
852        assert!(note.contains("`python x.py --n 3`"), "{note}");
853        let kept = crate::swap_log::rows();
854        assert_eq!(kept.len(), 1, "{kept:?}");
855        assert!(kept[0].applied, "{kept:?}");
856        assert_eq!(kept[0].ran, "uv run x.py --n 3");
857
858        std::fs::write(repo.join("ljos.toml"), "rewrite = false\n").unwrap();
859        let _ = crate::take_guard_note();
860        let verdict = crate::tcb_verdict_in(&call.cue, dir);
861        assert!(verdict.is_none(), "{verdict:?}");
862        assert!(crate::take_guard_note().is_none(), "nothing was swapped");
863        assert_eq!(pick(&call, None, judge), None, "the line runs as written");
864        let kept = crate::swap_log::rows();
865        assert_eq!(kept.len(), 2, "{kept:?}");
866        assert!(!kept[1].applied, "the switch declined it: {kept:?}");
867    }
868}