1use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::Effect;
11use crate::ir::{DecisionTrace, PolicyDecision, RuleMatch, RuleSkip};
12use crate::sandbox_types::{SandboxPolicy, ViolationAction};
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15
16struct ToolAlias {
22 canonical: &'static str,
23 internal: &'static str,
24}
25
26const TOOL_ALIASES: &[ToolAlias] = &[
27 ToolAlias {
28 canonical: "shell",
29 internal: "Bash",
30 },
31 ToolAlias {
32 canonical: "read",
33 internal: "Read",
34 },
35 ToolAlias {
36 canonical: "write",
37 internal: "Write",
38 },
39 ToolAlias {
40 canonical: "edit",
41 internal: "Edit",
42 },
43 ToolAlias {
44 canonical: "glob",
45 internal: "Glob",
46 },
47 ToolAlias {
48 canonical: "grep",
49 internal: "Grep",
50 },
51 ToolAlias {
52 canonical: "web_fetch",
53 internal: "WebFetch",
54 },
55 ToolAlias {
56 canonical: "web_search",
57 internal: "WebSearch",
58 },
59];
60
61fn canonical_to_internal(clash_name: &str) -> Option<&'static str> {
63 let lower = clash_name.to_lowercase();
64 TOOL_ALIASES
65 .iter()
66 .find(|a| a.canonical.to_lowercase() == lower)
67 .map(|a| a.internal)
68}
69
70fn internal_to_canonical(internal_name: &str) -> Option<&'static str> {
72 let lower = internal_name.to_lowercase();
73 TOOL_ALIASES
74 .iter()
75 .find(|a| a.internal.to_lowercase() == lower)
76 .map(|a| a.canonical)
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum Value {
87 Env(String),
89 Literal(String),
91 Path(Vec<Value>),
93}
94
95impl std::fmt::Display for Value {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 Value::Env(env) => write!(f, "${env}"),
99 Value::Literal(lit) => write!(f, "'{lit}'"),
100 Value::Path(values) => write!(f, "{values:#?}"),
101 }
102 }
103}
104
105impl Value {
106 pub fn resolve(&self) -> String {
108 match self {
109 Value::Env(var) => std::env::var(var).unwrap_or_default(),
110 Value::Literal(s) => s.clone(),
111 Value::Path(parts) => parts
112 .iter()
113 .map(|p| p.resolve())
114 .collect::<Vec<_>>()
115 .join("/"),
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum Pattern {
128 Wildcard,
130 Literal(Value),
132 Regex(
134 #[serde(
135 serialize_with = "serialize_regex",
136 deserialize_with = "deserialize_regex"
137 )]
138 Arc<Regex>,
139 ),
140 AnyOf(Vec<Pattern>),
142 Not(Box<Pattern>),
144 Prefix(Value),
147 ChildOf(Value),
150}
151
152fn serialize_regex<S: serde::Serializer>(re: &Arc<Regex>, s: S) -> Result<S::Ok, S::Error> {
153 s.serialize_str(re.as_str())
154}
155
156fn deserialize_regex<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Arc<Regex>, D::Error> {
157 let s = String::deserialize(d)?;
158 Regex::new(&s)
159 .map(Arc::new)
160 .map_err(serde::de::Error::custom)
161}
162
163impl Pattern {
164 pub fn matches(&self, value: &str) -> bool {
166 match self {
167 Pattern::Wildcard => true,
168 Pattern::Literal(v) => v.resolve() == value,
169 Pattern::Regex(re) => re.is_match(value),
170 Pattern::AnyOf(pats) => pats.iter().any(|p| p.matches(value)),
171 Pattern::Not(p) => !p.matches(value),
172 Pattern::Prefix(v) => {
173 let prefix = v.resolve();
174 !prefix.is_empty() && (value == prefix || value.starts_with(&format!("{prefix}/")))
176 }
177 Pattern::ChildOf(v) => {
178 let parent = v.resolve();
179 value
180 .strip_prefix(&format!("{parent}/"))
181 .is_some_and(|rest| !rest.contains('/'))
182 }
183 }
184 }
185
186 pub fn specificity(&self) -> u8 {
188 match self {
189 Pattern::Wildcard => 0,
190 Pattern::Not(_) => 1,
191 Pattern::AnyOf(_) => 1,
192 Pattern::Regex(_) => 2,
193 Pattern::Prefix(_) => 3,
194 Pattern::ChildOf(_) => 3,
195 Pattern::Literal(_) => 4,
196 }
197 }
198}
199
200impl PartialEq for Pattern {
201 fn eq(&self, other: &Self) -> bool {
202 match (self, other) {
203 (Pattern::Wildcard, Pattern::Wildcard) => true,
204 (Pattern::Literal(a), Pattern::Literal(b)) => a == b,
205 (Pattern::Regex(a), Pattern::Regex(b)) => a.as_str() == b.as_str(),
206 (Pattern::AnyOf(a), Pattern::AnyOf(b)) => a == b,
207 (Pattern::Not(a), Pattern::Not(b)) => a == b,
208 (Pattern::Prefix(a), Pattern::Prefix(b)) => a == b,
209 (Pattern::ChildOf(a), Pattern::ChildOf(b)) => a == b,
210 _ => false,
211 }
212 }
213}
214
215impl Eq for Pattern {}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum Observable {
225 ToolName,
227 HookType,
229 AgentName,
231 PositionalArg(i32),
233 HasArg,
235 NamedArg(String),
237 NestedField(Vec<String>),
239 FsOp,
242 FsPath,
245 NetDomain,
248 Mode,
251}
252
253impl Observable {
254 pub fn specificity(&self) -> u8 {
256 match self {
257 Observable::ToolName => 1,
258 Observable::HookType => 1,
259 Observable::AgentName => 1,
260 Observable::PositionalArg(_) => 2,
261 Observable::HasArg => 1,
262 Observable::NamedArg(_) => 2,
263 Observable::NestedField(path) => 2 + path.len().min(3) as u8,
264 Observable::FsOp => 1,
265 Observable::FsPath => 2,
266 Observable::NetDomain => 2,
267 Observable::Mode => 0,
268 }
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct SandboxRef(pub String);
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum Decision {
288 Allow(Option<SandboxRef>),
290 Deny,
292 Ask(Option<SandboxRef>),
294}
295
296impl Decision {
297 pub fn effect(&self) -> Effect {
298 match self {
299 Decision::Allow(_) => Effect::Allow,
300 Decision::Deny => Effect::Deny,
301 Decision::Ask(_) => Effect::Ask,
302 }
303 }
304
305 pub fn sandbox_ref(&self) -> Option<&SandboxRef> {
306 match self {
307 Decision::Allow(sb) | Decision::Ask(sb) => sb.as_ref(),
308 Decision::Deny => None,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum Node {
321 Condition {
323 observe: Observable,
324 pattern: Pattern,
325 children: Vec<Node>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 doc: Option<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 source: Option<String>,
333 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
336 terminal: bool,
337 },
338 Decision(Decision),
340}
341
342impl Node {
343 pub fn stamp_source(&mut self, source: &str) {
347 if let Node::Condition {
348 source: slot @ None,
349 ..
350 } = self
351 {
352 *slot = Some(source.to_string());
353 }
354 }
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct CompiledPolicy {
364 pub sandboxes: HashMap<String, SandboxPolicy>,
366 pub tree: Vec<Node>,
368 #[serde(default = "default_effect")]
370 pub default_effect: Effect,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub default_sandbox: Option<String>,
374 #[serde(default, skip_serializing_if = "ViolationAction::is_default")]
376 pub on_sandbox_violation: ViolationAction,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub harness_defaults: Option<bool>,
381}
382
383fn default_effect() -> Effect {
384 Effect::Ask
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct PolicyManifest {
396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 pub includes: Vec<IncludeEntry>,
399 #[serde(flatten)]
401 pub policy: CompiledPolicy,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct IncludeEntry {
407 pub path: String,
410}
411
412impl CompiledPolicy {
413 pub fn rule_count(&self) -> usize {
415 self.tree.len()
416 }
417
418 pub fn format_rules(&self) -> Vec<String> {
420 super::format::format_rules(self)
421 }
422
423 pub fn format_tree(&self) -> Vec<String> {
425 super::format::format_tree(self)
426 }
427
428 pub fn harness_node_count(&self) -> usize {
430 self.tree
431 .iter()
432 .filter(|n| match n {
433 Node::Condition { source, .. } => source.as_deref() == Some("harness"),
434 _ => false,
435 })
436 .count()
437 }
438
439 pub fn tree_without_harness(&self) -> Vec<&Node> {
441 self.tree
442 .iter()
443 .filter(|n| match n {
444 Node::Condition { source, .. } => source.as_deref() != Some("harness"),
445 _ => true,
446 })
447 .collect()
448 }
449
450 pub fn format_tree_filtered(&self, include_harness: bool) -> Vec<String> {
452 if include_harness {
453 super::format::format_tree(self)
454 } else {
455 let nodes = self.tree_without_harness();
456 super::format::format_tree_nodes(&nodes)
457 }
458 }
459}
460
461#[derive(Debug)]
467pub struct QueryContext {
468 pub tool_name: String,
470 pub args: Vec<String>,
472 pub tool_input: serde_json::Value,
474 pub hook_type: Option<String>,
476 pub agent_name: Option<String>,
478 pub fs_op: Option<String>,
480 pub fs_path: Option<String>,
482 pub net_domain: Option<String>,
484 pub mode: Option<String>,
486}
487
488impl QueryContext {
489 pub fn from_tool(tool_name: &str, tool_input: &serde_json::Value) -> Self {
491 let args = match tool_name {
492 "Bash" => {
493 let command = tool_input
494 .get("command")
495 .and_then(|v| v.as_str())
496 .unwrap_or("");
497 let parts: Vec<&str> = command.split_whitespace().collect();
498 let (bin, rest) = parse_bash_bin_args(&parts);
499 let mut args = vec![bin];
500 args.extend(rest);
501 args
502 }
503 "WebFetch" => tool_input
504 .get("url")
505 .and_then(|v| v.as_str())
506 .and_then(extract_domain)
507 .map(|d| vec![d])
508 .unwrap_or_default(),
509 _ => vec![],
510 };
511
512 let (fs_op, fs_path) = match tool_name {
514 "Read" => (
515 Some("read".to_string()),
516 tool_input
517 .get("file_path")
518 .and_then(|v| v.as_str())
519 .map(resolve_relative_path),
520 ),
521 "Glob" | "Grep" => (
522 Some("read".to_string()),
523 tool_input
524 .get("path")
525 .or_else(|| tool_input.get("pattern"))
526 .and_then(|v| v.as_str())
527 .map(resolve_relative_path),
528 ),
529 "Write" | "Edit" => (
530 Some("write".to_string()),
531 tool_input
532 .get("file_path")
533 .and_then(|v| v.as_str())
534 .map(resolve_relative_path),
535 ),
536 _ => (None, None),
537 };
538
539 let net_domain = match tool_name {
540 "WebFetch" => tool_input
541 .get("url")
542 .and_then(|v| v.as_str())
543 .and_then(extract_domain),
544 "WebSearch" => Some("*".to_string()),
545 _ => None,
546 };
547
548 QueryContext {
549 tool_name: tool_name.to_string(),
550 args,
551 tool_input: tool_input.clone(),
552 hook_type: None,
553 agent_name: None,
554 fs_op,
555 fs_path,
556 net_domain,
557 mode: None,
558 }
559 }
560
561 fn extract(&self, obs: &Observable) -> Option<Vec<String>> {
563 match obs {
564 Observable::ToolName => Some(vec![self.tool_name.clone()]),
565 Observable::HookType => self.hook_type.clone().map(|h| vec![h]),
566 Observable::AgentName => self.agent_name.clone().map(|a| vec![a]),
567 Observable::PositionalArg(i) => {
568 let idx = *i as usize;
569 self.args.get(idx).map(|a| vec![a.clone()])
570 }
571 Observable::HasArg => Some(self.args.clone()),
572 Observable::NamedArg(name) => self
573 .tool_input
574 .get(name)
575 .and_then(|v| v.as_str())
576 .map(|s| vec![s.to_string()]),
577 Observable::NestedField(path) => {
578 let mut current = &self.tool_input;
579 for segment in path {
580 current = current.get(segment)?;
581 }
582 current.as_str().map(|s| vec![s.to_string()])
583 }
584 Observable::FsOp => self.fs_op.clone().map(|op| vec![op]),
585 Observable::FsPath => self.fs_path.clone().map(|p| vec![p]),
586 Observable::NetDomain => self.net_domain.clone().map(|d| vec![d]),
587 Observable::Mode => self.mode.clone().map(|m| vec![m]),
588 }
589 }
590}
591
592fn resolve_relative_path(path: &str) -> String {
594 super::path::PathResolver::from_env().resolve_relative(path)
595}
596
597fn extract_domain(url: &str) -> Option<String> {
599 let without_scheme = url
601 .strip_prefix("https://")
602 .or_else(|| url.strip_prefix("http://"))
603 .unwrap_or(url);
604 let host = without_scheme.split('/').next()?;
605 let domain = host.split(':').next()?;
607 if domain.is_empty() {
608 None
609 } else {
610 Some(domain.to_string())
611 }
612}
613
614pub(crate) fn parse_bash_bin_args(parts: &[&str]) -> (String, Vec<String>) {
622 let mut i = 0;
623
624 loop {
625 while i < parts.len() && is_env_assignment(parts[i]) {
626 i += 1;
627 }
628
629 if i < parts.len() && parts[i] == "env" {
630 i += 1;
631 while i < parts.len() && is_env_assignment(parts[i]) {
632 i += 1;
633 }
634 continue;
635 }
636
637 if i < parts.len()
638 && let Some(skip) = transparent_prefix_skip(parts[i], parts.get(i + 1..).unwrap_or(&[]))
639 {
640 i += 1 + skip;
641 continue;
642 }
643
644 break;
645 }
646
647 match parts.get(i) {
648 Some(bin) => (
649 bin.to_string(),
650 parts[i + 1..].iter().map(|s| s.to_string()).collect(),
651 ),
652 None => (String::new(), vec![]),
653 }
654}
655
656fn is_env_assignment(token: &str) -> bool {
657 match token.find('=') {
658 Some(0) | None => false,
659 Some(pos) => {
660 let name = &token[..pos];
661 let mut chars = name.chars();
662 match chars.next() {
663 Some(c) if c.is_ascii_alphabetic() || c == '_' => {
664 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
665 }
666 _ => false,
667 }
668 }
669 }
670}
671
672fn transparent_prefix_skip(cmd: &str, rest: &[&str]) -> Option<usize> {
673 match cmd {
674 "time" => Some(skip_flags(rest, &["-f", "-o"])),
675 "command" => {
676 if rest.first().is_some_and(|f| *f == "-v" || *f == "-V") {
677 None
678 } else {
679 Some(skip_flags(rest, &[]))
680 }
681 }
682 "nice" => Some(skip_flags(rest, &["-n"])),
683 "nohup" => Some(0),
684 "timeout" => {
685 let flags = skip_flags(rest, &["-s", "-k", "--signal", "--kill-after"]);
686 if flags < rest.len() {
687 Some(flags + 1)
688 } else {
689 Some(flags)
690 }
691 }
692 _ => None,
693 }
694}
695
696fn skip_flags(tokens: &[&str], value_flags: &[&str]) -> usize {
697 let mut i = 0;
698 while i < tokens.len() && tokens[i].starts_with('-') {
699 let flag = tokens[i];
700 i += 1;
701 if flag.contains('=') {
702 continue;
703 }
704 if value_flags.contains(&flag) && i < tokens.len() {
705 i += 1;
706 }
707 }
708 i
709}
710
711#[derive(Debug, Clone, Default)]
717pub struct EvalTrace {
718 pub matched: Vec<TraceEntry>,
720 pub skipped: Vec<TraceEntry>,
722 pub dead_ends: Vec<TraceEntry>,
724}
725
726#[derive(Debug, Clone)]
728pub struct TraceEntry {
729 pub path: Vec<String>,
731 pub observable: String,
733 pub pattern_desc: String,
735 pub tested_value: Option<String>,
737}
738
739pub fn eval(nodes: &[Node], ctx: &QueryContext) -> Option<Decision> {
747 for node in nodes {
748 match node {
749 Node::Decision(d) => return Some(d.clone()),
750 Node::Condition {
751 observe,
752 pattern,
753 children,
754 terminal,
755 ..
756 } => {
757 if matches_observable(observe, pattern, *terminal, ctx)
758 && let Some(d) = eval(children, ctx)
759 {
760 return Some(d);
761 }
762 }
763 }
764 }
765 None
766}
767
768pub fn eval_traced(
770 nodes: &[Node],
771 ctx: &QueryContext,
772 trace: &mut EvalTrace,
773 path: &mut Vec<String>,
774) -> Option<Decision> {
775 for node in nodes {
776 match node {
777 Node::Decision(d) => return Some(d.clone()),
778 Node::Condition {
779 observe,
780 pattern,
781 children,
782 terminal,
783 ..
784 } => {
785 let obs_name = format!("{observe:?}");
786 let pat_desc = format!("{pattern:?}");
787 let values = ctx.extract(observe);
788 let tested = values.as_ref().map(|vs| vs.join(", "));
789
790 if matches_observable(observe, pattern, *terminal, ctx) {
791 path.push(obs_name.clone());
792 if let Some(d) = eval_traced(children, ctx, trace, path) {
793 trace.matched.push(TraceEntry {
794 path: path.clone(),
795 observable: obs_name,
796 pattern_desc: pat_desc,
797 tested_value: tested,
798 });
799 path.pop();
800 return Some(d);
801 }
802 trace.dead_ends.push(TraceEntry {
804 path: path.clone(),
805 observable: obs_name,
806 pattern_desc: pat_desc,
807 tested_value: tested,
808 });
809 path.pop();
810 } else {
811 trace.skipped.push(TraceEntry {
812 path: path.clone(),
813 observable: obs_name,
814 pattern_desc: pat_desc,
815 tested_value: tested,
816 });
817 }
818 }
819 }
820 }
821 None
822}
823
824fn find_match_path_dfs(nodes: &[Node], ctx: &QueryContext) -> Option<Vec<usize>> {
826 for (i, node) in nodes.iter().enumerate() {
827 match node {
828 Node::Decision(_) => return Some(vec![i]),
829 Node::Condition {
830 observe,
831 pattern,
832 children,
833 terminal,
834 ..
835 } => {
836 if matches_observable(observe, pattern, *terminal, ctx)
837 && let Some(mut child_path) = find_match_path_dfs(children, ctx)
838 {
839 child_path.insert(0, i);
840 return Some(child_path);
841 }
842 }
843 }
844 }
845 None
846}
847
848fn matches_observable(
854 obs: &Observable,
855 pattern: &Pattern,
856 terminal: bool,
857 ctx: &QueryContext,
858) -> bool {
859 match obs {
860 Observable::HasArg => {
861 ctx.args.iter().any(|arg| pattern.matches(arg))
863 }
864 Observable::PositionalArg(i) if terminal => {
865 let idx = *i as usize;
866 match ctx.args.get(idx) {
867 Some(val) if pattern.matches(val) => ctx.args.len() == idx + 1,
868 _ => false,
869 }
870 }
871 Observable::ToolName => {
872 let tool = &ctx.tool_name;
875 let tool_lower = tool.to_lowercase();
876 let canonical = internal_to_canonical(tool);
877
878 let mut aliases = vec![tool.clone(), tool_lower.clone()];
880 if let Some(c) = canonical {
881 aliases.push(c.to_string());
882 let c_lower = c.to_lowercase();
883 if c_lower != c {
884 aliases.push(c_lower);
885 }
886 }
887
888 match pattern {
889 Pattern::Wildcard => true,
890 Pattern::Literal(v) => {
891 let pat = v.resolve();
892 let pat_lower = pat.to_lowercase();
893 aliases.iter().any(|a| a == &pat || a.to_lowercase() == pat_lower)
895 || canonical_to_internal(&pat)
897 .is_some_and(|resolved| resolved.eq_ignore_ascii_case(tool))
898 }
899 _ => {
900 aliases.iter().any(|v| pattern.matches(v))
902 }
903 }
904 }
905 _ => {
906 if let Some(values) = ctx.extract(obs) {
908 values.iter().any(|v| pattern.matches(v))
909 } else {
910 matches!(pattern, Pattern::Wildcard)
912 }
913 }
914 }
915}
916
917impl CompiledPolicy {
922 pub fn evaluate(&self, tool_name: &str, tool_input: &serde_json::Value) -> PolicyDecision {
924 let ctx = QueryContext::from_tool(tool_name, tool_input);
925 self.evaluate_ctx(&ctx)
926 }
927
928 pub fn evaluate_with_mode(
930 &self,
931 tool_name: &str,
932 tool_input: &serde_json::Value,
933 mode: Option<&str>,
934 ) -> PolicyDecision {
935 self.evaluate_with_context(tool_name, tool_input, mode, None)
936 }
937
938 pub fn evaluate_with_context(
940 &self,
941 tool_name: &str,
942 tool_input: &serde_json::Value,
943 mode: Option<&str>,
944 agent_name: Option<&str>,
945 ) -> PolicyDecision {
946 let mut ctx = QueryContext::from_tool(tool_name, tool_input);
947 ctx.mode = mode.map(|m| m.to_string());
948 ctx.agent_name = agent_name.map(|a| a.to_string());
949 self.evaluate_ctx(&ctx)
950 }
951
952 pub fn find_match_path(
957 &self,
958 tool_name: &str,
959 tool_input: &serde_json::Value,
960 ) -> Option<Vec<usize>> {
961 let ctx = QueryContext::from_tool(tool_name, tool_input);
962 find_match_path_dfs(&self.tree, &ctx)
963 }
964
965 pub fn evaluate_ctx(&self, ctx: &QueryContext) -> PolicyDecision {
967 let mut trace = EvalTrace::default();
968 let mut path = Vec::new();
969
970 let decision = eval_traced(&self.tree, ctx, &mut trace, &mut path);
971
972 match decision {
973 Some(d) => {
974 let mut effect = d.effect();
975 let cwd_path = std::env::current_dir().unwrap_or_default();
976 let sandbox = d
977 .sandbox_ref()
978 .and_then(|sr| self.sandboxes.get(&sr.0))
979 .cloned()
980 .map(|sbx| sbx.expand_worktree_rules(&cwd_path));
981
982 let mut sandbox_denial: Option<String> = None;
985 if effect == Effect::Allow
986 && ctx.tool_name != "Bash"
987 && let Some(ref sbx) = sandbox
988 && let Some(ref fs_op) = ctx.fs_op
989 && let Some(ref fs_path) = ctx.fs_path
990 && fs_path.starts_with('/')
991 {
992 use crate::sandbox_types::Cap;
993 let required = match fs_op.as_str() {
994 "read" => Cap::READ,
995 "write" => Cap::WRITE | Cap::CREATE,
996 _ => Cap::empty(),
997 };
998 let cwd = cwd_path.to_string_lossy().to_string();
999 if let Some(explanation) = sbx.explain_denial(fs_path, &cwd, required) {
1000 effect = Effect::Deny;
1001 let sandbox_name =
1002 d.sandbox_ref().map(|sr| sr.0.as_str()).unwrap_or("unnamed");
1003 sandbox_denial = Some(format!("sandbox '{sandbox_name}': {explanation}"));
1004 }
1005 }
1006
1007 let resolution = match sandbox_denial {
1008 Some(ref detail) => format!("result: {effect} ({detail})"),
1009 None => format!("result: {effect}"),
1010 };
1011
1012 PolicyDecision {
1013 effect,
1014 reason: Some(resolution.clone()),
1015 trace: self.build_decision_trace(&trace, &resolution),
1016 sandbox,
1017 sandbox_name: d.sandbox_ref().cloned(),
1018 }
1019 }
1020 None => {
1021 let resolution = format!("no rules matched, default: {}", self.default_effect);
1022
1023 PolicyDecision {
1024 effect: self.default_effect,
1025 reason: Some(resolution.clone()),
1026 trace: self.build_decision_trace(&trace, &resolution),
1027 sandbox: None,
1028 sandbox_name: None,
1029 }
1030 }
1031 }
1032 }
1033
1034 fn build_decision_trace(&self, trace: &EvalTrace, resolution: &str) -> DecisionTrace {
1035 let mut matched_rules = Vec::new();
1036 let mut skipped_rules = Vec::new();
1037
1038 for (i, entry) in trace.matched.iter().enumerate() {
1039 matched_rules.push(RuleMatch {
1040 rule_index: i,
1041 description: format!(
1042 "{}={}",
1043 entry.observable,
1044 entry.tested_value.as_deref().unwrap_or("?")
1045 ),
1046 effect: Effect::Allow, has_active_constraints: true,
1048 node_id: None,
1049 });
1050 }
1051
1052 for (i, entry) in trace.skipped.iter().enumerate() {
1053 skipped_rules.push(RuleSkip {
1054 rule_index: i,
1055 description: format!("{}: {}", entry.observable, entry.pattern_desc),
1056 reason: format!(
1057 "pattern mismatch (value: {})",
1058 entry.tested_value.as_deref().unwrap_or("absent")
1059 ),
1060 });
1061 }
1062
1063 DecisionTrace {
1064 matched_rules,
1065 skipped_rules,
1066 final_resolution: resolution.to_string(),
1067 }
1068 }
1069
1070 pub fn validate(&self) -> Vec<String> {
1072 let mut errors = Vec::new();
1073 self.validate_nodes(&self.tree, &mut errors);
1074 errors
1075 }
1076
1077 fn validate_nodes(&self, nodes: &[Node], errors: &mut Vec<String>) {
1078 for node in nodes {
1079 match node {
1080 Node::Decision(d) => {
1081 if let Some(sr) = d.sandbox_ref()
1082 && !self.sandboxes.contains_key(&sr.0)
1083 {
1084 errors.push(format!(
1085 "sandbox reference '{}' not found in sandboxes map",
1086 sr.0
1087 ));
1088 }
1089 }
1090 Node::Condition { children, .. } => {
1091 self.validate_nodes(children, errors);
1092 }
1093 }
1094 }
1095 }
1096
1097 pub fn platform_warnings(&self) -> Vec<String> {
1102 use crate::sandbox_types::{NetworkPolicy, PathMatch};
1103
1104 let mut warnings = Vec::new();
1105 for (name, sandbox) in &self.sandboxes {
1106 for rule in &sandbox.rules {
1107 if rule.path_match == PathMatch::Regex {
1108 warnings.push(format!(
1109 "sandbox '{}': regex path rule '{}' is not enforced on Linux \
1110 (Landlock cannot match regex paths)",
1111 name, rule.path,
1112 ));
1113 }
1114 }
1115 if let NetworkPolicy::AllowDomains(domains) = &sandbox.network {
1116 warnings.push(format!(
1117 "sandbox '{}': domain filtering ({}) is advisory on Linux \
1118 (relies on HTTP_PROXY which programs can bypass)",
1119 name,
1120 domains.join(", "),
1121 ));
1122 }
1123 }
1124 warnings
1125 }
1126}
1127
1128impl Node {
1133 pub fn compact(nodes: Vec<Node>) -> Vec<Node> {
1136 let mut out: Vec<Node> = Vec::new();
1137
1138 for node in nodes {
1140 match node {
1141 Node::Condition {
1142 observe,
1143 pattern,
1144 children,
1145 doc,
1146 source,
1147 terminal,
1148 } => {
1149 if let Some(existing) = out.iter_mut().find_map(|n| match n {
1150 Node::Condition {
1151 observe: o,
1152 pattern: p,
1153 children: c,
1154 doc: d,
1155 ..
1156 } if *o == observe && *p == pattern => Some((c, d)),
1157 _ => None,
1158 }) {
1159 existing.0.extend(children);
1160 if existing.1.is_none() {
1161 *existing.1 = doc;
1162 }
1163 } else {
1164 out.push(Node::Condition {
1165 observe,
1166 pattern,
1167 children,
1168 doc,
1169 source,
1170 terminal,
1171 });
1172 }
1173 }
1174 decision => out.push(decision),
1175 }
1176 }
1177
1178 for node in &mut out {
1180 if let Node::Condition { children, .. } = node {
1181 *children = Self::compact(std::mem::take(children));
1182 }
1183 }
1184
1185 out.sort_by(|a, b| Self::sort_key(b).cmp(&Self::sort_key(a)));
1188
1189 out
1190 }
1191
1192 fn sort_key(node: &Node) -> (u8, u8, u8) {
1195 match node {
1196 Node::Condition {
1197 observe, pattern, ..
1198 } => (1, pattern.specificity(), observe.specificity()),
1199 Node::Decision(_) => (0, 0, 0),
1200 }
1201 }
1202}
1203
1204pub fn detect_unreachable(nodes: &[Node]) -> Vec<String> {
1206 let mut warnings = Vec::new();
1207 detect_unreachable_inner(nodes, &mut warnings, &[]);
1208 warnings
1209}
1210
1211fn detect_unreachable_inner(nodes: &[Node], warnings: &mut Vec<String>, path: &[String]) {
1212 let mut seen_wildcard = false;
1213 for node in nodes {
1214 match node {
1215 Node::Condition {
1216 observe,
1217 pattern,
1218 children,
1219 ..
1220 } => {
1221 if seen_wildcard {
1222 warnings.push(format!(
1223 "unreachable branch at {:?}: {:?} after wildcard",
1224 path, observe
1225 ));
1226 }
1227 if matches!(pattern, Pattern::Wildcard) {
1228 seen_wildcard = true;
1229 }
1230 let mut child_path = path.to_vec();
1231 child_path.push(format!("{observe:?}"));
1232 detect_unreachable_inner(children, warnings, &child_path);
1233 }
1234 Node::Decision(_) => {
1235 if seen_wildcard {
1236 warnings.push(format!(
1237 "unreachable decision at {:?}: decision after wildcard",
1238 path
1239 ));
1240 }
1241 }
1242 }
1243 }
1244}
1245
1246#[cfg(test)]
1251mod tests {
1252 use super::*;
1253
1254 fn make_ctx(tool: &str, command: &str) -> QueryContext {
1255 let input = if tool == "Bash" {
1256 serde_json::json!({"command": command})
1257 } else {
1258 serde_json::json!({})
1259 };
1260 QueryContext::from_tool(tool, &input)
1261 }
1262
1263 #[test]
1264 fn simple_decision() {
1265 let nodes = vec![Node::Decision(Decision::Allow(None))];
1266 let ctx = make_ctx("Bash", "echo hello");
1267 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1268 }
1269
1270 #[test]
1271 fn tool_name_match() {
1272 let nodes = vec![Node::Condition {
1273 observe: Observable::ToolName,
1274 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1275 children: vec![Node::Decision(Decision::Allow(None))],
1276 doc: None,
1277 source: None,
1278 terminal: false,
1279 }];
1280 let ctx = make_ctx("Bash", "echo hello");
1281 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1282 }
1283
1284 #[test]
1285 fn tool_name_mismatch() {
1286 let nodes = vec![Node::Condition {
1287 observe: Observable::ToolName,
1288 pattern: Pattern::Literal(Value::Literal("Read".into())),
1289 children: vec![Node::Decision(Decision::Allow(None))],
1290 doc: None,
1291 source: None,
1292 terminal: false,
1293 }];
1294 let ctx = make_ctx("Bash", "echo hello");
1295 assert_eq!(eval(&nodes, &ctx), None);
1296 }
1297
1298 #[test]
1299 fn terminal_exact_match() {
1300 let nodes = vec![Node::Condition {
1302 observe: Observable::ToolName,
1303 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1304 children: vec![Node::Condition {
1305 observe: Observable::PositionalArg(0),
1306 pattern: Pattern::Literal(Value::Literal("git".into())),
1307 children: vec![Node::Condition {
1308 observe: Observable::PositionalArg(1),
1309 pattern: Pattern::Literal(Value::Literal("commit".into())),
1310 children: vec![Node::Decision(Decision::Deny)],
1311 doc: None,
1312 source: None,
1313 terminal: true,
1314 }],
1315 doc: None,
1316 source: None,
1317 terminal: false,
1318 }],
1319 doc: None,
1320 source: None,
1321 terminal: false,
1322 }];
1323
1324 let ctx = make_ctx("Bash", "git commit");
1326 assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny));
1327
1328 let ctx = make_ctx("Bash", "git commit --amend");
1330 assert_eq!(eval(&nodes, &ctx), None);
1331
1332 let ctx = make_ctx("Bash", "git push");
1334 assert_eq!(eval(&nodes, &ctx), None);
1335 }
1336
1337 #[test]
1338 fn terminal_single_binary() {
1339 let nodes = vec![Node::Condition {
1341 observe: Observable::PositionalArg(0),
1342 pattern: Pattern::Literal(Value::Literal("ls".into())),
1343 children: vec![Node::Decision(Decision::Allow(None))],
1344 doc: None,
1345 source: None,
1346 terminal: true,
1347 }];
1348
1349 let ctx = make_ctx("Bash", "ls");
1350 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1351
1352 let ctx = make_ctx("Bash", "ls -la");
1353 assert_eq!(eval(&nodes, &ctx), None);
1354 }
1355
1356 #[test]
1357 fn non_terminal_allows_extra_args() {
1358 let nodes = vec![Node::Condition {
1360 observe: Observable::PositionalArg(0),
1361 pattern: Pattern::Literal(Value::Literal("git".into())),
1362 children: vec![Node::Condition {
1363 observe: Observable::PositionalArg(1),
1364 pattern: Pattern::Literal(Value::Literal("commit".into())),
1365 children: vec![Node::Decision(Decision::Allow(None))],
1366 doc: None,
1367 source: None,
1368 terminal: false,
1369 }],
1370 doc: None,
1371 source: None,
1372 terminal: false,
1373 }];
1374
1375 let ctx = make_ctx("Bash", "git commit --amend");
1376 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1377 }
1378
1379 #[test]
1380 fn positional_arg_match() {
1381 let nodes = vec![Node::Condition {
1382 observe: Observable::ToolName,
1383 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1384 children: vec![Node::Condition {
1385 observe: Observable::PositionalArg(0),
1386 pattern: Pattern::Literal(Value::Literal("git".into())),
1387 children: vec![Node::Decision(Decision::Allow(None))],
1388 doc: None,
1389 source: None,
1390 terminal: false,
1391 }],
1392 doc: None,
1393 source: None,
1394 terminal: false,
1395 }];
1396 let ctx = make_ctx("Bash", "git push");
1397 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1398 }
1399
1400 #[test]
1401 fn has_arg_match() {
1402 let nodes = vec![Node::Condition {
1403 observe: Observable::ToolName,
1404 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1405 children: vec![Node::Condition {
1406 observe: Observable::HasArg,
1407 pattern: Pattern::Literal(Value::Literal("--force".into())),
1408 children: vec![Node::Decision(Decision::Deny)],
1409 doc: None,
1410 source: None,
1411 terminal: false,
1412 }],
1413 doc: None,
1414 source: None,
1415 terminal: false,
1416 }];
1417 let ctx = make_ctx("Bash", "git push --force origin main");
1418 assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny));
1419 }
1420
1421 #[test]
1422 fn has_arg_no_match() {
1423 let nodes = vec![Node::Condition {
1424 observe: Observable::ToolName,
1425 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1426 children: vec![Node::Condition {
1427 observe: Observable::HasArg,
1428 pattern: Pattern::Literal(Value::Literal("--force".into())),
1429 children: vec![Node::Decision(Decision::Deny)],
1430 doc: None,
1431 source: None,
1432 terminal: false,
1433 }],
1434 doc: None,
1435 source: None,
1436 terminal: false,
1437 }];
1438 let ctx = make_ctx("Bash", "git push origin main");
1439 assert_eq!(eval(&nodes, &ctx), None);
1440 }
1441
1442 #[test]
1443 fn specificity_ordering() {
1444 let nodes = vec![
1446 Node::Condition {
1447 observe: Observable::ToolName,
1448 pattern: Pattern::Wildcard,
1449 children: vec![Node::Decision(Decision::Ask(None))],
1450 doc: None,
1451 source: None,
1452 terminal: false,
1453 },
1454 Node::Condition {
1455 observe: Observable::ToolName,
1456 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1457 children: vec![Node::Decision(Decision::Allow(None))],
1458 doc: None,
1459 source: None,
1460 terminal: false,
1461 },
1462 ];
1463 let nodes = Node::compact(nodes);
1464 let ctx = make_ctx("Bash", "echo hello");
1466 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1467 }
1468
1469 #[test]
1470 fn backtracking() {
1471 let nodes = vec![
1474 Node::Condition {
1475 observe: Observable::ToolName,
1476 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1477 children: vec![Node::Condition {
1478 observe: Observable::PositionalArg(0),
1479 pattern: Pattern::Literal(Value::Literal("cargo".into())),
1480 children: vec![Node::Decision(Decision::Allow(None))],
1481 doc: None,
1482 source: None,
1483 terminal: false,
1484 }],
1485 doc: None,
1486 source: None,
1487 terminal: false,
1488 },
1489 Node::Condition {
1490 observe: Observable::ToolName,
1491 pattern: Pattern::Wildcard,
1492 children: vec![Node::Decision(Decision::Ask(None))],
1493 doc: None,
1494 source: None,
1495 terminal: false,
1496 },
1497 ];
1498 let ctx = make_ctx("Bash", "git push");
1500 assert_eq!(eval(&nodes, &ctx), Some(Decision::Ask(None)));
1501 }
1502
1503 #[test]
1504 fn nested_field_match() {
1505 let nodes = vec![Node::Condition {
1506 observe: Observable::NestedField(vec!["file_path".into()]),
1507 pattern: Pattern::Regex(Arc::new(Regex::new(r".*\.rs$").unwrap())),
1508 children: vec![Node::Decision(Decision::Allow(None))],
1509 doc: None,
1510 source: None,
1511 terminal: false,
1512 }];
1513 let input = serde_json::json!({"file_path": "/src/main.rs"});
1514 let ctx = QueryContext::from_tool("Edit", &input);
1515 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1516 }
1517
1518 #[test]
1519 fn regex_pattern() {
1520 let nodes = vec![Node::Condition {
1521 observe: Observable::PositionalArg(0),
1522 pattern: Pattern::Regex(Arc::new(Regex::new(r"^cargo").unwrap())),
1523 children: vec![Node::Decision(Decision::Allow(None))],
1524 doc: None,
1525 source: None,
1526 terminal: false,
1527 }];
1528 let ctx = make_ctx("Bash", "cargo-clippy check");
1529 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1530 }
1531
1532 #[test]
1533 fn any_of_pattern() {
1534 let nodes = vec![Node::Condition {
1535 observe: Observable::PositionalArg(0),
1536 pattern: Pattern::AnyOf(vec![
1537 Pattern::Literal(Value::Literal("cargo".into())),
1538 Pattern::Literal(Value::Literal("rustc".into())),
1539 ]),
1540 children: vec![Node::Decision(Decision::Allow(None))],
1541 doc: None,
1542 source: None,
1543 terminal: false,
1544 }];
1545
1546 let ctx = make_ctx("Bash", "rustc main.rs");
1547 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1548
1549 let ctx = make_ctx("Bash", "gcc main.c");
1550 assert_eq!(eval(&nodes, &ctx), None);
1551 }
1552
1553 #[test]
1554 fn not_pattern() {
1555 let nodes = vec![Node::Condition {
1556 observe: Observable::PositionalArg(0),
1557 pattern: Pattern::Not(Box::new(Pattern::Literal(Value::Literal("rm".into())))),
1558 children: vec![Node::Decision(Decision::Allow(None))],
1559 doc: None,
1560 source: None,
1561 terminal: false,
1562 }];
1563
1564 let ctx = make_ctx("Bash", "ls -la");
1565 assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1566
1567 let ctx = make_ctx("Bash", "rm -rf /");
1568 assert_eq!(eval(&nodes, &ctx), None);
1569 }
1570
1571 #[test]
1572 fn sandbox_ref_validation() {
1573 let policy = CompiledPolicy {
1574 sandboxes: HashMap::new(),
1575 tree: vec![Node::Decision(Decision::Allow(Some(SandboxRef(
1576 "missing".into(),
1577 ))))],
1578 default_effect: Effect::Deny,
1579 default_sandbox: None,
1580 on_sandbox_violation: Default::default(),
1581 harness_defaults: None,
1582 };
1583 let errors = policy.validate();
1584 assert_eq!(errors.len(), 1);
1585 assert!(errors[0].contains("missing"));
1586 }
1587
1588 #[test]
1589 fn sandbox_ref_valid() {
1590 let mut sandboxes = HashMap::new();
1591 sandboxes.insert(
1592 "cwd_access".to_string(),
1593 SandboxPolicy {
1594 default: crate::sandbox_types::Cap::READ,
1595 rules: vec![],
1596 network: crate::sandbox_types::NetworkPolicy::Deny,
1597 doc: None,
1598 },
1599 );
1600 let policy = CompiledPolicy {
1601 sandboxes,
1602 tree: vec![Node::Decision(Decision::Allow(Some(SandboxRef(
1603 "cwd_access".into(),
1604 ))))],
1605 default_effect: Effect::Deny,
1606 default_sandbox: None,
1607 on_sandbox_violation: Default::default(),
1608 harness_defaults: None,
1609 };
1610 assert!(policy.validate().is_empty());
1611 }
1612
1613 #[test]
1614 fn compiled_policy_evaluate() {
1615 let policy = CompiledPolicy {
1616 sandboxes: HashMap::new(),
1617 tree: vec![
1618 Node::Condition {
1619 observe: Observable::ToolName,
1620 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1621 children: vec![Node::Condition {
1622 observe: Observable::PositionalArg(0),
1623 pattern: Pattern::Literal(Value::Literal("git".into())),
1624 children: vec![
1625 Node::Condition {
1626 observe: Observable::HasArg,
1627 pattern: Pattern::Literal(Value::Literal("--force".into())),
1628 children: vec![Node::Decision(Decision::Deny)],
1629 doc: None,
1630 source: None,
1631 terminal: false,
1632 },
1633 Node::Decision(Decision::Allow(None)),
1634 ],
1635 doc: None,
1636 source: None,
1637 terminal: false,
1638 }],
1639 doc: None,
1640 source: None,
1641 terminal: false,
1642 },
1643 Node::Condition {
1644 observe: Observable::ToolName,
1645 pattern: Pattern::Wildcard,
1646 children: vec![Node::Decision(Decision::Allow(None))],
1647 doc: None,
1648 source: None,
1649 terminal: false,
1650 },
1651 ],
1652 default_effect: Effect::Deny,
1653 default_sandbox: None,
1654 on_sandbox_violation: Default::default(),
1655 harness_defaults: None,
1656 };
1657
1658 let d = policy.evaluate("Bash", &serde_json::json!({"command": "git push"}));
1660 assert_eq!(d.effect, Effect::Allow);
1661
1662 let d = policy.evaluate("Bash", &serde_json::json!({"command": "git push --force"}));
1664 assert_eq!(d.effect, Effect::Deny);
1665
1666 let d = policy.evaluate("Read", &serde_json::json!({}));
1668 assert_eq!(d.effect, Effect::Allow);
1669 }
1670
1671 #[test]
1672 fn unreachable_branch_detection() {
1673 let nodes = vec![
1674 Node::Condition {
1675 observe: Observable::ToolName,
1676 pattern: Pattern::Wildcard,
1677 children: vec![Node::Decision(Decision::Allow(None))],
1678 doc: None,
1679 source: None,
1680 terminal: false,
1681 },
1682 Node::Condition {
1683 observe: Observable::ToolName,
1684 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1685 children: vec![Node::Decision(Decision::Deny)],
1686 doc: None,
1687 source: None,
1688 terminal: false,
1689 },
1690 ];
1691 let warnings = detect_unreachable(&nodes);
1692 assert!(!warnings.is_empty());
1693 }
1694
1695 #[test]
1696 fn value_env_resolve() {
1697 unsafe { std::env::set_var("MATCH_TREE_TEST_VAR", "test_value") };
1699 let v = Value::Env("MATCH_TREE_TEST_VAR".into());
1700 assert_eq!(v.resolve(), "test_value");
1701 unsafe { std::env::remove_var("MATCH_TREE_TEST_VAR") };
1702 }
1703
1704 #[test]
1705 fn value_path_resolve() {
1706 let v = Value::Path(vec![
1707 Value::Literal("/home".into()),
1708 Value::Literal("user".into()),
1709 Value::Literal(".ssh".into()),
1710 ]);
1711 assert_eq!(v.resolve(), "/home/user/.ssh");
1712 }
1713
1714 #[test]
1715 fn eval_trace_collection() {
1716 let nodes = vec![
1717 Node::Condition {
1718 observe: Observable::ToolName,
1719 pattern: Pattern::Literal(Value::Literal("Read".into())),
1720 children: vec![Node::Decision(Decision::Allow(None))],
1721 doc: None,
1722 source: None,
1723 terminal: false,
1724 },
1725 Node::Condition {
1726 observe: Observable::ToolName,
1727 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1728 children: vec![Node::Decision(Decision::Deny)],
1729 doc: None,
1730 source: None,
1731 terminal: false,
1732 },
1733 ];
1734
1735 let ctx = make_ctx("Bash", "echo hello");
1736 let mut trace = EvalTrace::default();
1737 let mut path = Vec::new();
1738 let result = eval_traced(&nodes, &ctx, &mut trace, &mut path);
1739
1740 assert_eq!(result, Some(Decision::Deny));
1741 assert_eq!(trace.skipped.len(), 1); assert_eq!(trace.matched.len(), 1); }
1744
1745 #[test]
1746 fn pattern_specificity_order() {
1747 assert!(
1748 Pattern::Literal(Value::Literal("x".into())).specificity()
1749 > Pattern::Regex(Arc::new(Regex::new("x").unwrap())).specificity()
1750 );
1751 assert!(
1752 Pattern::Regex(Arc::new(Regex::new("x").unwrap())).specificity()
1753 > Pattern::Wildcard.specificity()
1754 );
1755 assert!(Pattern::AnyOf(vec![]).specificity() > Pattern::Wildcard.specificity());
1756 }
1757
1758 #[test]
1759 fn named_arg_match() {
1760 let nodes = vec![Node::Condition {
1761 observe: Observable::NamedArg("file_path".into()),
1762 pattern: Pattern::Regex(Arc::new(Regex::new(r"\.env").unwrap())),
1763 children: vec![Node::Decision(Decision::Deny)],
1764 doc: None,
1765 source: None,
1766 terminal: false,
1767 }];
1768 let input = serde_json::json!({"file_path": "/project/.env"});
1769 let ctx = QueryContext::from_tool("Write", &input);
1770 assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny));
1771 }
1772
1773 #[test]
1774 fn env_var_prefix_stripped_in_bash() {
1775 let policy = CompiledPolicy {
1777 sandboxes: HashMap::new(),
1778 tree: vec![Node::Condition {
1779 observe: Observable::ToolName,
1780 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1781 children: vec![Node::Condition {
1782 observe: Observable::PositionalArg(0),
1783 pattern: Pattern::Literal(Value::Literal("cargo".into())),
1784 children: vec![Node::Decision(Decision::Allow(None))],
1785 doc: None,
1786 source: None,
1787 terminal: false,
1788 }],
1789 doc: None,
1790 source: None,
1791 terminal: false,
1792 }],
1793 default_effect: Effect::Deny,
1794 default_sandbox: None,
1795 on_sandbox_violation: Default::default(),
1796 harness_defaults: None,
1797 };
1798
1799 let input = serde_json::json!({"command": "cargo check"});
1801 let ctx = QueryContext::from_tool("Bash", &input);
1802 assert_eq!(ctx.args[0], "cargo");
1803 let result = policy.evaluate_ctx(&ctx);
1804 assert_eq!(result.effect, Effect::Allow);
1805
1806 let input2 = serde_json::json!({"command": "SOME_ENV=foo cargo check"});
1808 let ctx2 = QueryContext::from_tool("Bash", &input2);
1809 assert_eq!(ctx2.args[0], "cargo", "env var prefix should be stripped");
1810 let result2 = policy.evaluate_ctx(&ctx2);
1811 assert_eq!(result2.effect, Effect::Allow);
1812
1813 let input3 = serde_json::json!({"command": "A=1 B=2 cargo build"});
1815 let ctx3 = QueryContext::from_tool("Bash", &input3);
1816 assert_eq!(ctx3.args[0], "cargo");
1817
1818 let input4 = serde_json::json!({"command": "env RUST_BACKTRACE=1 cargo test"});
1820 let ctx4 = QueryContext::from_tool("Bash", &input4);
1821 assert_eq!(ctx4.args[0], "cargo");
1822 }
1823
1824 #[test]
1825 fn serde_roundtrip() {
1826 let policy = CompiledPolicy {
1827 sandboxes: HashMap::new(),
1828 tree: vec![Node::Condition {
1829 observe: Observable::ToolName,
1830 pattern: Pattern::Literal(Value::Literal("Bash".into())),
1831 children: vec![Node::Decision(Decision::Allow(None))],
1832 doc: None,
1833 source: None,
1834 terminal: false,
1835 }],
1836 default_effect: Effect::Deny,
1837 default_sandbox: None,
1838 on_sandbox_violation: Default::default(),
1839 harness_defaults: None,
1840 };
1841 let json = serde_json::to_string_pretty(&policy).unwrap();
1842 let deserialized: CompiledPolicy = serde_json::from_str(&json).unwrap();
1843 assert_eq!(deserialized.tree.len(), 1);
1844 assert_eq!(deserialized.default_effect, Effect::Deny);
1845 }
1846
1847 #[test]
1852 fn resolve_relative_path_absolute_unchanged() {
1853 let result = resolve_relative_path("/usr/bin/ls");
1854 assert_eq!(result, "/usr/bin/ls");
1855 }
1856
1857 #[test]
1858 fn resolve_relative_path_prepends_pwd() {
1859 let saved = std::env::var("PWD").ok();
1861 unsafe { std::env::set_var("PWD", "/home/user/project") };
1862 let result = resolve_relative_path("src/main.rs");
1863 assert_eq!(result, "/home/user/project/src/main.rs");
1864 match saved {
1865 Some(v) => unsafe { std::env::set_var("PWD", v) },
1866 None => unsafe { std::env::remove_var("PWD") },
1867 }
1868 }
1869
1870 #[test]
1871 fn resolve_relative_path_empty() {
1872 let saved = std::env::var("PWD").ok();
1873 unsafe { std::env::set_var("PWD", "/home/user") };
1874 let result = resolve_relative_path("");
1875 assert_eq!(result, "/home/user/");
1877 match saved {
1878 Some(v) => unsafe { std::env::set_var("PWD", v) },
1879 None => unsafe { std::env::remove_var("PWD") },
1880 }
1881 }
1882
1883 #[test]
1884 fn resolve_relative_path_no_leading_slash() {
1885 let saved = std::env::var("PWD").ok();
1886 unsafe { std::env::set_var("PWD", "/workspace") };
1887 let result = resolve_relative_path("foo/bar.txt");
1888 assert_eq!(result, "/workspace/foo/bar.txt");
1889 match saved {
1890 Some(v) => unsafe { std::env::set_var("PWD", v) },
1891 None => unsafe { std::env::remove_var("PWD") },
1892 }
1893 }
1894
1895 #[test]
1900 fn extract_domain_https_url() {
1901 assert_eq!(
1902 extract_domain("https://example.com/path"),
1903 Some("example.com".into())
1904 );
1905 }
1906
1907 #[test]
1908 fn extract_domain_with_port() {
1909 assert_eq!(
1910 extract_domain("https://example.com:8080/path"),
1911 Some("example.com".into())
1912 );
1913 }
1914
1915 #[test]
1916 fn extract_domain_with_path() {
1917 assert_eq!(
1918 extract_domain("https://api.github.com/repos/owner/repo"),
1919 Some("api.github.com".into())
1920 );
1921 }
1922
1923 #[test]
1924 fn extract_domain_http_url() {
1925 assert_eq!(
1926 extract_domain("http://example.com"),
1927 Some("example.com".into())
1928 );
1929 }
1930
1931 #[test]
1932 fn extract_domain_without_scheme() {
1933 assert_eq!(
1934 extract_domain("example.com/path"),
1935 Some("example.com".into())
1936 );
1937 }
1938
1939 #[test]
1940 fn extract_domain_empty_input() {
1941 assert_eq!(extract_domain(""), None);
1942 }
1943
1944 #[test]
1945 fn extract_domain_scheme_only() {
1946 assert_eq!(extract_domain("https://"), None);
1947 }
1948
1949 #[test]
1954 fn value_literal_resolve() {
1955 let v = Value::Literal("hello".into());
1956 assert_eq!(v.resolve(), "hello");
1957 }
1958
1959 #[test]
1960 fn value_env_resolve_missing() {
1961 let v = Value::Env("NONEXISTENT_CLASH_TEST_VAR_XYZ".into());
1962 assert_eq!(v.resolve(), "");
1963 }
1964
1965 #[test]
1966 fn value_path_resolve_with_env() {
1967 unsafe { std::env::set_var("CLASH_TEST_HOME", "/home/testuser") };
1968 let v = Value::Path(vec![
1969 Value::Env("CLASH_TEST_HOME".into()),
1970 Value::Literal("projects".into()),
1971 ]);
1972 assert_eq!(v.resolve(), "/home/testuser/projects");
1973 unsafe { std::env::remove_var("CLASH_TEST_HOME") };
1974 }
1975
1976 #[test]
1981 fn pattern_prefix_exact_match() {
1982 let pat = Pattern::Prefix(Value::Literal("/home/user".into()));
1983 assert!(pat.matches("/home/user"));
1984 }
1985
1986 #[test]
1987 fn pattern_prefix_child_path() {
1988 let pat = Pattern::Prefix(Value::Literal("/home/user".into()));
1989 assert!(pat.matches("/home/user/documents/file.txt"));
1990 }
1991
1992 #[test]
1993 fn pattern_prefix_non_child_similar() {
1994 let pat = Pattern::Prefix(Value::Literal("/home/user".into()));
1996 assert!(!pat.matches("/home/username"));
1997 }
1998
1999 #[test]
2000 fn pattern_prefix_empty_matches_nothing() {
2001 let pat = Pattern::Prefix(Value::Env("CLASH_SURELY_UNSET_VAR_XYZ".into()));
2003 assert!(!pat.matches("/any/path"));
2004 assert!(!pat.matches(""));
2005 }
2006
2007 #[test]
2008 fn pattern_literal_with_env() {
2009 unsafe { std::env::set_var("CLASH_TEST_TOOL", "Bash") };
2010 let pat = Pattern::Literal(Value::Env("CLASH_TEST_TOOL".into()));
2011 assert!(pat.matches("Bash"));
2012 assert!(!pat.matches("Read"));
2013 unsafe { std::env::remove_var("CLASH_TEST_TOOL") };
2014 }
2015
2016 #[test]
2017 fn pattern_wildcard_matches_anything() {
2018 assert!(Pattern::Wildcard.matches(""));
2019 assert!(Pattern::Wildcard.matches("anything"));
2020 assert!(Pattern::Wildcard.matches("/some/path"));
2021 }
2022
2023 #[test]
2024 fn pattern_any_of_matches_any() {
2025 let pat = Pattern::AnyOf(vec![
2026 Pattern::Literal(Value::Literal("cat".into())),
2027 Pattern::Literal(Value::Literal("dog".into())),
2028 ]);
2029 assert!(pat.matches("cat"));
2030 assert!(pat.matches("dog"));
2031 assert!(!pat.matches("fish"));
2032 }
2033
2034 #[test]
2035 fn pattern_not_inverts() {
2036 let pat = Pattern::Not(Box::new(Pattern::Literal(Value::Literal("rm".into()))));
2037 assert!(pat.matches("ls"));
2038 assert!(!pat.matches("rm"));
2039 }
2040
2041 #[test]
2046 fn from_tool_bash_parses_args() {
2047 let input = serde_json::json!({"command": "git push origin main"});
2048 let ctx = QueryContext::from_tool("Bash", &input);
2049 assert_eq!(ctx.tool_name, "Bash");
2050 assert_eq!(ctx.args[0], "git");
2051 assert!(ctx.args.contains(&"push".to_string()));
2052 assert!(ctx.fs_op.is_none());
2053 assert!(ctx.net_domain.is_none());
2054 }
2055
2056 #[test]
2057 fn from_tool_read_extracts_path() {
2058 let input = serde_json::json!({"file_path": "/src/main.rs"});
2059 let ctx = QueryContext::from_tool("Read", &input);
2060 assert_eq!(ctx.tool_name, "Read");
2061 assert_eq!(ctx.fs_op.as_deref(), Some("read"));
2062 assert_eq!(ctx.fs_path.as_deref(), Some("/src/main.rs"));
2063 assert!(ctx.args.is_empty());
2064 }
2065
2066 #[test]
2067 fn from_tool_write_extracts_path() {
2068 let input = serde_json::json!({"file_path": "/tmp/output.txt"});
2069 let ctx = QueryContext::from_tool("Write", &input);
2070 assert_eq!(ctx.fs_op.as_deref(), Some("write"));
2071 assert_eq!(ctx.fs_path.as_deref(), Some("/tmp/output.txt"));
2072 }
2073
2074 #[test]
2075 fn from_tool_edit_extracts_path() {
2076 let input = serde_json::json!({"file_path": "/project/lib.rs"});
2077 let ctx = QueryContext::from_tool("Edit", &input);
2078 assert_eq!(ctx.fs_op.as_deref(), Some("write"));
2079 assert_eq!(ctx.fs_path.as_deref(), Some("/project/lib.rs"));
2080 }
2081
2082 #[test]
2083 fn from_tool_glob_extracts_path() {
2084 let input = serde_json::json!({"path": "/src", "pattern": "*.rs"});
2085 let ctx = QueryContext::from_tool("Glob", &input);
2086 assert_eq!(ctx.fs_op.as_deref(), Some("read"));
2087 assert_eq!(ctx.fs_path.as_deref(), Some("/src"));
2088 }
2089
2090 #[test]
2091 fn from_tool_grep_falls_back_to_pattern() {
2092 let input = serde_json::json!({"pattern": "/some/path"});
2093 let ctx = QueryContext::from_tool("Grep", &input);
2094 assert_eq!(ctx.fs_op.as_deref(), Some("read"));
2095 assert_eq!(ctx.fs_path.as_deref(), Some("/some/path"));
2096 }
2097
2098 #[test]
2099 fn from_tool_webfetch_extracts_domain() {
2100 let input = serde_json::json!({"url": "https://api.github.com/repos"});
2101 let ctx = QueryContext::from_tool("WebFetch", &input);
2102 assert_eq!(ctx.net_domain.as_deref(), Some("api.github.com"));
2103 assert!(ctx.fs_op.is_none());
2104 }
2105
2106 #[test]
2107 fn from_tool_websearch_wildcard_domain() {
2108 let input = serde_json::json!({"query": "rust async"});
2109 let ctx = QueryContext::from_tool("WebSearch", &input);
2110 assert_eq!(ctx.net_domain.as_deref(), Some("*"));
2111 }
2112
2113 #[test]
2114 fn from_tool_unknown_empty() {
2115 let input = serde_json::json!({"some": "data"});
2116 let ctx = QueryContext::from_tool("UnknownTool", &input);
2117 assert_eq!(ctx.tool_name, "UnknownTool");
2118 assert!(ctx.args.is_empty());
2119 assert!(ctx.fs_op.is_none());
2120 assert!(ctx.fs_path.is_none());
2121 assert!(ctx.net_domain.is_none());
2122 }
2123
2124 #[test]
2129 fn platform_warnings_regex_path() {
2130 use crate::sandbox_types::*;
2131
2132 let policy = CompiledPolicy {
2133 sandboxes: HashMap::from([(
2134 "dev".to_string(),
2135 SandboxPolicy {
2136 default: Cap::READ | Cap::EXECUTE,
2137 rules: vec![SandboxRule {
2138 effect: RuleEffect::Allow,
2139 caps: Cap::WRITE,
2140 path: r"/tmp/build-\d+".to_string(),
2141 path_match: PathMatch::Regex,
2142 follow_worktrees: false,
2143 doc: None,
2144 }],
2145 network: NetworkPolicy::Deny,
2146 doc: None,
2147 },
2148 )]),
2149 tree: vec![],
2150 default_effect: Effect::Deny,
2151 default_sandbox: None,
2152 on_sandbox_violation: Default::default(),
2153 harness_defaults: None,
2154 };
2155 let warnings = policy.platform_warnings();
2156 assert_eq!(warnings.len(), 1);
2157 assert!(warnings[0].contains("regex"));
2158 assert!(warnings[0].contains("Linux"));
2159 }
2160
2161 #[test]
2162 fn platform_warnings_allow_domains() {
2163 use crate::sandbox_types::*;
2164
2165 let policy = CompiledPolicy {
2166 sandboxes: HashMap::from([(
2167 "net".to_string(),
2168 SandboxPolicy {
2169 default: Cap::READ | Cap::EXECUTE,
2170 rules: vec![],
2171 network: NetworkPolicy::AllowDomains(vec!["github.com".to_string()]),
2172 doc: None,
2173 },
2174 )]),
2175 tree: vec![],
2176 default_effect: Effect::Deny,
2177 default_sandbox: None,
2178 on_sandbox_violation: Default::default(),
2179 harness_defaults: None,
2180 };
2181 let warnings = policy.platform_warnings();
2182 assert_eq!(warnings.len(), 1);
2183 assert!(warnings[0].contains("advisory"));
2184 assert!(warnings[0].contains("Linux"));
2185 }
2186
2187 #[test]
2188 fn platform_warnings_none_for_clean_policy() {
2189 use crate::sandbox_types::*;
2190
2191 let policy = CompiledPolicy {
2192 sandboxes: HashMap::from([(
2193 "dev".to_string(),
2194 SandboxPolicy {
2195 default: Cap::READ | Cap::EXECUTE,
2196 rules: vec![SandboxRule {
2197 effect: RuleEffect::Allow,
2198 caps: Cap::WRITE,
2199 path: "/tmp".to_string(),
2200 path_match: PathMatch::Subpath,
2201 follow_worktrees: false,
2202 doc: None,
2203 }],
2204 network: NetworkPolicy::Deny,
2205 doc: None,
2206 },
2207 )]),
2208 tree: vec![],
2209 default_effect: Effect::Deny,
2210 default_sandbox: None,
2211 on_sandbox_violation: Default::default(),
2212 harness_defaults: None,
2213 };
2214 assert!(policy.platform_warnings().is_empty());
2215 }
2216
2217 fn tool_name_matches(pattern_str: &str, context_tool: &str) -> bool {
2223 let pattern = Pattern::Literal(Value::Literal(pattern_str.to_string()));
2224 let ctx = QueryContext::from_tool(context_tool, &serde_json::json!({}));
2225 matches_observable(&Observable::ToolName, &pattern, false, &ctx)
2226 }
2227
2228 #[test]
2229 fn canonical_shell_matches_bash() {
2230 assert!(tool_name_matches("shell", "Bash"));
2231 }
2232
2233 #[test]
2234 fn canonical_read_matches_read() {
2235 assert!(tool_name_matches("read", "Read"));
2236 }
2237
2238 #[test]
2239 fn canonical_write_matches_write() {
2240 assert!(tool_name_matches("write", "Write"));
2241 }
2242
2243 #[test]
2244 fn canonical_edit_matches_edit() {
2245 assert!(tool_name_matches("edit", "Edit"));
2246 }
2247
2248 #[test]
2249 fn canonical_web_fetch_matches_webfetch() {
2250 assert!(tool_name_matches("web_fetch", "WebFetch"));
2251 }
2252
2253 #[test]
2254 fn canonical_web_search_matches_websearch() {
2255 assert!(tool_name_matches("web_search", "WebSearch"));
2256 }
2257
2258 #[test]
2259 fn case_insensitive_bash_matches() {
2260 assert!(tool_name_matches("bash", "Bash"));
2261 assert!(tool_name_matches("BASH", "Bash"));
2262 assert!(tool_name_matches("Bash", "Bash"));
2263 }
2264
2265 #[test]
2266 fn case_insensitive_shell_matches() {
2267 assert!(tool_name_matches("Shell", "Bash"));
2268 assert!(tool_name_matches("SHELL", "Bash"));
2269 }
2270
2271 #[test]
2272 fn internal_name_still_matches() {
2273 assert!(tool_name_matches("Bash", "Bash"));
2275 assert!(tool_name_matches("Read", "Read"));
2276 assert!(tool_name_matches("WebFetch", "WebFetch"));
2277 }
2278
2279 #[test]
2280 fn unknown_tool_does_not_match() {
2281 assert!(!tool_name_matches("shell", "Read"));
2282 assert!(!tool_name_matches("run_shell_command", "Bash"));
2283 }
2284
2285 #[test]
2286 fn wildcard_matches_any_tool() {
2287 let pattern = Pattern::Wildcard;
2288 let ctx = QueryContext::from_tool("Bash", &serde_json::json!({}));
2289 assert!(matches_observable(
2290 &Observable::ToolName,
2291 &pattern,
2292 false,
2293 &ctx
2294 ));
2295 }
2296
2297 #[test]
2302 fn compiled_policy_harness_defaults_field() {
2303 let json = r#"{
2304 "schema_version": 5,
2305 "default_effect": "ask",
2306 "sandboxes": {},
2307 "tree": [],
2308 "harness_defaults": false
2309 }"#;
2310 let policy: CompiledPolicy = serde_json::from_str(json).unwrap();
2311 assert_eq!(policy.harness_defaults, Some(false));
2312 }
2313
2314 #[test]
2315 fn count_harness_nodes() {
2316 let policy = CompiledPolicy {
2317 sandboxes: HashMap::new(),
2318 tree: vec![
2319 Node::Condition {
2320 observe: Observable::FsOp,
2321 pattern: Pattern::Literal(Value::Literal("read".to_string())),
2322 children: vec![Node::Decision(Decision::Allow(None))],
2323 doc: None,
2324 source: Some("harness".to_string()),
2325 terminal: false,
2326 },
2327 Node::Condition {
2328 observe: Observable::ToolName,
2329 pattern: Pattern::Literal(Value::Literal("Bash".to_string())),
2330 children: vec![Node::Decision(Decision::Allow(None))],
2331 doc: None,
2332 source: Some("~/.clash/policy.star".to_string()),
2333 terminal: false,
2334 },
2335 ],
2336 default_effect: Effect::Ask,
2337 default_sandbox: None,
2338 on_sandbox_violation: ViolationAction::default(),
2339 harness_defaults: None,
2340 };
2341 assert_eq!(policy.harness_node_count(), 1);
2342 assert_eq!(policy.tree_without_harness().len(), 1);
2343 }
2344
2345 #[test]
2346 fn compiled_policy_harness_defaults_absent() {
2347 let json = r#"{
2348 "schema_version": 5,
2349 "default_effect": "ask",
2350 "sandboxes": {},
2351 "tree": []
2352 }"#;
2353 let policy: CompiledPolicy = serde_json::from_str(json).unwrap();
2354 assert_eq!(policy.harness_defaults, None);
2355 }
2356}