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
15pub 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#[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 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 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 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 if self.preset == PolicyPreset::UntrustedMcp {
373 if self.allowed_capabilities.contains(&capability) {
374 return PolicyDecision::Allow;
375 }
376 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 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 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 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 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}