feat(core): persist network approvals in execpolicy (#12357)

## Summary
Persist network approval allow/deny decisions as `network_rule(...)`
entries in execpolicy (not proxy config)

It adds `network_rule` parsing + append support in `codex-execpolicy`,
including `decision="prompt"` (parse-only; not compiled into proxy
allow/deny lists)
- compile execpolicy network rules into proxy allow/deny lists and
update the live proxy state on approval
- preserve requirements execpolicy `network_rule(...)` entries when
merging with file-based execpolicy
- reject broad wildcard hosts (for example `*`) for persisted
`network_rule(...)`
This commit is contained in:
viyatb-oai
2026-02-23 21:37:46 -08:00
committed by GitHub
parent af215eb390
commit c3048ff90a
31 changed files with 1617 additions and 13 deletions

View File

@@ -6,6 +6,9 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use crate::decision::Decision;
use crate::rule::NetworkRuleProtocol;
use crate::rule::normalize_network_rule_host;
use serde_json;
use thiserror::Error;
@@ -13,6 +16,8 @@ use thiserror::Error;
pub enum AmendError {
#[error("prefix rule requires at least one token")]
EmptyPrefix,
#[error("invalid network rule: {0}")]
InvalidNetworkRule(String),
#[error("policy path has no parent: {path}")]
MissingParent { path: PathBuf },
#[error("failed to create policy directory {dir}: {source}")]
@@ -22,6 +27,8 @@ pub enum AmendError {
},
#[error("failed to format prefix tokens: {source}")]
SerializePrefix { source: serde_json::Error },
#[error("failed to serialize network rule field: {source}")]
SerializeNetworkRule { source: serde_json::Error },
#[error("failed to open policy file {path}: {source}")]
OpenPolicyFile {
path: PathBuf,
@@ -71,7 +78,54 @@ pub fn blocking_append_allow_prefix_rule(
.map_err(|source| AmendError::SerializePrefix { source })?;
let pattern = format!("[{}]", tokens.join(", "));
let rule = format!(r#"prefix_rule(pattern={pattern}, decision="allow")"#);
append_rule_line(policy_path, &rule)
}
/// Note this function uses advisory file locking and performs blocking I/O, so it should be used
/// with [`tokio::task::spawn_blocking`] when called from an async context.
pub fn blocking_append_network_rule(
policy_path: &Path,
host: &str,
protocol: NetworkRuleProtocol,
decision: Decision,
justification: Option<&str>,
) -> Result<(), AmendError> {
let host = normalize_network_rule_host(host)
.map_err(|err| AmendError::InvalidNetworkRule(err.to_string()))?;
if let Some(raw) = justification
&& raw.trim().is_empty()
{
return Err(AmendError::InvalidNetworkRule(
"justification cannot be empty".to_string(),
));
}
let host = serde_json::to_string(&host)
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
let protocol = serde_json::to_string(protocol.as_policy_string())
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
let decision = serde_json::to_string(match decision {
Decision::Allow => "allow",
Decision::Prompt => "prompt",
Decision::Forbidden => "deny",
})
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
let mut args = vec![
format!("host={host}"),
format!("protocol={protocol}"),
format!("decision={decision}"),
];
if let Some(justification) = justification {
let justification = serde_json::to_string(justification)
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
args.push(format!("justification={justification}"));
}
let rule = format!("network_rule({})", args.join(", "));
append_rule_line(policy_path, &rule)
}
fn append_rule_line(policy_path: &Path, rule: &str) -> Result<(), AmendError> {
let dir = policy_path
.parent()
.ok_or_else(|| AmendError::MissingParent {
@@ -87,7 +141,8 @@ pub fn blocking_append_allow_prefix_rule(
});
}
}
append_locked_line(policy_path, &rule)
append_locked_line(policy_path, rule)
}
fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> {
@@ -215,4 +270,69 @@ prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
"#
);
}
#[test]
fn appends_network_rule() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("rules").join("default.rules");
blocking_append_network_rule(
&policy_path,
"Api.GitHub.com",
NetworkRuleProtocol::Https,
Decision::Allow,
Some("Allow https_connect access to api.github.com"),
)
.expect("append network rule");
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
assert_eq!(
contents,
r#"network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
"#
);
}
#[test]
fn appends_prefix_and_network_rules() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("rules").join("default.rules");
blocking_append_allow_prefix_rule(&policy_path, &[String::from("curl")])
.expect("append prefix rule");
blocking_append_network_rule(
&policy_path,
"api.github.com",
NetworkRuleProtocol::Https,
Decision::Allow,
Some("Allow https_connect access to api.github.com"),
)
.expect("append network rule");
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
assert_eq!(
contents,
r#"prefix_rule(pattern=["curl"], decision="allow")
network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
"#
);
}
#[test]
fn rejects_wildcard_network_rule_host() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("rules").join("default.rules");
let err = blocking_append_network_rule(
&policy_path,
"*.example.com",
NetworkRuleProtocol::Https,
Decision::Allow,
None,
)
.expect_err("wildcards should be rejected");
assert_eq!(
err.to_string(),
"invalid network rule: invalid rule: network_rule host must be a specific host; wildcards are not allowed"
);
}
}

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,7 @@ pub use execpolicycheck::ExecPolicyCheckCommand;
pub use parser::PolicyParser;
pub use policy::Evaluation;
pub use policy::Policy;
pub use rule::NetworkRuleProtocol;
pub use rule::Rule;
pub use rule::RuleMatch;
pub use rule::RuleRef;

