permissions: make profiles represent enforcement

This commit is contained in:
Michael Bolin
2026-04-23 17:08:51 -07:00
parent a9f75e5cda
commit 6c5b77a080
73 changed files with 2091 additions and 890 deletions
+4 -3
View File
@@ -1,4 +1,5 @@
use crate::mcp::RequestId;
use crate::models::AdditionalPermissionProfile;
use crate::models::PermissionProfile;
use crate::parse_command::ParsedCommand;
use crate::protocol::FileChange;
@@ -28,7 +29,7 @@ pub struct ResolvedPermissionProfile {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EscalationPermissions {
/// Permissions to merge with the active turn permissions.
AdditionalPermissionProfile(PermissionProfile),
AdditionalPermissionProfile(AdditionalPermissionProfile),
/// Fully resolved permissions that should replace the active turn permissions.
ResolvedPermissionProfile(ResolvedPermissionProfile),
}
@@ -249,7 +250,7 @@ pub struct ExecApprovalRequestEvent {
/// Optional additional filesystem permissions requested for this command.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<PermissionProfile>,
pub additional_permissions: Option<AdditionalPermissionProfile>,
/// Ordered list of decisions the client may present for this prompt.
///
/// When absent, clients should derive the legacy default set from the
@@ -285,7 +286,7 @@ impl ExecApprovalRequestEvent {
network_approval_context: Option<&NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>,
proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>,
additional_permissions: Option<&PermissionProfile>,
additional_permissions: Option<&AdditionalPermissionProfile>,
) -> Vec<ReviewDecision> {
if network_approval_context.is_some() {
let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession];
+406 -27
View File
@@ -266,43 +266,274 @@ impl NetworkPermissions {
}
}
/// Partial permission overlay used for per-command requests and approved
/// session/turn grants.
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
pub struct PermissionProfile {
pub struct AdditionalPermissionProfile {
pub network: Option<NetworkPermissions>,
pub file_system: Option<FileSystemPermissions>,
}
impl PermissionProfile {
impl AdditionalPermissionProfile {
pub fn is_empty(&self) -> bool {
self.network.is_none() && self.file_system.is_none()
}
}
#[derive(
Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
)]
#[serde(rename_all = "snake_case")]
pub enum SandboxEnforcement {
/// Codex owns sandbox construction for this profile.
#[default]
Managed,
/// No outer filesystem sandbox should be applied.
Disabled,
/// Filesystem isolation is enforced by an external caller.
External,
}
impl SandboxEnforcement {
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
match sandbox_policy {
SandboxPolicy::DangerFullAccess => Self::Disabled,
SandboxPolicy::ExternalSandbox { .. } => Self::External,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => Self::Managed,
}
}
}
/// Filesystem permissions for profiles where Codex owns sandbox construction.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum ManagedFileSystemPermissions {
/// Apply a managed filesystem sandbox from the listed entries.
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
Restricted {
entries: Vec<FileSystemSandboxEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
glob_scan_max_depth: Option<NonZeroUsize>,
},
/// Apply a managed sandbox that allows all filesystem access.
Unrestricted,
}
impl ManagedFileSystemPermissions {
fn from_sandbox_policy(file_system_sandbox_policy: &FileSystemSandboxPolicy) -> Self {
match file_system_sandbox_policy.kind {
FileSystemSandboxKind::Restricted => Self::Restricted {
entries: file_system_sandbox_policy.entries.clone(),
glob_scan_max_depth: file_system_sandbox_policy
.glob_scan_max_depth
.and_then(NonZeroUsize::new),
},
FileSystemSandboxKind::Unrestricted => Self::Unrestricted,
FileSystemSandboxKind::ExternalSandbox => unreachable!(
"external filesystem policies are represented by PermissionProfile::External"
),
}
}
pub fn to_sandbox_policy(&self) -> FileSystemSandboxPolicy {
match self {
Self::Restricted {
entries,
glob_scan_max_depth,
} => FileSystemSandboxPolicy {
kind: FileSystemSandboxKind::Restricted,
glob_scan_max_depth: glob_scan_max_depth.map(usize::from),
entries: entries.clone(),
},
Self::Unrestricted => FileSystemSandboxPolicy::unrestricted(),
}
}
}
/// Canonical active runtime permissions for a conversation, turn, or command.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum PermissionProfile {
/// Codex owns sandbox construction for this profile.
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
Managed {
file_system: ManagedFileSystemPermissions,
network: NetworkSandboxPolicy,
},
/// Do not apply an outer sandbox.
Disabled,
/// Filesystem isolation is enforced by an external caller.
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
External { network: NetworkSandboxPolicy },
}
impl Default for PermissionProfile {
fn default() -> Self {
Self::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
entries: Vec::new(),
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
}
}
}
impl PermissionProfile {
pub fn from_runtime_permissions(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
) -> Self {
Self {
network: Some(network_sandbox_policy.into()),
file_system: Some(file_system_sandbox_policy.into()),
let enforcement = match file_system_sandbox_policy.kind {
FileSystemSandboxKind::Restricted | FileSystemSandboxKind::Unrestricted => {
SandboxEnforcement::Managed
}
FileSystemSandboxKind::ExternalSandbox => SandboxEnforcement::External,
};
Self::from_runtime_permissions_with_enforcement(
enforcement,
file_system_sandbox_policy,
network_sandbox_policy,
)
}
pub fn from_runtime_permissions_with_enforcement(
enforcement: SandboxEnforcement,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
) -> Self {
match file_system_sandbox_policy.kind {
FileSystemSandboxKind::ExternalSandbox => Self::External {
network: network_sandbox_policy,
},
FileSystemSandboxKind::Unrestricted
if enforcement == SandboxEnforcement::Disabled
&& network_sandbox_policy.is_enabled() =>
{
Self::Disabled
}
FileSystemSandboxKind::Restricted | FileSystemSandboxKind::Unrestricted => {
Self::Managed {
file_system: ManagedFileSystemPermissions::from_sandbox_policy(
file_system_sandbox_policy,
),
network: network_sandbox_policy,
}
}
}
}
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
Self::from_runtime_permissions(
Self::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, cwd),
NetworkSandboxPolicy::from(sandbox_policy),
)
}
pub fn enforcement(&self) -> SandboxEnforcement {
match self {
Self::Managed { .. } => SandboxEnforcement::Managed,
Self::Disabled => SandboxEnforcement::Disabled,
Self::External { .. } => SandboxEnforcement::External,
}
}
pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy {
self.file_system.as_ref().map_or_else(
|| FileSystemSandboxPolicy::restricted(Vec::new()),
FileSystemSandboxPolicy::from,
)
match self {
Self::Managed { file_system, .. } => file_system.to_sandbox_policy(),
Self::Disabled => FileSystemSandboxPolicy::unrestricted(),
Self::External { .. } => FileSystemSandboxPolicy::external_sandbox(),
}
}
pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy {
if self
match self {
Self::Managed { network, .. } | Self::External { network } => *network,
Self::Disabled => NetworkSandboxPolicy::Enabled,
}
}
pub fn to_legacy_sandbox_policy(&self, cwd: &Path) -> io::Result<SandboxPolicy> {
match self {
Self::Managed {
file_system,
network,
} => file_system
.to_sandbox_policy()
.to_legacy_sandbox_policy(*network, cwd),
Self::Disabled => Ok(SandboxPolicy::DangerFullAccess),
Self::External { network } => Ok(SandboxPolicy::ExternalSandbox {
network_access: if network.is_enabled() {
crate::protocol::NetworkAccess::Enabled
} else {
crate::protocol::NetworkAccess::Restricted
},
}),
}
}
pub fn to_runtime_permissions(&self) -> (FileSystemSandboxPolicy, NetworkSandboxPolicy) {
(
self.file_system_sandbox_policy(),
self.network_sandbox_policy(),
)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum TaggedPermissionProfile {
#[serde(rename_all = "snake_case")]
Managed {
file_system: ManagedFileSystemPermissions,
network: NetworkSandboxPolicy,
},
Disabled,
#[serde(rename_all = "snake_case")]
External {
network: NetworkSandboxPolicy,
},
}
impl From<TaggedPermissionProfile> for PermissionProfile {
fn from(value: TaggedPermissionProfile) -> Self {
match value {
TaggedPermissionProfile::Managed {
file_system,
network,
} => Self::Managed {
file_system,
network,
},
TaggedPermissionProfile::Disabled => Self::Disabled,
TaggedPermissionProfile::External { network } => Self::External { network },
}
}
}
/// Pre-tagged shape written to rollout files before `PermissionProfile`
/// represented enforcement explicitly.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyPermissionProfile {
network: Option<NetworkPermissions>,
file_system: Option<FileSystemPermissions>,
}
impl From<LegacyPermissionProfile> for PermissionProfile {
fn from(value: LegacyPermissionProfile) -> Self {
let file_system_sandbox_policy = value.file_system.as_ref().map_or_else(
|| FileSystemSandboxPolicy::restricted(Vec::new()),
FileSystemSandboxPolicy::from,
);
let network_sandbox_policy = if value
.network
.as_ref()
.and_then(|network| network.enabled)
@@ -311,19 +542,27 @@ impl PermissionProfile {
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
}
};
Self::from_runtime_permissions(&file_system_sandbox_policy, network_sandbox_policy)
}
}
pub fn to_legacy_sandbox_policy(&self, cwd: &Path) -> io::Result<SandboxPolicy> {
self.file_system_sandbox_policy()
.to_legacy_sandbox_policy(self.network_sandbox_policy(), cwd)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum PermissionProfileDe {
Tagged(TaggedPermissionProfile),
Legacy(LegacyPermissionProfile),
}
pub fn to_runtime_permissions(&self) -> (FileSystemSandboxPolicy, NetworkSandboxPolicy) {
(
self.file_system_sandbox_policy(),
self.network_sandbox_policy(),
)
impl<'de> Deserialize<'de> for PermissionProfile {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match PermissionProfileDe::deserialize(deserializer)? {
PermissionProfileDe::Tagged(tagged) => tagged.into(),
PermissionProfileDe::Legacy(legacy) => legacy.into(),
})
}
}
@@ -977,7 +1216,7 @@ pub struct ShellToolCallParams {
pub prefix_rule: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<PermissionProfile>,
pub additional_permissions: Option<AdditionalPermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
@@ -1003,7 +1242,7 @@ pub struct ShellCommandToolCallParams {
pub prefix_rule: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub additional_permissions: Option<PermissionProfile>,
pub additional_permissions: Option<AdditionalPermissionProfile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
@@ -1448,13 +1687,13 @@ mod tests {
}
#[test]
fn permission_profile_is_empty_when_all_fields_are_none() {
assert_eq!(PermissionProfile::default().is_empty(), true);
fn additional_permission_profile_is_empty_when_all_fields_are_none() {
assert_eq!(AdditionalPermissionProfile::default().is_empty(), true);
}
#[test]
fn permission_profile_is_not_empty_when_field_is_present_but_nested_empty() {
let permission_profile = PermissionProfile {
fn additional_permission_profile_is_not_empty_when_field_is_present_but_nested_empty() {
let permission_profile = AdditionalPermissionProfile {
network: Some(NetworkPermissions { enabled: None }),
file_system: None,
};
@@ -1483,6 +1722,146 @@ mod tests {
);
}
#[test]
fn permission_profile_deserializes_legacy_rollout_shape() -> Result<()> {
let legacy = serde_json::json!({
"network": {
"enabled": true,
},
"file_system": {
"entries": [{
"path": {
"type": "special",
"value": {
"kind": "root",
},
},
"access": "write",
}],
"glob_scan_max_depth": 2,
},
});
let permission_profile: PermissionProfile = serde_json::from_value(legacy)?;
assert_eq!(
permission_profile,
PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}],
glob_scan_max_depth: NonZeroUsize::new(2),
},
network: NetworkSandboxPolicy::Enabled,
}
);
Ok(())
}
#[test]
fn permission_profile_round_trip_preserves_disabled_sandbox() -> Result<()> {
let cwd = tempdir()?;
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::DangerFullAccess,
cwd.path(),
);
assert_eq!(permission_profile, PermissionProfile::Disabled);
assert_eq!(
permission_profile.to_legacy_sandbox_policy(cwd.path())?,
SandboxPolicy::DangerFullAccess
);
assert_eq!(
permission_profile.to_runtime_permissions(),
(
FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled
)
);
Ok(())
}
#[test]
fn permission_profile_from_runtime_permissions_preserves_external_sandbox() {
let permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::external_sandbox(),
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
permission_profile,
PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
}
);
assert_eq!(
PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::Managed,
&FileSystemSandboxPolicy::external_sandbox(),
NetworkSandboxPolicy::Restricted,
),
permission_profile,
);
}
#[test]
fn permission_profile_from_runtime_permissions_preserves_unrestricted_managed_network() {
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::External,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
permission_profile,
PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Restricted,
},
"the legacy ExternalSandbox projection must not hide a split unrestricted filesystem policy"
);
assert_eq!(
permission_profile.to_runtime_permissions(),
(
FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
)
);
}
#[test]
fn permission_profile_round_trip_preserves_external_sandbox() -> Result<()> {
let cwd = tempdir()?;
let sandbox_policy = SandboxPolicy::ExternalSandbox {
network_access: crate::protocol::NetworkAccess::Restricted,
};
let permission_profile =
PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy, cwd.path());
assert_eq!(
permission_profile,
PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
}
);
assert_eq!(
permission_profile.to_legacy_sandbox_policy(cwd.path())?,
sandbox_policy
);
assert_eq!(
permission_profile.to_runtime_permissions(),
(
FileSystemSandboxPolicy::external_sandbox(),
NetworkSandboxPolicy::Restricted
)
);
Ok(())
}
#[test]
fn file_system_permissions_with_glob_scan_depth_uses_canonical_json() -> Result<()> {
let path = AbsolutePathBuf::try_from(PathBuf::from(if cfg!(windows) {
+57
View File
@@ -340,6 +340,41 @@ impl FileSystemSandboxPolicy {
rebuilt
}
/// Preserve explicit read-deny rules from `existing` when a caller
/// replaces the allow side of a policy.
pub fn preserve_deny_read_restrictions_from(&mut self, existing: &Self) {
let has_deny_read_entries = existing
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None);
if matches!(self.kind, FileSystemSandboxKind::Unrestricted) && has_deny_read_entries {
*self = Self::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]);
}
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
return;
}
if self.glob_scan_max_depth.is_none() {
self.glob_scan_max_depth = existing.glob_scan_max_depth;
}
for deny_entry in existing
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
{
if !self.entries.iter().any(|entry| entry == deny_entry) {
self.entries.push(deny_entry.clone());
}
}
}
/// Returns true when a restricted policy contains any entry that really
/// reduces a broader `:root = write` grant.
///
@@ -2297,6 +2332,28 @@ mod tests {
);
}
#[test]
fn preserving_deny_entries_keeps_unrestricted_policy_enforceable() {
let deny_entry = unreadable_glob_entry("/tmp/project/**/*.env".to_string());
let mut existing = FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]);
existing.glob_scan_max_depth = Some(2);
let mut replacement = FileSystemSandboxPolicy::unrestricted();
replacement.preserve_deny_read_restrictions_from(&existing);
let mut expected = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
},
deny_entry,
]);
expected.glob_scan_max_depth = Some(2);
assert_eq!(replacement, expected);
}
fn deny_policy(path: &Path) -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
+3 -1
View File
@@ -41,6 +41,7 @@ use crate::models::MessagePhase;
use crate::models::PermissionProfile;
use crate::models::ResponseInputItem;
use crate::models::ResponseItem;
use crate::models::SandboxEnforcement;
use crate::models::WebSearchAction;
use crate::num_format::format_with_separators;
use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
@@ -3062,7 +3063,8 @@ impl TurnContextItem {
&self.cwd,
)
});
PermissionProfile::from_runtime_permissions(
PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(&self.sandbox_policy),
&file_system_sandbox_policy,
NetworkSandboxPolicy::from(&self.sandbox_policy),
)
+4 -4
View File
@@ -1,6 +1,6 @@
use crate::models::AdditionalPermissionProfile;
use crate::models::FileSystemPermissions;
use crate::models::NetworkPermissions;
use crate::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -28,7 +28,7 @@ impl RequestPermissionProfile {
}
}
impl From<RequestPermissionProfile> for PermissionProfile {
impl From<RequestPermissionProfile> for AdditionalPermissionProfile {
fn from(value: RequestPermissionProfile) -> Self {
Self {
network: value.network,
@@ -37,8 +37,8 @@ impl From<RequestPermissionProfile> for PermissionProfile {
}
}
impl From<PermissionProfile> for RequestPermissionProfile {
fn from(value: PermissionProfile) -> Self {
impl From<AdditionalPermissionProfile> for RequestPermissionProfile {
fn from(value: AdditionalPermissionProfile) -> Self {
Self {
network: value.network,
file_system: value.file_system,