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 Composite {
40 sources: Vec<RequirementSource>,
41 },
42 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#[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 pub network: Option<Sourced<NetworkConstraints>>,
175 pub filesystem: Option<Sourced<FilesystemConstraints>>,
177 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 None,
193 ),
194 approvals_reviewer: ConstrainedWithSource::new(
195 Constrained::allow_any_from_default(),
196 None,
197 ),
198 permission_profile: ConstrainedWithSource::new(
199 Constrained::allow_any(PermissionProfile::read_only()),
200 None,
201 ),
202 windows_sandbox_mode: ConstrainedWithSource::new(
203 Constrained::allow_any(None),
204 None,
205 ),
206 windows_sandbox_private_desktop: None,
207 web_search_mode: ConstrainedWithSource::new(
208 Constrained::allow_any(WebSearchMode::Cached),
209 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(None),
223 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#[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 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 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
451fn 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#[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 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 #[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
831pub(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#[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#[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 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 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#[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 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 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 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 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 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 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 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 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(None),
1588 None,
1589 ),
1590 };
1591 (sandbox_mode, sandbox_private_desktop)
1592 }
1593 None => (
1594 ConstrainedWithSource::new(
1595 Constrained::allow_any(None),
1596 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 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(None),
1712 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 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 false,
2222 source.clone(),
2223 )),
2224 allow_login_shell: Some(Sourced::new(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 true,
2246 enforce_source.clone(),
2247 )),
2248 allow_appshots: Some(Sourced::new(false, enforce_source.clone(),)),
2249 allow_remote_control: Some(Sourced::new(
2250 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 false,
3122 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 false,
3231 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}