Skip to main content

zerobox_protocol/
models.rs

1use std::io;
2use std::num::NonZeroUsize;
3use std::path::Path;
4
5use serde::Deserialize;
6use serde::Deserializer;
7use serde::Serialize;
8use serde::ser::Serializer;
9use ts_rs::TS;
10
11use crate::permissions::FileSystemAccessMode;
12use crate::permissions::FileSystemPath;
13use crate::permissions::FileSystemSandboxEntry;
14use crate::permissions::FileSystemSandboxKind;
15use crate::permissions::FileSystemSandboxPolicy;
16use crate::permissions::FileSystemSpecialPath;
17use crate::permissions::NetworkSandboxPolicy;
18use crate::protocol::SandboxPolicy;
19use schemars::JsonSchema;
20use zerobox_utils_absolute_path::AbsolutePathBuf;
21
22/// Controls the per-command sandbox override requested by a shell-like tool call.
23#[derive(
24    Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
25)]
26#[serde(rename_all = "snake_case")]
27pub enum SandboxPermissions {
28    /// Run with the turn's configured sandbox policy unchanged.
29    #[default]
30    UseDefault,
31    /// Request to run outside the sandbox.
32    RequireEscalated,
33    /// Request to stay in the sandbox while widening permissions for this
34    /// command only.
35    WithAdditionalPermissions,
36}
37
38impl SandboxPermissions {
39    /// True if SandboxPermissions requires full unsandboxed execution (i.e. RequireEscalated)
40    pub fn requires_escalated_permissions(self) -> bool {
41        matches!(self, SandboxPermissions::RequireEscalated)
42    }
43
44    /// True if SandboxPermissions requests any explicit per-command override
45    /// beyond `UseDefault`.
46    pub fn requests_sandbox_override(self) -> bool {
47        !matches!(self, SandboxPermissions::UseDefault)
48    }
49
50    /// True if SandboxPermissions uses the sandboxed per-command permission
51    /// widening flow.
52    pub fn uses_additional_permissions(self) -> bool {
53        matches!(self, SandboxPermissions::WithAdditionalPermissions)
54    }
55}
56
57#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, JsonSchema, TS)]
58pub struct FileSystemPermissions {
59    pub entries: Vec<FileSystemSandboxEntry>,
60    pub glob_scan_max_depth: Option<NonZeroUsize>,
61}
62
63pub type LegacyReadWriteRoots = (Option<Vec<AbsolutePathBuf>>, Option<Vec<AbsolutePathBuf>>);
64
65impl FileSystemPermissions {
66    pub fn is_empty(&self) -> bool {
67        self.entries.is_empty()
68    }
69
70    pub fn from_read_write_roots(
71        read: Option<Vec<AbsolutePathBuf>>,
72        write: Option<Vec<AbsolutePathBuf>>,
73    ) -> Self {
74        let mut entries = Vec::new();
75        if let Some(read) = read {
76            entries.extend(read.into_iter().map(|path| FileSystemSandboxEntry {
77                path: FileSystemPath::Path { path },
78                access: FileSystemAccessMode::Read,
79            }));
80        }
81        if let Some(write) = write {
82            entries.extend(write.into_iter().map(|path| FileSystemSandboxEntry {
83                path: FileSystemPath::Path { path },
84                access: FileSystemAccessMode::Write,
85            }));
86        }
87        Self {
88            entries,
89            glob_scan_max_depth: None,
90        }
91    }
92
93    pub fn explicit_path_entries(
94        &self,
95    ) -> impl Iterator<Item = (&AbsolutePathBuf, FileSystemAccessMode)> {
96        self.entries.iter().filter_map(|entry| match &entry.path {
97            FileSystemPath::Path { path } => Some((path, entry.access)),
98            FileSystemPath::GlobPattern { .. } | FileSystemPath::Special { .. } => None,
99        })
100    }
101
102    pub fn legacy_read_write_roots(&self) -> Option<LegacyReadWriteRoots> {
103        self.as_legacy_permissions()
104            .map(|legacy| (legacy.read, legacy.write))
105    }
106
107    fn as_legacy_permissions(&self) -> Option<LegacyFileSystemPermissions> {
108        if self.glob_scan_max_depth.is_some() {
109            return None;
110        }
111
112        let mut read = Vec::new();
113        let mut write = Vec::new();
114
115        for entry in &self.entries {
116            let FileSystemPath::Path { path } = &entry.path else {
117                return None;
118            };
119            match entry.access {
120                FileSystemAccessMode::Read => read.push(path.clone()),
121                FileSystemAccessMode::Write => write.push(path.clone()),
122                FileSystemAccessMode::None => return None,
123            }
124        }
125
126        Some(LegacyFileSystemPermissions {
127            read: (!read.is_empty()).then_some(read),
128            write: (!write.is_empty()).then_some(write),
129        })
130    }
131}
132
133#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
134#[serde(deny_unknown_fields)]
135struct LegacyFileSystemPermissions {
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    read: Option<Vec<AbsolutePathBuf>>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    write: Option<Vec<AbsolutePathBuf>>,
140}
141
142#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144struct CanonicalFileSystemPermissions {
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    entries: Vec<FileSystemSandboxEntry>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    glob_scan_max_depth: Option<NonZeroUsize>,
149}
150
151#[derive(Debug, Clone, Deserialize)]
152#[serde(untagged)]
153enum FileSystemPermissionsDe {
154    Canonical(CanonicalFileSystemPermissions),
155    Legacy(LegacyFileSystemPermissions),
156}
157
158impl Serialize for FileSystemPermissions {
159    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
160    where
161        S: Serializer,
162    {
163        if let Some(legacy) = self.as_legacy_permissions() {
164            legacy.serialize(serializer)
165        } else {
166            CanonicalFileSystemPermissions {
167                entries: self.entries.clone(),
168                glob_scan_max_depth: self.glob_scan_max_depth,
169            }
170            .serialize(serializer)
171        }
172    }
173}
174
175impl<'de> Deserialize<'de> for FileSystemPermissions {
176    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
177    where
178        D: Deserializer<'de>,
179    {
180        match FileSystemPermissionsDe::deserialize(deserializer)? {
181            FileSystemPermissionsDe::Canonical(CanonicalFileSystemPermissions {
182                entries,
183                glob_scan_max_depth,
184            }) => Ok(Self {
185                entries,
186                glob_scan_max_depth,
187            }),
188            FileSystemPermissionsDe::Legacy(LegacyFileSystemPermissions { read, write }) => {
189                Ok(Self::from_read_write_roots(read, write))
190            }
191        }
192    }
193}
194
195#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
196pub struct NetworkPermissions {
197    pub enabled: Option<bool>,
198}
199
200impl NetworkPermissions {
201    pub fn is_empty(&self) -> bool {
202        self.enabled.is_none()
203    }
204}
205
206/// Partial permission overlay used for per-command requests and approved
207/// session/turn grants.
208#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
209pub struct AdditionalPermissionProfile {
210    pub network: Option<NetworkPermissions>,
211    pub file_system: Option<FileSystemPermissions>,
212}
213
214impl AdditionalPermissionProfile {
215    pub fn is_empty(&self) -> bool {
216        self.network.is_none() && self.file_system.is_none()
217    }
218}
219
220#[derive(
221    Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
222)]
223#[serde(rename_all = "snake_case")]
224pub enum SandboxEnforcement {
225    /// Codex owns sandbox construction for this profile.
226    #[default]
227    Managed,
228    /// No outer filesystem sandbox should be applied.
229    Disabled,
230    /// Filesystem isolation is enforced by an external caller.
231    External,
232}
233
234impl SandboxEnforcement {
235    pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
236        match sandbox_policy {
237            SandboxPolicy::DangerFullAccess => Self::Disabled,
238            SandboxPolicy::ExternalSandbox { .. } => Self::External,
239            SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => Self::Managed,
240        }
241    }
242}
243
244/// Filesystem permissions for profiles where Codex owns sandbox construction.
245#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
246#[serde(tag = "type", rename_all = "snake_case")]
247#[ts(tag = "type")]
248pub enum ManagedFileSystemPermissions {
249    /// Apply a managed filesystem sandbox from the listed entries.
250    #[serde(rename_all = "snake_case")]
251    #[ts(rename_all = "snake_case")]
252    Restricted {
253        entries: Vec<FileSystemSandboxEntry>,
254        #[serde(default, skip_serializing_if = "Option::is_none")]
255        #[ts(optional)]
256        glob_scan_max_depth: Option<NonZeroUsize>,
257    },
258    /// Apply a managed sandbox that allows all filesystem access.
259    Unrestricted,
260}
261
262impl ManagedFileSystemPermissions {
263    fn from_sandbox_policy(file_system_sandbox_policy: &FileSystemSandboxPolicy) -> Self {
264        match file_system_sandbox_policy.kind {
265            FileSystemSandboxKind::Restricted => Self::Restricted {
266                entries: file_system_sandbox_policy.entries.clone(),
267                glob_scan_max_depth: file_system_sandbox_policy
268                    .glob_scan_max_depth
269                    .and_then(NonZeroUsize::new),
270            },
271            FileSystemSandboxKind::Unrestricted => Self::Unrestricted,
272            FileSystemSandboxKind::ExternalSandbox => unreachable!(
273                "external filesystem policies are represented by PermissionProfile::External"
274            ),
275        }
276    }
277
278    pub fn to_sandbox_policy(&self) -> FileSystemSandboxPolicy {
279        match self {
280            Self::Restricted {
281                entries,
282                glob_scan_max_depth,
283            } => FileSystemSandboxPolicy {
284                kind: FileSystemSandboxKind::Restricted,
285                glob_scan_max_depth: glob_scan_max_depth.map(usize::from),
286                entries: entries.clone(),
287            },
288            Self::Unrestricted => FileSystemSandboxPolicy::unrestricted(),
289        }
290    }
291}
292
293/// Canonical active runtime permissions for a conversation, turn, or command.
294#[derive(Debug, Clone, Eq, PartialEq, Serialize, JsonSchema, TS)]
295#[serde(tag = "type", rename_all = "snake_case")]
296#[ts(tag = "type")]
297pub enum PermissionProfile {
298    /// Codex owns sandbox construction for this profile.
299    #[serde(rename_all = "snake_case")]
300    #[ts(rename_all = "snake_case")]
301    Managed {
302        file_system: ManagedFileSystemPermissions,
303        network: NetworkSandboxPolicy,
304    },
305    /// Do not apply an outer sandbox.
306    Disabled,
307    /// Filesystem isolation is enforced by an external caller.
308    #[serde(rename_all = "snake_case")]
309    #[ts(rename_all = "snake_case")]
310    External { network: NetworkSandboxPolicy },
311}
312
313/// Metadata for the named or implicit built-in permissions profile that
314/// produced the active `PermissionProfile`.
315///
316/// The runtime must honor `PermissionProfile`; this sidecar exists so clients
317/// can display stable profile identity without trying to reverse-engineer a
318/// name from the compiled permissions.
319#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
320pub struct ActivePermissionProfile {
321    /// Profile identifier from `default_permissions` or the implicit built-in
322    /// default, such as `:workspace` or a user-defined `[permissions.<id>]`
323    /// profile.
324    pub id: String,
325
326    /// Optional parent profile identifier once permissions profiles support
327    /// inheritance. This is always `None` until that config feature exists.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    #[ts(optional)]
330    pub extends: Option<String>,
331
332    /// Bounded user-requested modifications applied on top of the named
333    /// profile, if any.
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub modifications: Vec<ActivePermissionProfileModification>,
336}
337
338#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
339#[serde(tag = "type", rename_all = "snake_case")]
340#[ts(tag = "type")]
341pub enum ActivePermissionProfileModification {
342    /// Additional concrete directory that should be writable.
343    #[serde(rename_all = "snake_case")]
344    #[ts(rename_all = "snake_case")]
345    AdditionalWritableRoot { path: AbsolutePathBuf },
346}
347
348impl ActivePermissionProfile {
349    pub fn new(id: impl Into<String>) -> Self {
350        Self {
351            id: id.into(),
352            extends: None,
353            modifications: Vec::new(),
354        }
355    }
356
357    pub fn with_modifications(
358        mut self,
359        modifications: Vec<ActivePermissionProfileModification>,
360    ) -> Self {
361        self.modifications = modifications;
362        self
363    }
364}
365
366impl Default for PermissionProfile {
367    fn default() -> Self {
368        Self::Managed {
369            file_system: ManagedFileSystemPermissions::Restricted {
370                entries: Vec::new(),
371                glob_scan_max_depth: None,
372            },
373            network: NetworkSandboxPolicy::Restricted,
374        }
375    }
376}
377
378impl PermissionProfile {
379    /// Managed read-only filesystem access with restricted network access.
380    pub fn read_only() -> Self {
381        Self::Managed {
382            file_system: ManagedFileSystemPermissions::Restricted {
383                entries: vec![FileSystemSandboxEntry {
384                    path: FileSystemPath::Special {
385                        value: FileSystemSpecialPath::Root,
386                    },
387                    access: FileSystemAccessMode::Read,
388                }],
389                glob_scan_max_depth: None,
390            },
391            network: NetworkSandboxPolicy::Restricted,
392        }
393    }
394
395    /// Managed workspace-write filesystem access with restricted network
396    /// access.
397    ///
398    /// The returned profile contains symbolic `:project_roots` entries that
399    /// must be resolved against the active permission root before enforcement.
400    pub fn workspace_write() -> Self {
401        Self::workspace_write_with(
402            &[],
403            NetworkSandboxPolicy::Restricted,
404            /*exclude_tmpdir_env_var*/ false,
405            /*exclude_slash_tmp*/ false,
406        )
407    }
408
409    /// Managed workspace-write filesystem access with the legacy
410    /// `sandbox_workspace_write` knobs applied directly to the profile.
411    ///
412    /// The returned profile contains symbolic `:project_roots` entries that
413    /// must be resolved against the active permission root before enforcement.
414    pub fn workspace_write_with(
415        writable_roots: &[AbsolutePathBuf],
416        network: NetworkSandboxPolicy,
417        exclude_tmpdir_env_var: bool,
418        exclude_slash_tmp: bool,
419    ) -> Self {
420        let file_system = FileSystemSandboxPolicy::workspace_write(
421            writable_roots,
422            exclude_tmpdir_env_var,
423            exclude_slash_tmp,
424        );
425        Self::Managed {
426            file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
427            network,
428        }
429    }
430
431    pub fn from_runtime_permissions(
432        file_system_sandbox_policy: &FileSystemSandboxPolicy,
433        network_sandbox_policy: NetworkSandboxPolicy,
434    ) -> Self {
435        let enforcement = match file_system_sandbox_policy.kind {
436            FileSystemSandboxKind::Restricted | FileSystemSandboxKind::Unrestricted => {
437                SandboxEnforcement::Managed
438            }
439            FileSystemSandboxKind::ExternalSandbox => SandboxEnforcement::External,
440        };
441        Self::from_runtime_permissions_with_enforcement(
442            enforcement,
443            file_system_sandbox_policy,
444            network_sandbox_policy,
445        )
446    }
447
448    pub fn from_runtime_permissions_with_enforcement(
449        enforcement: SandboxEnforcement,
450        file_system_sandbox_policy: &FileSystemSandboxPolicy,
451        network_sandbox_policy: NetworkSandboxPolicy,
452    ) -> Self {
453        match file_system_sandbox_policy.kind {
454            FileSystemSandboxKind::ExternalSandbox => Self::External {
455                network: network_sandbox_policy,
456            },
457            FileSystemSandboxKind::Unrestricted if enforcement == SandboxEnforcement::Disabled => {
458                Self::Disabled
459            }
460            FileSystemSandboxKind::Restricted | FileSystemSandboxKind::Unrestricted => {
461                Self::Managed {
462                    file_system: ManagedFileSystemPermissions::from_sandbox_policy(
463                        file_system_sandbox_policy,
464                    ),
465                    network: network_sandbox_policy,
466                }
467            }
468        }
469    }
470
471    pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
472        Self::from_runtime_permissions_with_enforcement(
473            SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
474            &FileSystemSandboxPolicy::from(sandbox_policy),
475            NetworkSandboxPolicy::from(sandbox_policy),
476        )
477    }
478
479    pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
480        Self::from_runtime_permissions_with_enforcement(
481            SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
482            &FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd),
483            NetworkSandboxPolicy::from(sandbox_policy),
484        )
485    }
486
487    pub fn enforcement(&self) -> SandboxEnforcement {
488        match self {
489            Self::Managed { .. } => SandboxEnforcement::Managed,
490            Self::Disabled => SandboxEnforcement::Disabled,
491            Self::External { .. } => SandboxEnforcement::External,
492        }
493    }
494
495    pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy {
496        match self {
497            Self::Managed { file_system, .. } => file_system.to_sandbox_policy(),
498            Self::Disabled => FileSystemSandboxPolicy::unrestricted(),
499            Self::External { .. } => FileSystemSandboxPolicy::external_sandbox(),
500        }
501    }
502
503    pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy {
504        match self {
505            Self::Managed { network, .. } | Self::External { network } => *network,
506            Self::Disabled => NetworkSandboxPolicy::Enabled,
507        }
508    }
509
510    pub fn to_legacy_sandbox_policy(&self, cwd: &Path) -> io::Result<SandboxPolicy> {
511        match self {
512            Self::Managed {
513                file_system,
514                network,
515            } => file_system
516                .to_sandbox_policy()
517                .to_legacy_sandbox_policy(*network, cwd),
518            Self::Disabled => Ok(SandboxPolicy::DangerFullAccess),
519            Self::External { network } => Ok(SandboxPolicy::ExternalSandbox {
520                network_access: if network.is_enabled() {
521                    crate::protocol::NetworkAccess::Enabled
522                } else {
523                    crate::protocol::NetworkAccess::Restricted
524                },
525            }),
526        }
527    }
528
529    pub fn to_runtime_permissions(&self) -> (FileSystemSandboxPolicy, NetworkSandboxPolicy) {
530        (
531            self.file_system_sandbox_policy(),
532            self.network_sandbox_policy(),
533        )
534    }
535}
536
537#[derive(Debug, Clone, Deserialize)]
538#[serde(tag = "type", rename_all = "snake_case")]
539enum TaggedPermissionProfile {
540    #[serde(rename_all = "snake_case")]
541    Managed {
542        file_system: ManagedFileSystemPermissions,
543        network: NetworkSandboxPolicy,
544    },
545    Disabled,
546    #[serde(rename_all = "snake_case")]
547    External {
548        network: NetworkSandboxPolicy,
549    },
550}
551
552impl From<TaggedPermissionProfile> for PermissionProfile {
553    fn from(value: TaggedPermissionProfile) -> Self {
554        match value {
555            TaggedPermissionProfile::Managed {
556                file_system,
557                network,
558            } => Self::Managed {
559                file_system,
560                network,
561            },
562            TaggedPermissionProfile::Disabled => Self::Disabled,
563            TaggedPermissionProfile::External { network } => Self::External { network },
564        }
565    }
566}
567
568/// Pre-tagged shape written to rollout files before `PermissionProfile`
569/// represented enforcement explicitly.
570#[derive(Debug, Clone, Default, Deserialize)]
571#[serde(deny_unknown_fields)]
572struct LegacyPermissionProfile {
573    network: Option<NetworkPermissions>,
574    file_system: Option<FileSystemPermissions>,
575}
576
577impl From<LegacyPermissionProfile> for PermissionProfile {
578    fn from(value: LegacyPermissionProfile) -> Self {
579        let file_system_sandbox_policy = value.file_system.as_ref().map_or_else(
580            || FileSystemSandboxPolicy::restricted(Vec::new()),
581            FileSystemSandboxPolicy::from,
582        );
583        let network_sandbox_policy = if value
584            .network
585            .as_ref()
586            .and_then(|network| network.enabled)
587            .unwrap_or(false)
588        {
589            NetworkSandboxPolicy::Enabled
590        } else {
591            NetworkSandboxPolicy::Restricted
592        };
593        Self::from_runtime_permissions(&file_system_sandbox_policy, network_sandbox_policy)
594    }
595}
596
597#[derive(Debug, Clone, Deserialize)]
598#[serde(untagged)]
599enum PermissionProfileDe {
600    Tagged(TaggedPermissionProfile),
601    Legacy(LegacyPermissionProfile),
602}
603
604impl<'de> Deserialize<'de> for PermissionProfile {
605    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
606    where
607        D: Deserializer<'de>,
608    {
609        Ok(match PermissionProfileDe::deserialize(deserializer)? {
610            PermissionProfileDe::Tagged(tagged) => tagged.into(),
611            PermissionProfileDe::Legacy(legacy) => legacy.into(),
612        })
613    }
614}
615
616impl From<NetworkSandboxPolicy> for NetworkPermissions {
617    fn from(value: NetworkSandboxPolicy) -> Self {
618        Self {
619            enabled: Some(value.is_enabled()),
620        }
621    }
622}
623
624impl From<&FileSystemSandboxPolicy> for FileSystemPermissions {
625    fn from(value: &FileSystemSandboxPolicy) -> Self {
626        let entries = match value.kind {
627            FileSystemSandboxKind::Restricted => value.entries.clone(),
628            FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
629                vec![FileSystemSandboxEntry {
630                    path: FileSystemPath::Special {
631                        value: FileSystemSpecialPath::Root,
632                    },
633                    access: FileSystemAccessMode::Write,
634                }]
635            }
636        };
637        Self {
638            entries,
639            glob_scan_max_depth: value.glob_scan_max_depth.and_then(NonZeroUsize::new),
640        }
641    }
642}
643
644impl From<&FileSystemPermissions> for FileSystemSandboxPolicy {
645    fn from(value: &FileSystemPermissions) -> Self {
646        let mut policy = FileSystemSandboxPolicy::restricted(value.entries.clone());
647        policy.glob_scan_max_depth = value.glob_scan_max_depth.map(usize::from);
648        policy
649    }
650}