mirror of
https://github.com/openai/codex.git
synced 2026-05-04 21:32:21 +03:00
Add remote_sandbox_config to our config requirements (#18763)
## Why Customers need finer-grained control over allowed sandbox modes based on the host Codex is running on. For example, they may want stricter sandbox limits on devboxes while keeping a different default elsewhere. Our current cloud requirements can target user/account groups, but they cannot vary sandbox requirements by host. That makes remote development environments awkward because the same top-level `allowed_sandbox_modes` has to apply everywhere. ## What Adds a new `remote_sandbox_config` section to `requirements.toml`: ```toml allowed_sandbox_modes = ["read-only"] [[remote_sandbox_config]] hostname_patterns = ["*.org"] allowed_sandbox_modes = ["read-only", "workspace-write"] [[remote_sandbox_config]] hostname_patterns = ["*.sh", "runner-*.ci"] allowed_sandbox_modes = ["read-only", "danger-full-access"] ``` During requirements resolution, Codex resolves the local host name once, preferring the machine FQDN when available and falling back to the cleaned kernel hostname. This host classification is best effort rather than authenticated device proof. Each requirements source applies its first matching `remote_sandbox_config` entry before it is merged with other sources. The shared merge helper keeps that `apply_remote_sandbox_config` step paired with requirements merging so new requirements sources do not have to remember the extra call. That preserves source precedence: a lower-precedence requirements file with a matching `remote_sandbox_config` cannot override a higher-precedence source that already set `allowed_sandbox_modes`. This also wires the hostname-aware resolution through app-server, CLI/TUI config loading, config API reads, and config layer metadata so they all evaluate remote sandbox requirements consistently. ## Verification - `cargo test -p codex-config remote_sandbox_config` - `cargo test -p codex-config host_name` - `cargo test -p codex-core load_config_layers_applies_matching_remote_sandbox_config` - `cargo test -p codex-core system_remote_sandbox_config_keeps_cloud_sandbox_modes` - `cargo test -p codex-config` - `cargo test -p codex-core` unit tests passed; `tests/all.rs` integration matrix was intentionally stopped after the relevant focused tests passed - `just fix -p codex-config` - `just fix -p codex-core` - `cargo check -p codex-app-server`
This commit is contained in:
@@ -11,6 +11,7 @@ use serde::de::value::Error as ValueDeserializerError;
|
||||
use serde::de::value::StrDeserializer;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use wildmatch::WildMatchPattern;
|
||||
|
||||
use super::requirements_exec_policy::RequirementsExecPolicy;
|
||||
use super::requirements_exec_policy::RequirementsExecPolicyToml;
|
||||
@@ -620,6 +621,7 @@ pub struct ConfigRequirementsToml {
|
||||
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
|
||||
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
|
||||
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
|
||||
pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
|
||||
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
|
||||
#[serde(rename = "features", alias = "feature_requirements")]
|
||||
pub feature_requirements: Option<FeatureRequirementsToml>,
|
||||
@@ -633,6 +635,12 @@ pub struct ConfigRequirementsToml {
|
||||
pub guardian_policy_config: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct RemoteSandboxConfigToml {
|
||||
pub hostname_patterns: Vec<String>,
|
||||
pub allowed_sandbox_modes: Vec<SandboxModeRequirement>,
|
||||
}
|
||||
|
||||
/// Value paired with the requirement source it came from, for better error
|
||||
/// messages.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -693,6 +701,7 @@ impl ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: _,
|
||||
allowed_approvals_reviewers: _,
|
||||
allowed_sandbox_modes: _,
|
||||
remote_sandbox_config: _,
|
||||
allowed_web_search_modes: _,
|
||||
feature_requirements: _,
|
||||
mcp_servers: _,
|
||||
@@ -759,6 +768,7 @@ impl ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
|
||||
allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
|
||||
feature_requirements: feature_requirements.map(|sourced| sourced.value),
|
||||
mcp_servers: mcp_servers.map(|sourced| sourced.value),
|
||||
@@ -772,6 +782,19 @@ impl ConfigRequirementsWithSources {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_hostname(hostname: &str) -> Option<String> {
|
||||
let hostname = hostname.trim().trim_end_matches('.');
|
||||
(!hostname.is_empty()).then(|| hostname.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn hostname_matches_any_pattern(hostname: &str, patterns: &[String]) -> bool {
|
||||
patterns.iter().any(|pattern| {
|
||||
normalize_hostname(pattern)
|
||||
.map(|pattern| WildMatchPattern::<'*', '?'>::new_case_insensitive(&pattern))
|
||||
.is_some_and(|pattern| pattern.matches(hostname))
|
||||
})
|
||||
}
|
||||
|
||||
/// Currently, `external-sandbox` is not supported in config.toml, but it is
|
||||
/// supported through programmatic use.
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
@@ -806,10 +829,27 @@ pub enum ResidencyRequirement {
|
||||
}
|
||||
|
||||
impl ConfigRequirementsToml {
|
||||
pub fn apply_remote_sandbox_config(&mut self, hostname: Option<&str>) {
|
||||
let Some(hostname) = hostname.and_then(normalize_hostname) else {
|
||||
return;
|
||||
};
|
||||
let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(matched_config) = remote_sandbox_config
|
||||
.iter()
|
||||
.find(|config| hostname_matches_any_pattern(&hostname, &config.hostname_patterns))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.allowed_sandbox_modes = Some(matched_config.allowed_sandbox_modes.clone());
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.allowed_approval_policies.is_none()
|
||||
&& self.allowed_approvals_reviewers.is_none()
|
||||
&& self.allowed_sandbox_modes.is_none()
|
||||
&& self.remote_sandbox_config.is_none()
|
||||
&& self.allowed_web_search_modes.is_none()
|
||||
&& self
|
||||
.feature_requirements
|
||||
@@ -1099,6 +1139,7 @@ mod tests {
|
||||
allowed_approval_policies,
|
||||
allowed_approvals_reviewers,
|
||||
allowed_sandbox_modes,
|
||||
remote_sandbox_config: _,
|
||||
allowed_web_search_modes,
|
||||
feature_requirements,
|
||||
mcp_servers,
|
||||
@@ -1161,6 +1202,7 @@ mod tests {
|
||||
allowed_approval_policies: Some(allowed_approval_policies.clone()),
|
||||
allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()),
|
||||
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
|
||||
remote_sandbox_config: None,
|
||||
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
|
||||
feature_requirements: Some(feature_requirements.clone()),
|
||||
mcp_servers: None,
|
||||
@@ -1883,6 +1925,172 @@ allowed_approvals_reviewers = ["user"]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_remote_sandbox_config_requires_hostname_patterns_list() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["*.org", "runner-??.ci"]
|
||||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
"#;
|
||||
let config: ConfigRequirementsToml = from_str(toml_str)?;
|
||||
|
||||
assert_eq!(
|
||||
config.remote_sandbox_config,
|
||||
Some(vec![RemoteSandboxConfigToml {
|
||||
hostname_patterns: vec!["*.org".to_string(), "runner-??.ci".to_string()],
|
||||
allowed_sandbox_modes: vec![
|
||||
SandboxModeRequirement::ReadOnly,
|
||||
SandboxModeRequirement::WorkspaceWrite,
|
||||
],
|
||||
}])
|
||||
);
|
||||
|
||||
let err = from_str::<ConfigRequirementsToml>(
|
||||
r#"
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = "*.org"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#,
|
||||
)
|
||||
.expect_err("hostname_patterns should be list-only");
|
||||
assert!(
|
||||
err.to_string().contains("invalid type: string"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> {
|
||||
let source = RequirementSource::CloudRequirements;
|
||||
let mut requirements_toml: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["build-*.example.com"]
|
||||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["build-01.example.com"]
|
||||
allowed_sandbox_modes = ["read-only", "danger-full-access"]
|
||||
"#,
|
||||
)?;
|
||||
requirements_toml.apply_remote_sandbox_config(Some("BUILD-01.EXAMPLE.COM."));
|
||||
let mut requirements_with_sources = ConfigRequirementsWithSources::default();
|
||||
requirements_with_sources.merge_unset_fields(source.clone(), requirements_toml);
|
||||
|
||||
assert_eq!(
|
||||
requirements_with_sources
|
||||
.allowed_sandbox_modes
|
||||
.as_ref()
|
||||
.map(|sourced| sourced.value.clone()),
|
||||
Some(vec![
|
||||
SandboxModeRequirement::ReadOnly,
|
||||
SandboxModeRequirement::WorkspaceWrite,
|
||||
])
|
||||
);
|
||||
|
||||
let requirements = ConfigRequirements::try_from(requirements_with_sources)?;
|
||||
let root = if cfg!(windows) { "C:\\repo" } else { "/repo" };
|
||||
assert!(
|
||||
requirements
|
||||
.sandbox_policy
|
||||
.can_set(&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?],
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
requirements
|
||||
.sandbox_policy
|
||||
.can_set(&SandboxPolicy::DangerFullAccess),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: "DangerFullAccess".into(),
|
||||
allowed: "[ReadOnly, WorkspaceWrite]".into(),
|
||||
requirement_source: source,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_sandbox_config_non_match_preserves_top_level() -> Result<()> {
|
||||
let mut requirements_toml: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["build-*.example.com"]
|
||||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
"#,
|
||||
)?;
|
||||
requirements_toml.apply_remote_sandbox_config(Some("laptop.example.com"));
|
||||
let mut requirements_with_sources = ConfigRequirementsWithSources::default();
|
||||
requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, requirements_toml);
|
||||
let requirements = ConfigRequirements::try_from(requirements_with_sources)?;
|
||||
|
||||
assert_eq!(
|
||||
requirements
|
||||
.sandbox_policy
|
||||
.can_set(&SandboxPolicy::DangerFullAccess),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: "DangerFullAccess".into(),
|
||||
allowed: "[ReadOnly]".into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> {
|
||||
let high_source = RequirementSource::CloudRequirements;
|
||||
let mut high_precedence: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#,
|
||||
)?;
|
||||
high_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com"));
|
||||
|
||||
let mut low_precedence: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
[[remote_sandbox_config]]
|
||||
hostname_patterns = ["runner-*.ci.example.com"]
|
||||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
"#,
|
||||
)?;
|
||||
low_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com"));
|
||||
|
||||
let mut requirements_with_sources = ConfigRequirementsWithSources::default();
|
||||
requirements_with_sources.merge_unset_fields(high_source.clone(), high_precedence);
|
||||
requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, low_precedence);
|
||||
let requirements = ConfigRequirements::try_from(requirements_with_sources)?;
|
||||
|
||||
assert_eq!(
|
||||
requirements
|
||||
.sandbox_policy
|
||||
.can_set(&SandboxPolicy::new_workspace_write_policy()),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: "WorkspaceWrite".into(),
|
||||
allowed: "[ReadOnly]".into(),
|
||||
requirement_source: high_source,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_allowed_web_search_modes() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
|
||||
97
codex-rs/config/src/host_name.rs
Normal file
97
codex-rs/config/src/host_name.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
#[cfg(unix)]
|
||||
use dns_lookup::AddrInfoHints;
|
||||
#[cfg(unix)]
|
||||
use dns_lookup::getaddrinfo;
|
||||
use std::sync::LazyLock;
|
||||
#[cfg(windows)]
|
||||
use winapi_util::sysinfo::ComputerNameKind;
|
||||
#[cfg(windows)]
|
||||
use winapi_util::sysinfo::get_computer_name;
|
||||
|
||||
static HOST_NAME: LazyLock<Option<String>> = LazyLock::new(compute_host_name);
|
||||
|
||||
pub fn host_name() -> Option<String> {
|
||||
HOST_NAME.clone()
|
||||
}
|
||||
|
||||
fn compute_host_name() -> Option<String> {
|
||||
let kernel_hostname = gethostname::gethostname();
|
||||
let kernel_hostname = normalize_host_name(&kernel_hostname.to_string_lossy())?;
|
||||
|
||||
// Remote sandbox requirements are meant to target remote hosts by DNS name,
|
||||
// so prefer the canonical FQDN when the local resolver can provide one.
|
||||
// This is best-effort host classification, not authenticated device proof.
|
||||
if let Some(fqdn) = local_fqdn_for_hostname(&kernel_hostname) {
|
||||
return Some(fqdn);
|
||||
}
|
||||
|
||||
// Some machines have only a short local hostname or resolver setup that
|
||||
// does not return AI_CANONNAME. Keep matching behavior best-effort by
|
||||
// falling back to the cleaned kernel hostname instead of returning None.
|
||||
Some(kernel_hostname)
|
||||
}
|
||||
|
||||
fn normalize_host_name(hostname: &str) -> Option<String> {
|
||||
let hostname = hostname.trim().trim_end_matches('.');
|
||||
(!hostname.is_empty()).then(|| hostname.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn local_fqdn_for_hostname(hostname: &str) -> Option<String> {
|
||||
let hints = AddrInfoHints {
|
||||
flags: libc::AI_CANONNAME,
|
||||
..AddrInfoHints::default()
|
||||
};
|
||||
|
||||
getaddrinfo(Some(hostname), /*service*/ None, Some(hints))
|
||||
.ok()?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|addr| addr.canonname)
|
||||
// getaddrinfo may return the short hostname as canonname when no FQDN
|
||||
// is available. Treat only DNS-qualified names as an FQDN result.
|
||||
.find_map(|hostname| normalize_fqdn_candidate(&hostname))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn local_fqdn_for_hostname(_hostname: &str) -> Option<String> {
|
||||
get_computer_name(ComputerNameKind::PhysicalDnsFullyQualified)
|
||||
.ok()
|
||||
.and_then(|hostname| hostname.into_string().ok())
|
||||
.and_then(|hostname| normalize_fqdn_candidate(&hostname))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn local_fqdn_for_hostname(_hostname: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_fqdn_candidate(hostname: &str) -> Option<String> {
|
||||
normalize_host_name(hostname).filter(|hostname| hostname.contains('.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_fqdn_candidate;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn normalize_fqdn_candidate_accepts_dns_qualified_name() {
|
||||
assert_eq!(
|
||||
normalize_fqdn_candidate("runner-01.ci.example.com"),
|
||||
Some("runner-01.ci.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_fqdn_candidate_rejects_short_name() {
|
||||
assert_eq!(normalize_fqdn_candidate("runner-01"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_fqdn_candidate_trims_trailing_dot_and_normalizes_case() {
|
||||
assert_eq!(
|
||||
normalize_fqdn_candidate("RUNNER-01.CI.EXAMPLE.COM."),
|
||||
Some("runner-01.ci.example.com".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod config_toml;
|
||||
mod constraint;
|
||||
mod diagnostics;
|
||||
mod fingerprint;
|
||||
mod host_name;
|
||||
mod key_aliases;
|
||||
mod marketplace_edit;
|
||||
mod mcp_edit;
|
||||
@@ -43,6 +44,7 @@ pub use config_requirements::NetworkDomainPermissionsToml;
|
||||
pub use config_requirements::NetworkRequirementsToml;
|
||||
pub use config_requirements::NetworkUnixSocketPermissionToml;
|
||||
pub use config_requirements::NetworkUnixSocketPermissionsToml;
|
||||
pub use config_requirements::RemoteSandboxConfigToml;
|
||||
pub use config_requirements::RequirementSource;
|
||||
pub use config_requirements::ResidencyRequirement;
|
||||
pub use config_requirements::SandboxModeRequirement;
|
||||
@@ -63,6 +65,7 @@ pub use diagnostics::format_config_error;
|
||||
pub use diagnostics::format_config_error_with_source;
|
||||
pub use diagnostics::io_error_from_config_error;
|
||||
pub use fingerprint::version_for_toml;
|
||||
pub use host_name::host_name;
|
||||
pub use marketplace_edit::MarketplaceConfigUpdate;
|
||||
pub use marketplace_edit::RemoveMarketplaceConfigOutcome;
|
||||
pub use marketplace_edit::record_user_marketplace;
|
||||
|
||||
Reference in New Issue
Block a user