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"
);
}
}