mirror of
https://github.com/openai/codex.git
synced 2026-05-01 20:02:05 +03:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user