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 Refuse(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: "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
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 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
169pub 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
219pub 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#[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
255fn 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
276fn updated_input(input: &str, call: &HookCall, context: &str, fix: &Rewrite) -> Option<String> {
281 let v: Value = serde_json::from_str(input.trim()).ok()?;
282 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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}