Skip to main content

glass/browser/
policy.rs

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