View File

@@ -18,6 +18,8 @@ use std::sync::Arc;
use crate::decision::Decision;
use crate::error::Error;
use crate::error::Result;
use crate::rule::NetworkRule;
use crate::rule::NetworkRuleProtocol;
use crate::rule::PatternToken;
use crate::rule::PrefixPattern;
use crate::rule::PrefixRule;
@@ -71,12 +73,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 +89,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::from_parts(self.rules_by_program, self.network_rules)
}
}
@@ -142,6 +150,13 @@ fn parse_examples<'v>(examples: UnpackList<Value<'v>>) -> Result<Vec<Vec<String>
examples.items.into_iter().map(parse_example).collect()
}
fn parse_network_rule_decision(raw: &str) -> Result<Decision> {
match raw {
"deny" => Ok(Decision::Forbidden),
other => Decision::parse(other),
}
}
fn parse_example<'v>(value: Value<'v>) -> Result<Vec<String>> {
if let Some(raw) = value.unpack_str() {
parse_string_example(raw)
@@ -266,4 +281,31 @@ 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 protocol = NetworkRuleProtocol::parse(protocol)?;
let decision = parse_network_rule_decision(decision)?;
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 mut builder = policy_builder(eval);
builder.add_network_rule(NetworkRule {
host: crate::rule::normalize_network_rule_host(host)?,
protocol,
decision,
justification,
});
Ok(NoneType)
}
}

View File

