Compare commits

...

1 Commits

Author SHA1 Message Date
viyatb-oai
6fcb37e01b feat(execpolicy): add network_rule parsing and persistence 2026-01-29 10:03:21 -08:00
6 changed files with 192 additions and 4 deletions

View File

@@ -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)

View File

@@ -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;

View File

@@ -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<String, RuleRef>,
network_rules: Vec<NetworkRule>,
}
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<NoneType> {
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)
}
}

View File

@@ -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<String, RuleRef>,
network_rules: Vec<NetworkRule>,
}
impl Policy {
pub fn new(rules_by_program: MultiMap<String, RuleRef>) -> Self {
Self { rules_by_program }
pub fn new(
rules_by_program: MultiMap<String, RuleRef>,
network_rules: Vec<NetworkRule>,
) -> 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<String, RuleRef> {
&self.rules_by_program
}
pub fn network_rules(&self) -> &[NetworkRule] {
&self.network_rules
}
pub fn get_allowed_prefixes(&self) -> Vec<Vec<String>> {
let mut prefixes = Vec::new();

View File

@@ -92,6 +92,55 @@ pub struct PrefixRule {
pub justification: Option<String>,
}
#[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<Self> {
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<Self> {
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<String>,
}
pub trait Rule: Any + Debug + Send + Sync {
fn program(&self) -> &str;

View File

@@ -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#"