1use serde::Serialize;
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
12use std::path::{Path, PathBuf};
13use url::Url;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, clap::ValueEnum)]
16#[serde(rename_all = "snake_case")]
17pub enum PolicyCapability {
18 Attach,
19 PersistentProfile,
20 Evaluate,
21 Upload,
22 Download,
23 Screenshot,
24 RawCdp,
25 ReadFormValues,
26 ReadSensitiveFormValues,
27 CoordinateClick,
28 ConsentDismissal,
29 DeclaredAgentIdentity,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
33#[serde(rename_all = "snake_case")]
34pub enum PolicyPreset {
35 Development,
36 #[clap(name = "ci")]
37 Ci,
38 Polite,
39 Hardened,
40 #[clap(name = "untrusted-mcp")]
41 UntrustedMcp,
42}
43
44impl std::str::FromStr for PolicyPreset {
45 type Err = PolicyError;
46
47 fn from_str(value: &str) -> Result<Self, Self::Err> {
48 match value {
49 "development" | "dev" => Ok(Self::Development),
50 "ci" => Ok(Self::Ci),
51 "polite" => Ok(Self::Polite),
52 "hardened" => Ok(Self::Hardened),
53 "untrusted-mcp" | "untrusted_mcp" => Ok(Self::UntrustedMcp),
54 _ => Err(PolicyError::InvalidConfiguration {
55 reason: format!(
56 "policy preset must be dev, ci, polite, hardened, or untrusted-mcp, got: {value}"
57 ),
58 }),
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64#[serde(tag = "decision", rename_all = "snake_case")]
65pub enum PolicyDecision {
66 Allow,
67 Deny { reason: String },
68 RequireConfirmation { reason: String },
69}
70
71#[derive(Debug, Clone)]
72pub struct BrowserPolicy {
73 preset: PolicyPreset,
74 allowed_capabilities: BTreeSet<PolicyCapability>,
75 confirmed_capabilities: BTreeSet<PolicyCapability>,
76 allowed_hosts: BTreeSet<String>,
77 denied_hosts: BTreeSet<String>,
78 confirmation_tokens: std::sync::Arc<std::sync::Mutex<BTreeMap<PolicyCapability, u32>>>,
79 pinned_hosts: BTreeMap<String, IpAddr>,
80 workspace_root: PathBuf,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84#[serde(tag = "kind", rename_all = "snake_case")]
85pub enum PolicyError {
86 Denied { operation: String, reason: String },
87 ConfirmationRequired { operation: String, reason: String },
88 InvalidConfiguration { reason: String },
89}
90
91impl fmt::Display for PolicyError {
92 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93 match self {
94 Self::Denied { operation, reason } => {
95 write!(formatter, "policy denied {operation}: {reason}")
96 }
97 Self::ConfirmationRequired { operation, reason } => {
98 write!(
99 formatter,
100 "policy confirmation required for {operation}: {reason}"
101 )
102 }
103 Self::InvalidConfiguration { reason } => {
104 write!(formatter, "invalid browser policy: {reason}")
105 }
106 }
107 }
108}
109
110impl std::error::Error for PolicyError {}
111
112impl BrowserPolicy {
113 pub fn from_preset(
115 preset: PolicyPreset,
116 workspace_root: impl AsRef<Path>,
117 ) -> Result<Self, PolicyError> {
118 match preset {
119 PolicyPreset::Development => Self::development(workspace_root),
120 PolicyPreset::Ci => Self::ci(workspace_root),
121 PolicyPreset::Polite => Self::polite(workspace_root),
122 PolicyPreset::Hardened => Self::hardened(workspace_root),
123 PolicyPreset::UntrustedMcp => Self::untrusted_mcp(workspace_root),
124 }
125 }
126
127 pub fn development(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
128 Self::new(PolicyPreset::Development, workspace_root, [], [])
129 }
130
131 pub fn ci(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
132 Self::new(PolicyPreset::Ci, workspace_root, [], [])
133 }
134
135 pub fn polite(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
136 Self::new(PolicyPreset::Polite, workspace_root, [], [])
137 }
138
139 pub fn hardened(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
140 Self::new(PolicyPreset::Hardened, workspace_root, [], [])
141 }
142
143 pub fn untrusted_mcp(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
144 Self::new(PolicyPreset::UntrustedMcp, workspace_root, [], [])
145 }
146
147 pub fn new(
148 preset: PolicyPreset,
149 workspace_root: impl AsRef<Path>,
150 allowed_capabilities: impl IntoIterator<Item = PolicyCapability>,
151 confirmed_capabilities: impl IntoIterator<Item = PolicyCapability>,
152 ) -> Result<Self, PolicyError> {
153 let workspace_root = std::fs::canonicalize(workspace_root.as_ref()).map_err(|error| {
154 PolicyError::InvalidConfiguration {
155 reason: format!("workspace root must exist and be canonicalizable: {error}"),
156 }
157 })?;
158 if !workspace_root.is_dir() {
159 return Err(PolicyError::InvalidConfiguration {
160 reason: "workspace root must be a directory".to_string(),
161 });
162 }
163 let allowed_capabilities: BTreeSet<_> = allowed_capabilities.into_iter().collect();
164 let confirmed_capabilities: BTreeSet<_> = confirmed_capabilities.into_iter().collect();
165 if allowed_capabilities
166 .iter()
167 .any(|capability| confirmed_capabilities.contains(capability))
168 {
169 return Err(PolicyError::InvalidConfiguration {
170 reason: "a capability cannot be both allowed and confirmation-required".to_string(),
171 });
172 }
173 Ok(Self {
174 preset,
175 allowed_capabilities,
176 confirmed_capabilities,
177 allowed_hosts: BTreeSet::new(),
178 denied_hosts: BTreeSet::new(),
179 confirmation_tokens: Default::default(),
180 pinned_hosts: BTreeMap::new(),
181 workspace_root,
182 })
183 }
184
185 pub fn with_host_rules(
186 mut self,
187 allowed_hosts: impl IntoIterator<Item = String>,
188 denied_hosts: impl IntoIterator<Item = String>,
189 ) -> Result<Self, PolicyError> {
190 self.allowed_hosts = normalize_host_rules(allowed_hosts)?;
191 self.denied_hosts = normalize_host_rules(denied_hosts)?;
192 if self
193 .allowed_hosts
194 .iter()
195 .any(|host| self.denied_hosts.contains(host))
196 {
197 return Err(PolicyError::InvalidConfiguration {
198 reason: "a host cannot be both allowed and denied".to_string(),
199 });
200 }
201 Ok(self)
202 }
203
204 pub fn preset(&self) -> PolicyPreset {
205 self.preset
206 }
207
208 pub fn with_confirmation_tokens(
209 self,
210 capabilities: impl IntoIterator<Item = PolicyCapability>,
211 ) -> Result<Self, PolicyError> {
212 let mut tokens =
213 self.confirmation_tokens
214 .lock()
215 .map_err(|_| PolicyError::InvalidConfiguration {
216 reason: "confirmation token state is unavailable".to_string(),
217 })?;
218 for capability in capabilities {
219 if capability == PolicyCapability::RawCdp {
220 return Err(PolicyError::InvalidConfiguration {
221 reason: "raw CDP is an unlimited escape hatch and supports explicit allow only"
222 .to_string(),
223 });
224 }
225 if !self.confirmed_capabilities.contains(&capability) {
226 return Err(PolicyError::InvalidConfiguration {
227 reason: format!(
228 "{capability:?} needs --policy-confirm before a one-operation token"
229 ),
230 });
231 }
232 *tokens.entry(capability).or_default() += 1;
233 }
234 drop(tokens);
235 Ok(self)
236 }
237
238 pub fn workspace_root(&self) -> &Path {
239 &self.workspace_root
240 }
241
242 pub async fn prepare_hardened_session(
243 &mut self,
244 attached: bool,
245 ) -> Result<Option<String>, PolicyError> {
246 if matches!(
248 self.preset,
249 PolicyPreset::Development | PolicyPreset::Ci | PolicyPreset::Polite
250 ) {
251 return Ok(None);
252 }
253 if self.allowed_hosts.is_empty() {
254 return Err(PolicyError::InvalidConfiguration {
255 reason: "hardened and untrusted-mcp modes require at least one exact --policy-allow-host"
256 .to_string(),
257 });
258 }
259 let mut resolver_rules = Vec::with_capacity(self.allowed_hosts.len());
260 for host in &self.allowed_hosts {
261 let address = if let Ok(address) = host.parse::<IpAddr>() {
262 address
263 } else {
264 if attached {
265 return Err(PolicyError::InvalidConfiguration {
266 reason: "hardened attach requires public IP-literal allow rules to avoid DNS rebinding"
267 .to_string(),
268 });
269 }
270 let addresses: Vec<IpAddr> = tokio::net::lookup_host((host.as_str(), 443))
271 .await
272 .map_err(|error| PolicyError::InvalidConfiguration {
273 reason: format!("could not resolve allowed host {host}: {error}"),
274 })?
275 .map(|address| address.ip())
276 .collect();
277 if addresses.is_empty() || addresses.iter().copied().any(is_non_public_ip) {
278 return Err(PolicyError::InvalidConfiguration {
279 reason: format!(
280 "allowed host {host} did not resolve only to public addresses"
281 ),
282 });
283 }
284 addresses
285 .iter()
286 .copied()
287 .find(IpAddr::is_ipv4)
288 .ok_or_else(|| PolicyError::InvalidConfiguration {
289 reason: format!(
290 "allowed host {host} needs a public IPv4 address for resolver pinning"
291 ),
292 })?
293 };
294 if is_non_public_ip(address) {
295 return Err(PolicyError::InvalidConfiguration {
296 reason: format!("allowed host {host} is not a public address"),
297 });
298 }
299 self.pinned_hosts.insert(host.clone(), address);
300 if !attached {
301 resolver_rules.push(format!("MAP {host} {address}"));
302 }
303 }
304 Ok((!resolver_rules.is_empty()).then(|| resolver_rules.join(",")))
305 }
306
307 pub fn decide(&self, capability: PolicyCapability) -> PolicyDecision {
308 if self.preset == PolicyPreset::UntrustedMcp {
310 if self.allowed_capabilities.contains(&capability) {
311 return PolicyDecision::Allow;
312 }
313 if matches!(
315 capability,
316 PolicyCapability::RawCdp | PolicyCapability::PersistentProfile
317 ) {
318 return PolicyDecision::Deny {
319 reason: format!("{capability:?} is disabled in untrusted-mcp mode"),
320 };
321 }
322 if self.confirmed_capabilities.contains(&capability) {
323 return PolicyDecision::RequireConfirmation {
324 reason: format!("{capability:?} requires confirmation in untrusted-mcp mode"),
325 };
326 }
327 return PolicyDecision::Deny {
328 reason: format!("{capability:?} is disabled by the untrusted-mcp preset"),
329 };
330 }
331
332 if self.preset == PolicyPreset::Ci {
334 if matches!(
335 capability,
336 PolicyCapability::RawCdp | PolicyCapability::PersistentProfile
337 ) {
338 return PolicyDecision::Deny {
339 reason: format!("{capability:?} is disabled in CI mode"),
340 };
341 }
342 if self.allowed_capabilities.contains(&capability) {
343 return PolicyDecision::Allow;
344 }
345 return PolicyDecision::Allow;
346 }
347
348 if matches!(
350 self.preset,
351 PolicyPreset::Development | PolicyPreset::Polite
352 ) || self.allowed_capabilities.contains(&capability)
353 {
354 PolicyDecision::Allow
355 } else if self.confirmed_capabilities.contains(&capability) {
356 PolicyDecision::RequireConfirmation {
357 reason: format!("{capability:?} is privileged in hardened mode"),
358 }
359 } else {
360 PolicyDecision::Deny {
361 reason: format!("{capability:?} is disabled by the hardened preset"),
362 }
363 }
364 }
365
366 pub fn require(&self, capability: PolicyCapability) -> Result<(), PolicyError> {
367 match self.decide(capability) {
368 PolicyDecision::Allow => Ok(()),
369 PolicyDecision::Deny { reason } => Err(PolicyError::Denied {
370 operation: capability_name(capability).to_string(),
371 reason,
372 }),
373 PolicyDecision::RequireConfirmation { reason } => {
374 let mut tokens = self.confirmation_tokens.lock().map_err(|_| {
375 PolicyError::InvalidConfiguration {
376 reason: "confirmation token state is unavailable".to_string(),
377 }
378 })?;
379 let remaining = tokens.entry(capability).or_default();
380 if *remaining > 0 {
381 *remaining -= 1;
382 Ok(())
383 } else {
384 Err(PolicyError::ConfirmationRequired {
385 operation: capability_name(capability).to_string(),
386 reason,
387 })
388 }
389 }
390 }
391 }
392
393 pub fn require_for_batch(&self, capability: PolicyCapability) -> Result<(), PolicyError> {
397 match self.decide(capability) {
398 PolicyDecision::Allow => Ok(()),
399 PolicyDecision::Deny { reason } => Err(PolicyError::Denied {
400 operation: capability_name(capability).to_string(),
401 reason,
402 }),
403 PolicyDecision::RequireConfirmation { reason } => {
404 Err(PolicyError::ConfirmationRequired {
405 operation: capability_name(capability).to_string(),
406 reason,
407 })
408 }
409 }
410 }
411
412 pub fn allow_sensitive_form_values(&self) -> bool {
416 self.allowed_capabilities
417 .contains(&PolicyCapability::ReadSensitiveFormValues)
418 }
419
420 pub async fn require_url(&self, value: &str) -> Result<Url, PolicyError> {
421 let url = Url::parse(value).map_err(|error| PolicyError::Denied {
422 operation: "navigate".to_string(),
423 reason: format!("URL is invalid: {error}"),
424 })?;
425 let host = url.host_str().map(|host| host.trim_end_matches('.'));
426 if host.is_some_and(|host| self.denied_hosts.contains(host)) {
427 return Err(url_denied("host is explicitly denied"));
428 }
429 if !self.allowed_hosts.is_empty()
430 && !host.is_some_and(|host| self.allowed_hosts.contains(host))
431 {
432 return Err(url_denied("host is not in the explicit allow list"));
433 }
434 if matches!(
435 self.preset,
436 PolicyPreset::Development | PolicyPreset::Polite
437 ) {
438 return Ok(url);
439 }
440 if url.host_str().is_some_and(|host| host.ends_with('.')) {
441 return Err(url_denied(
442 "hardened URLs must use a canonical host without a trailing dot",
443 ));
444 }
445 if !matches!(url.scheme(), "http" | "https") {
446 return Err(url_denied(
447 "hardened navigation permits only http and https",
448 ));
449 }
450 if !url.username().is_empty() || url.password().is_some() {
451 return Err(url_denied("URLs containing credentials are not permitted"));
452 }
453 let host = host.ok_or_else(|| url_denied("URL must contain a host"))?;
454 if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") {
455 return Err(url_denied("localhost destinations are not permitted"));
456 }
457 let Some(address) = self.pinned_hosts.get(host).copied() else {
458 return Err(url_denied(
459 "hardened host was not pinned at session startup",
460 ));
461 };
462 if is_non_public_ip(address) {
463 return Err(url_denied(
464 "host resolves to a non-public or reserved network destination",
465 ));
466 }
467 Ok(url)
468 }
469
470 pub fn require_existing_path(&self, value: &Path) -> Result<PathBuf, PolicyError> {
471 let canonical = std::fs::canonicalize(value).map_err(|error| PolicyError::Denied {
472 operation: "filesystem_read".to_string(),
473 reason: format!("path must exist and be canonicalizable: {error}"),
474 })?;
475 self.require_within_workspace(&canonical, "filesystem_read")?;
476 Ok(canonical)
477 }
478
479 pub fn require_output_path(&self, value: &Path) -> Result<PathBuf, PolicyError> {
480 if std::fs::symlink_metadata(value).is_ok() {
481 let canonical = std::fs::canonicalize(value).map_err(|error| PolicyError::Denied {
482 operation: "filesystem_write".to_string(),
483 reason: format!("existing output must be canonicalizable: {error}"),
484 })?;
485 self.require_within_workspace(&canonical, "filesystem_write")?;
486 return Ok(canonical);
487 }
488 let name = value.file_name().ok_or_else(|| PolicyError::Denied {
489 operation: "filesystem_write".to_string(),
490 reason: "output path must name a file or directory".to_string(),
491 })?;
492 let parent = value.parent().unwrap_or_else(|| Path::new("."));
493 let parent = std::fs::canonicalize(parent).map_err(|error| PolicyError::Denied {
494 operation: "filesystem_write".to_string(),
495 reason: format!("output parent must exist and be canonicalizable: {error}"),
496 })?;
497 self.require_within_workspace(&parent, "filesystem_write")?;
498 Ok(parent.join(name))
499 }
500
501 fn require_within_workspace(
502 &self,
503 canonical: &Path,
504 operation: &str,
505 ) -> Result<(), PolicyError> {
506 if matches!(
507 self.preset,
508 PolicyPreset::Development | PolicyPreset::Polite
509 ) || canonical.starts_with(&self.workspace_root)
510 {
511 Ok(())
512 } else {
513 Err(PolicyError::Denied {
514 operation: operation.to_string(),
515 reason: "path escapes the configured workspace root".to_string(),
516 })
517 }
518 }
519
520 pub fn is_polite(&self) -> bool {
521 self.preset == PolicyPreset::Polite
522 }
523}
524
525fn capability_name(capability: PolicyCapability) -> &'static str {
526 match capability {
527 PolicyCapability::Attach => "attach",
528 PolicyCapability::PersistentProfile => "persistent_profile",
529 PolicyCapability::Evaluate => "evaluate",
530 PolicyCapability::Upload => "upload",
531 PolicyCapability::Download => "download",
532 PolicyCapability::Screenshot => "screenshot",
533 PolicyCapability::RawCdp => "raw_cdp",
534 PolicyCapability::ReadFormValues => "read_form_values",
535 PolicyCapability::ReadSensitiveFormValues => "read_sensitive_form_values",
536 PolicyCapability::CoordinateClick => "coordinate_click",
537 PolicyCapability::ConsentDismissal => "consent_dismissal",
538 PolicyCapability::DeclaredAgentIdentity => "declared_agent_identity",
539 }
540}
541
542fn normalize_host_rules(
543 hosts: impl IntoIterator<Item = String>,
544) -> Result<BTreeSet<String>, PolicyError> {
545 hosts
546 .into_iter()
547 .map(|host| {
548 let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
549 if host.is_empty()
550 || host.contains('/')
551 || host.contains(':')
552 || host.contains('*')
553 || Url::parse(&format!("https://{host}/"))
554 .ok()
555 .and_then(|url| url.host_str().map(str::to_string))
556 .as_deref()
557 != Some(host.as_str())
558 {
559 return Err(PolicyError::InvalidConfiguration {
560 reason: format!("invalid exact host rule: {host}"),
561 });
562 }
563 Ok(host)
564 })
565 .collect()
566}
567
568fn url_denied(reason: &str) -> PolicyError {
569 PolicyError::Denied {
570 operation: "navigate".to_string(),
571 reason: reason.to_string(),
572 }
573}
574
575fn is_non_public_ip(address: IpAddr) -> bool {
576 match address {
577 IpAddr::V4(address) => {
578 let value = u32::from(address);
579 [
580 ("0.0.0.0", 8),
581 ("10.0.0.0", 8),
582 ("100.64.0.0", 10),
583 ("127.0.0.0", 8),
584 ("169.254.0.0", 16),
585 ("172.16.0.0", 12),
586 ("192.0.0.0", 24),
587 ("192.0.2.0", 24),
588 ("192.88.99.0", 24),
589 ("192.168.0.0", 16),
590 ("198.18.0.0", 15),
591 ("198.51.100.0", 24),
592 ("203.0.113.0", 24),
593 ("224.0.0.0", 4),
594 ("240.0.0.0", 4),
595 ]
596 .into_iter()
597 .any(|(network, prefix)| {
598 ipv4_in_prefix(
599 value,
600 u32::from(network.parse::<Ipv4Addr>().unwrap()),
601 prefix,
602 )
603 })
604 }
605 IpAddr::V6(address) => {
606 address.is_loopback()
607 || address.is_multicast()
608 || address.is_unspecified()
609 || [
610 ("64:ff9b::", 96),
611 ("64:ff9b:1::", 48),
612 ("100::", 64),
613 ("2001::", 32),
614 ("2001:db8::", 32),
615 ("2002::", 16),
616 ("fc00::", 7),
617 ("fe80::", 10),
618 ("fec0::", 10),
619 ]
620 .into_iter()
621 .any(|(network, prefix)| {
622 ipv6_in_prefix(
623 u128::from(address),
624 u128::from(network.parse::<Ipv6Addr>().unwrap()),
625 prefix,
626 )
627 })
628 || address
629 .to_ipv4_mapped()
630 .is_some_and(|address| is_non_public_ip(IpAddr::V4(address)))
631 }
632 }
633}
634
635fn ipv4_in_prefix(value: u32, network: u32, prefix: u32) -> bool {
636 let mask = u32::MAX.checked_shl(32 - prefix).unwrap_or(0);
637 value & mask == network & mask
638}
639
640fn ipv6_in_prefix(value: u128, network: u128, prefix: u32) -> bool {
641 let mask = u128::MAX.checked_shl(128 - prefix).unwrap_or(0);
642 value & mask == network & mask
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
650 fn hardened_capabilities_are_typed_denials_or_confirmations() {
651 let root = std::env::current_dir().unwrap();
652 let denied = BrowserPolicy::hardened(&root).unwrap();
653 assert!(matches!(
654 denied.decide(PolicyCapability::Evaluate),
655 PolicyDecision::Deny { .. }
656 ));
657 let confirm = BrowserPolicy::new(
658 PolicyPreset::Hardened,
659 &root,
660 [],
661 [PolicyCapability::Evaluate],
662 )
663 .unwrap();
664 assert!(matches!(
665 confirm.require(PolicyCapability::Evaluate),
666 Err(PolicyError::ConfirmationRequired { .. })
667 ));
668 let approved = confirm
669 .with_confirmation_tokens([PolicyCapability::Evaluate])
670 .unwrap();
671 assert!(approved.require(PolicyCapability::Evaluate).is_ok());
672 assert!(matches!(
673 approved.require(PolicyCapability::Evaluate),
674 Err(PolicyError::ConfirmationRequired { .. })
675 ));
676 assert!(
677 BrowserPolicy::new(
678 PolicyPreset::Hardened,
679 &root,
680 [PolicyCapability::Evaluate],
681 [PolicyCapability::Evaluate],
682 )
683 .is_err()
684 );
685 }
686
687 #[tokio::test]
688 async fn hardened_urls_reject_alternate_private_and_local_forms() {
689 let policy = BrowserPolicy::hardened(std::env::current_dir().unwrap()).unwrap();
690 for value in [
691 "file:///etc/passwd",
692 "data:text/plain,secret",
693 "http://localhost/",
694 "http://127.1/",
695 "http://2130706433/",
696 "http://[::1]/",
697 "http://[::ffff:127.0.0.1]/",
698 "https://user:secret@example.com/",
699 ] {
700 assert!(policy.require_url(value).await.is_err(), "accepted {value}");
701 }
702 for value in [
703 "192.0.0.8",
704 "198.18.0.1",
705 "198.51.100.1",
706 "203.0.113.1",
707 "64:ff9b::1",
708 "2001::1",
709 "2002::1",
710 "fec0::1",
711 ] {
712 assert!(is_non_public_ip(value.parse().unwrap()), "accepted {value}");
713 }
714 }
715
716 #[tokio::test]
717 async fn explicit_host_rules_are_canonical_and_pinned() {
718 let root = std::env::current_dir().unwrap();
719 let denied = BrowserPolicy::development(&root)
720 .unwrap()
721 .with_host_rules([], ["example.com".to_string()])
722 .unwrap();
723 assert!(denied.require_url("https://example.com./").await.is_err());
724
725 let mut pinned = BrowserPolicy::hardened(&root)
726 .unwrap()
727 .with_host_rules(["8.8.8.8".to_string()], [])
728 .unwrap();
729 let rules = pinned.prepare_hardened_session(false).await.unwrap();
730 assert_eq!(rules.as_deref(), Some("MAP 8.8.8.8 8.8.8.8"));
731 assert!(pinned.require_url("https://8.8.8.8/").await.is_ok());
732 }
733
734 #[test]
735 fn canonical_paths_reject_symlink_escape() {
736 #[cfg(unix)]
737 {
738 use std::os::unix::fs::symlink;
739 let root = std::env::temp_dir().join(format!("glass-policy-{}", std::process::id()));
740 let _ = std::fs::remove_dir_all(&root);
741 std::fs::create_dir_all(&root).unwrap();
742 symlink("/etc", root.join("escape")).unwrap();
743 let policy = BrowserPolicy::hardened(&root).unwrap();
744 assert!(
745 policy
746 .require_existing_path(&root.join("escape/passwd"))
747 .is_err()
748 );
749 assert!(
750 policy
751 .require_output_path(&root.join("escape/passwd"))
752 .is_err()
753 );
754 let _ = std::fs::remove_dir_all(root);
755 }
756 }
757}