@@ -1,11 +1,14 @@
use crate::decision::Decision;
use crate::error::Error;
use crate::error::Result;
use crate::rule::NetworkRule;
use crate::rule::NetworkRuleProtocol;
use crate::rule::PatternToken;
use crate::rule::PrefixPattern;
use crate::rule::PrefixRule;
use crate::rule::RuleMatch;
use crate::rule::RuleRef;
use crate::rule::normalize_network_rule_host;
use multimap::MultiMap;
use serde::Deserialize;
use serde::Serialize;
@@ -16,11 +19,22 @@ 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 }
Self::from_parts(rules_by_program, Vec::new())
}
pub fn from_parts(
rules_by_program: MultiMap<String, RuleRef>,
network_rules: Vec<NetworkRule>,
) -> Self {
Self {
rules_by_program,
network_rules,
}
}
pub fn empty() -> Self {
@@ -31,6 +45,10 @@ impl Policy {
&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();
@@ -77,6 +95,51 @@ impl Policy {
Ok(())
}
pub fn add_network_rule(
&mut self,
host: &str,
protocol: NetworkRuleProtocol,
decision: Decision,
justification: Option<String>,
) -> Result<()> {
let host = normalize_network_rule_host(host)?;
if let Some(raw) = justification.as_deref()
&& raw.trim().is_empty()
{
return Err(Error::InvalidRule(
"justification cannot be empty".to_string(),
));
}
self.network_rules.push(NetworkRule {
host,
protocol,
decision,
justification,
});
Ok(())
}
pub fn compiled_network_domains(&self) -> (Vec<String>, Vec<String>) {
let mut allowed = Vec::new();
let mut denied = Vec::new();
for rule in &self.network_rules {
match rule.decision {
Decision::Allow => {
denied.retain(|entry| entry != &rule.host);
upsert_domain(&mut allowed, &rule.host);
}
Decision::Forbidden => {
allowed.retain(|entry| entry != &rule.host);
upsert_domain(&mut denied, &rule.host);
}
Decision::Prompt => {}
}
}
(allowed, denied)
}
pub fn check<F>(&self, cmd: &[String], heuristics_fallback: &F) -> Evaluation
where
F: Fn(&[String]) -> Decision,
@@ -140,6 +203,11 @@ impl Policy {
}
}
fn upsert_domain(entries: &mut Vec<String>, host: &str) {
entries.retain(|entry| entry != host);
entries.push(host.to_string());
}
fn render_pattern_token(token: &PatternToken) -> String {
match token {
PatternToken::Single(value) => value.clone(),

View File

@@ -92,6 +92,103 @@ pub struct PrefixRule {
pub justification: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NetworkRuleProtocol {
Http,
Https,
Socks5Tcp,
Socks5Udp,
}
impl NetworkRuleProtocol {
pub fn parse(raw: &str) -> Result<Self> {
match raw {
"http" => Ok(Self::Http),
"https" | "https_connect" | "http-connect" => Ok(Self::Https),
"socks5_tcp" => Ok(Self::Socks5Tcp),
"socks5_udp" => Ok(Self::Socks5Udp),
other => Err(Error::InvalidRule(format!(
"network_rule protocol must be one of http, https, socks5_tcp, socks5_udp (got {other})"
))),
}
}
pub fn as_policy_string(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
Self::Socks5Tcp => "socks5_tcp",
Self::Socks5Udp => "socks5_udp",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NetworkRule {
pub host: String,
pub protocol: NetworkRuleProtocol,
pub decision: Decision,
pub justification: Option<String>,
}
pub(crate) fn normalize_network_rule_host(raw: &str) -> Result<String> {
let mut host = raw.trim();
if host.is_empty() {
return Err(Error::InvalidRule(
"network_rule host cannot be empty".to_string(),
));
}
if host.contains("://") || host.contains('/') || host.contains('?') || host.contains('#') {
return Err(Error::InvalidRule(
"network_rule host must be a hostname or IP literal (without scheme or path)"
.to_string(),
));
}
if let Some(stripped) = host.strip_prefix('[') {
let Some((inside, rest)) = stripped.split_once(']') else {
return Err(Error::InvalidRule(
"network_rule host has an invalid bracketed IPv6 literal".to_string(),
));
};
let port_ok = rest
.strip_prefix(':')
.is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()));
if !rest.is_empty() && !port_ok {
return Err(Error::InvalidRule(format!(
"network_rule host contains an unsupported suffix: {raw}"
)));
}
host = inside;
} else if host.matches(':').count() == 1
&& let Some((candidate, port)) = host.rsplit_once(':')
&& !candidate.is_empty()
&& !port.is_empty()
&& port.chars().all(|c| c.is_ascii_digit())
{
host = candidate;
}
let normalized = host.trim_end_matches('.').trim().to_ascii_lowercase();
if normalized.is_empty() {
return Err(Error::InvalidRule(
"network_rule host cannot be empty".to_string(),
));
}
if normalized.contains('*') {
return Err(Error::InvalidRule(
"network_rule host must be a specific host; wildcards are not allowed".to_string(),
));
}
if normalized.chars().any(char::is_whitespace) {
return Err(Error::InvalidRule(
"network_rule host cannot contain whitespace".to_string(),
));
}
Ok(normalized)
}
pub trait Rule: Any + Debug + Send + Sync {
fn program(&self) -> &str;