clonebox 1.1.17__py3-none-any.whl → 1.1.19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clonebox/health/probes.py CHANGED
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
8
8
  from datetime import datetime
9
9
  from typing import Optional
10
10
 
11
+ from clonebox.policies import PolicyEngine, PolicyViolationError
11
12
  from .models import HealthCheckResult, HealthStatus, ProbeConfig
12
13
 
13
14
  try:
@@ -57,6 +58,19 @@ class HTTPProbe(HealthProbe):
57
58
 
58
59
  start = time.time()
59
60
  try:
61
+ policy = PolicyEngine.load_effective()
62
+ if policy is not None:
63
+ try:
64
+ policy.assert_url_allowed(config.url)
65
+ except PolicyViolationError as e:
66
+ duration_ms = (time.time() - start) * 1000
67
+ return self._create_result(
68
+ config,
69
+ HealthStatus.UNHEALTHY,
70
+ duration_ms,
71
+ error=str(e),
72
+ )
73
+
60
74
  req = urllib.request.Request(
61
75
  config.url,
62
76
  method=config.method,
@@ -0,0 +1,13 @@
1
+ from .engine import PolicyEngine, PolicyValidationError, PolicyViolationError
2
+ from .models import PolicyFile, PolicySet, NetworkPolicy, OperationsPolicy, ResourcesPolicy
3
+
4
+ __all__ = [
5
+ "PolicyEngine",
6
+ "PolicyValidationError",
7
+ "PolicyViolationError",
8
+ "PolicyFile",
9
+ "PolicySet",
10
+ "NetworkPolicy",
11
+ "OperationsPolicy",
12
+ "ResourcesPolicy",
13
+ ]
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ import yaml
9
+
10
+ from .models import PolicyFile
11
+ from .validators import extract_hostname, is_host_allowed
12
+
13
+
14
+ class PolicyValidationError(ValueError):
15
+ pass
16
+
17
+
18
+ class PolicyViolationError(PermissionError):
19
+ pass
20
+
21
+
22
+ DEFAULT_PROJECT_POLICY_FILES = (".clonebox-policy.yaml", ".clonebox-policy.yml")
23
+ DEFAULT_GLOBAL_POLICY_FILE = Path.home() / ".clonebox.d" / "policy.yaml"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PolicyEngine:
28
+ policy: PolicyFile
29
+ source: Path
30
+
31
+ @classmethod
32
+ def load(cls, path: Path) -> "PolicyEngine":
33
+ try:
34
+ raw = yaml.safe_load(path.read_text())
35
+ except Exception as e:
36
+ raise PolicyValidationError(f"Failed to read policy file {path}: {e}")
37
+
38
+ if not isinstance(raw, dict):
39
+ raise PolicyValidationError("Policy file must be a YAML mapping")
40
+
41
+ try:
42
+ policy = PolicyFile.model_validate(raw)
43
+ except Exception as e:
44
+ raise PolicyValidationError(str(e))
45
+
46
+ return cls(policy=policy, source=path)
47
+
48
+ @classmethod
49
+ def find_policy_file(cls, start: Optional[Path] = None) -> Optional[Path]:
50
+ start_path = (start or Path.cwd()).expanduser().resolve()
51
+ if start_path.is_file():
52
+ start_path = start_path.parent
53
+
54
+ current = start_path
55
+ while True:
56
+ for name in DEFAULT_PROJECT_POLICY_FILES:
57
+ candidate = current / name
58
+ if candidate.exists() and candidate.is_file():
59
+ return candidate
60
+
61
+ if current.parent == current:
62
+ break
63
+ current = current.parent
64
+
65
+ if DEFAULT_GLOBAL_POLICY_FILE.exists() and DEFAULT_GLOBAL_POLICY_FILE.is_file():
66
+ return DEFAULT_GLOBAL_POLICY_FILE
67
+
68
+ return None
69
+
70
+ @classmethod
71
+ def load_effective(cls, start: Optional[Path] = None) -> Optional["PolicyEngine"]:
72
+ policy_path = cls.find_policy_file(start=start)
73
+ if not policy_path:
74
+ return None
75
+ return cls.load(policy_path)
76
+
77
+ def assert_url_allowed(self, url: str) -> None:
78
+ network = self.policy.policies.network
79
+ if network is None:
80
+ return
81
+
82
+ hostname = extract_hostname(url)
83
+ if not hostname:
84
+ raise PolicyViolationError(f"URL has no hostname: {url}")
85
+
86
+ if not is_host_allowed(hostname, network.allowlist, network.blocklist):
87
+ raise PolicyViolationError(f"Network access denied by policy: {hostname}")
88
+
89
+ @staticmethod
90
+ def _operation_matches(operation: str, pattern: str) -> bool:
91
+ operation = (operation or "").strip().lower()
92
+ pattern = (pattern or "").strip().lower()
93
+ if not operation or not pattern:
94
+ return False
95
+ return fnmatch.fnmatch(operation, pattern)
96
+
97
+ def requires_approval(self, operation: str) -> bool:
98
+ ops = self.policy.policies.operations
99
+ if ops is None:
100
+ return False
101
+
102
+ if any(self._operation_matches(operation, p) for p in ops.auto_approve or []):
103
+ return False
104
+ return any(
105
+ self._operation_matches(operation, p) for p in ops.require_approval or []
106
+ )
107
+
108
+ def assert_operation_approved(self, operation: str, approved: bool) -> None:
109
+ if self.requires_approval(operation) and not approved:
110
+ raise PolicyViolationError(
111
+ f"Operation requires approval by policy: {operation} (pass --approve)"
112
+ )
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional
4
+
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+
8
+ class NetworkPolicy(BaseModel):
9
+ allowlist: List[str] = Field(default_factory=list)
10
+ blocklist: List[str] = Field(default_factory=list)
11
+
12
+ @field_validator("allowlist", "blocklist")
13
+ @classmethod
14
+ def _validate_patterns(cls, v: List[str]) -> List[str]:
15
+ if v is None:
16
+ return []
17
+ if not isinstance(v, list):
18
+ raise TypeError("must be a list")
19
+ for item in v:
20
+ if not isinstance(item, str) or not item.strip():
21
+ raise ValueError("patterns must be non-empty strings")
22
+ return v
23
+
24
+
25
+ class OperationsPolicy(BaseModel):
26
+ require_approval: List[str] = Field(default_factory=list)
27
+ auto_approve: List[str] = Field(default_factory=list)
28
+
29
+ @field_validator("require_approval", "auto_approve")
30
+ @classmethod
31
+ def _validate_ops(cls, v: List[str]) -> List[str]:
32
+ if v is None:
33
+ return []
34
+ if not isinstance(v, list):
35
+ raise TypeError("must be a list")
36
+ for item in v:
37
+ if not isinstance(item, str) or not item.strip():
38
+ raise ValueError("operation names must be non-empty strings")
39
+ return v
40
+
41
+
42
+ class ResourcesPolicy(BaseModel):
43
+ max_vms_per_user: Optional[int] = None
44
+ max_disk_gb: Optional[int] = None
45
+
46
+
47
+ class PolicySet(BaseModel):
48
+ network: Optional[NetworkPolicy] = None
49
+ operations: Optional[OperationsPolicy] = None
50
+ resources: Optional[ResourcesPolicy] = None
51
+
52
+
53
+ class PolicyFile(BaseModel):
54
+ version: str
55
+ policies: PolicySet
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from typing import List
5
+ from urllib.parse import urlparse
6
+
7
+
8
+ def extract_hostname(url: str) -> str:
9
+ parsed = urlparse(url)
10
+ return (parsed.hostname or "").strip().lower()
11
+
12
+
13
+ def host_matches(hostname: str, pattern: str) -> bool:
14
+ hostname = (hostname or "").strip().lower()
15
+ pattern = (pattern or "").strip().lower()
16
+ if not hostname or not pattern:
17
+ return False
18
+ return fnmatch.fnmatch(hostname, pattern)
19
+
20
+
21
+ def is_host_allowed(hostname: str, allowlist: List[str], blocklist: List[str]) -> bool:
22
+ if any(host_matches(hostname, p) for p in blocklist or []):
23
+ return False
24
+ if allowlist:
25
+ return any(host_matches(hostname, p) for p in allowlist)
26
+ return True