mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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:
@@ -55,8 +55,11 @@ use codex_hooks::HookResult;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
@@ -165,6 +168,7 @@ use crate::mentions::build_connector_slug_counts;
|
||||
use crate::mentions::build_skill_name_counts;
|
||||
use crate::mentions::collect_explicit_app_ids;
|
||||
use crate::mentions::collect_tool_mentions_from_messages;
|
||||
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::proposed_plan_parser::ProposedPlanParser;
|
||||
use crate::proposed_plan_parser::ProposedPlanSegment;
|
||||
@@ -2377,6 +2381,103 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_network_policy_amendment(
|
||||
&self,
|
||||
amendment: &NetworkPolicyAmendment,
|
||||
network_approval_context: &NetworkApprovalContext,
|
||||
) -> anyhow::Result<()> {
|
||||
let host =
|
||||
Self::validated_network_policy_amendment_host(amendment, network_approval_context)?;
|
||||
let codex_home = self
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.codex_home()
|
||||
.clone();
|
||||
let execpolicy_amendment =
|
||||
execpolicy_network_rule_amendment(amendment, network_approval_context, &host);
|
||||
|
||||
if let Some(started_network_proxy) = self.services.network_proxy.as_ref() {
|
||||
let proxy = started_network_proxy.proxy();
|
||||
match amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => proxy
|
||||
.add_allowed_domain(&host)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to update runtime allowlist: {err}"))?,
|
||||
NetworkPolicyRuleAction::Deny => proxy
|
||||
.add_denied_domain(&host)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to update runtime denylist: {err}"))?,
|
||||
}
|
||||
}
|
||||
|
||||
self.services
|
||||
.exec_policy
|
||||
.append_network_rule_and_update(
|
||||
&codex_home,
|
||||
&host,
|
||||
execpolicy_amendment.protocol,
|
||||
execpolicy_amendment.decision,
|
||||
Some(execpolicy_amendment.justification),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!("failed to persist network policy amendment to execpolicy: {err}")
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validated_network_policy_amendment_host(
|
||||
amendment: &NetworkPolicyAmendment,
|
||||
network_approval_context: &NetworkApprovalContext,
|
||||
) -> anyhow::Result<String> {
|
||||
let approved_host = normalize_host(&network_approval_context.host);
|
||||
let amendment_host = normalize_host(&amendment.host);
|
||||
if amendment_host != approved_host {
|
||||
return Err(anyhow::anyhow!(
|
||||
"network policy amendment host '{}' does not match approved host '{}'",
|
||||
amendment.host,
|
||||
network_approval_context.host
|
||||
));
|
||||
}
|
||||
Ok(approved_host)
|
||||
}
|
||||
|
||||
pub(crate) async fn record_network_policy_amendment_message(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
amendment: &NetworkPolicyAmendment,
|
||||
) {
|
||||
let (action, list_name) = match amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => ("Allowed", "allowlist"),
|
||||
NetworkPolicyRuleAction::Deny => ("Denied", "denylist"),
|
||||
};
|
||||
let text = format!(
|
||||
"{action} network rule saved in execpolicy ({list_name}): {}",
|
||||
amendment.host
|
||||
);
|
||||
let message: ResponseItem = DeveloperInstructions::new(text.clone()).into();
|
||||
|
||||
if let Some(turn_context) = self.turn_context_for_sub_id(sub_id).await {
|
||||
self.record_conversation_items(&turn_context, std::slice::from_ref(&message))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
if self
|
||||
.inject_response_items(vec![ResponseInputItem::Message {
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
}])
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("no active turn found to record network policy amendment message for {sub_id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit an exec approval request event and await the user's decision.
|
||||
///
|
||||
/// The request is keyed by `call_id` + `approval_id` so matching responses are delivered
|
||||
@@ -2414,6 +2515,18 @@ impl Session {
|
||||
}
|
||||
|
||||
let parsed_cmd = parse_command(&command);
|
||||
let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| {
|
||||
vec![
|
||||
NetworkPolicyAmendment {
|
||||
host: context.host.clone(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
},
|
||||
NetworkPolicyAmendment {
|
||||
host: context.host.clone(),
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
},
|
||||
]
|
||||
});
|
||||
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
approval_id,
|
||||
@@ -2423,6 +2536,7 @@ impl Session {
|
||||
reason,
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
proposed_network_policy_amendments,
|
||||
parsed_cmd,
|
||||
});
|
||||
self.send_event(turn_context, event).await;
|
||||
@@ -6120,6 +6234,7 @@ mod tests {
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::CreditsSnapshot;
|
||||
use crate::protocol::InitialHistory;
|
||||
use crate::protocol::NetworkApprovalProtocol;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use crate::protocol::ResumedHistory;
|
||||
@@ -6246,6 +6361,41 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_network_policy_amendment_host_allows_normalized_match() {
|
||||
let amendment = NetworkPolicyAmendment {
|
||||
host: "ExAmPlE.Com.:443".to_string(),
|
||||
action: NetworkPolicyRuleAction::Allow,
|
||||
};
|
||||
let context = NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
|
||||
let host = Session::validated_network_policy_amendment_host(&amendment, &context)
|
||||
.expect("normalized hosts should match");
|
||||
|
||||
assert_eq!(host, "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_network_policy_amendment_host_rejects_mismatch() {
|
||||
let amendment = NetworkPolicyAmendment {
|
||||
host: "evil.example.com".to_string(),
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
};
|
||||
let context = NetworkApprovalContext {
|
||||
host: "api.example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Https,
|
||||
};
|
||||
|
||||
let err = Session::validated_network_policy_amendment_host(&amendment, &context)
|
||||
.expect_err("mismatched hosts should be rejected");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("does not match approved host"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_base_instructions_no_user_content() {
|
||||
let prompt_with_apply_patch_instructions =
|
||||
|
||||
@@ -13,10 +13,12 @@ use codex_execpolicy::AmendError;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Error as ExecPolicyRuleError;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_execpolicy::blocking_append_allow_prefix_rule;
|
||||
use codex_execpolicy::blocking_append_network_rule;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -293,6 +295,43 @@ impl ExecPolicyManager {
|
||||
self.policy.store(Arc::new(updated_policy));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn append_network_rule_and_update(
|
||||
&self,
|
||||
codex_home: &Path,
|
||||
host: &str,
|
||||
protocol: NetworkRuleProtocol,
|
||||
decision: Decision,
|
||||
justification: Option<String>,
|
||||
) -> Result<(), ExecPolicyUpdateError> {
|
||||
let policy_path = default_policy_path(codex_home);
|
||||
let host = host.to_string();
|
||||
spawn_blocking({
|
||||
let policy_path = policy_path.clone();
|
||||
let host = host.clone();
|
||||
let justification = justification.clone();
|
||||
move || {
|
||||
blocking_append_network_rule(
|
||||
&policy_path,
|
||||
&host,
|
||||
protocol,
|
||||
decision,
|
||||
justification.as_deref(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|source| ExecPolicyUpdateError::JoinBlockingTask { source })?
|
||||
.map_err(|source| ExecPolicyUpdateError::AppendRule {
|
||||
path: policy_path,
|
||||
source,
|
||||
})?;
|
||||
|
||||
let mut updated_policy = self.current().as_ref().clone();
|
||||
updated_policy.add_network_rule(&host, protocol, decision, justification)?;
|
||||
self.policy.store(Arc::new(updated_policy));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExecPolicyManager {
|
||||
@@ -440,7 +479,10 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Policy::new(combined_rules))
|
||||
let mut combined_network_rules = policy.network_rules().to_vec();
|
||||
combined_network_rules.extend(requirements_policy.as_ref().network_rules().iter().cloned());
|
||||
|
||||
Ok(Policy::from_parts(combined_rules, combined_network_rules))
|
||||
}
|
||||
|
||||
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
|
||||
@@ -914,6 +956,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
|
||||
let mut requirements_exec_policy = Policy::empty();
|
||||
requirements_exec_policy.add_network_rule(
|
||||
"blocked.example.com",
|
||||
codex_execpolicy::NetworkRuleProtocol::Https,
|
||||
Decision::Forbidden,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let requirements = ConfigRequirements {
|
||||
exec_policy: Some(codex_config::Sourced::new(
|
||||
codex_config::RequirementsExecPolicy::new(requirements_exec_policy),
|
||||
codex_config::RequirementSource::Unknown,
|
||||
)),
|
||||
..ConfigRequirements::default()
|
||||
};
|
||||
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?;
|
||||
let layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project { dot_codex_folder },
|
||||
TomlValue::Table(Default::default()),
|
||||
);
|
||||
let config_stack =
|
||||
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?;
|
||||
|
||||
let policy = load_exec_policy(&config_stack).await?;
|
||||
let (allowed, denied) = policy.compiled_network_domains();
|
||||
|
||||
assert!(allowed.is_empty());
|
||||
assert_eq!(denied, vec!["blocked.example.com".to_string()]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_policies_outside_policy_dir() {
|
||||
let temp_dir = tempdir().expect("create temp dir");
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use codex_execpolicy::Decision as ExecPolicyDecision;
|
||||
use codex_execpolicy::NetworkRuleProtocol as ExecPolicyNetworkRuleProtocol;
|
||||
use codex_network_proxy::BlockedRequest;
|
||||
use codex_network_proxy::NetworkDecisionSource;
|
||||
use codex_network_proxy::NetworkPolicyDecision;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
@@ -17,6 +21,13 @@ pub struct NetworkPolicyDecisionPayload {
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ExecPolicyNetworkRuleAmendment {
|
||||
pub protocol: ExecPolicyNetworkRuleProtocol,
|
||||
pub decision: ExecPolicyDecision,
|
||||
pub justification: String,
|
||||
}
|
||||
|
||||
impl NetworkPolicyDecisionPayload {
|
||||
pub(crate) fn is_ask_from_decider(&self) -> bool {
|
||||
self.decision == NetworkPolicyDecision::Ask && self.source == NetworkDecisionSource::Decider
|
||||
@@ -79,10 +90,42 @@ pub(crate) fn denied_network_policy_message(blocked: &BlockedRequest) -> Option<
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn execpolicy_network_rule_amendment(
|
||||
amendment: &NetworkPolicyAmendment,
|
||||
network_approval_context: &NetworkApprovalContext,
|
||||
host: &str,
|
||||
) -> ExecPolicyNetworkRuleAmendment {
|
||||
let protocol = match network_approval_context.protocol {
|
||||
NetworkApprovalProtocol::Http => ExecPolicyNetworkRuleProtocol::Http,
|
||||
NetworkApprovalProtocol::Https => ExecPolicyNetworkRuleProtocol::Https,
|
||||
NetworkApprovalProtocol::Socks5Tcp => ExecPolicyNetworkRuleProtocol::Socks5Tcp,
|
||||
NetworkApprovalProtocol::Socks5Udp => ExecPolicyNetworkRuleProtocol::Socks5Udp,
|
||||
};
|
||||
let (decision, action_verb) = match amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => (ExecPolicyDecision::Allow, "Allow"),
|
||||
NetworkPolicyRuleAction::Deny => (ExecPolicyDecision::Forbidden, "Deny"),
|
||||
};
|
||||
let protocol_label = match network_approval_context.protocol {
|
||||
NetworkApprovalProtocol::Http => "http",
|
||||
NetworkApprovalProtocol::Https => "https_connect",
|
||||
NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp",
|
||||
NetworkApprovalProtocol::Socks5Udp => "socks5_udp",
|
||||
};
|
||||
let justification = format!("{action_verb} {protocol_label} access to {host}");
|
||||
|
||||
ExecPolicyNetworkRuleAmendment {
|
||||
protocol,
|
||||
decision,
|
||||
justification,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_network_proxy::BlockedRequest;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -211,6 +254,27 @@ mod tests {
|
||||
assert_eq!(payload.protocol, Some(NetworkApprovalProtocol::Https));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execpolicy_network_rule_amendment_maps_protocol_action_and_justification() {
|
||||
let amendment = NetworkPolicyAmendment {
|
||||
action: NetworkPolicyRuleAction::Deny,
|
||||
host: "example.com".to_string(),
|
||||
};
|
||||
let context = NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Socks5Udp,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
execpolicy_network_rule_amendment(&amendment, &context, "example.com"),
|
||||
ExecPolicyNetworkRuleAmendment {
|
||||
protocol: ExecPolicyNetworkRuleProtocol::Socks5Udp,
|
||||
decision: ExecPolicyDecision::Forbidden,
|
||||
justification: "Deny socks5_udp access to example.com".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_network_policy_message_requires_deny_decision() {
|
||||
let blocked = BlockedRequest {
|
||||
|
||||
@@ -6,6 +6,9 @@ use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::exec_policy::ExecPolicyError;
|
||||
use crate::exec_policy::format_exec_policy_error_with_source;
|
||||
use crate::exec_policy::load_exec_policy;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
@@ -18,6 +21,7 @@ use codex_network_proxy::NetworkProxyConstraintError;
|
||||
use codex_network_proxy::NetworkProxyConstraints;
|
||||
use codex_network_proxy::NetworkProxyState;
|
||||
use codex_network_proxy::build_config_state;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use codex_network_proxy::validate_policy_against_constraints;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
@@ -49,7 +53,21 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec<LayerMtime
|
||||
.await
|
||||
.context("failed to load Codex config")?;
|
||||
|
||||
let config = config_from_layers(&config_layer_stack)?;
|
||||
let (exec_policy, warning) = match load_exec_policy(&config_layer_stack).await {
|
||||
Ok(policy) => (policy, None),
|
||||
Err(err @ ExecPolicyError::ParsePolicy { .. }) => {
|
||||
(codex_execpolicy::Policy::empty(), Some(err))
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
if let Some(err) = warning.as_ref() {
|
||||
tracing::warn!(
|
||||
"failed to parse execpolicy while building network proxy state: {}",
|
||||
format_exec_policy_error_with_source(err)
|
||||
);
|
||||
}
|
||||
|
||||
let config = config_from_layers(&config_layer_stack, &exec_policy)?;
|
||||
|
||||
let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?;
|
||||
let layer_mtimes = collect_layer_mtimes(&config_layer_stack);
|
||||
@@ -175,15 +193,46 @@ fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesTo
|
||||
}
|
||||
}
|
||||
|
||||
fn config_from_layers(layers: &ConfigLayerStack) -> Result<NetworkProxyConfig> {
|
||||
fn config_from_layers(
|
||||
layers: &ConfigLayerStack,
|
||||
exec_policy: &codex_execpolicy::Policy,
|
||||
) -> Result<NetworkProxyConfig> {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
for layer in layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) {
|
||||
let parsed = network_tables_from_toml(&layer.config)?;
|
||||
apply_network_tables(&mut config, parsed);
|
||||
}
|
||||
apply_exec_policy_network_rules(&mut config, exec_policy);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn apply_exec_policy_network_rules(
|
||||
config: &mut NetworkProxyConfig,
|
||||
exec_policy: &codex_execpolicy::Policy,
|
||||
) {
|
||||
let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains();
|
||||
for host in allowed_domains {
|
||||
upsert_network_domain(
|
||||
&mut config.network.allowed_domains,
|
||||
&mut config.network.denied_domains,
|
||||
host,
|
||||
);
|
||||
}
|
||||
for host in denied_domains {
|
||||
upsert_network_domain(
|
||||
&mut config.network.denied_domains,
|
||||
&mut config.network.allowed_domains,
|
||||
host,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_network_domain(target: &mut Vec<String>, opposite: &mut Vec<String>, host: String) {
|
||||
opposite.retain(|entry| normalize_host(entry) != host);
|
||||
target.retain(|entry| normalize_host(entry) != host);
|
||||
target.push(host);
|
||||
}
|
||||
|
||||
fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool {
|
||||
matches!(
|
||||
layer,
|
||||
@@ -260,6 +309,9 @@ impl ConfigReloader for MtimeConfigReloader {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -292,6 +344,45 @@ allowed_domains = ["higher.example.com"]
|
||||
assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execpolicy_network_rules_overlay_network_lists() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["config.example.com".to_string()];
|
||||
config.network.denied_domains = vec!["blocked.example.com".to_string()];
|
||||
|
||||
let mut exec_policy = Policy::empty();
|
||||
exec_policy
|
||||
.add_network_rule(
|
||||
"blocked.example.com",
|
||||
NetworkRuleProtocol::Https,
|
||||
Decision::Allow,
|
||||
None,
|
||||
)
|
||||
.expect("allow rule should be valid");
|
||||
exec_policy
|
||||
.add_network_rule(
|
||||
"api.example.com",
|
||||
NetworkRuleProtocol::Http,
|
||||
Decision::Forbidden,
|
||||
None,
|
||||
)
|
||||
.expect("deny rule should be valid");
|
||||
|
||||
apply_exec_policy_network_rules(&mut config, &exec_policy);
|
||||
|
||||
assert_eq!(
|
||||
config.network.allowed_domains,
|
||||
vec![
|
||||
"config.example.com".to_string(),
|
||||
"blocked.example.com".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
config.network.denied_domains,
|
||||
vec!["api.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_network_constraints_includes_allow_all_unix_sockets_flag() {
|
||||
let config: toml::Value = toml::from_str(
|
||||
|
||||
@@ -10,8 +10,12 @@ use codex_network_proxy::NetworkProtocol;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -19,6 +23,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -158,6 +163,7 @@ pub(crate) struct NetworkApprovalService {
|
||||
call_outcomes: Mutex<HashMap<String, NetworkApprovalOutcome>>,
|
||||
pending_host_approvals: Mutex<HashMap<HostApprovalKey, Arc<PendingHostApproval>>>,
|
||||
session_approved_hosts: Mutex<HashSet<HostApprovalKey>>,
|
||||
session_denied_hosts: Mutex<HashSet<HostApprovalKey>>,
|
||||
}
|
||||
|
||||
impl Default for NetworkApprovalService {
|
||||
@@ -167,6 +173,7 @@ impl Default for NetworkApprovalService {
|
||||
call_outcomes: Mutex::new(HashMap::new()),
|
||||
pending_host_approvals: Mutex::new(HashMap::new()),
|
||||
session_approved_hosts: Mutex::new(HashSet::new()),
|
||||
session_denied_hosts: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +279,13 @@ impl NetworkApprovalService {
|
||||
};
|
||||
let key = HostApprovalKey::from_request(&request, protocol);
|
||||
|
||||
{
|
||||
let denied_hosts = self.session_denied_hosts.lock().await;
|
||||
if denied_hosts.contains(&key) {
|
||||
return NetworkDecision::deny(REASON_NOT_ALLOWED);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let approved_hosts = self.session_approved_hosts.lock().await;
|
||||
if approved_hosts.contains(&key) {
|
||||
@@ -312,6 +326,10 @@ impl NetworkApprovalService {
|
||||
|
||||
let approval_id = Self::approval_id_for_key(&key);
|
||||
let prompt_command = vec!["network-access".to_string(), target.clone()];
|
||||
let network_approval_context = NetworkApprovalContext {
|
||||
host: request.host.clone(),
|
||||
protocol,
|
||||
};
|
||||
|
||||
let approval_decision = session
|
||||
.request_command_approval(
|
||||
@@ -321,19 +339,86 @@ impl NetworkApprovalService {
|
||||
prompt_command,
|
||||
turn_context.cwd.clone(),
|
||||
Some(prompt_reason),
|
||||
Some(NetworkApprovalContext {
|
||||
host: request.host.clone(),
|
||||
protocol,
|
||||
}),
|
||||
Some(network_approval_context.clone()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut cache_session_deny = false;
|
||||
let resolved = match approval_decision {
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
|
||||
PendingApprovalDecision::AllowOnce
|
||||
}
|
||||
ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession,
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {
|
||||
match session
|
||||
.persist_network_policy_amendment(
|
||||
&network_policy_amendment,
|
||||
&network_approval_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
session
|
||||
.record_network_policy_amendment_message(
|
||||
&turn_context.sub_id,
|
||||
&network_policy_amendment,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let message =
|
||||
format!("Failed to apply network policy amendment: {err}");
|
||||
warn!("{message}");
|
||||
session
|
||||
.send_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::Warning(WarningEvent { message }),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
PendingApprovalDecision::AllowForSession
|
||||
}
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
match session
|
||||
.persist_network_policy_amendment(
|
||||
&network_policy_amendment,
|
||||
&network_approval_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
session
|
||||
.record_network_policy_amendment_message(
|
||||
&turn_context.sub_id,
|
||||
&network_policy_amendment,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let message =
|
||||
format!("Failed to apply network policy amendment: {err}");
|
||||
warn!("{message}");
|
||||
session
|
||||
.send_event_raw(Event {
|
||||
id: turn_context.sub_id.clone(),
|
||||
msg: EventMsg::Warning(WarningEvent { message }),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
self.record_outcome_for_single_active_call(
|
||||
NetworkApprovalOutcome::DeniedByUser,
|
||||
)
|
||||
.await;
|
||||
cache_session_deny = true;
|
||||
PendingApprovalDecision::Deny
|
||||
}
|
||||
},
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByUser)
|
||||
.await;
|
||||
@@ -342,10 +427,23 @@ impl NetworkApprovalService {
|
||||
};
|
||||
|
||||
if matches!(resolved, PendingApprovalDecision::AllowForSession) {
|
||||
{
|
||||
let mut denied_hosts = self.session_denied_hosts.lock().await;
|
||||
denied_hosts.remove(&key);
|
||||
}
|
||||
let mut approved_hosts = self.session_approved_hosts.lock().await;
|
||||
approved_hosts.insert(key.clone());
|
||||
}
|
||||
|
||||
if cache_session_deny {
|
||||
{
|
||||
let mut approved_hosts = self.session_approved_hosts.lock().await;
|
||||
approved_hosts.remove(&key);
|
||||
}
|
||||
let mut denied_hosts = self.session_denied_hosts.lock().await;
|
||||
denied_hosts.insert(key.clone());
|
||||
}
|
||||
|
||||
pending.set_decision(resolved).await;
|
||||
let mut pending_approvals = self.pending_host_approvals.lock().await;
|
||||
pending_approvals.remove(&key);
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::tools::sandboxing::ToolRuntime;
|
||||
use crate::tools::sandboxing::default_exec_approval_requirement;
|
||||
use codex_otel::ToolDecisionSource;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
|
||||
pub(crate) struct ToolOrchestrator {
|
||||
@@ -145,6 +146,14 @@ impl ToolOrchestrator {
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => {}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {}
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
}
|
||||
},
|
||||
}
|
||||
already_approved = true;
|
||||
}
|
||||
@@ -273,6 +282,14 @@ impl ToolOrchestrator {
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => {}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {}
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ use crate::protocol::ExecCommandOutputDeltaEvent;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::ExecOutputStream;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::NetworkPolicyRuleAction;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::ReviewDecision;
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context as _;
|
||||
@@ -373,6 +375,16 @@ impl ZshExecBridge {
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
|
||||
(WrapperExecAction::Run, None, false)
|
||||
}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => (WrapperExecAction::Run, None, false),
|
||||
NetworkPolicyRuleAction::Deny => (
|
||||
WrapperExecAction::Deny,
|
||||
Some("command denied by host approval policy".to_string()),
|
||||
true,
|
||||
),
|
||||
},
|
||||
ReviewDecision::Denied => (
|
||||
WrapperExecAction::Deny,
|
||||
Some("command denied by host approval policy".to_string()),
|
||||
|
||||
Reference in New Issue
Block a user