1use std::cell::RefCell;
13
14use serde_json::{json, Value};
15
16use crate::{HookCall, HookShape, Rule};
17
18pub const RELAY_VAR: &str = "LJOS_HOOK_RELAY";
22
23const SHELL_TOOLS: &[&str] = &["Bash", "Shell", "run_terminal_command"];
26
27const POLICYD_REASON: &str = "ljos-policyd rewrote this line";
28
29const 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#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Outcome {
44 Run(Rewrite),
47 Warn(Rule),
49}
50
51#[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
71pub(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
94pub(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 codes: &'static [&'static str],
108 program: &'static str,
110 script: &'static str,
112 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#[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
158fn 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
169pub 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
218pub 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#[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
254fn 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
275fn updated_input(input: &str, call: &HookCall, context: &str, fix: &Rewrite) -> Option<String> {
280 let v: Value = serde_json::from_str(input.trim()).ok()?;
281 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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}