From 6fcb37e01bf667f417e9b40f6afc7eadb626408b Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Thu, 29 Jan 2026 10:03:21 -0800 Subject: [PATCH] feat(execpolicy): add network_rule parsing and persistence --- codex-rs/execpolicy/src/amend.rs | 53 ++++++++++++++++++++++++++++++ codex-rs/execpolicy/src/lib.rs | 4 +++ codex-rs/execpolicy/src/parser.rs | 42 ++++++++++++++++++++++- codex-rs/execpolicy/src/policy.rs | 18 ++++++++-- codex-rs/execpolicy/src/rule.rs | 49 +++++++++++++++++++++++++++ codex-rs/execpolicy/tests/basic.rs | 30 +++++++++++++++++ 6 files changed, 192 insertions(+), 4 deletions(-) diff --git a/codex-rs/execpolicy/src/amend.rs b/codex-rs/execpolicy/src/amend.rs index 9114d3a64f..c374c25624 100644 --- a/codex-rs/execpolicy/src/amend.rs +++ b/codex-rs/execpolicy/src/amend.rs @@ -22,6 +22,12 @@ pub enum AmendError { }, #[error("failed to format prefix tokens: {source}")] SerializePrefix { source: serde_json::Error }, + #[error("network rule host cannot be empty")] + EmptyNetworkHost, + #[error("network rule protocol must be http or https")] + InvalidNetworkProtocol, + #[error("network rule decision must be allow, deny, or ask")] + InvalidNetworkDecision, #[error("failed to open policy file {path}: {source}")] OpenPolicyFile { path: PathBuf, @@ -90,6 +96,53 @@ pub fn blocking_append_allow_prefix_rule( append_locked_line(policy_path, &rule) } +/// Append a `network_rule(...)` line to the policy file. +pub fn blocking_append_network_rule( + policy_path: &Path, + host: &str, + protocol: &str, + decision: &str, + justification: Option<&str>, +) -> Result<(), AmendError> { + let host = host.trim(); + if host.is_empty() { + return Err(AmendError::EmptyNetworkHost); + } + if !matches!(protocol, "http" | "https") { + return Err(AmendError::InvalidNetworkProtocol); + } + if !matches!(decision, "allow" | "deny" | "ask") { + return Err(AmendError::InvalidNetworkDecision); + } + let host = + serde_json::to_string(host).map_err(|source| AmendError::SerializePrefix { source })?; + let mut rule = + format!(r#"network_rule(host={host}, protocol="{protocol}", decision="{decision}""#); + if let Some(justification) = justification { + let justification = serde_json::to_string(justification) + .map_err(|source| AmendError::SerializePrefix { source })?; + rule.push_str(&format!(", justification={justification}")); + } + rule.push(')'); + + let dir = policy_path + .parent() + .ok_or_else(|| AmendError::MissingParent { + path: policy_path.to_path_buf(), + })?; + match std::fs::create_dir(dir) { + Ok(()) => {} + Err(ref source) if source.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(source) => { + return Err(AmendError::CreatePolicyDir { + dir: dir.to_path_buf(), + source, + }); + } + } + append_locked_line(policy_path, &rule) +} + fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> { let mut file = OpenOptions::new() .create(true) diff --git a/codex-rs/execpolicy/src/lib.rs b/codex-rs/execpolicy/src/lib.rs index a9453f28d3..2da6502ab6 100644 --- a/codex-rs/execpolicy/src/lib.rs +++ b/codex-rs/execpolicy/src/lib.rs @@ -8,6 +8,7 @@ pub mod rule; pub use amend::AmendError; pub use amend::blocking_append_allow_prefix_rule; +pub use amend::blocking_append_network_rule; pub use decision::Decision; pub use error::Error; pub use error::ErrorLocation; @@ -18,6 +19,9 @@ pub use execpolicycheck::ExecPolicyCheckCommand; pub use parser::PolicyParser; pub use policy::Evaluation; pub use policy::Policy; +pub use rule::NetworkRule; +pub use rule::NetworkRuleDecision; +pub use rule::NetworkRuleProtocol; pub use rule::Rule; pub use rule::RuleMatch; pub use rule::RuleRef; diff --git a/codex-rs/execpolicy/src/parser.rs b/codex-rs/execpolicy/src/parser.rs index 0ff0f4b34a..150951c9b9 100644 --- a/codex-rs/execpolicy/src/parser.rs +++ b/codex-rs/execpolicy/src/parser.rs @@ -18,6 +18,9 @@ use std::sync::Arc; use crate::decision::Decision; use crate::error::Error; use crate::error::Result; +use crate::rule::NetworkRule; +use crate::rule::NetworkRuleDecision; +use crate::rule::NetworkRuleProtocol; use crate::rule::PatternToken; use crate::rule::PrefixPattern; use crate::rule::PrefixRule; @@ -71,12 +74,14 @@ impl PolicyParser { #[derive(Debug, ProvidesStaticType)] struct PolicyBuilder { rules_by_program: MultiMap, + network_rules: Vec, } impl PolicyBuilder { fn new() -> Self { Self { rules_by_program: MultiMap::new(), + network_rules: Vec::new(), } } @@ -85,8 +90,12 @@ impl PolicyBuilder { .insert(rule.program().to_string(), rule); } + fn add_network_rule(&mut self, rule: NetworkRule) { + self.network_rules.push(rule); + } + fn build(self) -> crate::policy::Policy { - crate::policy::Policy::new(self.rules_by_program) + crate::policy::Policy::new(self.rules_by_program, self.network_rules) } } @@ -266,4 +275,35 @@ fn policy_builtins(builder: &mut GlobalsBuilder) { rules.into_iter().for_each(|rule| builder.add_rule(rule)); Ok(NoneType) } + + fn network_rule<'v>( + host: &'v str, + protocol: &'v str, + decision: &'v str, + justification: Option<&'v str>, + eval: &mut Evaluator<'v, '_, '_>, + ) -> anyhow::Result { + let host = host.trim(); + if host.is_empty() { + return Err(Error::InvalidRule("host cannot be empty".to_string()).into()); + } + + let justification = match justification { + Some(raw) if raw.trim().is_empty() => { + return Err(Error::InvalidRule("justification cannot be empty".to_string()).into()); + } + Some(raw) => Some(raw.to_string()), + None => None, + }; + + let rule = NetworkRule { + host: host.to_string(), + protocol: NetworkRuleProtocol::parse(protocol)?, + decision: NetworkRuleDecision::parse(decision)?, + justification, + }; + + policy_builder(eval).add_network_rule(rule); + Ok(NoneType) + } } diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index 0da0332d0b..c1be08f0df 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -1,6 +1,7 @@ use crate::decision::Decision; use crate::error::Error; use crate::error::Result; +use crate::rule::NetworkRule; use crate::rule::PatternToken; use crate::rule::PrefixPattern; use crate::rule::PrefixRule; @@ -16,21 +17,32 @@ type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>; #[derive(Clone, Debug)] pub struct Policy { rules_by_program: MultiMap, + network_rules: Vec, } impl Policy { - pub fn new(rules_by_program: MultiMap) -> Self { - Self { rules_by_program } + pub fn new( + rules_by_program: MultiMap, + network_rules: Vec, + ) -> Self { + Self { + rules_by_program, + network_rules, + } } pub fn empty() -> Self { - Self::new(MultiMap::new()) + Self::new(MultiMap::new(), Vec::new()) } pub fn rules(&self) -> &MultiMap { &self.rules_by_program } + pub fn network_rules(&self) -> &[NetworkRule] { + &self.network_rules + } + pub fn get_allowed_prefixes(&self) -> Vec> { let mut prefixes = Vec::new(); diff --git a/codex-rs/execpolicy/src/rule.rs b/codex-rs/execpolicy/src/rule.rs index b7c1a7cfde..a66975b520 100644 --- a/codex-rs/execpolicy/src/rule.rs +++ b/codex-rs/execpolicy/src/rule.rs @@ -92,6 +92,55 @@ pub struct PrefixRule { pub justification: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NetworkRuleProtocol { + Http, + Https, +} + +impl NetworkRuleProtocol { + pub fn parse(raw: &str) -> Result { + match raw { + "http" => Ok(Self::Http), + "https" => Ok(Self::Https), + other => Err(Error::InvalidRule(format!( + "invalid network protocol: {other}" + ))), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NetworkRuleDecision { + Allow, + Deny, + Ask, +} + +impl NetworkRuleDecision { + pub fn parse(raw: &str) -> Result { + match raw { + "allow" => Ok(Self::Allow), + "deny" => Ok(Self::Deny), + "ask" => Ok(Self::Ask), + other => Err(Error::InvalidRule(format!( + "invalid network decision: {other}" + ))), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkRule { + pub host: String, + pub protocol: NetworkRuleProtocol, + pub decision: NetworkRuleDecision, + pub justification: Option, +} + pub trait Rule: Any + Debug + Send + Sync { fn program(&self) -> &str; diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs index ed6cf3185e..af6564487c 100644 --- a/codex-rs/execpolicy/tests/basic.rs +++ b/codex-rs/execpolicy/tests/basic.rs @@ -6,6 +6,9 @@ use anyhow::Result; use codex_execpolicy::Decision; use codex_execpolicy::Error; use codex_execpolicy::Evaluation; +use codex_execpolicy::NetworkRule; +use codex_execpolicy::NetworkRuleDecision; +use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; use codex_execpolicy::RuleMatch; @@ -72,6 +75,33 @@ prefix_rule( Ok(()) } +#[test] +fn parses_network_rule() -> Result<()> { + let policy_src = r#" +network_rule( + host = "api.example.com", + protocol = "https", + decision = "allow", + justification = "Allow API calls", +) + "#; + + let mut parser = PolicyParser::new(); + parser.parse("test.rules", policy_src)?; + let policy = parser.build(); + + assert_eq!( + policy.network_rules(), + &[NetworkRule { + host: "api.example.com".to_string(), + protocol: NetworkRuleProtocol::Https, + decision: NetworkRuleDecision::Allow, + justification: Some("Allow API calls".to_string()), + }] + ); + Ok(()) +} + #[test] fn justification_is_attached_to_forbidden_matches() -> Result<()> { let policy_src = r#"