Skip to main content

codex_config/
config_requirements.rs

1use codex_protocol::config_types::ApprovalsReviewer;
2use codex_protocol::config_types::SandboxMode;
3use codex_protocol::config_types::WebSearchMode;
4use codex_protocol::models::PermissionProfile;
5use codex_protocol::openai_models::ReasoningEffort;
6use codex_protocol::protocol::AskForApproval;
7use codex_utils_absolute_path::AbsolutePathBuf;
8use serde::Deserialize;
9use serde::Serialize;
10use serde::de::Error as _;
11use serde::de::value::Error as ValueDeserializerError;
12use serde::de::value::StrDeserializer;
13use std::collections::BTreeMap;
14use std::fmt;
15use std::path::PathBuf;
16use wildmatch::WildMatchPattern;
17
18use super::requirements_exec_policy::RequirementsExecPolicy;
19use super::requirements_exec_policy::RequirementsExecPolicyToml;
20use crate::Constrained;
21use crate::ConstraintError;
22use crate::ManagedHooksRequirementsToml;
23use crate::config_toml::ConfigToml;
24use crate::mcp_requirements::McpServerRequirement;
25use crate::mcp_types::AppToolApproval;
26use crate::permissions_toml::PermissionProfileToml;
27use crate::types::FeedbackConfigToml;
28use crate::types::WindowsSandboxModeToml;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum RequirementSource {
32    Unknown,
33    MdmManagedPreferences {
34        domain: String,
35        key: String,
36    },
37    /// Multiple requirements layers contributed to the final value. Sources are
38    /// stored highest-priority first, matching the order surfaced in errors.
39    Composite {
40        sources: Vec<RequirementSource>,
41    },
42    /// A backend-delivered enterprise-managed layer. `id` is the stable backend
43    /// identifier; `name` is the admin-facing display name.
44    EnterpriseManaged {
45        id: String,
46        name: String,
47    },
48    SystemRequirementsToml {
49        file: AbsolutePathBuf,
50    },
51    LegacyManagedConfigTomlFromFile {
52        file: AbsolutePathBuf,
53    },
54    LegacyManagedConfigTomlFromMdm,
55}
56
57impl RequirementSource {
58    pub fn composite(sources: impl IntoIterator<Item = RequirementSource>) -> Self {
59        let mut flattened = Vec::new();
60        for source in sources {
61            source.append_to_composite(&mut flattened);
62        }
63
64        match flattened.len() {
65            0 => RequirementSource::Unknown,
66            1 => flattened.remove(0),
67            _ => RequirementSource::Composite { sources: flattened },
68        }
69    }
70
71    fn append_to_composite(self, flattened: &mut Vec<RequirementSource>) {
72        match self {
73            RequirementSource::Composite { sources } => {
74                for source in sources {
75                    source.append_to_composite(flattened);
76                }
77            }
78            source => {
79                if !flattened.contains(&source) {
80                    flattened.push(source);
81                }
82            }
83        }
84    }
85}
86
87impl fmt::Display for RequirementSource {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            RequirementSource::Unknown => write!(f, "<unspecified>"),
91            RequirementSource::MdmManagedPreferences { domain, key } => {
92                write!(f, "MDM {domain}:{key}")
93            }
94            RequirementSource::Composite { sources } => {
95                write!(f, "requirements layers: ")?;
96                for (index, source) in sources.iter().enumerate() {
97                    if index > 0 {
98                        write!(f, ", ")?;
99                    }
100                    write!(f, "{source}")?;
101                }
102                Ok(())
103            }
104            RequirementSource::EnterpriseManaged { id, name } => {
105                write!(f, "enterprise-managed requirements {name} ({id})")
106            }
107            RequirementSource::SystemRequirementsToml { file } => {
108                write!(f, "{}", file.as_path().display())
109            }
110            RequirementSource::LegacyManagedConfigTomlFromFile { file } => {
111                write!(f, "{}", file.as_path().display())
112            }
113            RequirementSource::LegacyManagedConfigTomlFromMdm => {
114                write!(f, "MDM managed_config.toml (legacy)")
115            }
116        }
117    }
118}
119
120#[derive(Debug, Clone, PartialEq)]
121pub struct ConstrainedWithSource<T> {
122    pub value: Constrained<T>,
123    pub source: Option<RequirementSource>,
124}
125
126impl<T> ConstrainedWithSource<T> {
127    pub fn new(value: Constrained<T>, source: Option<RequirementSource>) -> Self {
128        Self { value, source }
129    }
130}
131
132impl<T> std::ops::Deref for ConstrainedWithSource<T> {
133    type Target = Constrained<T>;
134
135    fn deref(&self) -> &Self::Target {
136        &self.value
137    }
138}
139
140impl<T> std::ops::DerefMut for ConstrainedWithSource<T> {
141    fn deref_mut(&mut self) -> &mut Self::Target {
142        &mut self.value
143    }
144}
145
146/// Normalized version of [`ConfigRequirementsToml`] after deserialization and
147/// normalization.
148#[derive(Debug, Clone, PartialEq)]
149pub struct ConfigRequirements {
150    pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
151    pub log_dir: Option<Sourced<AbsolutePathBuf>>,
152    pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
153    pub check_for_update_on_startup: Option<Sourced<bool>>,
154    pub allow_login_shell: Option<Sourced<bool>>,
155    pub feedback: Option<Sourced<FeedbackConfigToml>>,
156    pub approval_policy: ConstrainedWithSource<AskForApproval>,
157    pub approvals_reviewer: ConstrainedWithSource<ApprovalsReviewer>,
158    pub permission_profile: ConstrainedWithSource<PermissionProfile>,
159    pub windows_sandbox_mode: ConstrainedWithSource<Option<WindowsSandboxModeToml>>,
160    pub windows_sandbox_private_desktop: Option<Sourced<bool>>,
161    pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
162    pub allow_managed_hooks_only: Option<Sourced<bool>>,
163    pub allow_appshots: Option<Sourced<bool>>,
164    pub allow_remote_control: Option<Sourced<bool>>,
165    pub computer_use: Option<Sourced<ComputerUseRequirementsToml>>,
166    pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
167    pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
168    pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
169    pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
170    pub marketplaces: Option<Sourced<MarketplaceRequirementsToml>>,
171    pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
172    pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
173    /// Managed network constraints derived from requirements.
174    pub network: Option<Sourced<NetworkConstraints>>,
175    /// Managed filesystem constraints derived from requirements.
176    pub filesystem: Option<Sourced<FilesystemConstraints>>,
177    /// Source for the managed guardian policy config, when one is configured.
178    pub guardian_policy_config_source: Option<RequirementSource>,
179}
180
181impl Default for ConfigRequirements {
182    fn default() -> Self {
183        Self {
184            sqlite_home: None,
185            log_dir: None,
186            model_catalog_json: None,
187            check_for_update_on_startup: None,
188            allow_login_shell: None,
189            feedback: None,
190            approval_policy: ConstrainedWithSource::new(
191                Constrained::allow_any_from_default(),
192                /*source*/ None,
193            ),
194            approvals_reviewer: ConstrainedWithSource::new(
195                Constrained::allow_any_from_default(),
196                /*source*/ None,
197            ),
198            permission_profile: ConstrainedWithSource::new(
199                Constrained::allow_any(PermissionProfile::read_only()),
200                /*source*/ None,
201            ),
202            windows_sandbox_mode: ConstrainedWithSource::new(
203                Constrained::allow_any(/*initial_value*/ None),
204                /*source*/ None,
205            ),
206            windows_sandbox_private_desktop: None,
207            web_search_mode: ConstrainedWithSource::new(
208                Constrained::allow_any(WebSearchMode::Cached),
209                /*source*/ None,
210            ),
211            allow_managed_hooks_only: None,
212            allow_appshots: None,
213            allow_remote_control: None,
214            computer_use: None,
215            feature_requirements: None,
216            managed_hooks: None,
217            mcp_servers: None,
218            plugins: None,
219            marketplaces: None,
220            exec_policy: None,
221            enforce_residency: ConstrainedWithSource::new(
222                Constrained::allow_any(/*initial_value*/ None),
223                /*source*/ None,
224            ),
225            network: None,
226            filesystem: None,
227            guardian_policy_config_source: None,
228        }
229    }
230}
231
232impl ConfigRequirements {
233    pub fn exec_policy_source(&self) -> Option<&RequirementSource> {
234        self.exec_policy.as_ref().map(|policy| &policy.source)
235    }
236}
237
238#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
239pub struct PluginRequirementsToml {
240    pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
241}
242
243#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
244#[serde(deny_unknown_fields)]
245pub struct MarketplaceRequirementsToml {
246    pub restrict_to_allowed_sources: Option<bool>,
247    #[serde(default)]
248    pub allowed_sources: BTreeMap<String, MarketplaceAllowedSourceToml>,
249}
250
251impl MarketplaceRequirementsToml {
252    pub fn is_empty(&self) -> bool {
253        self.restrict_to_allowed_sources.is_none() && self.allowed_sources.is_empty()
254    }
255}
256
257/// Raw marketplace source rule whose active fields are interpreted after
258/// requirements composition.
259#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
260#[serde(deny_unknown_fields)]
261pub struct MarketplaceAllowedSourceToml {
262    pub source: Option<MarketplaceAllowedSourceKind>,
263    pub url: Option<String>,
264    #[serde(rename = "ref")]
265    pub ref_name: Option<String>,
266    pub host_pattern: Option<String>,
267    pub path: Option<PathBuf>,
268}
269
270#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
271#[serde(rename_all = "snake_case")]
272pub enum MarketplaceAllowedSourceKind {
273    Git,
274    HostPattern,
275    Local,
276}
277
278impl PluginRequirementsToml {
279    pub fn is_empty(&self) -> bool {
280        self.mcp_servers.as_ref().is_none_or(BTreeMap::is_empty)
281    }
282}
283
284#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
285pub struct NetworkDomainPermissionsToml {
286    #[serde(flatten)]
287    pub entries: BTreeMap<String, NetworkDomainPermissionToml>,
288}
289
290impl NetworkDomainPermissionsToml {
291    pub fn allowed_domains(&self) -> Option<Vec<String>> {
292        let allowed_domains: Vec<String> = self
293            .entries
294            .iter()
295            .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow))
296            .map(|(pattern, _)| pattern.clone())
297            .collect();
298        (!allowed_domains.is_empty()).then_some(allowed_domains)
299    }
300
301    pub fn denied_domains(&self) -> Option<Vec<String>> {
302        let denied_domains: Vec<String> = self
303            .entries
304            .iter()
305            .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny))
306            .map(|(pattern, _)| pattern.clone())
307            .collect();
308        (!denied_domains.is_empty()).then_some(denied_domains)
309    }
310}
311
312#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
313#[serde(rename_all = "lowercase")]
314pub enum NetworkDomainPermissionToml {
315    Allow,
316    Deny,
317}
318
319impl std::fmt::Display for NetworkDomainPermissionToml {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        let permission = match self {
322            Self::Allow => "allow",
323            Self::Deny => "deny",
324        };
325        f.write_str(permission)
326    }
327}
328
329#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
330pub struct NetworkUnixSocketPermissionsToml {
331    #[serde(flatten)]
332    pub entries: BTreeMap<String, NetworkUnixSocketPermissionToml>,
333}
334
335impl NetworkUnixSocketPermissionsToml {
336    pub fn allow_unix_sockets(&self) -> Vec<String> {
337        self.entries
338            .iter()
339            .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow))
340            .map(|(path, _)| path.clone())
341            .collect()
342    }
343}
344
345#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
346#[serde(rename_all = "lowercase")]
347pub enum NetworkUnixSocketPermissionToml {
348    Allow,
349    Deny,
350}
351
352impl std::fmt::Display for NetworkUnixSocketPermissionToml {
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        let permission = match self {
355            Self::Allow => "allow",
356            Self::Deny => "deny",
357        };
358        f.write_str(permission)
359    }
360}
361
362#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)]
363pub struct NetworkRequirementsToml {
364    pub enabled: Option<bool>,
365    pub http_port: Option<u16>,
366    pub socks_port: Option<u16>,
367    pub allow_upstream_proxy: Option<bool>,
368    pub dangerously_allow_non_loopback_proxy: Option<bool>,
369    pub dangerously_allow_all_unix_sockets: Option<bool>,
370    pub domains: Option<NetworkDomainPermissionsToml>,
371    /// When true, only managed `allowed_domains` are respected while managed
372    /// network enforcement is active. User allowlist entries are ignored.
373    pub managed_allowed_domains_only: Option<bool>,
374    pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
375    pub allow_local_binding: Option<bool>,
376}
377
378#[derive(Deserialize)]
379struct RawNetworkRequirementsToml {
380    enabled: Option<bool>,
381    http_port: Option<u16>,
382    socks_port: Option<u16>,
383    allow_upstream_proxy: Option<bool>,
384    dangerously_allow_non_loopback_proxy: Option<bool>,
385    dangerously_allow_all_unix_sockets: Option<bool>,
386    domains: Option<NetworkDomainPermissionsToml>,
387    #[serde(default)]
388    allowed_domains: Option<Vec<String>>,
389    /// When true, only managed `allowed_domains` are respected while managed
390    /// network enforcement is active. User allowlist entries are ignored.
391    managed_allowed_domains_only: Option<bool>,
392    #[serde(default)]
393    denied_domains: Option<Vec<String>>,
394    unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
395    #[serde(default)]
396    allow_unix_sockets: Option<Vec<String>>,
397    allow_local_binding: Option<bool>,
398}
399
400impl<'de> Deserialize<'de> for NetworkRequirementsToml {
401    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
402    where
403        D: serde::Deserializer<'de>,
404    {
405        let raw = RawNetworkRequirementsToml::deserialize(deserializer)?;
406        let RawNetworkRequirementsToml {
407            enabled,
408            http_port,
409            socks_port,
410            allow_upstream_proxy,
411            dangerously_allow_non_loopback_proxy,
412            dangerously_allow_all_unix_sockets,
413            domains,
414            allowed_domains,
415            managed_allowed_domains_only,
416            denied_domains,
417            unix_sockets,
418            allow_unix_sockets,
419            allow_local_binding,
420        } = raw;
421
422        if domains.is_some() && (allowed_domains.is_some() || denied_domains.is_some()) {
423            return Err(D::Error::custom(
424                "`experimental_network.domains` cannot be combined with legacy `allowed_domains` or `denied_domains`",
425            ));
426        }
427
428        if unix_sockets.is_some() && allow_unix_sockets.is_some() {
429            return Err(D::Error::custom(
430                "`experimental_network.unix_sockets` cannot be combined with legacy `allow_unix_sockets`",
431            ));
432        }
433
434        Ok(Self {
435            enabled,
436            http_port,
437            socks_port,
438            allow_upstream_proxy,
439            dangerously_allow_non_loopback_proxy,
440            dangerously_allow_all_unix_sockets,
441            domains: domains
442                .or_else(|| legacy_domain_permissions_from_lists(allowed_domains, denied_domains)),
443            managed_allowed_domains_only,
444            unix_sockets: unix_sockets
445                .or_else(|| legacy_unix_socket_permissions_from_list(allow_unix_sockets)),
446            allow_local_binding,
447        })
448    }
449}
450
451/// Legacy list normalization is intentionally lossy: explicit empty legacy
452/// lists are treated as unset when converted to the canonical network
453/// permission shape.
454fn legacy_domain_permissions_from_lists(
455    allowed_domains: Option<Vec<String>>,
456    denied_domains: Option<Vec<String>>,
457) -> Option<NetworkDomainPermissionsToml> {
458    let mut entries = BTreeMap::new();
459
460    for pattern in allowed_domains.unwrap_or_default() {
461        entries.insert(pattern, NetworkDomainPermissionToml::Allow);
462    }
463
464    for pattern in denied_domains.unwrap_or_default() {
465        entries.insert(pattern, NetworkDomainPermissionToml::Deny);
466    }
467
468    (!entries.is_empty()).then_some(NetworkDomainPermissionsToml { entries })
469}
470
471fn legacy_unix_socket_permissions_from_list(
472    allow_unix_sockets: Option<Vec<String>>,
473) -> Option<NetworkUnixSocketPermissionsToml> {
474    let entries = allow_unix_sockets
475        .unwrap_or_default()
476        .into_iter()
477        .map(|path| (path, NetworkUnixSocketPermissionToml::Allow))
478        .collect::<BTreeMap<_, _>>();
479
480    (!entries.is_empty()).then_some(NetworkUnixSocketPermissionsToml { entries })
481}
482
483/// Normalized network constraints derived from requirements TOML.
484#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
485pub struct NetworkConstraints {
486    pub enabled: Option<bool>,
487    pub http_port: Option<u16>,
488    pub socks_port: Option<u16>,
489    pub allow_upstream_proxy: Option<bool>,
490    pub dangerously_allow_non_loopback_proxy: Option<bool>,
491    pub dangerously_allow_all_unix_sockets: Option<bool>,
492    pub domains: Option<NetworkDomainPermissionsToml>,
493    /// When true, only managed `allowed_domains` are respected while managed
494    /// network enforcement is active. User allowlist entries are ignored.
495    pub managed_allowed_domains_only: Option<bool>,
496    pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
497    pub allow_local_binding: Option<bool>,
498}
499
500impl<'de> Deserialize<'de> for NetworkConstraints {
501    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
502    where
503        D: serde::Deserializer<'de>,
504    {
505        let requirements = NetworkRequirementsToml::deserialize(deserializer)?;
506        Ok(requirements.into())
507    }
508}
509
510impl From<NetworkRequirementsToml> for NetworkConstraints {
511    fn from(value: NetworkRequirementsToml) -> Self {
512        let NetworkRequirementsToml {
513            enabled,
514            http_port,
515            socks_port,
516            allow_upstream_proxy,
517            dangerously_allow_non_loopback_proxy,
518            dangerously_allow_all_unix_sockets,
519            domains,
520            managed_allowed_domains_only,
521            unix_sockets,
522            allow_local_binding,
523        } = value;
524        Self {
525            enabled,
526            http_port,
527            socks_port,
528            allow_upstream_proxy,
529            dangerously_allow_non_loopback_proxy,
530            dangerously_allow_all_unix_sockets,
531            domains,
532            managed_allowed_domains_only,
533            unix_sockets,
534            allow_local_binding,
535        }
536    }
537}
538
539#[derive(Debug, Clone, Default, PartialEq, Eq)]
540pub struct FilesystemRequirementsToml {
541    pub deny_read: Option<Vec<FilesystemDenyReadPattern>>,
542}
543
544#[derive(Deserialize)]
545struct RawFilesystemRequirementsToml {
546    deny_read: Option<Vec<FilesystemDenyReadPattern>>,
547    description: Option<serde::de::IgnoredAny>,
548    extends: Option<serde::de::IgnoredAny>,
549    workspace_roots: Option<serde::de::IgnoredAny>,
550    filesystem: Option<serde::de::IgnoredAny>,
551    network: Option<serde::de::IgnoredAny>,
552}
553
554impl<'de> Deserialize<'de> for FilesystemRequirementsToml {
555    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
556    where
557        D: serde::Deserializer<'de>,
558    {
559        let raw = RawFilesystemRequirementsToml::deserialize(deserializer)?;
560        let RawFilesystemRequirementsToml {
561            deny_read,
562            description,
563            extends,
564            workspace_roots,
565            filesystem,
566            network,
567        } = raw;
568
569        if description.is_some()
570            || extends.is_some()
571            || workspace_roots.is_some()
572            || filesystem.is_some()
573            || network.is_some()
574        {
575            return Err(D::Error::custom(
576                "`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile",
577            ));
578        }
579
580        Ok(Self { deny_read })
581    }
582}
583
584#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
585pub struct PermissionsRequirementsToml {
586    pub filesystem: Option<FilesystemRequirementsToml>,
587    // For legacy reasons, `filesystem` stays reserved for requirements-level
588    // filesystem constraints and cannot name a profile.
589    #[serde(default, flatten)]
590    pub profiles: BTreeMap<String, PermissionProfileToml>,
591}
592
593#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
594pub struct FilesystemConstraints {
595    pub deny_read: Vec<FilesystemDenyReadPattern>,
596}
597
598impl From<PermissionsRequirementsToml> for FilesystemConstraints {
599    fn from(value: PermissionsRequirementsToml) -> Self {
600        let deny_read = value
601            .filesystem
602            .and_then(|filesystem| filesystem.deny_read)
603            .unwrap_or_default();
604        Self { deny_read }
605    }
606}
607
608#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
609#[serde(transparent)]
610pub struct FilesystemDenyReadPattern(String);
611
612impl FilesystemDenyReadPattern {
613    pub fn as_str(&self) -> &str {
614        &self.0
615    }
616
617    pub fn contains_glob(&self) -> bool {
618        self.0.chars().any(is_glob_metacharacter)
619    }
620
621    pub fn from_input(input: &str) -> Result<Self, String> {
622        if !input.chars().any(is_glob_metacharacter) {
623            let path = deserialize_absolute_path(input)?;
624            return Ok(Self(path.to_string_lossy().into_owned()));
625        }
626
627        let (directory_prefix, suffix) = split_glob_pattern(input);
628        let normalized_prefix = if directory_prefix.is_empty() {
629            deserialize_absolute_path(".")?
630        } else {
631            deserialize_absolute_path(directory_prefix)?
632        };
633        let normalized_prefix = normalized_prefix.to_string_lossy();
634        let normalized = if suffix.is_empty() {
635            normalized_prefix.into_owned()
636        } else if normalized_prefix == "/" {
637            format!("/{suffix}")
638        } else {
639            format!("{normalized_prefix}/{suffix}")
640        };
641        Ok(Self(normalized))
642    }
643}
644
645impl From<AbsolutePathBuf> for FilesystemDenyReadPattern {
646    fn from(value: AbsolutePathBuf) -> Self {
647        Self(value.to_string_lossy().into_owned())
648    }
649}
650
651impl<'de> Deserialize<'de> for FilesystemDenyReadPattern {
652    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
653    where
654        D: serde::Deserializer<'de>,
655    {
656        let input = String::deserialize(deserializer)?;
657        Self::from_input(&input).map_err(D::Error::custom)
658    }
659}
660
661fn deserialize_absolute_path(input: &str) -> Result<AbsolutePathBuf, String> {
662    AbsolutePathBuf::deserialize(StrDeserializer::<ValueDeserializerError>::new(input))
663        .map_err(|err| err.to_string())
664}
665
666fn split_glob_pattern(input: &str) -> (&str, &str) {
667    let Some(first_glob) = input.find(is_glob_metacharacter) else {
668        return ("", input);
669    };
670    let separator_index = input[..first_glob]
671        .char_indices()
672        .rev()
673        .find(|(_, ch)| is_path_separator(*ch))
674        .map(|(index, _)| index);
675
676    match separator_index {
677        Some(0) => ("/", &input[1..]),
678        Some(index)
679            if cfg!(windows)
680                && index == 2
681                && input.as_bytes().get(1) == Some(&b':')
682                && input.as_bytes().get(2).is_some() =>
683        {
684            (&input[..=index], &input[index + 1..])
685        }
686        Some(index) => (&input[..index], &input[index + 1..]),
687        None => ("", input),
688    }
689}
690
691fn is_path_separator(ch: char) -> bool {
692    if cfg!(windows) {
693        ch == '/' || ch == '\\'
694    } else {
695        ch == '/'
696    }
697}
698
699fn is_glob_metacharacter(ch: char) -> bool {
700    matches!(ch, '*' | '?' | '[')
701}
702
703#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
704#[serde(rename_all = "snake_case")]
705pub enum WebSearchModeRequirement {
706    Disabled,
707    Cached,
708    Indexed,
709    Live,
710}
711
712impl From<WebSearchMode> for WebSearchModeRequirement {
713    fn from(mode: WebSearchMode) -> Self {
714        match mode {
715            WebSearchMode::Disabled => WebSearchModeRequirement::Disabled,
716            WebSearchMode::Cached => WebSearchModeRequirement::Cached,
717            WebSearchMode::Indexed => WebSearchModeRequirement::Indexed,
718            WebSearchMode::Live => WebSearchModeRequirement::Live,
719        }
720    }
721}
722
723impl From<WebSearchModeRequirement> for WebSearchMode {
724    fn from(mode: WebSearchModeRequirement) -> Self {
725        match mode {
726            WebSearchModeRequirement::Disabled => WebSearchMode::Disabled,
727            WebSearchModeRequirement::Cached => WebSearchMode::Cached,
728            WebSearchModeRequirement::Indexed => WebSearchMode::Indexed,
729            WebSearchModeRequirement::Live => WebSearchMode::Live,
730        }
731    }
732}
733
734impl fmt::Display for WebSearchModeRequirement {
735    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
736        match self {
737            WebSearchModeRequirement::Disabled => write!(f, "disabled"),
738            WebSearchModeRequirement::Cached => write!(f, "cached"),
739            WebSearchModeRequirement::Indexed => write!(f, "indexed"),
740            WebSearchModeRequirement::Live => write!(f, "live"),
741        }
742    }
743}
744
745#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
746pub struct ComputerUseRequirementsToml {
747    pub allow_locked_computer_use: Option<bool>,
748}
749
750impl ComputerUseRequirementsToml {
751    pub fn is_empty(&self) -> bool {
752        self.allow_locked_computer_use.is_none()
753    }
754}
755
756#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
757pub struct WindowsRequirementsToml {
758    pub allowed_sandbox_implementations: Option<Vec<WindowsSandboxModeToml>>,
759    pub sandbox_private_desktop: Option<bool>,
760}
761
762impl WindowsRequirementsToml {
763    pub fn is_empty(&self) -> bool {
764        self.allowed_sandbox_implementations.is_none() && self.sandbox_private_desktop.is_none()
765    }
766}
767
768#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
769pub struct FeatureRequirementsToml {
770    #[serde(flatten)]
771    pub entries: BTreeMap<String, bool>,
772}
773
774impl FeatureRequirementsToml {
775    pub fn is_empty(&self) -> bool {
776        self.entries.is_empty()
777    }
778}
779
780#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
781pub struct AppToolRequirementToml {
782    pub approval_mode: Option<AppToolApproval>,
783}
784
785impl AppToolRequirementToml {
786    pub fn is_empty(&self) -> bool {
787        self.approval_mode.is_none()
788    }
789}
790
791#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
792pub struct AppToolsRequirementsToml {
793    #[serde(default, flatten)]
794    pub tools: BTreeMap<String, AppToolRequirementToml>,
795}
796
797impl AppToolsRequirementsToml {
798    pub fn is_empty(&self) -> bool {
799        self.tools.values().all(AppToolRequirementToml::is_empty)
800    }
801}
802
803#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
804pub struct AppRequirementToml {
805    pub enabled: Option<bool>,
806    pub tools: Option<AppToolsRequirementsToml>,
807}
808
809impl AppRequirementToml {
810    pub fn is_empty(&self) -> bool {
811        self.enabled.is_none()
812            && self
813                .tools
814                .as_ref()
815                .is_none_or(AppToolsRequirementsToml::is_empty)
816    }
817}
818
819#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
820pub struct AppsRequirementsToml {
821    #[serde(default, flatten)]
822    pub apps: BTreeMap<String, AppRequirementToml>,
823}
824
825impl AppsRequirementsToml {
826    pub fn is_empty(&self) -> bool {
827        self.apps.values().all(AppRequirementToml::is_empty)
828    }
829}
830
831/// Merge app requirements from a lower-precedence source into an existing higher-precedence set.
832/// This lets managed sources (for example Cloud/MDM) enforce setting disablement across layers,
833/// while exact tool approval settings keep the higher-precedence value when present.
834pub(crate) fn merge_app_requirements_descending(
835    base: &mut AppsRequirementsToml,
836    incoming: AppsRequirementsToml,
837) {
838    for (app_id, incoming_requirement) in incoming.apps {
839        let base_requirement = base.apps.entry(app_id).or_default();
840        let higher_precedence = base_requirement.enabled;
841        let lower_precedence = incoming_requirement.enabled;
842        base_requirement.enabled =
843            if higher_precedence == Some(false) || lower_precedence == Some(false) {
844                Some(false)
845            } else {
846                higher_precedence.or(lower_precedence)
847            };
848
849        let Some(incoming_tools) = incoming_requirement.tools else {
850            continue;
851        };
852        let base_tools = base_requirement.tools.get_or_insert_with(Default::default);
853        for (tool_name, incoming_tool) in incoming_tools.tools {
854            let base_tool = base_tools.tools.entry(tool_name).or_default();
855            if base_tool.approval_mode.is_none() {
856                base_tool.approval_mode = incoming_tool.approval_mode;
857            }
858        }
859    }
860}
861
862/// Base config deserialized from system `requirements.toml` or MDM.
863#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
864pub struct ConfigRequirementsToml {
865    pub sqlite_home: Option<AbsolutePathBuf>,
866    pub log_dir: Option<AbsolutePathBuf>,
867    pub model_catalog_json: Option<AbsolutePathBuf>,
868    pub check_for_update_on_startup: Option<bool>,
869    pub allow_login_shell: Option<bool>,
870    pub feedback: Option<FeedbackConfigToml>,
871    pub allowed_approval_policies: Option<Vec<AskForApproval>>,
872    pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
873    pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
874    pub allowed_permission_profiles: Option<BTreeMap<String, bool>>,
875    pub default_permissions: Option<String>,
876    pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
877    pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
878    pub allow_managed_hooks_only: Option<bool>,
879    pub allow_appshots: Option<bool>,
880    pub allow_remote_control: Option<bool>,
881    pub computer_use: Option<ComputerUseRequirementsToml>,
882    pub windows: Option<WindowsRequirementsToml>,
883    #[serde(rename = "features", alias = "feature_requirements")]
884    pub feature_requirements: Option<FeatureRequirementsToml>,
885    pub hooks: Option<ManagedHooksRequirementsToml>,
886    pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
887    pub plugins: Option<BTreeMap<String, PluginRequirementsToml>>,
888    pub marketplaces: Option<MarketplaceRequirementsToml>,
889    pub apps: Option<AppsRequirementsToml>,
890    pub rules: Option<RequirementsExecPolicyToml>,
891    pub enforce_residency: Option<ResidencyRequirement>,
892    #[serde(rename = "experimental_network")]
893    pub network: Option<NetworkRequirementsToml>,
894    pub permissions: Option<PermissionsRequirementsToml>,
895    pub models: Option<ModelsRequirementsToml>,
896    pub guardian_policy_config: Option<String>,
897}
898
899#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
900pub struct ModelsRequirementsToml {
901    pub new_thread: Option<NewThreadModelDefaultsToml>,
902}
903
904impl ModelsRequirementsToml {
905    fn is_empty(&self) -> bool {
906        self.new_thread
907            .as_ref()
908            .is_none_or(NewThreadModelDefaultsToml::is_empty)
909    }
910}
911
912#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
913pub struct NewThreadModelDefaultsToml {
914    pub model: Option<String>,
915    pub model_reasoning_effort: Option<ReasoningEffort>,
916    pub service_tier: Option<String>,
917}
918
919impl NewThreadModelDefaultsToml {
920    fn is_empty(&self) -> bool {
921        self.model.is_none() && self.model_reasoning_effort.is_none() && self.service_tier.is_none()
922    }
923}
924
925#[derive(Deserialize, Debug, Clone, PartialEq)]
926pub struct RemoteSandboxConfigToml {
927    pub hostname_patterns: Vec<String>,
928    pub allowed_sandbox_modes: Vec<SandboxModeRequirement>,
929}
930
931/// Value paired with the requirement source it came from, for better error
932/// messages.
933#[derive(Debug, Clone, PartialEq)]
934pub struct Sourced<T> {
935    pub value: T,
936    pub source: RequirementSource,
937}
938
939impl<T> Sourced<T> {
940    pub fn new(value: T, source: RequirementSource) -> Self {
941        Self { value, source }
942    }
943}
944
945impl<T> std::ops::Deref for Sourced<T> {
946    type Target = T;
947
948    fn deref(&self) -> &Self::Target {
949        &self.value
950    }
951}
952
953#[derive(Debug, Clone, Default, PartialEq)]
954pub struct ConfigRequirementsWithSources {
955    pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
956    pub log_dir: Option<Sourced<AbsolutePathBuf>>,
957    pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
958    pub check_for_update_on_startup: Option<Sourced<bool>>,
959    pub allow_login_shell: Option<Sourced<bool>>,
960    pub feedback: Option<Sourced<FeedbackConfigToml>>,
961    pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
962    pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
963    pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
964    pub allowed_permission_profiles: Option<Sourced<BTreeMap<String, bool>>>,
965    pub default_permissions: Option<Sourced<String>>,
966    pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
967    pub allow_managed_hooks_only: Option<Sourced<bool>>,
968    pub allow_appshots: Option<Sourced<bool>>,
969    pub allow_remote_control: Option<Sourced<bool>>,
970    pub computer_use: Option<Sourced<ComputerUseRequirementsToml>>,
971    pub windows: Option<Sourced<WindowsRequirementsToml>>,
972    pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
973    pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
974    pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
975    pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
976    pub marketplaces: Option<Sourced<MarketplaceRequirementsToml>>,
977    pub apps: Option<Sourced<AppsRequirementsToml>>,
978    pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
979    pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
980    pub network: Option<Sourced<NetworkRequirementsToml>>,
981    pub permissions: Option<Sourced<PermissionsRequirementsToml>>,
982    pub models: Option<Sourced<ModelsRequirementsToml>>,
983    pub guardian_policy_config: Option<Sourced<String>>,
984}
985
986impl ConfigRequirementsWithSources {
987    pub fn merge_unset_fields(&mut self, source: RequirementSource, other: ConfigRequirementsToml) {
988        // For every field in `other` that is `Some`, if the corresponding field
989        // in `self` is `None`, copy the value from `other` into `self`.
990        macro_rules! fill_missing_take {
991            ($base:expr, $other:expr, $source:expr, { $($field:ident),+ $(,)? }) => {
992                $(
993                    if $base.$field.is_none()
994                        && let Some(value) = $other.$field.take()
995                    {
996                        $base.$field = Some(Sourced::new(value, $source.clone()));
997                    }
998                )+
999            };
1000        }
1001
1002        // Destructure without `..` so adding fields to `ConfigRequirementsToml`
1003        // forces this merge logic to be updated.
1004        let ConfigRequirementsToml {
1005            sqlite_home: _,
1006            log_dir: _,
1007            model_catalog_json: _,
1008            check_for_update_on_startup: _,
1009            allow_login_shell: _,
1010            feedback: _,
1011            allowed_approval_policies: _,
1012            allowed_approvals_reviewers: _,
1013            allowed_sandbox_modes: _,
1014            allowed_permission_profiles: _,
1015            default_permissions: _,
1016            remote_sandbox_config: _,
1017            allowed_web_search_modes: _,
1018            allow_managed_hooks_only: _,
1019            allow_appshots: _,
1020            allow_remote_control: _,
1021            computer_use: _,
1022            windows: _,
1023            feature_requirements: _,
1024            hooks: _,
1025            mcp_servers: _,
1026            plugins: _,
1027            marketplaces: _,
1028            apps: _,
1029            rules: _,
1030            enforce_residency: _,
1031            network: _,
1032            permissions: _,
1033            models: _,
1034            guardian_policy_config: _,
1035        } = &other;
1036
1037        let mut other = other;
1038        if other
1039            .guardian_policy_config
1040            .as_deref()
1041            .is_some_and(|value| value.trim().is_empty())
1042        {
1043            other.guardian_policy_config = None;
1044        }
1045        fill_missing_take!(
1046            self,
1047            other,
1048            source,
1049            {
1050                sqlite_home,
1051                log_dir,
1052                model_catalog_json,
1053                check_for_update_on_startup,
1054                allow_login_shell,
1055                feedback,
1056                allowed_approval_policies,
1057                allowed_approvals_reviewers,
1058                allowed_sandbox_modes,
1059                allowed_permission_profiles,
1060                default_permissions,
1061                allowed_web_search_modes,
1062                allow_managed_hooks_only,
1063                allow_appshots,
1064                allow_remote_control,
1065                computer_use,
1066                windows,
1067                feature_requirements,
1068                hooks,
1069                mcp_servers,
1070                plugins,
1071                marketplaces,
1072                rules,
1073                enforce_residency,
1074                network,
1075                permissions,
1076                models,
1077                guardian_policy_config,
1078            }
1079        );
1080
1081        if let Some(incoming_apps) = other.apps.take() {
1082            if let Some(existing_apps) = self.apps.as_mut() {
1083                merge_app_requirements_descending(&mut existing_apps.value, incoming_apps);
1084            } else {
1085                self.apps = Some(Sourced::new(incoming_apps, source));
1086            }
1087        }
1088    }
1089
1090    pub fn into_toml(self) -> ConfigRequirementsToml {
1091        let ConfigRequirementsWithSources {
1092            sqlite_home,
1093            log_dir,
1094            model_catalog_json,
1095            check_for_update_on_startup,
1096            allow_login_shell,
1097            feedback,
1098            allowed_approval_policies,
1099            allowed_approvals_reviewers,
1100            allowed_sandbox_modes,
1101            allowed_permission_profiles,
1102            default_permissions,
1103            allowed_web_search_modes,
1104            allow_managed_hooks_only,
1105            allow_appshots,
1106            allow_remote_control,
1107            computer_use,
1108            windows,
1109            feature_requirements,
1110            hooks,
1111            mcp_servers,
1112            plugins,
1113            marketplaces,
1114            apps,
1115            rules,
1116            enforce_residency,
1117            network,
1118            permissions,
1119            models,
1120            guardian_policy_config,
1121        } = self;
1122        ConfigRequirementsToml {
1123            sqlite_home: sqlite_home.map(|sourced| sourced.value),
1124            log_dir: log_dir.map(|sourced| sourced.value),
1125            model_catalog_json: model_catalog_json.map(|sourced| sourced.value),
1126            check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value),
1127            allow_login_shell: allow_login_shell.map(|sourced| sourced.value),
1128            feedback: feedback.map(|sourced| sourced.value),
1129            allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
1130            allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value),
1131            allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
1132            allowed_permission_profiles: allowed_permission_profiles.map(|sourced| sourced.value),
1133            default_permissions: default_permissions.map(|sourced| sourced.value),
1134            remote_sandbox_config: None,
1135            allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
1136            allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value),
1137            allow_appshots: allow_appshots.map(|sourced| sourced.value),
1138            allow_remote_control: allow_remote_control.map(|sourced| sourced.value),
1139            computer_use: computer_use.map(|sourced| sourced.value),
1140            windows: windows.map(|sourced| sourced.value),
1141            feature_requirements: feature_requirements.map(|sourced| sourced.value),
1142            hooks: hooks.map(|sourced| sourced.value),
1143            mcp_servers: mcp_servers.map(|sourced| sourced.value),
1144            plugins: plugins.map(|sourced| sourced.value),
1145            marketplaces: marketplaces.map(|sourced| sourced.value),
1146            apps: apps.map(|sourced| sourced.value),
1147            rules: rules.map(|sourced| sourced.value),
1148            enforce_residency: enforce_residency.map(|sourced| sourced.value),
1149            network: network.map(|sourced| sourced.value),
1150            permissions: permissions.map(|sourced| sourced.value),
1151            models: models.map(|sourced| sourced.value),
1152            guardian_policy_config: guardian_policy_config.map(|sourced| sourced.value),
1153        }
1154    }
1155}
1156
1157fn normalize_hostname(hostname: &str) -> Option<String> {
1158    let hostname = hostname.trim().trim_end_matches('.');
1159    (!hostname.is_empty()).then(|| hostname.to_ascii_lowercase())
1160}
1161
1162fn hostname_matches_any_pattern(hostname: &str, patterns: &[String]) -> bool {
1163    patterns.iter().any(|pattern| {
1164        normalize_hostname(pattern)
1165            .map(|pattern| WildMatchPattern::<'*', '?'>::new_case_insensitive(&pattern))
1166            .is_some_and(|pattern| pattern.matches(hostname))
1167    })
1168}
1169
1170/// Currently, `external-sandbox` is not supported in config.toml, but it is
1171/// supported through programmatic use.
1172#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
1173pub enum SandboxModeRequirement {
1174    #[serde(rename = "read-only")]
1175    ReadOnly,
1176
1177    #[serde(rename = "workspace-write")]
1178    WorkspaceWrite,
1179
1180    #[serde(rename = "danger-full-access")]
1181    DangerFullAccess,
1182
1183    #[serde(rename = "external-sandbox")]
1184    ExternalSandbox,
1185}
1186
1187impl From<SandboxMode> for SandboxModeRequirement {
1188    fn from(mode: SandboxMode) -> Self {
1189        match mode {
1190            SandboxMode::ReadOnly => SandboxModeRequirement::ReadOnly,
1191            SandboxMode::WorkspaceWrite => SandboxModeRequirement::WorkspaceWrite,
1192            SandboxMode::DangerFullAccess => SandboxModeRequirement::DangerFullAccess,
1193        }
1194    }
1195}
1196
1197#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1198#[serde(rename_all = "lowercase")]
1199pub enum ResidencyRequirement {
1200    Us,
1201}
1202
1203impl ConfigRequirementsToml {
1204    pub fn apply_remote_sandbox_config(&mut self, hostname: Option<&str>) {
1205        let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else {
1206            return;
1207        };
1208        let Some(hostname) = hostname.and_then(normalize_hostname) else {
1209            return;
1210        };
1211        let Some(matched_config) = remote_sandbox_config
1212            .iter()
1213            .find(|config| hostname_matches_any_pattern(&hostname, &config.hostname_patterns))
1214        else {
1215            return;
1216        };
1217        self.allowed_sandbox_modes = Some(matched_config.allowed_sandbox_modes.clone());
1218    }
1219
1220    pub fn is_empty(&self) -> bool {
1221        self.sqlite_home.is_none()
1222            && self.log_dir.is_none()
1223            && self.model_catalog_json.is_none()
1224            && self.check_for_update_on_startup.is_none()
1225            && self.allow_login_shell.is_none()
1226            && self
1227                .feedback
1228                .as_ref()
1229                .is_none_or(|feedback| feedback == &FeedbackConfigToml::default())
1230            && self.allowed_approval_policies.is_none()
1231            && self.allowed_approvals_reviewers.is_none()
1232            && self.allowed_sandbox_modes.is_none()
1233            && self.allowed_permission_profiles.is_none()
1234            && self.default_permissions.is_none()
1235            && self.remote_sandbox_config.is_none()
1236            && self.allowed_web_search_modes.is_none()
1237            && self.allow_managed_hooks_only.is_none()
1238            && self.allow_appshots.is_none()
1239            && self.allow_remote_control.is_none()
1240            && self
1241                .computer_use
1242                .as_ref()
1243                .is_none_or(ComputerUseRequirementsToml::is_empty)
1244            && self
1245                .windows
1246                .as_ref()
1247                .is_none_or(WindowsRequirementsToml::is_empty)
1248            && self
1249                .feature_requirements
1250                .as_ref()
1251                .is_none_or(FeatureRequirementsToml::is_empty)
1252            && self
1253                .hooks
1254                .as_ref()
1255                .is_none_or(ManagedHooksRequirementsToml::is_empty)
1256            && self.mcp_servers.is_none()
1257            && self
1258                .plugins
1259                .as_ref()
1260                .is_none_or(|plugins| plugins.values().all(PluginRequirementsToml::is_empty))
1261            && self
1262                .marketplaces
1263                .as_ref()
1264                .is_none_or(MarketplaceRequirementsToml::is_empty)
1265            && self
1266                .apps
1267                .as_ref()
1268                .is_none_or(AppsRequirementsToml::is_empty)
1269            && self.rules.is_none()
1270            && self.enforce_residency.is_none()
1271            && self.network.is_none()
1272            && self.permissions.is_none()
1273            && self
1274                .models
1275                .as_ref()
1276                .is_none_or(ModelsRequirementsToml::is_empty)
1277            && self
1278                .guardian_policy_config
1279                .as_deref()
1280                .is_none_or(|value| value.trim().is_empty())
1281    }
1282
1283    /// Applies the requirements whose values replace config values.
1284    ///
1285    /// This projection is shared by config/read and config-lock export so
1286    /// both surfaces describe the same behavior as the final runtime config.
1287    pub fn apply_exact_to_config(&self, config: &mut ConfigToml) {
1288        macro_rules! apply_exact {
1289            ($field:ident) => {
1290                if let Some(value) = self.$field.as_ref() {
1291                    config.$field = Some(value.clone());
1292                }
1293            };
1294        }
1295
1296        apply_exact!(sqlite_home);
1297        apply_exact!(log_dir);
1298        apply_exact!(model_catalog_json);
1299        apply_exact!(check_for_update_on_startup);
1300        apply_exact!(allow_login_shell);
1301
1302        if let Some(enabled) = self.feedback.as_ref().and_then(|feedback| feedback.enabled) {
1303            config.feedback.get_or_insert_default().enabled = Some(enabled);
1304        }
1305        if let Some(sandbox_private_desktop) = self
1306            .windows
1307            .as_ref()
1308            .and_then(|windows| windows.sandbox_private_desktop)
1309        {
1310            config
1311                .windows
1312                .get_or_insert_default()
1313                .sandbox_private_desktop = Some(sandbox_private_desktop);
1314        }
1315    }
1316
1317    /// Returns the exact managed field affected by editing `segments`.
1318    pub fn exact_requirement_for_config_path(&self, segments: &[String]) -> Option<&'static str> {
1319        let managed_fields: [(bool, &[&str], &'static str); 7] = [
1320            (self.sqlite_home.is_some(), &["sqlite_home"], "sqlite_home"),
1321            (self.log_dir.is_some(), &["log_dir"], "log_dir"),
1322            (
1323                self.model_catalog_json.is_some(),
1324                &["model_catalog_json"],
1325                "model_catalog_json",
1326            ),
1327            (
1328                self.check_for_update_on_startup.is_some(),
1329                &["check_for_update_on_startup"],
1330                "check_for_update_on_startup",
1331            ),
1332            (
1333                self.allow_login_shell.is_some(),
1334                &["allow_login_shell"],
1335                "allow_login_shell",
1336            ),
1337            (
1338                self.feedback
1339                    .as_ref()
1340                    .and_then(|feedback| feedback.enabled)
1341                    .is_some(),
1342                &["feedback", "enabled"],
1343                "feedback.enabled",
1344            ),
1345            (
1346                self.windows
1347                    .as_ref()
1348                    .and_then(|windows| windows.sandbox_private_desktop)
1349                    .is_some(),
1350                &["windows", "sandbox_private_desktop"],
1351                "windows.sandbox_private_desktop",
1352            ),
1353        ];
1354
1355        managed_fields
1356            .into_iter()
1357            .find_map(|(is_managed, managed_path, field)| {
1358                (is_managed && config_paths_overlap(segments, managed_path)).then_some(field)
1359            })
1360    }
1361}
1362
1363fn config_paths_overlap(segments: &[String], managed_path: &[&str]) -> bool {
1364    segments
1365        .iter()
1366        .zip(managed_path)
1367        .all(|(segment, managed_segment)| segment == managed_segment)
1368}
1369
1370fn validate_mcp_server_requirements(
1371    requirements: &BTreeMap<String, McpServerRequirement>,
1372    source: &RequirementSource,
1373    plugin_name: Option<&str>,
1374) -> Result<(), ConstraintError> {
1375    for (server_name, requirement) in requirements {
1376        requirement
1377            .validate()
1378            .map_err(|reason| ConstraintError::McpServerRequirementParse {
1379                server_name: plugin_name
1380                    .map(|plugin_name| format!("{plugin_name}/{server_name}"))
1381                    .unwrap_or_else(|| server_name.clone()),
1382                requirement_source: source.clone(),
1383                reason,
1384            })?;
1385    }
1386    Ok(())
1387}
1388
1389impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
1390    type Error = ConstraintError;
1391
1392    fn try_from(toml: ConfigRequirementsWithSources) -> Result<Self, Self::Error> {
1393        // Profile catalog selection remains on ConfigRequirementsToml for
1394        // config loading and requirements API projection. Managed new-thread
1395        // defaults also remain there because they are initialization values,
1396        // not runtime constraints.
1397        let ConfigRequirementsWithSources {
1398            sqlite_home,
1399            log_dir,
1400            model_catalog_json,
1401            check_for_update_on_startup,
1402            allow_login_shell,
1403            feedback,
1404            allowed_approval_policies,
1405            allowed_approvals_reviewers,
1406            allowed_sandbox_modes,
1407            allowed_permission_profiles: _,
1408            default_permissions: _,
1409            allowed_web_search_modes,
1410            allow_managed_hooks_only,
1411            allow_appshots,
1412            allow_remote_control,
1413            computer_use,
1414            windows,
1415            feature_requirements,
1416            hooks,
1417            mcp_servers,
1418            plugins,
1419            marketplaces,
1420            apps: _apps,
1421            rules,
1422            enforce_residency,
1423            network,
1424            permissions,
1425            models: _,
1426            guardian_policy_config,
1427        } = toml;
1428
1429        if let Some(requirements) = &mcp_servers {
1430            validate_mcp_server_requirements(
1431                &requirements.value,
1432                &requirements.source,
1433                /*plugin_name*/ None,
1434            )?;
1435        }
1436        if let Some(plugin_requirements) = &plugins {
1437            for (plugin_name, plugin) in &plugin_requirements.value {
1438                if let Some(requirements) = &plugin.mcp_servers {
1439                    validate_mcp_server_requirements(
1440                        requirements,
1441                        &plugin_requirements.source,
1442                        Some(plugin_name),
1443                    )?;
1444                }
1445            }
1446        }
1447
1448        let approval_policy = match allowed_approval_policies {
1449            Some(Sourced {
1450                value: policies,
1451                source: requirement_source,
1452            }) => {
1453                let Some(initial_value) = policies.first().copied() else {
1454                    return Err(ConstraintError::empty_field("allowed_approval_policies"));
1455                };
1456
1457                let requirement_source_for_error = requirement_source.clone();
1458                let constrained = Constrained::new(initial_value, move |candidate| {
1459                    if policies.contains(candidate) {
1460                        Ok(())
1461                    } else {
1462                        Err(ConstraintError::InvalidValue {
1463                            field_name: "approval_policy",
1464                            candidate: format!("{candidate:?}"),
1465                            allowed: format!("{policies:?}"),
1466                            requirement_source: requirement_source_for_error.clone(),
1467                        })
1468                    }
1469                })?;
1470                ConstrainedWithSource::new(constrained, Some(requirement_source))
1471            }
1472            None => ConstrainedWithSource::new(
1473                Constrained::allow_any_from_default(),
1474                /*source*/ None,
1475            ),
1476        };
1477
1478        let approvals_reviewer = match allowed_approvals_reviewers {
1479            Some(Sourced {
1480                value: reviewers,
1481                source: requirement_source,
1482            }) => {
1483                let Some(initial_value) = reviewers.first().copied() else {
1484                    return Err(ConstraintError::empty_field("allowed_approvals_reviewers"));
1485                };
1486
1487                let requirement_source_for_error = requirement_source.clone();
1488                let constrained = Constrained::new(initial_value, move |candidate| {
1489                    if reviewers.contains(candidate) {
1490                        Ok(())
1491                    } else {
1492                        Err(ConstraintError::InvalidValue {
1493                            field_name: "approvals_reviewer",
1494                            candidate: format!("{candidate:?}"),
1495                            allowed: format!("{reviewers:?}"),
1496                            requirement_source: requirement_source_for_error.clone(),
1497                        })
1498                    }
1499                })?;
1500                ConstrainedWithSource::new(constrained, Some(requirement_source))
1501            }
1502            None => ConstrainedWithSource::new(
1503                Constrained::allow_any_from_default(),
1504                /*source*/ None,
1505            ),
1506        };
1507
1508        let default_permission_profile = PermissionProfile::read_only();
1509        let permission_profile = match allowed_sandbox_modes {
1510            Some(Sourced {
1511                value: modes,
1512                source: requirement_source,
1513            }) => {
1514                if !modes.contains(&SandboxModeRequirement::ReadOnly) {
1515                    return Err(ConstraintError::InvalidValue {
1516                        field_name: "allowed_sandbox_modes",
1517                        candidate: format!("{modes:?}"),
1518                        allowed: "must include 'read-only' to allow any PermissionProfile"
1519                            .to_string(),
1520                        requirement_source,
1521                    });
1522                };
1523
1524                let requirement_source_for_error = requirement_source.clone();
1525                let constrained = Constrained::new(default_permission_profile, move |candidate| {
1526                    let mode = sandbox_mode_requirement_for_permission_profile(candidate);
1527                    if modes.contains(&mode) {
1528                        Ok(())
1529                    } else {
1530                        Err(ConstraintError::InvalidValue {
1531                            field_name: "sandbox_mode",
1532                            candidate: format!("{mode:?}"),
1533                            allowed: format!("{modes:?}"),
1534                            requirement_source: requirement_source_for_error.clone(),
1535                        })
1536                    }
1537                })?;
1538                ConstrainedWithSource::new(constrained, Some(requirement_source))
1539            }
1540            None => ConstrainedWithSource::new(
1541                Constrained::allow_any(default_permission_profile),
1542                /*source*/ None,
1543            ),
1544        };
1545        let (windows_sandbox_mode, windows_sandbox_private_desktop) = match windows {
1546            Some(Sourced {
1547                value:
1548                    WindowsRequirementsToml {
1549                        allowed_sandbox_implementations,
1550                        sandbox_private_desktop,
1551                    },
1552                source: requirement_source,
1553            }) => {
1554                let sandbox_private_desktop = sandbox_private_desktop
1555                    .map(|value| Sourced::new(value, requirement_source.clone()));
1556                let sandbox_mode = match allowed_sandbox_implementations {
1557                    Some(implementations) => {
1558                        if implementations.is_empty() {
1559                            return Err(ConstraintError::empty_field(
1560                                "windows.allowed_sandbox_implementations",
1561                            ));
1562                        }
1563                        // Prefer elevated when both Windows sandbox implementations are allowed.
1564                        let initial_value =
1565                            if implementations.contains(&WindowsSandboxModeToml::Elevated) {
1566                                WindowsSandboxModeToml::Elevated
1567                            } else {
1568                                WindowsSandboxModeToml::Unelevated
1569                            };
1570
1571                        let requirement_source_for_error = requirement_source.clone();
1572                        let constrained = Constrained::new(
1573                            Some(initial_value),
1574                            move |candidate| match candidate {
1575                                Some(candidate) if implementations.contains(candidate) => Ok(()),
1576                                _ => Err(ConstraintError::InvalidValue {
1577                                    field_name: "windows.sandbox",
1578                                    candidate: format!("{candidate:?}"),
1579                                    allowed: format!("{implementations:?}"),
1580                                    requirement_source: requirement_source_for_error.clone(),
1581                                }),
1582                            },
1583                        )?;
1584                        ConstrainedWithSource::new(constrained, Some(requirement_source))
1585                    }
1586                    None => ConstrainedWithSource::new(
1587                        Constrained::allow_any(/*initial_value*/ None),
1588                        /*source*/ None,
1589                    ),
1590                };
1591                (sandbox_mode, sandbox_private_desktop)
1592            }
1593            None => (
1594                ConstrainedWithSource::new(
1595                    Constrained::allow_any(/*initial_value*/ None),
1596                    /*source*/ None,
1597                ),
1598                None,
1599            ),
1600        };
1601        let exec_policy = match rules {
1602            Some(Sourced { value, source }) => {
1603                let policy = value.to_requirements_policy().map_err(|err| {
1604                    ConstraintError::ExecPolicyParse {
1605                        requirement_source: source.clone(),
1606                        reason: err.to_string(),
1607                    }
1608                })?;
1609                Some(Sourced::new(policy, source))
1610            }
1611            None => None,
1612        };
1613        let web_search_mode = match allowed_web_search_modes {
1614            Some(Sourced {
1615                value: modes,
1616                source: requirement_source,
1617            }) => {
1618                let mut accepted = modes.into_iter().collect::<std::collections::BTreeSet<_>>();
1619                accepted.insert(WebSearchModeRequirement::Disabled);
1620                let allowed_for_error = format!(
1621                    "{:?}",
1622                    accepted
1623                        .iter()
1624                        .copied()
1625                        .map(WebSearchMode::from)
1626                        .collect::<Vec<_>>()
1627                );
1628
1629                let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) {
1630                    WebSearchMode::Cached
1631                } else if accepted.contains(&WebSearchModeRequirement::Indexed) {
1632                    WebSearchMode::Indexed
1633                } else if accepted.contains(&WebSearchModeRequirement::Live) {
1634                    WebSearchMode::Live
1635                } else {
1636                    WebSearchMode::Disabled
1637                };
1638                let requirement_source_for_error = requirement_source.clone();
1639                let constrained = Constrained::new(initial_value, move |candidate| {
1640                    if accepted.contains(&(*candidate).into()) {
1641                        Ok(())
1642                    } else {
1643                        Err(ConstraintError::InvalidValue {
1644                            field_name: "web_search_mode",
1645                            candidate: format!("{candidate:?}"),
1646                            allowed: allowed_for_error.clone(),
1647                            requirement_source: requirement_source_for_error.clone(),
1648                        })
1649                    }
1650                })?;
1651                ConstrainedWithSource::new(constrained, Some(requirement_source))
1652            }
1653            None => ConstrainedWithSource::new(
1654                Constrained::allow_any(WebSearchMode::Cached),
1655                /*source*/ None,
1656            ),
1657        };
1658        let feature_requirements =
1659            feature_requirements.filter(|requirements| !requirements.value.is_empty());
1660        let managed_hooks = hooks
1661            .filter(|managed_hooks| managed_hooks.value.handler_count() > 0)
1662            .map(|sourced_hooks| {
1663                let Sourced {
1664                    value,
1665                    source: requirement_source,
1666                } = sourced_hooks;
1667                let allowed = value;
1668                let allowed_for_error = format!("{allowed:?}");
1669                let requirement_source_for_error = requirement_source.clone();
1670                let constrained = Constrained::new(allowed.clone(), move |candidate| {
1671                    if candidate == &allowed {
1672                        Ok(())
1673                    } else {
1674                        Err(ConstraintError::InvalidValue {
1675                            field_name: "hooks",
1676                            candidate: format!("{candidate:?}"),
1677                            allowed: allowed_for_error.clone(),
1678                            requirement_source: requirement_source_for_error.clone(),
1679                        })
1680                    }
1681                })?;
1682                Ok(ConstrainedWithSource::new(
1683                    constrained,
1684                    Some(requirement_source),
1685                ))
1686            })
1687            .transpose()?;
1688
1689        let enforce_residency = match enforce_residency {
1690            Some(Sourced {
1691                value: residency,
1692                source: requirement_source,
1693            }) => {
1694                let required = Some(residency);
1695                let requirement_source_for_error = requirement_source.clone();
1696                let constrained = Constrained::new(required, move |candidate| {
1697                    if candidate == &required {
1698                        Ok(())
1699                    } else {
1700                        Err(ConstraintError::InvalidValue {
1701                            field_name: "enforce_residency",
1702                            candidate: format!("{candidate:?}"),
1703                            allowed: format!("{required:?}"),
1704                            requirement_source: requirement_source_for_error.clone(),
1705                        })
1706                    }
1707                })?;
1708                ConstrainedWithSource::new(constrained, Some(requirement_source))
1709            }
1710            None => ConstrainedWithSource::new(
1711                Constrained::allow_any(/*initial_value*/ None),
1712                /*source*/ None,
1713            ),
1714        };
1715        let network = network.map(|sourced_network| {
1716            let Sourced { value, source } = sourced_network;
1717            Sourced::new(NetworkConstraints::from(value), source)
1718        });
1719        let filesystem = permissions.map(|sourced_permissions| {
1720            let Sourced { value, source } = sourced_permissions;
1721            Sourced::new(FilesystemConstraints::from(value), source)
1722        });
1723        let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source);
1724        Ok(ConfigRequirements {
1725            sqlite_home,
1726            log_dir,
1727            model_catalog_json,
1728            check_for_update_on_startup,
1729            allow_login_shell,
1730            feedback,
1731            approval_policy,
1732            approvals_reviewer,
1733            permission_profile,
1734            windows_sandbox_mode,
1735            windows_sandbox_private_desktop,
1736            web_search_mode,
1737            allow_managed_hooks_only,
1738            allow_appshots,
1739            allow_remote_control,
1740            computer_use,
1741            feature_requirements,
1742            managed_hooks,
1743            mcp_servers,
1744            plugins,
1745            marketplaces,
1746            exec_policy,
1747            enforce_residency,
1748            network,
1749            filesystem,
1750            guardian_policy_config_source,
1751        })
1752    }
1753}
1754
1755pub fn sandbox_mode_requirement_for_permission_profile(
1756    permission_profile: &PermissionProfile,
1757) -> SandboxModeRequirement {
1758    match permission_profile {
1759        PermissionProfile::Disabled => SandboxModeRequirement::DangerFullAccess,
1760        PermissionProfile::External { .. } => SandboxModeRequirement::ExternalSandbox,
1761        PermissionProfile::Managed { .. } => {
1762            let file_system_policy = permission_profile.file_system_sandbox_policy();
1763            if file_system_policy.has_full_disk_write_access() {
1764                SandboxModeRequirement::DangerFullAccess
1765            } else if file_system_policy
1766                .entries
1767                .iter()
1768                .any(|entry| entry.access.can_write())
1769            {
1770                SandboxModeRequirement::WorkspaceWrite
1771            } else {
1772                SandboxModeRequirement::ReadOnly
1773            }
1774        }
1775    }
1776}
1777
1778#[cfg(test)]
1779mod tests {
1780    use super::*;
1781    use crate::HookEventsToml;
1782    use crate::McpServerCommandMatcher;
1783    use crate::McpServerIdentity;
1784    use crate::McpServerValueMatcher;
1785    use anyhow::Result;
1786    use codex_execpolicy::Decision;
1787    use codex_execpolicy::Evaluation;
1788    use codex_execpolicy::RuleMatch;
1789    use codex_protocol::permissions::NetworkSandboxPolicy;
1790    use codex_utils_absolute_path::AbsolutePathBuf;
1791    use codex_utils_absolute_path::AbsolutePathBufGuard;
1792    use pretty_assertions::assert_eq;
1793    use toml::from_str;
1794
1795    fn tokens(cmd: &[&str]) -> Vec<String> {
1796        cmd.iter().map(std::string::ToString::to_string).collect()
1797    }
1798
1799    fn system_requirements_toml_file_for_test() -> Result<AbsolutePathBuf> {
1800        Ok(AbsolutePathBuf::try_from(
1801            std::env::temp_dir().join("requirements.toml"),
1802        )?)
1803    }
1804
1805    #[test]
1806    fn exact_requirement_for_config_path_matches_overlapping_paths() {
1807        let managed_path = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed"))
1808            .expect("managed path should be absolute");
1809        let requirements = ConfigRequirementsToml {
1810            sqlite_home: Some(managed_path.clone()),
1811            log_dir: Some(managed_path.clone()),
1812            model_catalog_json: Some(managed_path),
1813            check_for_update_on_startup: Some(false),
1814            allow_login_shell: Some(false),
1815            feedback: Some(FeedbackConfigToml {
1816                enabled: Some(false),
1817            }),
1818            windows: Some(WindowsRequirementsToml {
1819                sandbox_private_desktop: Some(false),
1820                ..Default::default()
1821            }),
1822            ..Default::default()
1823        };
1824        let cases: &[(&[&str], Option<&str>)] = &[
1825            (&["sqlite_home"], Some("sqlite_home")),
1826            (&["log_dir"], Some("log_dir")),
1827            (&["model_catalog_json"], Some("model_catalog_json")),
1828            (
1829                &["check_for_update_on_startup"],
1830                Some("check_for_update_on_startup"),
1831            ),
1832            (&["allow_login_shell"], Some("allow_login_shell")),
1833            (&["feedback", "enabled"], Some("feedback.enabled")),
1834            (
1835                &["windows", "sandbox_private_desktop"],
1836                Some("windows.sandbox_private_desktop"),
1837            ),
1838            (&[], Some("sqlite_home")),
1839            (&["feedback"], Some("feedback.enabled")),
1840            (
1841                &["windows", "sandbox_private_desktop", "value"],
1842                Some("windows.sandbox_private_desktop"),
1843            ),
1844            (&["feedback", "other"], None),
1845            (&["windows", "sandbox"], None),
1846        ];
1847
1848        for (segments, expected) in cases {
1849            let segments = segments.iter().map(ToString::to_string).collect::<Vec<_>>();
1850            assert_eq!(
1851                requirements.exact_requirement_for_config_path(&segments),
1852                *expected,
1853                "segments: {segments:?}"
1854            );
1855        }
1856    }
1857
1858    #[test]
1859    fn composite_requirement_source_flattens_and_deduplicates_sources() {
1860        let mdm_source = RequirementSource::MdmManagedPreferences {
1861            domain: "com.openai.codex".to_string(),
1862            key: "requirements_toml_base64".to_string(),
1863        };
1864        let legacy_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
1865
1866        assert_eq!(
1867            RequirementSource::composite([
1868                mdm_source.clone(),
1869                RequirementSource::composite([legacy_source.clone(), mdm_source.clone()]),
1870            ]),
1871            RequirementSource::Composite {
1872                sources: vec![mdm_source, legacy_source],
1873            }
1874        );
1875    }
1876
1877    fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
1878        let ConfigRequirementsToml {
1879            sqlite_home,
1880            log_dir,
1881            model_catalog_json,
1882            check_for_update_on_startup,
1883            allow_login_shell,
1884            feedback,
1885            allowed_approval_policies,
1886            allowed_approvals_reviewers,
1887            allowed_sandbox_modes,
1888            allowed_permission_profiles,
1889            default_permissions,
1890            remote_sandbox_config: _,
1891            allowed_web_search_modes,
1892            allow_managed_hooks_only,
1893            allow_appshots,
1894            allow_remote_control,
1895            computer_use,
1896            windows,
1897            feature_requirements,
1898            hooks,
1899            mcp_servers,
1900            plugins,
1901            marketplaces,
1902            apps,
1903            rules,
1904            enforce_residency,
1905            network,
1906            permissions,
1907            models,
1908            guardian_policy_config,
1909        } = toml;
1910        ConfigRequirementsWithSources {
1911            sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1912            log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1913            model_catalog_json: model_catalog_json
1914                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1915            check_for_update_on_startup: check_for_update_on_startup
1916                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1917            allow_login_shell: allow_login_shell
1918                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1919            feedback: feedback.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1920            allowed_approval_policies: allowed_approval_policies
1921                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1922            allowed_approvals_reviewers: allowed_approvals_reviewers
1923                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1924            allowed_sandbox_modes: allowed_sandbox_modes
1925                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1926            allowed_permission_profiles: allowed_permission_profiles
1927                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1928            default_permissions: default_permissions
1929                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1930            allowed_web_search_modes: allowed_web_search_modes
1931                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1932            allow_managed_hooks_only: allow_managed_hooks_only
1933                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1934            allow_appshots: allow_appshots
1935                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1936            allow_remote_control: allow_remote_control
1937                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1938            computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1939            windows: windows.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1940            feature_requirements: feature_requirements
1941                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1942            hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1943            mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1944            plugins: plugins.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1945            marketplaces: marketplaces.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1946            apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1947            rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1948            enforce_residency: enforce_residency
1949                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1950            network: network.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1951            permissions: permissions.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1952            models: models.map(|value| Sourced::new(value, RequirementSource::Unknown)),
1953            guardian_policy_config: guardian_policy_config
1954                .map(|value| Sourced::new(value, RequirementSource::Unknown)),
1955        }
1956    }
1957
1958    #[test]
1959    fn deserialize_allow_managed_hooks_only() -> Result<()> {
1960        let requirements: ConfigRequirementsToml = from_str(
1961            r#"
1962                allow_managed_hooks_only = true
1963            "#,
1964        )?;
1965
1966        assert_eq!(requirements.allow_managed_hooks_only, Some(true));
1967        assert!(!requirements.is_empty());
1968        Ok(())
1969    }
1970
1971    #[test]
1972    fn allow_managed_hooks_only_false_is_still_configured() -> Result<()> {
1973        let requirements: ConfigRequirementsToml = from_str(
1974            r#"
1975                allow_managed_hooks_only = false
1976            "#,
1977        )?;
1978
1979        assert_eq!(requirements.allow_managed_hooks_only, Some(false));
1980        assert!(!requirements.is_empty());
1981        Ok(())
1982    }
1983
1984    #[test]
1985    fn deserialize_managed_permission_profiles() -> Result<()> {
1986        let requirements: ConfigRequirementsToml = from_str(
1987            r#"
1988                default_permissions = "managed-standard"
1989
1990                [allowed_permission_profiles]
1991                managed-standard = true
1992                managed-build = true
1993
1994                [permissions.managed-standard]
1995                extends = ":workspace"
1996
1997                [permissions.managed-build]
1998                extends = "managed-standard"
1999            "#,
2000        )?;
2001
2002        assert_eq!(
2003            requirements.allowed_permission_profiles,
2004            Some(BTreeMap::from([
2005                ("managed-build".to_string(), true),
2006                ("managed-standard".to_string(), true),
2007            ]))
2008        );
2009        assert_eq!(
2010            requirements.default_permissions,
2011            Some("managed-standard".to_string())
2012        );
2013        let permissions = requirements
2014            .permissions
2015            .as_ref()
2016            .expect("managed permission profiles");
2017        assert!(permissions.profiles.contains_key("managed-standard"));
2018        assert!(
2019            permissions
2020                .profiles
2021                .get("managed-build")
2022                .and_then(|profile| profile.extends.as_deref())
2023                .is_some()
2024        );
2025        assert!(!requirements.is_empty());
2026        Ok(())
2027    }
2028
2029    #[test]
2030    fn deserialize_allow_appshots() -> Result<()> {
2031        let requirements: ConfigRequirementsToml = from_str(
2032            r#"
2033                allow_appshots = true
2034            "#,
2035        )?;
2036
2037        assert_eq!(requirements.allow_appshots, Some(true));
2038        assert!(!requirements.is_empty());
2039        Ok(())
2040    }
2041
2042    #[test]
2043    fn filesystem_requirements_table_cannot_define_a_permission_profile() {
2044        let err = from_str::<ConfigRequirementsToml>(
2045            r#"
2046                [permissions.filesystem]
2047                extends = ":workspace"
2048            "#,
2049        )
2050        .expect_err("filesystem requirements cannot define a permission profile");
2051
2052        assert!(
2053            err.to_string().contains(
2054                "`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile"
2055            ),
2056            "unexpected error: {err:#}"
2057        );
2058    }
2059
2060    #[test]
2061    fn allow_appshots_false_is_still_configured() -> Result<()> {
2062        let requirements: ConfigRequirementsToml = from_str(
2063            r#"
2064                allow_appshots = false
2065            "#,
2066        )?;
2067
2068        assert_eq!(requirements.allow_appshots, Some(false));
2069        assert!(!requirements.is_empty());
2070        Ok(())
2071    }
2072
2073    #[test]
2074    fn allow_remote_control_false_is_still_configured() -> Result<()> {
2075        let requirements: ConfigRequirementsToml = from_str(
2076            r#"
2077                allow_remote_control = false
2078            "#,
2079        )?;
2080
2081        assert_eq!(requirements.allow_remote_control, Some(false));
2082        assert!(!requirements.is_empty());
2083        Ok(())
2084    }
2085
2086    #[test]
2087    fn deserialize_computer_use_requirements() -> Result<()> {
2088        let requirements: ConfigRequirementsToml = from_str(
2089            r#"
2090                [computer_use]
2091                allow_locked_computer_use = false
2092            "#,
2093        )?;
2094
2095        assert_eq!(
2096            requirements.computer_use,
2097            Some(ComputerUseRequirementsToml {
2098                allow_locked_computer_use: Some(false),
2099            })
2100        );
2101        assert!(!requirements.is_empty());
2102        Ok(())
2103    }
2104
2105    #[test]
2106    fn deserialize_new_thread_model_defaults() -> Result<()> {
2107        let requirements: ConfigRequirementsToml = from_str(
2108            r#"
2109                [models.new_thread]
2110                model = "managed-model"
2111                model_reasoning_effort = "medium"
2112                service_tier = "fast"
2113            "#,
2114        )?;
2115
2116        assert_eq!(
2117            requirements.models,
2118            Some(ModelsRequirementsToml {
2119                new_thread: Some(NewThreadModelDefaultsToml {
2120                    model: Some("managed-model".to_string()),
2121                    model_reasoning_effort: Some(ReasoningEffort::Medium),
2122                    service_tier: Some("fast".to_string()),
2123                }),
2124            })
2125        );
2126        assert!(!requirements.is_empty());
2127        Ok(())
2128    }
2129
2130    #[test]
2131    fn merge_unset_fields_copies_every_field_and_sets_sources() {
2132        let mut target = ConfigRequirementsWithSources::default();
2133        let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
2134
2135        let allowed_approval_policies = vec![AskForApproval::UnlessTrusted, AskForApproval::Never];
2136        let allowed_approvals_reviewers =
2137            vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User];
2138        let allowed_sandbox_modes = vec![
2139            SandboxModeRequirement::WorkspaceWrite,
2140            SandboxModeRequirement::DangerFullAccess,
2141        ];
2142        let allowed_web_search_modes = vec![
2143            WebSearchModeRequirement::Cached,
2144            WebSearchModeRequirement::Live,
2145        ];
2146        let feature_requirements = FeatureRequirementsToml {
2147            entries: BTreeMap::from([("personality".to_string(), true)]),
2148        };
2149        let computer_use = ComputerUseRequirementsToml {
2150            allow_locked_computer_use: Some(false),
2151        };
2152        let models = ModelsRequirementsToml {
2153            new_thread: Some(NewThreadModelDefaultsToml {
2154                model: Some("managed-model".to_string()),
2155                model_reasoning_effort: Some(ReasoningEffort::Medium),
2156                service_tier: Some("fast".to_string()),
2157            }),
2158        };
2159        let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state"))
2160            .expect("managed sqlite home should be absolute");
2161        let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs"))
2162            .expect("managed log dir should be absolute");
2163        let model_catalog_json =
2164            AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json"))
2165                .expect("managed model catalog path should be absolute");
2166        let feedback = FeedbackConfigToml {
2167            enabled: Some(false),
2168        };
2169        let windows = WindowsRequirementsToml {
2170            allowed_sandbox_implementations: None,
2171            sandbox_private_desktop: Some(true),
2172        };
2173        let enforce_residency = ResidencyRequirement::Us;
2174        let enforce_source = source.clone();
2175        let guardian_policy_config = "Use the company-managed guardian policy.".to_string();
2176
2177        // Intentionally constructed without `..Default::default()` so adding a new field to
2178        // `ConfigRequirementsToml` forces this test to be updated.
2179        let other = ConfigRequirementsToml {
2180            sqlite_home: Some(sqlite_home.clone()),
2181            log_dir: Some(log_dir.clone()),
2182            model_catalog_json: Some(model_catalog_json.clone()),
2183            check_for_update_on_startup: Some(false),
2184            allow_login_shell: Some(false),
2185            feedback: Some(feedback.clone()),
2186            allowed_approval_policies: Some(allowed_approval_policies.clone()),
2187            allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()),
2188            allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
2189            allowed_permission_profiles: Some(BTreeMap::from([("managed".to_string(), true)])),
2190            default_permissions: Some("managed".to_string()),
2191            remote_sandbox_config: None,
2192            allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
2193            allow_managed_hooks_only: Some(true),
2194            allow_appshots: Some(false),
2195            allow_remote_control: Some(false),
2196            computer_use: Some(computer_use.clone()),
2197            windows: Some(windows.clone()),
2198            feature_requirements: Some(feature_requirements.clone()),
2199            hooks: None,
2200            mcp_servers: None,
2201            plugins: None,
2202            marketplaces: None,
2203            apps: None,
2204            rules: None,
2205            enforce_residency: Some(enforce_residency),
2206            network: None,
2207            permissions: None,
2208            models: Some(models.clone()),
2209            guardian_policy_config: Some(guardian_policy_config.clone()),
2210        };
2211
2212        target.merge_unset_fields(source.clone(), other);
2213
2214        assert_eq!(
2215            target,
2216            ConfigRequirementsWithSources {
2217                sqlite_home: Some(Sourced::new(sqlite_home, source.clone())),
2218                log_dir: Some(Sourced::new(log_dir, source.clone())),
2219                model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())),
2220                check_for_update_on_startup: Some(Sourced::new(
2221                    /*value*/ false,
2222                    source.clone(),
2223                )),
2224                allow_login_shell: Some(Sourced::new(/*value*/ false, source.clone())),
2225                feedback: Some(Sourced::new(feedback, source.clone())),
2226                allowed_approval_policies: Some(Sourced::new(
2227                    allowed_approval_policies,
2228                    source.clone()
2229                )),
2230                allowed_approvals_reviewers: Some(Sourced::new(
2231                    allowed_approvals_reviewers,
2232                    source.clone(),
2233                )),
2234                allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)),
2235                allowed_permission_profiles: Some(Sourced::new(
2236                    BTreeMap::from([("managed".to_string(), true)]),
2237                    source.clone(),
2238                )),
2239                default_permissions: Some(Sourced::new("managed".to_string(), source.clone(),)),
2240                allowed_web_search_modes: Some(Sourced::new(
2241                    allowed_web_search_modes,
2242                    enforce_source.clone(),
2243                )),
2244                allow_managed_hooks_only: Some(Sourced::new(
2245                    /*value*/ true,
2246                    enforce_source.clone(),
2247                )),
2248                allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)),
2249                allow_remote_control: Some(Sourced::new(
2250                    /*value*/ false,
2251                    enforce_source.clone(),
2252                )),
2253                computer_use: Some(Sourced::new(computer_use, enforce_source.clone())),
2254                windows: Some(Sourced::new(windows, enforce_source.clone())),
2255                feature_requirements: Some(Sourced::new(
2256                    feature_requirements,
2257                    enforce_source.clone(),
2258                )),
2259                hooks: None,
2260                mcp_servers: None,
2261                plugins: None,
2262                marketplaces: None,
2263                apps: None,
2264                rules: None,
2265                enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)),
2266                network: None,
2267                permissions: None,
2268                models: Some(Sourced::new(models, source.clone())),
2269                guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)),
2270            }
2271        );
2272    }
2273
2274    #[test]
2275    fn merge_unset_fields_fills_missing_values() -> Result<()> {
2276        let source: ConfigRequirementsToml = from_str(
2277            r#"
2278                allowed_approval_policies = ["on-request"]
2279            "#,
2280        )?;
2281
2282        let source_location = RequirementSource::MdmManagedPreferences {
2283            domain: "com.codex".to_string(),
2284            key: "allowed_approval_policies".to_string(),
2285        };
2286
2287        let mut empty_target = ConfigRequirementsWithSources::default();
2288        empty_target.merge_unset_fields(source_location.clone(), source);
2289        assert_eq!(
2290            empty_target,
2291            ConfigRequirementsWithSources {
2292                allowed_approval_policies: Some(Sourced::new(
2293                    vec![AskForApproval::OnRequest],
2294                    source_location,
2295                )),
2296                allowed_approvals_reviewers: None,
2297                allowed_sandbox_modes: None,
2298                allowed_permission_profiles: None,
2299                default_permissions: None,
2300                allowed_web_search_modes: None,
2301                allow_managed_hooks_only: None,
2302                allow_appshots: None,
2303                allow_remote_control: None,
2304                computer_use: None,
2305                windows: None,
2306                feature_requirements: None,
2307                hooks: None,
2308                mcp_servers: None,
2309                plugins: None,
2310                marketplaces: None,
2311                apps: None,
2312                rules: None,
2313                enforce_residency: None,
2314                network: None,
2315                permissions: None,
2316                models: None,
2317                guardian_policy_config: None,
2318                ..Default::default()
2319            }
2320        );
2321        Ok(())
2322    }
2323
2324    #[test]
2325    fn merge_unset_fields_does_not_overwrite_existing_values() -> Result<()> {
2326        let existing_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
2327        let mut populated_target = ConfigRequirementsWithSources::default();
2328        let populated_requirements: ConfigRequirementsToml = from_str(
2329            r#"
2330                allowed_approval_policies = ["never"]
2331            "#,
2332        )?;
2333        populated_target.merge_unset_fields(existing_source.clone(), populated_requirements);
2334
2335        let source: ConfigRequirementsToml = from_str(
2336            r#"
2337                allowed_approval_policies = ["on-request"]
2338            "#,
2339        )?;
2340        let source_location = RequirementSource::MdmManagedPreferences {
2341            domain: "com.codex".to_string(),
2342            key: "allowed_approval_policies".to_string(),
2343        };
2344        populated_target.merge_unset_fields(source_location, source);
2345
2346        assert_eq!(
2347            populated_target,
2348            ConfigRequirementsWithSources {
2349                allowed_approval_policies: Some(Sourced::new(
2350                    vec![AskForApproval::Never],
2351                    existing_source,
2352                )),
2353                allowed_approvals_reviewers: None,
2354                allowed_sandbox_modes: None,
2355                allowed_permission_profiles: None,
2356                default_permissions: None,
2357                allowed_web_search_modes: None,
2358                allow_managed_hooks_only: None,
2359                allow_appshots: None,
2360                allow_remote_control: None,
2361                computer_use: None,
2362                windows: None,
2363                feature_requirements: None,
2364                hooks: None,
2365                mcp_servers: None,
2366                plugins: None,
2367                marketplaces: None,
2368                apps: None,
2369                rules: None,
2370                enforce_residency: None,
2371                network: None,
2372                permissions: None,
2373                models: None,
2374                guardian_policy_config: None,
2375                ..Default::default()
2376            }
2377        );
2378        Ok(())
2379    }
2380
2381    #[test]
2382    fn merge_unset_fields_ignores_blank_guardian_override() {
2383        let mut target = ConfigRequirementsWithSources::default();
2384        target.merge_unset_fields(
2385            RequirementSource::LegacyManagedConfigTomlFromMdm,
2386            ConfigRequirementsToml {
2387                guardian_policy_config: Some("   \n\t".to_string()),
2388                ..Default::default()
2389            },
2390        );
2391        target.merge_unset_fields(
2392            RequirementSource::SystemRequirementsToml {
2393                file: system_requirements_toml_file_for_test()
2394                    .expect("system requirements.toml path"),
2395            },
2396            ConfigRequirementsToml {
2397                guardian_policy_config: Some("Use the system guardian policy.".to_string()),
2398                ..Default::default()
2399            },
2400        );
2401
2402        assert_eq!(
2403            target.guardian_policy_config,
2404            Some(Sourced::new(
2405                "Use the system guardian policy.".to_string(),
2406                RequirementSource::SystemRequirementsToml {
2407                    file: system_requirements_toml_file_for_test()
2408                        .expect("system requirements.toml path"),
2409                },
2410            )),
2411        );
2412    }
2413
2414    #[test]
2415    fn deserialize_guardian_policy_config() -> Result<()> {
2416        let requirements: ConfigRequirementsToml = from_str(
2417            r#"
2418guardian_policy_config = """
2419Use the cloud-managed guardian policy.
2420"""
2421"#,
2422        )?;
2423
2424        assert_eq!(
2425            requirements.guardian_policy_config.as_deref(),
2426            Some("Use the cloud-managed guardian policy.\n")
2427        );
2428        Ok(())
2429    }
2430
2431    #[test]
2432    fn blank_guardian_policy_config_is_empty() -> Result<()> {
2433        let requirements: ConfigRequirementsToml = from_str(
2434            r#"
2435guardian_policy_config = """
2436
2437"""
2438"#,
2439        )?;
2440
2441        assert!(requirements.is_empty());
2442        Ok(())
2443    }
2444
2445    #[test]
2446    fn allowed_approvals_reviewers_is_not_empty() -> Result<()> {
2447        let requirements: ConfigRequirementsToml = from_str(
2448            r#"
2449allowed_approvals_reviewers = ["user"]
2450"#,
2451        )?;
2452
2453        assert!(!requirements.is_empty());
2454        Ok(())
2455    }
2456
2457    #[test]
2458    fn deserialize_filesystem_deny_read_requirements() -> Result<()> {
2459        let deny_read_0 = if cfg!(windows) {
2460            r"C:\Users\alice\.gitconfig"
2461        } else {
2462            "/home/alice/.gitconfig"
2463        };
2464        let deny_read_1 = if cfg!(windows) {
2465            r"C:\Users\alice\.ssh"
2466        } else {
2467            "/home/alice/.ssh"
2468        };
2469        let toml_str = format!(
2470            r#"
2471            [permissions.filesystem]
2472            deny_read = [{deny_read_0:?}, {deny_read_1:?}]
2473        "#
2474        );
2475
2476        let config: ConfigRequirementsToml = from_str(&toml_str)?;
2477        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
2478
2479        assert_eq!(
2480            requirements.filesystem,
2481            Some(Sourced::new(
2482                FilesystemConstraints {
2483                    deny_read: vec![
2484                        AbsolutePathBuf::from_absolute_path(deny_read_0)?.into(),
2485                        AbsolutePathBuf::from_absolute_path(deny_read_1)?.into(),
2486                    ],
2487                },
2488                RequirementSource::Unknown,
2489            ))
2490        );
2491
2492        Ok(())
2493    }
2494
2495    #[test]
2496    fn deserialize_filesystem_deny_read_glob_requirements() -> Result<()> {
2497        let temp_dir = std::env::temp_dir();
2498        let _guard = AbsolutePathBufGuard::new(&temp_dir);
2499        let config: ConfigRequirementsToml = from_str(
2500            r#"
2501            [permissions.filesystem]
2502            deny_read = ["./private/**/*.txt"]
2503        "#,
2504        )?;
2505        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
2506
2507        assert_eq!(
2508            requirements.filesystem,
2509            Some(Sourced::new(
2510                FilesystemConstraints {
2511                    deny_read: vec![
2512                        FilesystemDenyReadPattern::from_input("./private/**/*.txt")
2513                            .expect("normalize glob pattern"),
2514                    ],
2515                },
2516                RequirementSource::Unknown,
2517            ))
2518        );
2519        Ok(())
2520    }
2521
2522    #[test]
2523    fn deserialize_apps_requirements() -> Result<()> {
2524        let toml_str = r#"
2525            [apps.connector_123123]
2526            enabled = false
2527        "#;
2528        let requirements: ConfigRequirementsToml = from_str(toml_str)?;
2529
2530        assert_eq!(
2531            requirements.apps,
2532            Some(AppsRequirementsToml {
2533                apps: BTreeMap::from([(
2534                    "connector_123123".to_string(),
2535                    AppRequirementToml {
2536                        enabled: Some(false),
2537                        tools: None,
2538                    },
2539                )]),
2540            })
2541        );
2542        Ok(())
2543    }
2544
2545    #[test]
2546    fn deserialize_apps_tool_requirements() -> Result<()> {
2547        let toml_str = r#"
2548            [apps.connector_123123.tools."calendar/list_events"]
2549            approval_mode = "approve"
2550        "#;
2551        let requirements: ConfigRequirementsToml = from_str(toml_str)?;
2552
2553        assert_eq!(
2554            requirements.apps,
2555            Some(AppsRequirementsToml {
2556                apps: BTreeMap::from([(
2557                    "connector_123123".to_string(),
2558                    AppRequirementToml {
2559                        enabled: None,
2560                        tools: Some(AppToolsRequirementsToml {
2561                            tools: BTreeMap::from([(
2562                                "calendar/list_events".to_string(),
2563                                AppToolRequirementToml {
2564                                    approval_mode: Some(AppToolApproval::Approve),
2565                                },
2566                            )]),
2567                        }),
2568                    },
2569                )]),
2570            })
2571        );
2572        Ok(())
2573    }
2574
2575    fn apps_requirements(entries: &[(&str, Option<bool>)]) -> AppsRequirementsToml {
2576        AppsRequirementsToml {
2577            apps: entries
2578                .iter()
2579                .map(|(app_id, enabled)| {
2580                    (
2581                        (*app_id).to_string(),
2582                        AppRequirementToml {
2583                            enabled: *enabled,
2584                            tools: None,
2585                        },
2586                    )
2587                })
2588                .collect(),
2589        }
2590    }
2591
2592    fn app_tool_requirements(
2593        app_id: &str,
2594        tool_name: &str,
2595        approval_mode: AppToolApproval,
2596    ) -> AppsRequirementsToml {
2597        AppsRequirementsToml {
2598            apps: BTreeMap::from([(
2599                app_id.to_string(),
2600                AppRequirementToml {
2601                    enabled: None,
2602                    tools: Some(AppToolsRequirementsToml {
2603                        tools: BTreeMap::from([(
2604                            tool_name.to_string(),
2605                            AppToolRequirementToml {
2606                                approval_mode: Some(approval_mode),
2607                            },
2608                        )]),
2609                    }),
2610                },
2611            )]),
2612        }
2613    }
2614
2615    #[test]
2616    fn merge_app_requirements_descending_unions_distinct_apps() {
2617        let mut merged = apps_requirements(&[("connector_high", Some(false))]);
2618        let lower = apps_requirements(&[("connector_low", Some(true))]);
2619
2620        merge_app_requirements_descending(&mut merged, lower);
2621
2622        assert_eq!(
2623            merged,
2624            apps_requirements(&[
2625                ("connector_high", Some(false)),
2626                ("connector_low", Some(true))
2627            ]),
2628        );
2629    }
2630
2631    #[test]
2632    fn merge_app_requirements_descending_prefers_false_from_lower_precedence() {
2633        let mut merged = apps_requirements(&[("connector_123123", Some(true))]);
2634        let lower = apps_requirements(&[("connector_123123", Some(false))]);
2635
2636        merge_app_requirements_descending(&mut merged, lower);
2637
2638        assert_eq!(
2639            merged,
2640            apps_requirements(&[("connector_123123", Some(false))]),
2641        );
2642    }
2643
2644    #[test]
2645    fn merge_app_requirements_descending_keeps_higher_true_when_lower_is_unset() {
2646        let mut merged = apps_requirements(&[("connector_123123", Some(true))]);
2647        let lower = apps_requirements(&[("connector_123123", None)]);
2648
2649        merge_app_requirements_descending(&mut merged, lower);
2650
2651        assert_eq!(
2652            merged,
2653            apps_requirements(&[("connector_123123", Some(true))]),
2654        );
2655    }
2656
2657    #[test]
2658    fn merge_app_requirements_descending_uses_lower_value_when_higher_missing() {
2659        let mut merged = apps_requirements(&[]);
2660        let lower = apps_requirements(&[("connector_123123", Some(true))]);
2661
2662        merge_app_requirements_descending(&mut merged, lower);
2663
2664        assert_eq!(
2665            merged,
2666            apps_requirements(&[("connector_123123", Some(true))]),
2667        );
2668    }
2669
2670    #[test]
2671    fn merge_app_requirements_descending_preserves_higher_false_when_lower_missing_app() {
2672        let mut merged = apps_requirements(&[("connector_123123", Some(false))]);
2673        let lower = apps_requirements(&[]);
2674
2675        merge_app_requirements_descending(&mut merged, lower);
2676
2677        assert_eq!(
2678            merged,
2679            apps_requirements(&[("connector_123123", Some(false))]),
2680        );
2681    }
2682
2683    #[test]
2684    fn merge_app_requirements_descending_preserves_higher_tool_approval_mode() {
2685        let mut merged = app_tool_requirements(
2686            "connector_123123",
2687            "calendar/list_events",
2688            AppToolApproval::Approve,
2689        );
2690        let lower = app_tool_requirements(
2691            "connector_123123",
2692            "calendar/list_events",
2693            AppToolApproval::Prompt,
2694        );
2695
2696        merge_app_requirements_descending(&mut merged, lower);
2697
2698        assert_eq!(
2699            merged,
2700            app_tool_requirements(
2701                "connector_123123",
2702                "calendar/list_events",
2703                AppToolApproval::Approve,
2704            )
2705        );
2706    }
2707
2708    #[test]
2709    fn merge_app_requirements_descending_uses_lower_tool_approval_when_higher_missing() {
2710        let mut merged = apps_requirements(&[("connector_123123", None)]);
2711        let lower = app_tool_requirements(
2712            "connector_123123",
2713            "calendar/list_events",
2714            AppToolApproval::Approve,
2715        );
2716
2717        merge_app_requirements_descending(&mut merged, lower);
2718
2719        assert_eq!(
2720            merged,
2721            app_tool_requirements(
2722                "connector_123123",
2723                "calendar/list_events",
2724                AppToolApproval::Approve,
2725            )
2726        );
2727    }
2728
2729    #[test]
2730    fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() {
2731        let higher_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
2732        let lower_source = RequirementSource::MdmManagedPreferences {
2733            domain: "com.openai.codex".to_string(),
2734            key: "requirements_toml_base64".to_string(),
2735        };
2736        let mut target = ConfigRequirementsWithSources::default();
2737
2738        target.merge_unset_fields(
2739            higher_source.clone(),
2740            ConfigRequirementsToml {
2741                apps: Some(apps_requirements(&[
2742                    ("connector_high", Some(true)),
2743                    ("connector_shared", Some(true)),
2744                ])),
2745                ..Default::default()
2746            },
2747        );
2748        target.merge_unset_fields(
2749            lower_source,
2750            ConfigRequirementsToml {
2751                apps: Some(apps_requirements(&[
2752                    ("connector_low", Some(false)),
2753                    ("connector_shared", Some(false)),
2754                ])),
2755                ..Default::default()
2756            },
2757        );
2758
2759        let apps = target.apps.expect("apps should be present");
2760        assert_eq!(
2761            apps.value,
2762            apps_requirements(&[
2763                ("connector_high", Some(true)),
2764                ("connector_low", Some(false)),
2765                ("connector_shared", Some(false)),
2766            ])
2767        );
2768        assert_eq!(apps.source, higher_source);
2769    }
2770
2771    #[test]
2772    fn merge_unset_fields_apps_empty_higher_source_does_not_block_lower_disables() {
2773        let mut target = ConfigRequirementsWithSources::default();
2774
2775        target.merge_unset_fields(
2776            RequirementSource::LegacyManagedConfigTomlFromMdm,
2777            ConfigRequirementsToml {
2778                apps: Some(apps_requirements(&[])),
2779                ..Default::default()
2780            },
2781        );
2782        target.merge_unset_fields(
2783            RequirementSource::LegacyManagedConfigTomlFromMdm,
2784            ConfigRequirementsToml {
2785                apps: Some(apps_requirements(&[("connector_123123", Some(false))])),
2786                ..Default::default()
2787            },
2788        );
2789
2790        assert_eq!(
2791            target.apps.map(|apps| apps.value),
2792            Some(apps_requirements(&[("connector_123123", Some(false))])),
2793        );
2794    }
2795
2796    #[test]
2797    fn constraint_error_includes_requirement_source() -> Result<()> {
2798        let source: ConfigRequirementsToml = from_str(
2799            r#"
2800                allowed_approval_policies = ["on-request"]
2801                allowed_approvals_reviewers = ["auto_review"]
2802                allowed_sandbox_modes = ["read-only"]
2803            "#,
2804        )?;
2805
2806        let requirements_toml_file = system_requirements_toml_file_for_test()?;
2807        let source_location = RequirementSource::SystemRequirementsToml {
2808            file: requirements_toml_file,
2809        };
2810
2811        let mut target = ConfigRequirementsWithSources::default();
2812        target.merge_unset_fields(source_location.clone(), source);
2813        let requirements = ConfigRequirements::try_from(target)?;
2814
2815        assert_eq!(
2816            requirements.approval_policy.can_set(&AskForApproval::Never),
2817            Err(ConstraintError::InvalidValue {
2818                field_name: "approval_policy",
2819                candidate: "Never".into(),
2820                allowed: "[OnRequest]".into(),
2821                requirement_source: source_location.clone(),
2822            })
2823        );
2824        assert_eq!(
2825            requirements
2826                .permission_profile
2827                .can_set(&PermissionProfile::Disabled),
2828            Err(ConstraintError::InvalidValue {
2829                field_name: "sandbox_mode",
2830                candidate: "DangerFullAccess".into(),
2831                allowed: "[ReadOnly]".into(),
2832                requirement_source: source_location.clone(),
2833            })
2834        );
2835        assert_eq!(
2836            requirements
2837                .approvals_reviewer
2838                .can_set(&ApprovalsReviewer::User),
2839            Err(ConstraintError::InvalidValue {
2840                field_name: "approvals_reviewer",
2841                candidate: "User".into(),
2842                allowed: "[AutoReview]".into(),
2843                requirement_source: source_location,
2844            })
2845        );
2846
2847        Ok(())
2848    }
2849
2850    #[test]
2851    fn constraint_error_includes_composite_requirement_source() -> Result<()> {
2852        let source: ConfigRequirementsToml = from_str(
2853            r#"
2854                allowed_approval_policies = ["on-request"]
2855            "#,
2856        )?;
2857
2858        let source_location = RequirementSource::composite([
2859            RequirementSource::MdmManagedPreferences {
2860                domain: "com.openai.codex".to_string(),
2861                key: "requirements_toml_base64".to_string(),
2862            },
2863            RequirementSource::LegacyManagedConfigTomlFromMdm,
2864        ]);
2865
2866        let mut target = ConfigRequirementsWithSources::default();
2867        target.merge_unset_fields(source_location.clone(), source);
2868        let requirements = ConfigRequirements::try_from(target)?;
2869
2870        assert_eq!(
2871            requirements.approval_policy.can_set(&AskForApproval::Never),
2872            Err(ConstraintError::InvalidValue {
2873                field_name: "approval_policy",
2874                candidate: "Never".into(),
2875                allowed: "[OnRequest]".into(),
2876                requirement_source: source_location,
2877            })
2878        );
2879
2880        Ok(())
2881    }
2882
2883    #[test]
2884    fn constrained_fields_store_requirement_source() -> Result<()> {
2885        let source: ConfigRequirementsToml = from_str(
2886            r#"
2887                allowed_approval_policies = ["on-request"]
2888                allowed_approvals_reviewers = ["auto_review"]
2889                allowed_sandbox_modes = ["read-only"]
2890                allowed_web_search_modes = ["cached"]
2891                enforce_residency = "us"
2892                [features]
2893                personality = true
2894            "#,
2895        )?;
2896
2897        let source_location = RequirementSource::LegacyManagedConfigTomlFromMdm;
2898        let mut target = ConfigRequirementsWithSources::default();
2899        target.merge_unset_fields(source_location.clone(), source);
2900        let requirements = ConfigRequirements::try_from(target)?;
2901
2902        assert_eq!(
2903            requirements.approval_policy.source,
2904            Some(source_location.clone())
2905        );
2906        assert_eq!(
2907            requirements.approvals_reviewer.source,
2908            Some(source_location.clone())
2909        );
2910        assert_eq!(
2911            requirements.permission_profile.source,
2912            Some(source_location.clone())
2913        );
2914        assert_eq!(
2915            requirements.web_search_mode.source,
2916            Some(source_location.clone())
2917        );
2918        assert_eq!(
2919            requirements
2920                .feature_requirements
2921                .as_ref()
2922                .map(|requirements| requirements.source.clone()),
2923            Some(source_location.clone())
2924        );
2925        assert_eq!(requirements.enforce_residency.source, Some(source_location));
2926
2927        Ok(())
2928    }
2929
2930    #[test]
2931    fn deserialize_allowed_approval_policies() -> Result<()> {
2932        let toml_str = r#"
2933            allowed_approval_policies = ["untrusted", "on-request"]
2934        "#;
2935        let config: ConfigRequirementsToml = from_str(toml_str)?;
2936        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
2937
2938        assert_eq!(
2939            requirements.approval_policy.value(),
2940            AskForApproval::UnlessTrusted,
2941            "currently, there is no way to specify the default value for approval policy in the toml, so it picks the first allowed value"
2942        );
2943        assert!(
2944            requirements
2945                .approval_policy
2946                .can_set(&AskForApproval::UnlessTrusted)
2947                .is_ok()
2948        );
2949        assert!(
2950            requirements
2951                .approval_policy
2952                .can_set(&AskForApproval::OnRequest)
2953                .is_ok()
2954        );
2955        assert_eq!(
2956            requirements.approval_policy.can_set(&AskForApproval::Never),
2957            Err(ConstraintError::InvalidValue {
2958                field_name: "approval_policy",
2959                candidate: "Never".into(),
2960                allowed: "[UnlessTrusted, OnRequest]".into(),
2961                requirement_source: RequirementSource::Unknown,
2962            })
2963        );
2964        assert!(
2965            requirements
2966                .permission_profile
2967                .can_set(&PermissionProfile::read_only())
2968                .is_ok()
2969        );
2970
2971        Ok(())
2972    }
2973
2974    #[test]
2975    fn deserialize_allowed_approvals_reviewers() -> Result<()> {
2976        let toml_str = r#"
2977            allowed_approvals_reviewers = ["auto_review", "user"]
2978        "#;
2979        let config: ConfigRequirementsToml = from_str(toml_str)?;
2980        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
2981
2982        assert_eq!(
2983            requirements.approvals_reviewer.value(),
2984            ApprovalsReviewer::AutoReview,
2985            "currently, there is no way to specify the default value for approvals reviewer in the toml, so it picks the first allowed value"
2986        );
2987        assert!(
2988            requirements
2989                .approvals_reviewer
2990                .can_set(&ApprovalsReviewer::AutoReview)
2991                .is_ok()
2992        );
2993        assert!(
2994            requirements
2995                .approvals_reviewer
2996                .can_set(&ApprovalsReviewer::User)
2997                .is_ok()
2998        );
2999
3000        Ok(())
3001    }
3002
3003    #[test]
3004    fn deserialize_allowed_windows_sandbox_implementations() -> Result<()> {
3005        let toml_str = r#"
3006            [windows]
3007            allowed_sandbox_implementations = ["elevated"]
3008        "#;
3009        let config: ConfigRequirementsToml = from_str(toml_str)?;
3010        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3011
3012        assert_eq!(
3013            requirements.windows_sandbox_mode.value(),
3014            Some(WindowsSandboxModeToml::Elevated)
3015        );
3016        assert!(
3017            requirements
3018                .windows_sandbox_mode
3019                .can_set(&Some(WindowsSandboxModeToml::Elevated))
3020                .is_ok()
3021        );
3022        assert!(
3023            requirements
3024                .windows_sandbox_mode
3025                .can_set(&Some(WindowsSandboxModeToml::Unelevated))
3026                .is_err()
3027        );
3028        assert!(requirements.windows_sandbox_mode.can_set(&None).is_err());
3029
3030        Ok(())
3031    }
3032
3033    #[test]
3034    fn empty_allowed_windows_sandbox_implementations_is_rejected() -> Result<()> {
3035        let toml_str = r#"
3036            [windows]
3037            allowed_sandbox_implementations = []
3038        "#;
3039        let config: ConfigRequirementsToml = from_str(toml_str)?;
3040
3041        assert_eq!(
3042            ConfigRequirements::try_from(with_unknown_source(config)),
3043            Err(ConstraintError::EmptyField {
3044                field_name: "windows.allowed_sandbox_implementations".to_string(),
3045            })
3046        );
3047
3048        Ok(())
3049    }
3050
3051    #[test]
3052    fn allowed_windows_sandbox_implementations_prefer_elevated_fallback() -> Result<()> {
3053        let toml_str = r#"
3054            [windows]
3055            allowed_sandbox_implementations = ["unelevated", "elevated"]
3056        "#;
3057        let config: ConfigRequirementsToml = from_str(toml_str)?;
3058        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3059
3060        assert_eq!(
3061            requirements.windows_sandbox_mode.value(),
3062            Some(WindowsSandboxModeToml::Elevated)
3063        );
3064
3065        Ok(())
3066    }
3067
3068    #[test]
3069    fn deserialize_legacy_allowed_approvals_reviewer() -> Result<()> {
3070        let toml_str = r#"
3071            allowed_approvals_reviewers = ["guardian_subagent", "user"]
3072        "#;
3073        let config: ConfigRequirementsToml = from_str(toml_str)?;
3074        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3075
3076        assert_eq!(
3077            requirements.approvals_reviewer.value(),
3078            ApprovalsReviewer::AutoReview
3079        );
3080
3081        Ok(())
3082    }
3083
3084    #[test]
3085    fn empty_allowed_approvals_reviewers_is_rejected() -> Result<()> {
3086        let toml_str = r#"
3087            allowed_approvals_reviewers = []
3088        "#;
3089        let config: ConfigRequirementsToml = from_str(toml_str)?;
3090        let err = ConfigRequirements::try_from(with_unknown_source(config))
3091            .expect_err("empty approvals reviewer allow-list should be rejected");
3092
3093        assert_eq!(
3094            err,
3095            ConstraintError::EmptyField {
3096                field_name: "allowed_approvals_reviewers".to_string(),
3097            }
3098        );
3099
3100        Ok(())
3101    }
3102
3103    #[test]
3104    fn deserialize_allowed_sandbox_modes() -> Result<()> {
3105        let toml_str = r#"
3106            allowed_sandbox_modes = ["read-only", "workspace-write"]
3107        "#;
3108        let config: ConfigRequirementsToml = from_str(toml_str)?;
3109        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3110
3111        let root = if cfg!(windows) { "C:\\repo" } else { "/repo" };
3112        assert!(
3113            requirements
3114                .permission_profile
3115                .can_set(&PermissionProfile::read_only())
3116                .is_ok()
3117        );
3118        let workspace_write_profile = PermissionProfile::workspace_write_with(
3119            &[AbsolutePathBuf::from_absolute_path(root)?],
3120            NetworkSandboxPolicy::Restricted,
3121            /*exclude_tmpdir_env_var*/ false,
3122            /*exclude_slash_tmp*/ false,
3123        );
3124        assert!(
3125            requirements
3126                .permission_profile
3127                .can_set(&workspace_write_profile)
3128                .is_ok()
3129        );
3130        assert_eq!(
3131            requirements
3132                .permission_profile
3133                .can_set(&PermissionProfile::Disabled),
3134            Err(ConstraintError::InvalidValue {
3135                field_name: "sandbox_mode",
3136                candidate: "DangerFullAccess".into(),
3137                allowed: "[ReadOnly, WorkspaceWrite]".into(),
3138                requirement_source: RequirementSource::Unknown,
3139            })
3140        );
3141        assert_eq!(
3142            requirements
3143                .permission_profile
3144                .can_set(&PermissionProfile::External {
3145                    network: NetworkSandboxPolicy::Restricted,
3146                }),
3147            Err(ConstraintError::InvalidValue {
3148                field_name: "sandbox_mode",
3149                candidate: "ExternalSandbox".into(),
3150                allowed: "[ReadOnly, WorkspaceWrite]".into(),
3151                requirement_source: RequirementSource::Unknown,
3152            })
3153        );
3154
3155        Ok(())
3156    }
3157
3158    #[test]
3159    fn deserialize_remote_sandbox_config_requires_hostname_patterns_list() -> Result<()> {
3160        let toml_str = r#"
3161            [[remote_sandbox_config]]
3162            hostname_patterns = ["*.org", "runner-??.ci"]
3163            allowed_sandbox_modes = ["read-only", "workspace-write"]
3164        "#;
3165        let config: ConfigRequirementsToml = from_str(toml_str)?;
3166
3167        assert_eq!(
3168            config.remote_sandbox_config,
3169            Some(vec![RemoteSandboxConfigToml {
3170                hostname_patterns: vec!["*.org".to_string(), "runner-??.ci".to_string()],
3171                allowed_sandbox_modes: vec![
3172                    SandboxModeRequirement::ReadOnly,
3173                    SandboxModeRequirement::WorkspaceWrite,
3174                ],
3175            }])
3176        );
3177
3178        let err = from_str::<ConfigRequirementsToml>(
3179            r#"
3180                [[remote_sandbox_config]]
3181                hostname_patterns = "*.org"
3182                allowed_sandbox_modes = ["read-only"]
3183            "#,
3184        )
3185        .expect_err("hostname_patterns should be list-only");
3186        assert!(
3187            err.to_string().contains("invalid type: string"),
3188            "unexpected error: {err}"
3189        );
3190
3191        Ok(())
3192    }
3193
3194    #[test]
3195    fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> {
3196        let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
3197        let mut requirements_toml: ConfigRequirementsToml = from_str(
3198            r#"
3199                allowed_sandbox_modes = ["read-only"]
3200
3201                [[remote_sandbox_config]]
3202                hostname_patterns = ["build-*.example.com"]
3203                allowed_sandbox_modes = ["read-only", "workspace-write"]
3204
3205                [[remote_sandbox_config]]
3206                hostname_patterns = ["build-01.example.com"]
3207                allowed_sandbox_modes = ["read-only", "danger-full-access"]
3208            "#,
3209        )?;
3210        requirements_toml.apply_remote_sandbox_config(Some("BUILD-01.EXAMPLE.COM."));
3211        let mut requirements_with_sources = ConfigRequirementsWithSources::default();
3212        requirements_with_sources.merge_unset_fields(source.clone(), requirements_toml);
3213
3214        assert_eq!(
3215            requirements_with_sources
3216                .allowed_sandbox_modes
3217                .as_ref()
3218                .map(|sourced| sourced.value.clone()),
3219            Some(vec![
3220                SandboxModeRequirement::ReadOnly,
3221                SandboxModeRequirement::WorkspaceWrite,
3222            ])
3223        );
3224
3225        let requirements = ConfigRequirements::try_from(requirements_with_sources)?;
3226        let root = if cfg!(windows) { "C:\\repo" } else { "/repo" };
3227        let workspace_write_profile = PermissionProfile::workspace_write_with(
3228            &[AbsolutePathBuf::from_absolute_path(root)?],
3229            NetworkSandboxPolicy::Restricted,
3230            /*exclude_tmpdir_env_var*/ false,
3231            /*exclude_slash_tmp*/ false,
3232        );
3233        assert!(
3234            requirements
3235                .permission_profile
3236                .can_set(&workspace_write_profile)
3237                .is_ok()
3238        );
3239        assert_eq!(
3240            requirements
3241                .permission_profile
3242                .can_set(&PermissionProfile::Disabled),
3243            Err(ConstraintError::InvalidValue {
3244                field_name: "sandbox_mode",
3245                candidate: "DangerFullAccess".into(),
3246                allowed: "[ReadOnly, WorkspaceWrite]".into(),
3247                requirement_source: source,
3248            })
3249        );
3250
3251        Ok(())
3252    }
3253
3254    #[test]
3255    fn remote_sandbox_config_non_match_preserves_top_level() -> Result<()> {
3256        let mut requirements_toml: ConfigRequirementsToml = from_str(
3257            r#"
3258                allowed_sandbox_modes = ["read-only"]
3259
3260                [[remote_sandbox_config]]
3261                hostname_patterns = ["build-*.example.com"]
3262                allowed_sandbox_modes = ["read-only", "workspace-write"]
3263            "#,
3264        )?;
3265        requirements_toml.apply_remote_sandbox_config(Some("laptop.example.com"));
3266        let mut requirements_with_sources = ConfigRequirementsWithSources::default();
3267        requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, requirements_toml);
3268        let requirements = ConfigRequirements::try_from(requirements_with_sources)?;
3269
3270        assert_eq!(
3271            requirements
3272                .permission_profile
3273                .can_set(&PermissionProfile::Disabled),
3274            Err(ConstraintError::InvalidValue {
3275                field_name: "sandbox_mode",
3276                candidate: "DangerFullAccess".into(),
3277                allowed: "[ReadOnly]".into(),
3278                requirement_source: RequirementSource::Unknown,
3279            })
3280        );
3281
3282        Ok(())
3283    }
3284
3285    #[test]
3286    fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> {
3287        let high_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
3288        let mut high_precedence: ConfigRequirementsToml = from_str(
3289            r#"
3290                allowed_sandbox_modes = ["read-only"]
3291            "#,
3292        )?;
3293        high_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com"));
3294
3295        let mut low_precedence: ConfigRequirementsToml = from_str(
3296            r#"
3297                [[remote_sandbox_config]]
3298                hostname_patterns = ["runner-*.ci.example.com"]
3299                allowed_sandbox_modes = ["read-only", "workspace-write"]
3300            "#,
3301        )?;
3302        low_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com"));
3303
3304        let mut requirements_with_sources = ConfigRequirementsWithSources::default();
3305        requirements_with_sources.merge_unset_fields(high_source.clone(), high_precedence);
3306        requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, low_precedence);
3307        let requirements = ConfigRequirements::try_from(requirements_with_sources)?;
3308
3309        assert_eq!(
3310            requirements
3311                .permission_profile
3312                .can_set(&PermissionProfile::workspace_write()),
3313            Err(ConstraintError::InvalidValue {
3314                field_name: "sandbox_mode",
3315                candidate: "WorkspaceWrite".into(),
3316                allowed: "[ReadOnly]".into(),
3317                requirement_source: high_source,
3318            })
3319        );
3320
3321        Ok(())
3322    }
3323
3324    #[test]
3325    fn deserialize_allowed_web_search_modes() -> Result<()> {
3326        let toml_str = r#"
3327            allowed_web_search_modes = ["cached"]
3328        "#;
3329        let config: ConfigRequirementsToml = from_str(toml_str)?;
3330        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3331
3332        assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Cached);
3333        assert!(
3334            requirements
3335                .web_search_mode
3336                .can_set(&WebSearchMode::Disabled)
3337                .is_ok()
3338        );
3339        assert_eq!(
3340            requirements.web_search_mode.can_set(&WebSearchMode::Live),
3341            Err(ConstraintError::InvalidValue {
3342                field_name: "web_search_mode",
3343                candidate: "Live".into(),
3344                allowed: "[Disabled, Cached]".into(),
3345                requirement_source: RequirementSource::Unknown,
3346            })
3347        );
3348        assert!(
3349            requirements
3350                .web_search_mode
3351                .can_set(&WebSearchMode::Cached)
3352                .is_ok()
3353        );
3354
3355        Ok(())
3356    }
3357
3358    #[test]
3359    fn allowed_web_search_modes_supports_indexed() -> Result<()> {
3360        let config: ConfigRequirementsToml = from_str(
3361            r#"
3362                allowed_web_search_modes = ["indexed"]
3363            "#,
3364        )?;
3365        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3366
3367        assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Indexed);
3368        for mode in [WebSearchMode::Disabled, WebSearchMode::Indexed] {
3369            assert!(requirements.web_search_mode.can_set(&mode).is_ok());
3370        }
3371        for mode in [WebSearchMode::Cached, WebSearchMode::Live] {
3372            assert_eq!(
3373                requirements.web_search_mode.can_set(&mode),
3374                Err(ConstraintError::InvalidValue {
3375                    field_name: "web_search_mode",
3376                    candidate: format!("{mode:?}"),
3377                    allowed: "[Disabled, Indexed]".into(),
3378                    requirement_source: RequirementSource::Unknown,
3379                })
3380            );
3381        }
3382
3383        Ok(())
3384    }
3385
3386    #[test]
3387    fn allowed_web_search_modes_allows_disabled() -> Result<()> {
3388        let toml_str = r#"
3389            allowed_web_search_modes = ["disabled"]
3390        "#;
3391        let config: ConfigRequirementsToml = from_str(toml_str)?;
3392        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3393
3394        assert_eq!(
3395            requirements.web_search_mode.value(),
3396            WebSearchMode::Disabled
3397        );
3398        assert!(
3399            requirements
3400                .web_search_mode
3401                .can_set(&WebSearchMode::Disabled)
3402                .is_ok()
3403        );
3404        assert_eq!(
3405            requirements.web_search_mode.can_set(&WebSearchMode::Cached),
3406            Err(ConstraintError::InvalidValue {
3407                field_name: "web_search_mode",
3408                candidate: "Cached".into(),
3409                allowed: "[Disabled]".into(),
3410                requirement_source: RequirementSource::Unknown,
3411            })
3412        );
3413        Ok(())
3414    }
3415
3416    #[test]
3417    fn allowed_web_search_modes_empty_restricts_to_disabled() -> Result<()> {
3418        let toml_str = r#"
3419            allowed_web_search_modes = []
3420        "#;
3421        let config: ConfigRequirementsToml = from_str(toml_str)?;
3422        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3423
3424        assert_eq!(
3425            requirements.web_search_mode.value(),
3426            WebSearchMode::Disabled
3427        );
3428        assert!(
3429            requirements
3430                .web_search_mode
3431                .can_set(&WebSearchMode::Disabled)
3432                .is_ok()
3433        );
3434        assert_eq!(
3435            requirements.web_search_mode.can_set(&WebSearchMode::Cached),
3436            Err(ConstraintError::InvalidValue {
3437                field_name: "web_search_mode",
3438                candidate: "Cached".into(),
3439                allowed: "[Disabled]".into(),
3440                requirement_source: RequirementSource::Unknown,
3441            })
3442        );
3443        Ok(())
3444    }
3445
3446    #[test]
3447    fn deserialize_feature_requirements() -> Result<()> {
3448        let toml_str = r#"
3449            [features]
3450            apps = false
3451            personality = true
3452        "#;
3453        let config: ConfigRequirementsToml = from_str(toml_str)?;
3454        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3455
3456        assert_eq!(
3457            requirements.feature_requirements,
3458            Some(Sourced::new(
3459                FeatureRequirementsToml {
3460                    entries: BTreeMap::from([
3461                        ("apps".to_string(), false),
3462                        ("personality".to_string(), true),
3463                    ]),
3464                },
3465                RequirementSource::Unknown,
3466            ))
3467        );
3468
3469        Ok(())
3470    }
3471
3472    #[test]
3473    fn deserialize_managed_hooks_requirements() -> Result<()> {
3474        let toml_str = r#"
3475managed_dir = "/enterprise/hooks"
3476windows_managed_dir = 'C:\enterprise\hooks'
3477
3478[[PreToolUse]]
3479matcher = "^Bash$"
3480
3481[[PreToolUse.hooks]]
3482type = "command"
3483command = "python3 /enterprise/hooks/pre.py"
3484timeout = 10
3485statusMessage = "checking"
3486        "#;
3487        let hooks: ManagedHooksRequirementsToml = from_str(toml_str)?;
3488
3489        assert_eq!(
3490            hooks.managed_dir.as_deref(),
3491            Some(std::path::Path::new("/enterprise/hooks"))
3492        );
3493        assert_eq!(hooks.handler_count(), 1);
3494        assert_eq!(hooks.hooks.pre_tool_use.len(), 1);
3495        Ok(())
3496    }
3497
3498    #[test]
3499    fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> {
3500        let mut target = ConfigRequirementsWithSources::default();
3501        target.merge_unset_fields(
3502            RequirementSource::LegacyManagedConfigTomlFromMdm,
3503            from_str::<ConfigRequirementsToml>(
3504                r#"
3505[hooks]
3506managed_dir = "/cloud/hooks"
3507
3508[[hooks.PreToolUse]]
3509matcher = "^Bash$"
3510
3511[[hooks.PreToolUse.hooks]]
3512type = "command"
3513command = "python3 /cloud/hooks/pre.py"
3514                "#,
3515            )?,
3516        );
3517        target.merge_unset_fields(
3518            RequirementSource::SystemRequirementsToml {
3519                file: system_requirements_toml_file_for_test()?,
3520            },
3521            from_str::<ConfigRequirementsToml>(
3522                r#"
3523[hooks]
3524managed_dir = "/system/hooks"
3525
3526[[hooks.PreToolUse]]
3527matcher = "^Bash$"
3528
3529[[hooks.PreToolUse.hooks]]
3530type = "command"
3531command = "python3 /system/hooks/pre.py"
3532                "#,
3533            )?,
3534        );
3535
3536        assert_eq!(
3537            target
3538                .hooks
3539                .as_ref()
3540                .and_then(|hooks| hooks.value.managed_dir.as_ref())
3541                .map(std::path::PathBuf::as_path),
3542            Some(std::path::Path::new("/cloud/hooks"))
3543        );
3544        assert_eq!(
3545            target.hooks.as_ref().map(|hooks| hooks.source.clone()),
3546            Some(RequirementSource::LegacyManagedConfigTomlFromMdm)
3547        );
3548        Ok(())
3549    }
3550
3551    #[test]
3552    fn managed_hooks_constraint_rejects_drift() -> Result<()> {
3553        let config: ConfigRequirementsToml = from_str(
3554            r#"
3555[hooks]
3556managed_dir = "/enterprise/hooks"
3557
3558[[hooks.PreToolUse]]
3559matcher = "^Bash$"
3560
3561[[hooks.PreToolUse.hooks]]
3562type = "command"
3563command = "python3 /enterprise/hooks/pre.py"
3564            "#,
3565        )?;
3566        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
3567        let mut managed_hooks = requirements
3568            .managed_hooks
3569            .expect("expected managed hooks requirements");
3570
3571        let err = managed_hooks
3572            .set(ManagedHooksRequirementsToml {
3573                managed_dir: Some(std::path::PathBuf::from("/other/hooks")),
3574                windows_managed_dir: None,
3575                hooks: HookEventsToml::default(),
3576            })
3577            .expect_err("managed hooks should reject drift");
3578
3579        assert!(matches!(
3580            err,
3581            ConstraintError::InvalidValue {
3582                field_name: "hooks",
3583                requirement_source: RequirementSource::Unknown,
3584                ..
3585            }
3586        ));
3587        Ok(())
3588    }
3589
3590    #[test]
3591    fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> {
3592        let toml_str = r#"
3593            [experimental_network]
3594            enabled = true
3595            allow_upstream_proxy = false
3596            dangerously_allow_all_unix_sockets = true
3597            managed_allowed_domains_only = true
3598            allow_local_binding = false
3599
3600            [experimental_network.domains]
3601            "api.example.com" = "allow"
3602            "*.openai.com" = "allow"
3603            "blocked.example.com" = "deny"
3604
3605            [experimental_network.unix_sockets]
3606            "/tmp/example.sock" = "allow"
3607            "/tmp/blocked.sock" = "deny"
3608        "#;
3609
3610        let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
3611        let mut requirements_with_sources = ConfigRequirementsWithSources::default();
3612        requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?);
3613
3614        let requirements = ConfigRequirements::try_from(requirements_with_sources)?;
3615        let sourced_network = requirements
3616            .network
3617            .expect("network requirements should be preserved as constraints");
3618
3619        assert_eq!(sourced_network.source, source);
3620        assert_eq!(sourced_network.value.enabled, Some(true));
3621        assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false));
3622        assert_eq!(
3623            sourced_network.value.dangerously_allow_all_unix_sockets,
3624            Some(true)
3625        );
3626        assert_eq!(
3627            sourced_network.value.domains.as_ref(),
3628            Some(&NetworkDomainPermissionsToml {
3629                entries: BTreeMap::from([
3630                    (
3631                        "*.openai.com".to_string(),
3632                        NetworkDomainPermissionToml::Allow,
3633                    ),
3634                    (
3635                        "api.example.com".to_string(),
3636                        NetworkDomainPermissionToml::Allow,
3637                    ),
3638                    (
3639                        "blocked.example.com".to_string(),
3640                        NetworkDomainPermissionToml::Deny,
3641                    ),
3642                ]),
3643            })
3644        );
3645        assert_eq!(
3646            sourced_network.value.managed_allowed_domains_only,
3647            Some(true)
3648        );
3649        assert_eq!(
3650            sourced_network.value.unix_sockets.as_ref(),
3651            Some(&NetworkUnixSocketPermissionsToml {
3652                entries: BTreeMap::from([
3653                    (
3654                        "/tmp/blocked.sock".to_string(),
3655                        NetworkUnixSocketPermissionToml::Deny,
3656                    ),
3657                    (
3658                        "/tmp/example.sock".to_string(),
3659                        NetworkUnixSocketPermissionToml::Allow,
3660                    ),
3661                ]),
3662            })
3663        );
3664        assert_eq!(sourced_network.value.allow_local_binding, Some(false));
3665
3666        Ok(())
3667    }
3668
3669    #[test]
3670    fn legacy_network_requirements_are_preserved_as_constraints_with_source() -> Result<()> {
3671        let toml_str = r#"
3672            [experimental_network]
3673            enabled = true
3674            allow_upstream_proxy = false
3675            dangerously_allow_all_unix_sockets = true
3676            allowed_domains = ["api.example.com", "*.openai.com"]
3677            managed_allowed_domains_only = true
3678            denied_domains = ["blocked.example.com"]
3679            allow_unix_sockets = ["/tmp/example.sock"]
3680            allow_local_binding = false
3681        "#;
3682
3683        let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
3684        let mut requirements_with_sources = ConfigRequirementsWithSources::default();
3685        requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?);
3686
3687        let requirements = ConfigRequirements::try_from(requirements_with_sources)?;
3688        let sourced_network = requirements
3689            .network
3690            .expect("network requirements should be preserved as constraints");
3691
3692        assert_eq!(sourced_network.source, source);
3693        assert_eq!(sourced_network.value.enabled, Some(true));
3694        assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false));
3695        assert_eq!(
3696            sourced_network.value.dangerously_allow_all_unix_sockets,
3697            Some(true)
3698        );
3699        assert_eq!(
3700            sourced_network.value.domains.as_ref(),
3701            Some(&NetworkDomainPermissionsToml {
3702                entries: BTreeMap::from([
3703                    (
3704                        "*.openai.com".to_string(),
3705                        NetworkDomainPermissionToml::Allow,
3706                    ),
3707                    (
3708                        "api.example.com".to_string(),
3709                        NetworkDomainPermissionToml::Allow,
3710                    ),
3711                    (
3712                        "blocked.example.com".to_string(),
3713                        NetworkDomainPermissionToml::Deny,
3714                    ),
3715                ]),
3716            })
3717        );
3718        assert_eq!(
3719            sourced_network.value.managed_allowed_domains_only,
3720            Some(true)
3721        );
3722        assert_eq!(
3723            sourced_network.value.unix_sockets.as_ref(),
3724            Some(&NetworkUnixSocketPermissionsToml {
3725                entries: BTreeMap::from([(
3726                    "/tmp/example.sock".to_string(),
3727                    NetworkUnixSocketPermissionToml::Allow,
3728                )]),
3729            })
3730        );
3731        assert_eq!(sourced_network.value.allow_local_binding, Some(false));
3732
3733        Ok(())
3734    }
3735
3736    #[test]
3737    fn mixed_legacy_and_canonical_network_requirements_are_rejected() {
3738        let err = from_str::<ConfigRequirementsToml>(
3739            r#"
3740                [experimental_network]
3741                allowed_domains = ["api.example.com"]
3742
3743                [experimental_network.domains]
3744                "*.openai.com" = "allow"
3745            "#,
3746        )
3747        .expect_err("mixed network domain shapes should fail");
3748
3749        assert!(
3750            err.to_string()
3751                .contains("`experimental_network.domains` cannot be combined"),
3752            "unexpected error: {err:#}"
3753        );
3754
3755        let err = from_str::<ConfigRequirementsToml>(
3756            r#"
3757                [experimental_network]
3758                allow_unix_sockets = ["/tmp/example.sock"]
3759
3760                [experimental_network.unix_sockets]
3761                "/tmp/another.sock" = "allow"
3762            "#,
3763        )
3764        .expect_err("mixed network unix socket shapes should fail");
3765
3766        assert!(
3767            err.to_string()
3768                .contains("`experimental_network.unix_sockets` cannot be combined"),
3769            "unexpected error: {err:#}"
3770        );
3771    }
3772
3773    #[test]
3774    fn network_permission_containers_project_allowed_and_denied_entries() {
3775        let domains = NetworkDomainPermissionsToml {
3776            entries: BTreeMap::from([
3777                (
3778                    "*.openai.com".to_string(),
3779                    NetworkDomainPermissionToml::Allow,
3780                ),
3781                (
3782                    "api.example.com".to_string(),
3783                    NetworkDomainPermissionToml::Allow,
3784                ),
3785                (
3786                    "blocked.example.com".to_string(),
3787                    NetworkDomainPermissionToml::Deny,
3788                ),
3789            ]),
3790        };
3791        let unix_sockets = NetworkUnixSocketPermissionsToml {
3792            entries: BTreeMap::from([
3793                (
3794                    "/tmp/example.sock".to_string(),
3795                    NetworkUnixSocketPermissionToml::Allow,
3796                ),
3797                (
3798                    "/tmp/ignored.sock".to_string(),
3799                    NetworkUnixSocketPermissionToml::Deny,
3800                ),
3801            ]),
3802        };
3803
3804        assert_eq!(
3805            domains.allowed_domains(),
3806            Some(vec![
3807                "*.openai.com".to_string(),
3808                "api.example.com".to_string()
3809            ])
3810        );
3811        assert_eq!(
3812            domains.denied_domains(),
3813            Some(vec!["blocked.example.com".to_string()])
3814        );
3815        assert_eq!(
3816            NetworkDomainPermissionsToml {
3817                entries: BTreeMap::from([(
3818                    "api.example.com".to_string(),
3819                    NetworkDomainPermissionToml::Allow,
3820                )]),
3821            }
3822            .denied_domains(),
3823            None
3824        );
3825        assert_eq!(
3826            unix_sockets.allow_unix_sockets(),
3827            vec!["/tmp/example.sock".to_string()]
3828        );
3829    }
3830
3831    #[test]
3832    fn deserialize_mcp_server_requirements() -> Result<()> {
3833        let toml_str = r#"
3834            [mcp_servers.docs]
3835            description = "ignored legacy field"
3836
3837            [mcp_servers.docs.identity]
3838            command = "codex-mcp"
3839
3840            [mcp_servers.remote.identity]
3841            url = "https://example.com/mcp"
3842        "#;
3843        let requirements: ConfigRequirements =
3844            with_unknown_source(from_str(toml_str)?).try_into()?;
3845
3846        assert_eq!(
3847            requirements.mcp_servers,
3848            Some(Sourced::new(
3849                BTreeMap::from([
3850                    (
3851                        "docs".to_string(),
3852                        McpServerRequirement::Identity {
3853                            identity: McpServerIdentity::Command {
3854                                command: "codex-mcp".to_string(),
3855                            },
3856                        },
3857                    ),
3858                    (
3859                        "remote".to_string(),
3860                        McpServerRequirement::Identity {
3861                            identity: McpServerIdentity::Url {
3862                                url: "https://example.com/mcp".to_string(),
3863                            },
3864                        },
3865                    ),
3866                ]),
3867                RequirementSource::Unknown,
3868            ))
3869        );
3870        Ok(())
3871    }
3872
3873    #[test]
3874    fn deserialize_mcp_server_matcher_requirements() -> Result<()> {
3875        let toml_str = r#"
3876            [mcp_servers.internal_mcp_proxy.identity]
3877            command = { executable = "company-cli", args = [
3878                { match = "exact", value = "mcp" },
3879                { match = "exact", value = "proxy" },
3880                { match = "exact", value = "--server" },
3881                { match = "regex", expression = '^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$' },
3882            ] }
3883        "#;
3884        let requirements: ConfigRequirements =
3885            with_unknown_source(from_str(toml_str)?).try_into()?;
3886
3887        assert_eq!(
3888            requirements.mcp_servers,
3889            Some(Sourced::new(
3890                BTreeMap::from([(
3891                    "internal_mcp_proxy".to_string(),
3892                    McpServerRequirement::Command(McpServerCommandMatcher {
3893                        executable: "company-cli".to_string(),
3894                        args: vec![
3895                            McpServerValueMatcher::Exact {
3896                                value: "mcp".to_string(),
3897                            },
3898                            McpServerValueMatcher::Exact {
3899                                value: "proxy".to_string(),
3900                            },
3901                            McpServerValueMatcher::Exact {
3902                                value: "--server".to_string(),
3903                            },
3904                            McpServerValueMatcher::Regex {
3905                                expression: r"^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$"
3906                                    .to_string(),
3907                            },
3908                        ],
3909                    }),
3910                )]),
3911                RequirementSource::Unknown,
3912            ))
3913        );
3914        Ok(())
3915    }
3916
3917    #[test]
3918    fn invalid_mcp_server_requirement_regex_reports_the_server_name_and_source() -> Result<()> {
3919        let toml_str = r#"
3920            [mcp_servers.broken_rule.identity]
3921            url = { match = "regex", expression = "[" }
3922        "#;
3923
3924        let err = ConfigRequirements::try_from(with_unknown_source(from_str(toml_str)?))
3925            .expect_err("invalid matcher regex should fail requirements normalization");
3926        let ConstraintError::McpServerRequirementParse {
3927            server_name,
3928            requirement_source,
3929            reason,
3930        } = err
3931        else {
3932            panic!("unexpected error: {err:?}");
3933        };
3934
3935        assert_eq!(server_name, "broken_rule");
3936        assert_eq!(requirement_source, RequirementSource::Unknown);
3937        assert!(reason.contains("invalid regex `[`"), "{reason}");
3938        Ok(())
3939    }
3940
3941    #[test]
3942    fn deserialize_plugin_mcp_server_requirements() -> Result<()> {
3943        let toml_str = r#"
3944            [plugins."sample@test".mcp_servers.sample.identity]
3945            command = "sample-mcp"
3946
3947            [plugins."remote@test".mcp_servers.remote.identity]
3948            url = "https://example.com/mcp"
3949        "#;
3950        let requirements: ConfigRequirements =
3951            with_unknown_source(from_str(toml_str)?).try_into()?;
3952
3953        assert_eq!(
3954            requirements.plugins,
3955            Some(Sourced::new(
3956                BTreeMap::from([
3957                    (
3958                        "remote@test".to_string(),
3959                        PluginRequirementsToml {
3960                            mcp_servers: Some(BTreeMap::from([(
3961                                "remote".to_string(),
3962                                McpServerRequirement::Identity {
3963                                    identity: McpServerIdentity::Url {
3964                                        url: "https://example.com/mcp".to_string(),
3965                                    },
3966                                },
3967                            )])),
3968                        },
3969                    ),
3970                    (
3971                        "sample@test".to_string(),
3972                        PluginRequirementsToml {
3973                            mcp_servers: Some(BTreeMap::from([(
3974                                "sample".to_string(),
3975                                McpServerRequirement::Identity {
3976                                    identity: McpServerIdentity::Command {
3977                                        command: "sample-mcp".to_string(),
3978                                    },
3979                                },
3980                            )])),
3981                        },
3982                    ),
3983                ]),
3984                RequirementSource::Unknown,
3985            ))
3986        );
3987        Ok(())
3988    }
3989
3990    #[test]
3991    fn deserialize_plugin_mcp_server_matcher_requirement() -> Result<()> {
3992        let toml_str = r#"
3993            [plugins."sample@test".mcp_servers.internal_proxy.identity]
3994            command = { executable = "company-cli", args = [
3995                { match = "exact", value = "mcp" },
3996                { match = "regex", expression = '^https://[a-z]+\.example\.com$' },
3997            ] }
3998        "#;
3999        let requirements: ConfigRequirements =
4000            with_unknown_source(from_str(toml_str)?).try_into()?;
4001
4002        assert_eq!(
4003            requirements.plugins,
4004            Some(Sourced::new(
4005                BTreeMap::from([(
4006                    "sample@test".to_string(),
4007                    PluginRequirementsToml {
4008                        mcp_servers: Some(BTreeMap::from([(
4009                            "internal_proxy".to_string(),
4010                            McpServerRequirement::Command(McpServerCommandMatcher {
4011                                executable: "company-cli".to_string(),
4012                                args: vec![
4013                                    McpServerValueMatcher::Exact {
4014                                        value: "mcp".to_string(),
4015                                    },
4016                                    McpServerValueMatcher::Regex {
4017                                        expression: r"^https://[a-z]+\.example\.com$".to_string(),
4018                                    },
4019                                ],
4020                            }),
4021                        )])),
4022                    },
4023                )]),
4024                RequirementSource::Unknown,
4025            ))
4026        );
4027        Ok(())
4028    }
4029
4030    #[test]
4031    fn invalid_plugin_mcp_server_regex_reports_plugin_and_server_name() -> Result<()> {
4032        let toml_str = r#"
4033            [plugins."sample@test".mcp_servers.broken_rule.identity]
4034            url = { match = "regex", expression = "[" }
4035        "#;
4036
4037        let err = ConfigRequirements::try_from(with_unknown_source(from_str(toml_str)?))
4038            .expect_err("invalid plugin MCP regex should fail requirements normalization");
4039        let ConstraintError::McpServerRequirementParse {
4040            server_name,
4041            requirement_source,
4042            reason,
4043        } = err
4044        else {
4045            panic!("unexpected error: {err:?}");
4046        };
4047
4048        assert_eq!(server_name, "sample@test/broken_rule");
4049        assert_eq!(requirement_source, RequirementSource::Unknown);
4050        assert!(reason.contains("invalid regex `[`"), "{reason}");
4051        Ok(())
4052    }
4053
4054    #[test]
4055    fn deserialize_exec_policy_requirements() -> Result<()> {
4056        let toml_str = r#"
4057            [rules]
4058            prefix_rules = [
4059                { pattern = [{ token = "rm" }], decision = "forbidden" },
4060            ]
4061        "#;
4062        let config: ConfigRequirementsToml = from_str(toml_str)?;
4063        let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
4064        let policy = requirements.exec_policy.expect("exec policy").value;
4065
4066        assert_eq!(
4067            policy.as_ref().check(&tokens(&["rm", "-rf"]), &|_| {
4068                panic!("rule should match so heuristic should not be called");
4069            }),
4070            Evaluation {
4071                decision: Decision::Forbidden,
4072                matched_rules: vec![RuleMatch::PrefixRuleMatch {
4073                    matched_prefix: tokens(&["rm"]),
4074                    decision: Decision::Forbidden,
4075                    resolved_program: None,
4076                    justification: None,
4077                }],
4078            }
4079        );
4080
4081        Ok(())
4082    }
4083
4084    #[test]
4085    fn exec_policy_error_includes_requirement_source() -> Result<()> {
4086        let toml_str = r#"
4087            [rules]
4088            prefix_rules = [
4089                { pattern = [{ token = "rm" }] },
4090            ]
4091        "#;
4092        let config: ConfigRequirementsToml = from_str(toml_str)?;
4093        let requirements_toml_file = system_requirements_toml_file_for_test()?;
4094        let source_location = RequirementSource::SystemRequirementsToml {
4095            file: requirements_toml_file,
4096        };
4097
4098        let mut requirements_with_sources = ConfigRequirementsWithSources::default();
4099        requirements_with_sources.merge_unset_fields(source_location.clone(), config);
4100        let err = ConfigRequirements::try_from(requirements_with_sources)
4101            .expect_err("invalid exec policy");
4102
4103        assert_eq!(
4104            err,
4105            ConstraintError::ExecPolicyParse {
4106                requirement_source: source_location,
4107                reason: "rules prefix_rule at index 0 is missing a decision".to_string(),
4108            }
4109        );
4110
4111        Ok(())
4112    }
4113}