Enforce environment network policies for remote execution (#39980)

## Why

`EnvironmentConfig.network_policy` could describe attachment-owned traffic
restrictions, but core rejected every configured policy because execution did
not enforce it.

## What changed

- Resolve each remote environment's network policy for the selected command
  and apply it to the execution-scoped proxy.
- Compose owner rules with controller constraints and saved network decisions
  while preserving inherited domain and Unix-socket denials.
- Keep strict allowlists non-expandable, allow reviewable policies to use
  network approvals, and reject sandbox escalation that would bypass an owner
  policy.
- Reject policies for local execution, disabled managed enforcement, or a
  disabled controller proxy.

## Testing

Added coverage for policy composition, scoped remote proxy behavior, approval
and denial flows, offline execution, and unsupported environment authority.

GitOrigin-RevId: d9331f616df24de6cd13ed68196f0ff7b0ca4dd9
This commit is contained in:
sayan-oai
2026-08-21 17:29:41 +00:00
committed by copyberry
parent 8edb95f274
commit f580dd886f
13 changed files with 723 additions and 63 deletions
+70 -1
View File
@@ -179,6 +179,75 @@ impl NetworkProxySpec {
EnvironmentNetworkPolicy::from_config(&self.config, self.hard_deny_allowlist_misses)
}
pub(crate) fn for_environment(
controller: Option<&Self>,
policy: &EnvironmentNetworkPolicy,
permission_profile: &PermissionProfile,
exec_policy: &Policy,
) -> std::io::Result<Self> {
if matches!(permission_profile, PermissionProfile::Disabled) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"environment network policy requires managed network enforcement",
));
}
if controller.is_some_and(|controller| !controller.enabled()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"environment network policy cannot override a disabled controller proxy",
));
}
let mut spec = match controller {
Some(controller) => controller.recompute_for_permission_profile(permission_profile)?,
None => Self::from_config_and_constraints(
NetworkProxyConfig {
enabled: true,
// Without a controller, the owner supplies the entire permission ceiling.
dangerously_allow_all_unix_sockets: true,
allow_local_binding: true,
..NetworkProxyConfig::default()
},
/*requirements*/ None,
permission_profile,
)?,
};
policy.apply_to(&mut spec.config);
let protected_denials = spec.config.denied_domains().unwrap_or_default();
// A fixed controller allowlist remains a ceiling; an expandable one is only a baseline.
// Profiles without managed approvals must not reuse approvals cached by another profile.
let fixed_allowlist = spec.hard_deny_allowlist_misses
|| spec.constraints.allowlist_expansion_enabled == Some(false);
spec.hard_deny_allowlist_misses |= policy.managed_allowed_domains_only
|| !Self::managed_sandbox_active(permission_profile);
let allow_owner_grants = !spec.hard_deny_allowlist_misses && !fixed_allowlist;
if fixed_allowlist {
spec.constraints.allowlist_expansion_enabled = None;
} else {
spec.constraints.allowed_domains =
Some(spec.config.allowed_domains().unwrap_or_default());
spec.constraints.allowlist_expansion_enabled = Some(allow_owner_grants);
}
spec.constraints.denylist_expansion_enabled = Some(true);
// Saved grants can extend a reviewable owner policy; owner denials are restored last.
let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains();
if allow_owner_grants {
upsert_network_domains(&mut spec.config, allowed_domains, /*allow*/ true);
}
upsert_network_domains(&mut spec.config, denied_domains, /*allow*/ false);
upsert_network_domains(&mut spec.config, protected_denials, /*allow*/ false);
spec.constraints.denied_domains = spec.config.denied_domains();
validate_policy_against_constraints(&spec.config, &spec.constraints).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("environment network policy violates managed requirements: {error}"),
)
})?;
Ok(spec)
}
pub(crate) fn with_exec_policy_network_rules(
&self,
exec_policy: &Policy,
@@ -208,7 +277,7 @@ impl NetworkProxySpec {
})
}
fn build_state_with_audit_metadata(
pub(crate) fn build_state_with_audit_metadata(
&self,
audit_metadata: NetworkProxyAuditMetadata,
) -> std::io::Result<NetworkProxyState> {
@@ -1,7 +1,11 @@
use super::*;
use codex_config::NetworkDomainPermissionToml;
use codex_config::NetworkDomainPermissionsToml;
use codex_execpolicy::Decision::Allow;
use codex_execpolicy::NetworkRuleProtocol::Https;
use codex_network_proxy::NetworkDomainPermission;
use codex_network_proxy::NetworkUnixSocketPermission;
use codex_network_proxy::NetworkUnixSocketPermissions;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::NetworkSandboxPolicy;
@@ -40,6 +44,117 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() {
assert_eq!(state.audit_metadata(), &metadata);
}
#[test]
fn environment_policy_replaces_soft_controller_allowlist_and_preserves_denials() {
let requirements = NetworkConstraints {
domains: Some(domain_permissions([
("controller.example", NetworkDomainPermissionToml::Allow),
("blocked.example", NetworkDomainPermissionToml::Deny),
])),
..Default::default()
};
let profile = PermissionProfile::workspace_write();
let spec = NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig {
enabled: true,
allow_upstream_proxy: false,
unix_sockets: Some(NetworkUnixSocketPermissions {
entries: [
(
"/tmp/controller.sock".to_string(),
NetworkUnixSocketPermission::Deny,
),
(
"/tmp/allowed.sock".to_string(),
NetworkUnixSocketPermission::Allow,
),
]
.into(),
}),
..NetworkProxyConfig::default()
},
Some(requirements),
&profile,
)
.expect("controller policy should be valid");
let mut owner = NetworkProxyConfig::default();
owner.set_allowed_domains(vec!["owner.example".to_string()]);
owner.set_denied_domains(vec!["owner-blocked.example".to_string()]);
owner.set_allow_unix_sockets(vec![
"/tmp/controller.sock".to_string(),
"/private/tmp/controller.sock".to_string(),
"/tmp/allowed.sock".to_string(),
]);
owner.dangerously_allow_all_unix_sockets = true;
owner.allow_local_binding = true;
let owner_policy =
EnvironmentNetworkPolicy::from_config(&owner, /*managed_allowed_domains_only*/ false);
let compose = NetworkProxySpec::for_environment;
let empty = Policy::empty();
let disabled_controller = NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
/*requirements*/ None,
&profile,
)
.expect("disabled controller policy should be valid");
assert!(compose(Some(&disabled_controller), &owner_policy, &profile, &empty).is_err());
let restricted = compose(Some(&spec), &owner_policy, &profile, &empty)
.expect("owner policy should replace soft controller grants");
let mut saved = Policy::empty();
for host in ["saved.example", "owner-blocked.example"] {
saved
.add_network_rule(host, Https, Allow, /*justification*/ None)
.expect("saved network grant should be valid");
}
let rootless = compose(/*controller*/ None, &owner_policy, &profile, &saved)
.expect("an owner policy can create executor-side proxy state");
assert_eq!(
rootless.config.allowed_domains().unwrap(),
["owner.example", "saved.example"]
);
owner.upsert_domain_permission(
"blocked.example".to_string(),
NetworkDomainPermission::Deny,
normalize_host,
);
owner.unix_sockets.clone_from(&spec.config.unix_sockets);
owner.allow_upstream_proxy = false;
owner.dangerously_allow_all_unix_sockets = false;
owner.allow_local_binding = false;
assert_eq!(
restricted.environment_policy(),
EnvironmentNetworkPolicy::from_config(&owner, /*managed_allowed_domains_only*/ false)
);
let external = PermissionProfile::External {
network: NetworkSandboxPolicy::Enabled,
};
let external_rootless = compose(/*controller*/ None, &owner_policy, &external, &saved)
.expect("an externally sandboxed owner policy should remain strict");
assert_eq!(
external_rootless.environment_policy(),
EnvironmentNetworkPolicy {
managed_allowed_domains_only: true,
..owner_policy.clone()
}
);
let controller_policy = spec.environment_policy();
let external_rooted = compose(Some(&spec), &controller_policy, &external, &saved)
.expect("an externally sandboxed owner policy may retain its controller allowlist");
assert_eq!(
external_rooted.environment_policy(),
EnvironmentNetworkPolicy {
managed_allowed_domains_only: true,
..controller_policy
}
);
assert!(compose(Some(&spec), &owner_policy, &external, &empty).is_err());
owner.set_allowed_domains(vec!["*".to_string()]);
let wildcard_policy =
EnvironmentNetworkPolicy::from_config(&owner, /*managed_allowed_domains_only*/ false);
assert!(compose(Some(&spec), &wildcard_policy, &profile, &empty).is_err());
}
#[test]
fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
let mut config = NetworkProxyConfig::default();
+28 -6
View File
@@ -1,7 +1,9 @@
use std::collections::HashSet;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_exec_server::MAX_SELECTED_CAPABILITY_ROOTS;
use codex_exec_server::SelectedCapabilityRootsStatus;
use codex_execpolicy::Policy;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
@@ -11,6 +13,7 @@ use codex_protocol::protocol::TurnEnvironmentSelection;
use crate::config::ConstraintError;
use crate::config::ConstraintResult;
use crate::config::NetworkProxySpec;
use crate::session::session::Session;
use crate::session::session::SessionConfiguration;
use crate::session::session::SessionSettingsUpdate;
@@ -35,12 +38,31 @@ fn validate_environment_config(
selection: &TurnEnvironmentSelection,
config: &EnvironmentConfig,
) -> CodexResult<()> {
// The public type can be used by owners before runtime enforcement lands. Do not
// accept restrictions here until the managed proxy can actually enforce them.
if config.network_policy.is_some() {
return Err(CodexErr::InvalidRequest(
"attachment-owned network policy is not supported yet".to_string(),
));
if let Some(policy) = config.network_policy.as_ref() {
if selection.environment_id == LOCAL_ENVIRONMENT_ID {
return Err(CodexErr::InvalidRequest(
"attachment-owned network policy requires a remote executor".to_string(),
));
}
if config
.exec_policy
.as_ref()
.is_some_and(|policy| !policy.as_ref().network_rules().is_empty())
{
return Err(CodexErr::InvalidRequest(
"environment network restrictions must use network_policy".to_string(),
));
}
// Validate owner policy on its own; controller compatibility is checked at execution.
NetworkProxySpec::for_environment(
/*controller*/ None,
policy,
config.permission_profile.permission_profile(),
&Policy::empty(),
)
.map_err(|error| {
CodexErr::InvalidRequest(format!("invalid environment network policy: {error}"))
})?;
}
if config.selected_capability_roots.len() > MAX_SELECTED_CAPABILITY_ROOTS {
return Err(CodexErr::InvalidRequest(format!(
+84 -8
View File
@@ -1,7 +1,9 @@
use crate::config::NetworkProxySpec;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::GuardianReviewContext;
use crate::network_policy_decision::denied_network_policy_message;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::tools::approvals::ApprovalAction;
use crate::tools::approvals::ApprovalContext;
@@ -9,6 +11,7 @@ use crate::tools::events::truncate_rejection_message;
use crate::tools::sandboxing::ToolError;
use codex_network_proxy::BlockedRequest;
use codex_network_proxy::BlockedRequestObserver;
use codex_network_proxy::EnvironmentNetworkPolicy;
use codex_network_proxy::NetworkDecision;
use codex_network_proxy::NetworkPolicyDecider;
use codex_network_proxy::NetworkPolicyRequest;
@@ -45,7 +48,7 @@ use uuid::Uuid;
const ABANDONED_NETWORK_APPROVAL_MESSAGE: &str =
"network approval was cancelled before a decision was returned";
#[derive(Clone, Debug)]
#[derive(Debug)]
pub(crate) struct NetworkApprovalSpec {
pub network: Option<NetworkProxy>,
pub trigger: GuardianNetworkAccessTrigger,
@@ -54,6 +57,7 @@ pub(crate) struct NetworkApprovalSpec {
pub command: String,
pub environment_id: String,
pub permission_profile: PermissionProfile,
pub network_policy: Option<EnvironmentNetworkPolicy>,
}
#[derive(Clone, Debug)]
@@ -1021,8 +1025,8 @@ pub(crate) fn build_network_policy_decider(
}
pub(crate) async fn begin_network_approval(
session: &Session,
turn_id: &str,
session: &Arc<Session>,
turn: &TurnContext,
managed_network_active: bool,
spec: Option<NetworkApprovalSpec>,
) -> Result<Option<ActiveNetworkApproval>, ToolError> {
@@ -1033,21 +1037,93 @@ pub(crate) async fn begin_network_approval(
command,
environment_id,
permission_profile,
network_policy,
} = match spec {
Some(spec) => spec,
None => return Ok(None),
};
let Some(network) = network else {
return Ok(None);
};
if !managed_network_active {
return Ok(None);
}
let controller = turn.config.permissions.network.as_ref();
let owner_spec = network_policy
.as_ref()
.map(|policy| {
// Resolve owner policy once, where the command's actual enforcing proxy is built.
NetworkProxySpec::for_environment(
controller,
policy,
&permission_profile,
session.services.exec_policy.current().as_ref(),
)
})
.transpose()
.map_err(|error| {
ToolError::Rejected(format!(
"failed to resolve environment network policy: {error}"
))
})?;
let network = if let Some(owner_spec) = owner_spec.as_ref() {
if controller.is_some() {
network
.or_else(|| {
session
.services
.network_proxy
.load_full()
.map(|controller| controller.proxy())
})
.ok_or_else(|| {
ToolError::Rejected(
"environment network policy requires its configured controller proxy"
.to_string(),
)
})?
} else {
// This carrier never listens: the executor starts the real per-command proxy.
let state = owner_spec
.build_state_with_audit_metadata(
session.services.network_proxy_audit_metadata.clone(),
)
.map_err(|error| {
ToolError::Rejected(format!(
"failed to build environment network policy: {error}"
))
})?;
NetworkProxy::builder()
.state(Arc::new(state))
.managed_by_codex(/*managed_by_codex*/ false)
.build()
.await
.map_err(|error| {
ToolError::Rejected(format!(
"failed to build execution-scoped network proxy: {error}"
))
})?
}
} else if let Some(network) = network {
network
} else {
return Ok(None);
};
let environment_policy = owner_spec.map(|spec| spec.environment_policy());
let fallback_policy_decider = environment_policy.as_ref().map(|_| {
build_network_policy_decider(
Arc::clone(&session.services.network_approval),
Arc::new(RwLock::new(Arc::downgrade(session))),
)
});
let registration_id = Uuid::new_v4().to_string();
let attribution_token = Uuid::new_v4().to_string();
let execution_proxy = network
.for_execution(&environment_id, &registration_id, attribution_token)
.for_execution(
&environment_id,
&registration_id,
attribution_token,
environment_policy,
fallback_policy_decider,
)
.map_err(|err| {
ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other(
format!("failed to create execution-scoped network proxy: {err}"),
@@ -1059,7 +1135,7 @@ pub(crate) async fn begin_network_approval(
.network_approval
.register_call(ActiveNetworkApprovalCall {
registration_id: registration_id.clone(),
turn_id: turn_id.to_string(),
turn_id: turn.sub_id.clone(),
trigger,
tool_name,
command,
+53 -21
View File
@@ -6,12 +6,14 @@ simple sequence for any ToolRuntime: approval → select sandbox → attempt →
retry with an escalated sandbox strategy on denial (no re‑approval thanks to
caching).
*/
use crate::config::NetworkProxySpec;
use crate::guardian::GuardianReviewContext;
use crate::network_policy_decision::network_approval_context_from_payload;
use crate::tools::approvals::ApprovalContext;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::ActiveNetworkApproval;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::network_approval::begin_network_approval;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::sandboxing::ExecApprovalRequirement;
@@ -31,6 +33,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxType;
use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
use std::sync::Arc;
use std::time::Instant;
@@ -55,16 +58,16 @@ impl ToolOrchestrator {
req: &Rq,
tool_ctx: &ToolCtx,
attempt: &SandboxAttempt<'_>,
managed_network_active: bool,
network_approval_spec: Option<NetworkApprovalSpec>,
) -> (Result<Out, ToolError>, Option<DeferredNetworkApproval>)
where
T: ToolRuntime<Rq, Out>,
{
let network_approval = match begin_network_approval(
&tool_ctx.session,
&tool_ctx.step_context.turn.sub_id,
managed_network_active,
tool.network_approval_spec(req, tool_ctx),
&tool_ctx.step_context.turn,
attempt.enforce_managed_network,
network_approval_spec,
)
.await
{
@@ -141,6 +144,17 @@ impl ToolOrchestrator {
let mut already_approved = false;
let environment = tool.turn_environment(req);
let owner_network_policy = environment.config().network_policy.is_some();
if owner_network_policy
&& tool
.sandbox_permissions(req)
.requires_escalated_permissions()
{
return Err(ToolError::Rejected(
"attachment-owned network policy cannot be bypassed by sandbox escalation"
.to_string(),
));
}
let workspace_roots = environment.workspace_roots();
let executor_managed_process_sandbox = tool.uses_executor_managed_process_sandbox(req);
let permission_profile = environment.permission_profile();
@@ -212,12 +226,37 @@ impl ToolOrchestrator {
}
// 2) First attempt under the selected sandbox.
let sandbox_override = sandbox_override_for_first_attempt(
tool.sandbox_permissions(req),
&requirement,
&file_system_sandbox_policy,
);
let managed_network_active = turn_ctx.network.is_some();
let unsandboxed_allowed =
!owner_network_policy && unsandboxed_execution_allowed(&file_system_sandbox_policy);
let sandbox_override = if unsandboxed_allowed {
sandbox_override_for_first_attempt(
tool.sandbox_permissions(req),
&requirement,
&file_system_sandbox_policy,
)
} else {
SandboxOverride::NoOverride
};
let network_approval_spec = tool.network_approval_spec(req, tool_ctx);
// Offline owner attachments stay offline unless approved command permissions grant
// networking. Existing enabled controller proxies remain independently authoritative.
let managed_network_active = if owner_network_policy {
turn_ctx
.config
.permissions
.network
.as_ref()
.is_some_and(NetworkProxySpec::enabled)
|| network_approval_spec.as_ref().is_some_and(|spec| {
effective_network_sandbox_policy(
permission_profile.network_sandbox_policy(),
spec.trigger.additional_permissions.as_ref(),
)
.is_enabled()
})
} else {
turn_ctx.network.is_some()
};
let sandbox_preference = tool.sandbox_preference();
let sandbox_requested = match sandbox_override {
SandboxOverride::BypassSandboxFirstAttempt => false,
@@ -265,14 +304,8 @@ impl ToolOrchestrator {
};
let initial_attempt_start = Instant::now();
let (first_result, first_deferred_network_approval) = Self::run_attempt(
tool,
req,
tool_ctx,
&initial_attempt,
managed_network_active,
)
.await;
let (first_result, first_deferred_network_approval) =
Self::run_attempt(tool, req, tool_ctx, &initial_attempt, network_approval_spec).await;
let initial_duration = initial_attempt_start.elapsed();
match first_result {
Ok(out) => {
@@ -327,8 +360,6 @@ impl ToolOrchestrator {
);
return Err(ToolError::Codex(err));
}
let unsandboxed_allowed =
unsandboxed_execution_allowed(&file_system_sandbox_policy);
// Under `Never` or `OnRequest`, do not retry without sandbox;
// surface a concise sandbox denial that preserves the
// original output.
@@ -449,9 +480,10 @@ impl ToolOrchestrator {
};
// Second attempt.
let network_approval_spec = tool.network_approval_spec(req, tool_ctx);
let escalated_attempt_start = Instant::now();
let (retry_result, retry_deferred_network_approval) =
Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active)
Self::run_attempt(tool, req, tool_ctx, &retry_attempt, network_approval_spec)
.await;
let escalated_duration = escalated_attempt_start.elapsed();
match retry_result {
@@ -223,9 +223,14 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
&file_system_sandbox_policy,
);
let network =
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?;
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)
.cloned();
// No-proxy fast path; owners still need a spec for execution-only proxies.
if network.is_none() && req.turn_environment.config().network_policy.is_none() {
return None;
}
Some(NetworkApprovalSpec {
network: Some(network.clone()),
network,
tool_name: ctx.tool_name.clone(),
trigger: GuardianNetworkAccessTrigger {
call_id: ctx.call_id.clone(),
@@ -240,6 +245,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
command: req.hook_command.clone(),
environment_id: req.turn_environment.selection.environment_id.clone(),
permission_profile: req.turn_environment.permission_profile().clone(),
network_policy: req.turn_environment.config().network_policy.clone(),
})
}
+6 -1
View File
@@ -419,7 +419,12 @@ impl<'a> SandboxAttempt<'a> {
&'b self,
fallback: Option<&'b NetworkProxy>,
) -> Option<&'b NetworkProxy> {
fallback.map(|fallback| self.network_proxy.unwrap_or(fallback))
// Execution-only proxies need no fallback; offline attempts must not revive one.
if self.enforce_managed_network {
self.network_proxy.or(fallback)
} else {
None
}
}
pub fn env_for(
@@ -1,8 +1,11 @@
use anyhow::Context;
use anyhow::Result;
use codex_config::types::ApprovalsReviewer;
use codex_core::EnvironmentConfig;
use codex_core::EnvironmentNetworkPolicy;
use codex_core::TurnInputRequest;
use codex_core::config::Constrained;
use codex_core::config::NetworkProxySpec;
use codex_core::shell::ShellType;
use codex_core::shell::get_shell;
use codex_exec_server::CreateDirectoryOptions;
@@ -10,6 +13,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_exec_server::RemoveOptions;
use codex_features::Feature;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::approvals::NetworkPolicyAmendment;
@@ -19,6 +23,7 @@ use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::PermissionProfileSnapshot;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EnvironmentConfigState;
@@ -1936,6 +1941,198 @@ async fn remote_guardian_network_decisions_are_scoped_to_each_request_and_enviro
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn owner_network_policy_follows_the_selected_remote_command() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture");
skip_if_host_windows!(Ok(()));
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_no_remote_env!(Ok(()));
let server = start_mock_server().await;
let mut scenarios = vec![("ROOTED", managed_network_unified_exec_test(&server).await?)];
for (scenario, configured_controller) in [("ROOTLESS", false), ("USER_ROOTED", true)] {
let mut builder = test_codex().with_config(move |config| {
for feature in [Feature::UnifiedExec, Feature::ExecPermissionApprovals] {
config
.features
.enable(feature)
.expect("test config should allow feature update");
}
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config
.permissions
.set_permission_profile(PermissionProfile::workspace_write_with(
&[],
NetworkSandboxPolicy::Enabled,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
))
.expect("set permission profile");
config.permissions.network = configured_controller.then(|| {
NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig {
enabled: true,
allow_local_binding: true,
..NetworkProxyConfig::default()
},
/*requirements*/ None,
config.permissions.permission_profile(),
)
.expect("build user-configured controller proxy")
});
});
let test = builder.build_with_remote_and_local_env(&server).await?;
assert!(!test.config.managed_network_requirements_enabled());
assert_eq!(
test.session_configured.network_proxy.is_some(),
configured_controller
);
scenarios.push((scenario, test));
}
for (scenario, test) in scenarios {
let mut remote = test.executor_environment().selection().clone();
for (suffix, allowed_domain, expected) in [
("ALLOWED", NETWORK_TEST_HOST, "HTTP/1.1 502"),
("DENIED", "owner-only.invalid", "HTTP/1.1 403"),
("REVIEWED", "owner-only.invalid", "HTTP/1.1 502"),
(
"ESCALATED",
NETWORK_TEST_HOST,
"attachment-owned network policy cannot be bypassed",
),
("OFFLINE", "owner-only.invalid", "ROOTLESS_OWNER_OFFLINE"),
("GRANTED_DENIED", "owner-only.invalid", "HTTP/1.1 403"),
] {
let restricted = matches!(suffix, "OFFLINE" | "GRANTED_DENIED");
if scenario != "ROOTLESS" && (restricted || suffix == "ESCALATED") {
continue;
}
let marker = format!("{scenario}_OWNER_{suffix}");
let mut proxy_config = NetworkProxyConfig {
allow_local_binding: true,
..NetworkProxyConfig::default()
};
proxy_config.set_allowed_domains(vec![allowed_domain.to_string()]);
let owner_config = EnvironmentConfig {
allow_login_shell: test.config.permissions.allow_login_shell,
permission_profile: PermissionProfileSnapshot::legacy(if restricted {
PermissionProfile::workspace_write()
} else {
test.config.permissions.permission_profile().clone()
}),
shell_environment_policy: test.config.permissions.shell_environment_policy.clone(),
exec_policy: None,
mcp_policy: None,
network_policy: Some(EnvironmentNetworkPolicy::from_config(
&proxy_config,
/*managed_allowed_domains_only*/ suffix != "REVIEWED",
)),
selected_capability_roots: Vec::new(),
};
let mut primary = local(test.cwd.path().abs());
primary.config = EnvironmentConfigState::Ready(EnvironmentConfig {
permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled),
network_policy: None,
..owner_config.clone()
});
remote.config = EnvironmentConfigState::Ready(owner_config);
// Restricted owners run offline until an approved grant enables their filtered proxy.
let command = if suffix == "OFFLINE" {
format!(
"python3 -c \"import socket; sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); sock.connect(('198.51.100.1', 9))\" 2>/dev/null || printf {marker}"
)
} else {
remote_network_proxy_request_command(&marker)
};
let mut args = network_exec_args(&command);
args["environment_id"] = json!(REMOTE_ENVIRONMENT_ID);
if suffix == "ESCALATED" {
args["sandbox_permissions"] = json!("require_escalated");
args["justification"] = json!("attempt to bypass the owner network policy");
} else if suffix == "GRANTED_DENIED" {
args["sandbox_permissions"] = json!("with_additional_permissions");
args["additional_permissions"] = json!({"network": {"enabled": true}});
args["justification"] = json!("exercise attachment-scoped network access");
}
let guardian = if suffix == "REVIEWED" {
Some(
mount_sse_once_match(
&server,
is_guardian_request,
sse(vec![
ev_response_created("resp-owner-network-guardian"),
ev_assistant_message(
"msg-owner-network-guardian",
r#"{"outcome":"allow"}"#,
),
ev_completed("resp-owner-network-guardian"),
]),
)
.await,
)
} else {
None
};
let responses = mount_exec_network_turn(&server, &marker, &marker, args).await?;
submit_managed_network_turn(
&test,
"exercise the selected environment's network policy",
vec![primary, remote.clone()],
if suffix == "REVIEWED" {
ApprovalsReviewer::AutoReview
} else {
ApprovalsReviewer::User
},
if matches!(suffix, "REVIEWED" | "ESCALATED" | "GRANTED_DENIED") {
AskForApproval::OnRequest
} else {
AskForApproval::Never
},
)
.await?;
if suffix == "GRANTED_DENIED" {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
let EventMsg::ExecApprovalRequest(approval) = event else {
anyhow::bail!("expected additional permissions approval before completion")
};
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: Some(approval.turn_id),
decision: ReviewDecision::Approved,
})
.await?;
}
wait_for_completion_without_network_prompt(&test).await;
if let Some(guardian) = guardian {
assert_eq!(
guardian_network_triggers(&[&guardian])?,
vec![(marker.clone(), command)]
);
}
let output = responses
.function_call_output_text(&marker)
.context("expected remote network output")?;
assert!(
output.contains(expected),
"unexpected network output for {marker}: {output}"
);
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn approved_network_host_for_one_environment_still_prompts_in_another() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture");
+13 -7
View File
@@ -1425,7 +1425,7 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn owner_network_policy_is_rejected_until_runtime_enforcement_exists() -> Result<()> {
async fn owner_network_policy_rejects_unsupported_environment_authority() -> Result<()> {
let server = start_mock_server().await;
let test = test_codex().build_with_auto_env(&server).await?;
let selections = test.codex.environment_selections().await;
@@ -1434,9 +1434,7 @@ async fn owner_network_policy_is_rejected_until_runtime_enforcement_exists() ->
.context("thread should select its executor environment")?;
let owner_config = EnvironmentConfig {
allow_login_shell: test.config.permissions.allow_login_shell,
permission_profile: PermissionProfileSnapshot::legacy(
test.config.permissions.permission_profile().clone(),
),
permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::Disabled),
shell_environment_policy: test.config.permissions.shell_environment_policy.clone(),
exec_policy: None,
mcp_policy: None,
@@ -1460,15 +1458,23 @@ async fn owner_network_policy_is_rejected_until_runtime_enforcement_exists() ->
})
.await
.err()
.context("preview must not accept an unenforced policy")?;
.context("preview must not accept an unsupported environment policy")?;
let ready_error = test
.codex
.environment_ready(selection, owner_config)
.await
.expect_err("readiness must not accept an unenforced policy");
.expect_err("readiness must not accept an unsupported environment policy");
let expected = if selection.environment_id == LOCAL_ENVIRONMENT_ID {
"attachment-owned network policy requires a remote executor"
} else {
"environment network policy requires managed network enforcement"
};
for error in [preview_error.to_string(), ready_error.to_string()] {
assert!(error.contains("attachment-owned network policy is not supported yet"));
assert!(
error.contains(expected),
"unexpected validation error: {error}"
);
}
assert_eq!(test.codex.environment_selections().await, selections);
Ok(())
@@ -1,13 +1,14 @@
use crate::NetworkDomainPermissions;
use crate::NetworkProxyConfig;
use crate::NetworkUnixSocketPermission;
use crate::NetworkUnixSocketPermissions;
use serde::Deserialize;
use serde::Serialize;
/// Traffic restrictions supplied by the owner of one execution environment.
///
/// Proxy enablement, listeners, network mode, MITM, and credentials remain owned by the
/// controller's network-proxy runtime.
/// Proxy enablement, listeners, network mode, MITM, and credentials remain outside
/// attachment-owned traffic policy.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentNetworkPolicy {
@@ -31,4 +32,53 @@ impl EnvironmentNetworkPolicy {
managed_allowed_domains_only,
}
}
/// Applies attachment-owned traffic settings while preserving inherited denials and proxy setup.
pub fn apply_to(&self, config: &mut NetworkProxyConfig) {
// Use the owner's domain rules without dropping controller denials.
let inherited_denials = config.denied_domains().unwrap_or_default();
config.domains.clone_from(&self.domains);
for domain in inherited_denials {
config.upsert_domain_permission(
domain,
crate::NetworkDomainPermission::Deny,
crate::normalize_host,
);
}
let inherited_sockets = config.unix_sockets.take().unwrap_or_default();
let mut effective_sockets = self.unix_sockets.clone().unwrap_or_default();
// "Allow all" cannot override a socket denied by either policy.
let inherited_permits_all = config.dangerously_allow_all_unix_sockets
&& !inherited_sockets
.entries
.values()
.any(|permission| matches!(permission, NetworkUnixSocketPermission::Deny));
let owner_permits_all = self.dangerously_allow_all_unix_sockets
&& !effective_sockets
.entries
.values()
.any(|permission| matches!(permission, NetworkUnixSocketPermission::Deny));
// Keep shared socket grants; controller denials always take priority.
effective_sockets.entries.retain(|path, permission| {
matches!(permission, NetworkUnixSocketPermission::Deny)
|| inherited_permits_all
|| matches!(
inherited_sockets.entries.get(path),
Some(NetworkUnixSocketPermission::Allow)
)
});
for (path, permission) in inherited_sockets.entries {
if owner_permits_all || matches!(permission, NetworkUnixSocketPermission::Deny) {
effective_sockets.entries.insert(path, permission);
}
}
// Enable permissions only when both controller and owner allow them.
config.unix_sockets = (!effective_sockets.entries.is_empty()).then_some(effective_sockets);
config.dangerously_allow_all_unix_sockets = inherited_permits_all && owner_permits_all;
config.allow_upstream_proxy &= self.allow_upstream_proxy;
config.allow_local_binding &= self.allow_local_binding;
}
}
+87 -13
View File
@@ -867,6 +867,7 @@ impl NetworkProxy {
pub async fn remote_launch_config(&self) -> Result<crate::RemoteNetworkProxyLaunchConfig> {
let (mut config, brokerage_created_default_allowlist) =
self.state.current_cfg_with_brokerage_provenance().await?;
// Proxy enablement and credential brokerage remain controller-owned.
let mut broker_only_config = config::NetworkProxyConfig {
enabled: config.enabled,
credential_broker_openai_host: config.credential_broker_openai_host.clone(),
@@ -877,6 +878,14 @@ impl NetworkProxy {
broker_only_config.set_credential_broker_enabled(/*enabled*/ true);
broker_only_config.set_allowed_domains(vec!["*".to_string()]);
let broker_only = brokerage_created_default_allowlist && config == broker_only_config;
let environment_policy = self
.execution_scope
.as_ref()
.and_then(|scope| scope.environment_policy.as_ref());
if let Some(policy) = environment_policy {
policy.apply_to(&mut config);
}
if config.credential_broker {
config.enabled &= !broker_only;
config.credential_broker = false;
@@ -885,6 +894,10 @@ impl NetworkProxy {
config.mitm = false;
}
}
anyhow::ensure!(
environment_policy.is_none() || config.enabled,
"environment network policy requires an enabled executor proxy"
);
let proxy = crate::RemoteNetworkProxyConfig::from_effective_config(&config)?;
let (environment_id, execution_id) = self
.execution_scope
@@ -913,6 +926,7 @@ impl NetworkProxy {
let state = Arc::clone(&self.state);
let environment_id = scope.environment_id.clone();
let execution_id = scope.execution_id.clone();
let environment_policy_applies = scope.environment_policy.is_some();
let execution_lifetime = scope.lifetime_tx.subscribe();
Some(Arc::new(move |mut request: crate::NetworkPolicyRequest| {
let decider = Arc::clone(&decider);
@@ -928,8 +942,12 @@ impl NetworkProxy {
}
decision = async {
match state.host_blocked(&request.host, request.port).await {
Ok(HostBlockDecision::Allowed) => NetworkDecision::Allow,
Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)) => {
// Controller approval alone cannot bypass attachment policy.
Ok(HostBlockDecision::Allowed) if !environment_policy_applies => {
NetworkDecision::Allow
}
Ok(HostBlockDecision::Allowed)
| Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)) => {
decider.decide(request).await
}
Ok(HostBlockDecision::Blocked(reason)) => {
@@ -1710,13 +1728,15 @@ mod tests {
async fn remote_policy_decider_rechecks_live_policy_and_restores_attribution() -> Result<()> {
let captured = Arc::new(Mutex::new(None));
let captured_request = Arc::clone(&captured);
let config = NetworkProxyConfig {
let mut config = NetworkProxyConfig {
allow_local_binding: true,
..NetworkProxyConfig::default()
};
let proxy = NetworkProxy::builder()
.state(Arc::new(network_proxy_state_for_policy(config)))
.policy_decider(move |request: crate::NetworkPolicyRequest| {
config.set_allowed_domains(vec!["controller.example".to_string()]);
let mut owner_config = NetworkProxyConfig::default();
owner_config.set_allowed_domains(vec!["owner.example".to_string()]);
let fallback_policy_decider: Arc<dyn NetworkPolicyDecider> =
Arc::new(move |request: crate::NetworkPolicyRequest| {
let captured = Arc::clone(&captured_request);
async move {
*captured
@@ -1725,15 +1745,26 @@ mod tests {
Some((request.host, request.environment_id, request.execution_id));
crate::NetworkDecision::Allow
}
})
});
let proxy = NetworkProxy::builder()
.state(Arc::new(network_proxy_state_for_policy(config)))
.managed_by_codex(/*managed_by_codex*/ false)
.build()
.await?;
let scoped = proxy.for_execution("remote", "execution-1", "token-1".to_string())?;
let scoped = proxy.for_execution(
"remote",
"execution-1",
"token-1".to_string(),
Some(crate::EnvironmentNetworkPolicy::from_config(
&owner_config,
/*managed_allowed_domains_only*/ false,
)),
Some(Arc::clone(&fallback_policy_decider)),
)?;
let decider = scoped
.remote_policy_decider()
.expect("execution-scoped proxy should expose its policy decider");
let mut request = https_request("allowed.example");
let mut request = https_request("controller.example");
request.environment_id = Some("forged-environment".to_string());
request.execution_id = Some("forged-execution".to_string());
@@ -1743,7 +1774,7 @@ mod tests {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
Some((
"allowed.example".to_string(),
"controller.example".to_string(),
Some("remote".to_string()),
Some("execution-1".to_string())
))
@@ -1762,11 +1793,28 @@ mod tests {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
Some((
"allowed.example".to_string(),
"controller.example".to_string(),
Some("remote".to_string()),
Some("execution-1".to_string())
))
);
owner_config.set_denied_domains(vec!["denied.example".to_string()]);
assert_eq!(
scoped.remote_launch_config().await?.proxy.domains,
owner_config.domains
);
let strict_scoped = proxy.for_execution(
"owner-environment",
"execution-3",
"token-3".to_string(),
Some(crate::EnvironmentNetworkPolicy::from_config(
&owner_config,
/*managed_allowed_domains_only*/ true,
)),
Some(fallback_policy_decider),
)?;
assert!(strict_scoped.remote_policy_decider().is_none());
Ok(())
}
@@ -1790,7 +1838,13 @@ mod tests {
.managed_by_codex(/*managed_by_codex*/ false)
.build()
.await?;
let scoped = proxy.for_execution("remote", "execution-1", "token-1".to_string())?;
let scoped = proxy.for_execution(
"remote",
"execution-1",
"token-1".to_string(),
/*environment_policy*/ None,
/*fallback_policy_decider*/ None,
)?;
let decider = scoped
.remote_policy_decider()
.expect("execution-scoped proxy should expose its policy decider");
@@ -2052,7 +2106,13 @@ mod tests {
}
};
let scoped = proxy.for_execution("remote-env", "execution-1", "token-1".to_string())?;
let scoped = proxy.for_execution(
"remote-env",
"execution-1",
"token-1".to_string(),
/*environment_policy*/ None,
/*fallback_policy_decider*/ None,
)?;
let launch = scoped.remote_launch_config().await?;
let prepared = scoped.prepare_for_optional_environment(
HashMap::from([(
@@ -2065,6 +2125,20 @@ mod tests {
assert_eq!(launch.environment_id.as_deref(), Some("remote-env"));
assert_eq!(launch.execution_id.as_deref(), Some("execution-1"));
assert!(!launch.proxy.enabled);
let mut owner_config = NetworkProxyConfig::default();
owner_config.set_allowed_domains(vec!["owner.example".to_string()]);
let owner_scoped = proxy.for_execution(
"remote-env",
"execution-2",
"token-2".to_string(),
Some(crate::EnvironmentNetworkPolicy::from_config(
&owner_config,
/*managed_allowed_domains_only*/ false,
)),
/*fallback_policy_decider*/ None,
)?;
assert!(owner_scoped.remote_launch_config().await.is_err());
for (enabled, mode, allowed_domain, proxy_url) in [
(false, config::NetworkMode::Full, Some("*"), None),
(false, config::NetworkMode::Full, Some("example.com"), None),
@@ -4,6 +4,7 @@ pub(super) struct ExecutionScope {
pub(super) environment_id: String,
pub(super) execution_id: String,
pub(super) attribution_token: String,
pub(super) environment_policy: Option<crate::EnvironmentNetworkPolicy>,
// Dropping the execution scope closes this channel and cancels remote reviews.
pub(super) lifetime_tx: tokio::sync::watch::Sender<()>,
state: Arc<NetworkProxyState>,
@@ -16,12 +17,14 @@ impl Drop for ExecutionScope {
}
impl NetworkProxy {
/// Returns a proxy that attributes trusted bridge connections to one execution.
/// Returns a proxy with execution-specific attribution and attachment policy.
pub fn for_execution(
&self,
environment_id: &str,
execution_id: &str,
attribution_token: String,
environment_policy: Option<crate::EnvironmentNetworkPolicy>,
fallback_policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
) -> Result<Self> {
anyhow::ensure!(
self.execution_scope.is_none(),
@@ -32,10 +35,16 @@ impl NetworkProxy {
let (lifetime_tx, _) = tokio::sync::watch::channel(());
let mut proxy = self.clone();
proxy.policy_decider = proxy.policy_decider.or(fallback_policy_decider);
// Strict attachment allowlists cannot be expanded through approval callbacks.
if matches!(&environment_policy, Some(policy) if policy.managed_allowed_domains_only) {
proxy.policy_decider = None;
}
proxy.execution_scope = Some(Arc::new(ExecutionScope {
environment_id: environment_id.to_string(),
execution_id: execution_id.to_string(),
attribution_token,
environment_policy,
lifetime_tx,
state: Arc::clone(&self.state),
}));
-1
View File
@@ -33,7 +33,6 @@ pub struct EnvironmentConfig {
/// Additional managed MCP restrictions for this environment attachment.
pub mcp_policy: Option<EnvironmentMcpPolicy>,
/// Owner-provided traffic restrictions. `None` keeps the existing controller policy.
/// Core rejects `Some` until attachment-owned network enforcement is implemented.
pub network_policy: Option<EnvironmentNetworkPolicy>,
/// Capability roots selected for this thread's environment attachment.
pub selected_capability_roots: Vec<SelectedCapabilityRoot>,