openenv-openshell 0.1.1__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.
- openenv_openshell/__init__.py +30 -0
- openenv_openshell/_adapter.py +139 -0
- openenv_openshell/_compat.py +7 -0
- openenv_openshell/_naming.py +4 -0
- openenv_openshell/_sdk.py +324 -0
- openenv_openshell/config.py +249 -0
- openenv_openshell/errors.py +37 -0
- openenv_openshell/metadata.py +50 -0
- openenv_openshell/policy.py +108 -0
- openenv_openshell/provider.py +495 -0
- openenv_openshell/py.typed +1 -0
- openenv_openshell-0.1.1.dist-info/METADATA +296 -0
- openenv_openshell-0.1.1.dist-info/RECORD +15 -0
- openenv_openshell-0.1.1.dist-info/WHEEL +4 -0
- openenv_openshell-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Typed configuration for the OpenShell provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from math import isfinite
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import TypeAlias
|
|
13
|
+
|
|
14
|
+
from openenv_openshell._naming import MAX_SANDBOX_NAME_LENGTH
|
|
15
|
+
|
|
16
|
+
Policy: TypeAlias = str | Path | Mapping[str, object] | None
|
|
17
|
+
|
|
18
|
+
_MAX_SERVICE_NAME_LENGTH = 19
|
|
19
|
+
_MAX_LABEL_KEY_LENGTH = 128
|
|
20
|
+
_MAX_LABEL_VALUE_LENGTH = 256
|
|
21
|
+
_MAX_PORT = 65535
|
|
22
|
+
_NAME_PATTERN = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\Z")
|
|
23
|
+
_LABEL_KEY_PATTERN = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9._/-]*[A-Za-z0-9])?\Z")
|
|
24
|
+
_MEMORY_PATTERN = re.compile(r"(?P<amount>\d+(?:\.\d+)?)(?P<unit>[A-Za-z]+)?\Z")
|
|
25
|
+
_SENSITIVE_LABEL_TERMS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"credential",
|
|
28
|
+
"credentials",
|
|
29
|
+
"password",
|
|
30
|
+
"passwd",
|
|
31
|
+
"prompt",
|
|
32
|
+
"secret",
|
|
33
|
+
"token",
|
|
34
|
+
"user-data",
|
|
35
|
+
"user_data",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
_SENSITIVE_LABEL_PHRASES = ("api-key", "task-content", "user-data")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _PolicySnapshot(Mapping[str, object]):
|
|
42
|
+
"""Read-only mapping whose nested values are detached on every read."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, source: Mapping[str, object]) -> None:
|
|
45
|
+
"""Capture caller input without changing its authored types."""
|
|
46
|
+
self._values = deepcopy(dict(source))
|
|
47
|
+
|
|
48
|
+
def __getitem__(self, key: str) -> object:
|
|
49
|
+
"""Return a detached value so nested edits cannot change the snapshot."""
|
|
50
|
+
return deepcopy(self._values[key])
|
|
51
|
+
|
|
52
|
+
def __iter__(self) -> Iterator[str]:
|
|
53
|
+
"""Iterate the captured keys."""
|
|
54
|
+
return iter(self._values)
|
|
55
|
+
|
|
56
|
+
def __len__(self) -> int:
|
|
57
|
+
"""Return the number of captured fields."""
|
|
58
|
+
return len(self._values)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validate_name(value: str, *, field_name: str, max_length: int) -> None:
|
|
62
|
+
if not value:
|
|
63
|
+
msg = f"{field_name} must not be empty"
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
if len(value) > max_length or _NAME_PATTERN.fullmatch(value) is None:
|
|
66
|
+
msg = (
|
|
67
|
+
f"{field_name} must be at most {max_length} lowercase letters, "
|
|
68
|
+
"digits, or hyphens, and must start and end with a letter or digit"
|
|
69
|
+
)
|
|
70
|
+
raise ValueError(msg)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _validate_service_name(value: str) -> None:
|
|
74
|
+
# OpenShell 0.1.2 grpc/service.rs permits the unnamed exposure, otherwise
|
|
75
|
+
# applies MAX_ROUTABLE_NAME_LEN, forbids '--', and requires is_dns_label.
|
|
76
|
+
if value == "":
|
|
77
|
+
return
|
|
78
|
+
if (
|
|
79
|
+
len(value) > _MAX_SERVICE_NAME_LENGTH
|
|
80
|
+
or _NAME_PATTERN.fullmatch(value) is None
|
|
81
|
+
or "--" in value
|
|
82
|
+
):
|
|
83
|
+
msg = (
|
|
84
|
+
f"service_name must be empty or at most {_MAX_SERVICE_NAME_LENGTH} "
|
|
85
|
+
"lowercase letters, digits, or hyphens, start and end with a letter "
|
|
86
|
+
"or digit, and contain no consecutive hyphens"
|
|
87
|
+
)
|
|
88
|
+
raise ValueError(msg)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_positive_number(value: float, *, field_name: str) -> None:
|
|
92
|
+
if isinstance(value, bool) or not isfinite(value) or value <= 0:
|
|
93
|
+
msg = f"{field_name} must be a positive finite number"
|
|
94
|
+
raise ValueError(msg)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_labels(labels: Mapping[str, str]) -> None:
|
|
98
|
+
for key, value in labels.items():
|
|
99
|
+
if not isinstance(key, str) or not isinstance(value, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
100
|
+
msg = "labels must contain only string keys and values"
|
|
101
|
+
raise TypeError(msg)
|
|
102
|
+
if (
|
|
103
|
+
len(key) > _MAX_LABEL_KEY_LENGTH
|
|
104
|
+
or _LABEL_KEY_PATTERN.fullmatch(key) is None
|
|
105
|
+
):
|
|
106
|
+
msg = f"invalid label key: {key!r}"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
normalized_key = key.casefold().replace("_", "-")
|
|
109
|
+
normalized_parts = set(re.split(r"[-./]", normalized_key))
|
|
110
|
+
if normalized_parts & _SENSITIVE_LABEL_TERMS or any(
|
|
111
|
+
phrase in normalized_key for phrase in _SENSITIVE_LABEL_PHRASES
|
|
112
|
+
):
|
|
113
|
+
msg = f"label key must not identify sensitive or private data: {key!r}"
|
|
114
|
+
raise ValueError(msg)
|
|
115
|
+
if len(value) > _MAX_LABEL_VALUE_LENGTH or any(
|
|
116
|
+
character.isspace() and character != " " for character in value
|
|
117
|
+
):
|
|
118
|
+
msg = f"invalid label value for {key!r}"
|
|
119
|
+
raise ValueError(msg)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_command(command: Sequence[str] | None) -> tuple[str, ...]:
|
|
123
|
+
"""Require exact argv without exposing command contents in errors."""
|
|
124
|
+
if command is None:
|
|
125
|
+
msg = "OpenShell 0.1.2 requires explicit command argv; configure command"
|
|
126
|
+
raise ValueError(msg)
|
|
127
|
+
if isinstance(command, (str, bytes)) or not isinstance(command, Sequence): # pyright: ignore[reportUnnecessaryIsInstance] - Validate untyped callers.
|
|
128
|
+
msg = "command must be a non-empty sequence of string arguments"
|
|
129
|
+
raise TypeError(msg)
|
|
130
|
+
argv = tuple(command)
|
|
131
|
+
if (
|
|
132
|
+
not argv
|
|
133
|
+
or any(not isinstance(arg, str) or "\x00" in arg for arg in argv) # pyright: ignore[reportUnnecessaryIsInstance]
|
|
134
|
+
or not argv[0].strip()
|
|
135
|
+
):
|
|
136
|
+
msg = "command requires a non-empty executable and NUL-free string arguments"
|
|
137
|
+
raise ValueError(msg)
|
|
138
|
+
return argv
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class OpenShellResources:
|
|
143
|
+
"""Resource requests passed through to OpenShell."""
|
|
144
|
+
|
|
145
|
+
cpu: float | None = None
|
|
146
|
+
memory: str | None = None
|
|
147
|
+
gpu_count: int | None = None
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
"""Reject resource requests that cannot represent positive capacity."""
|
|
151
|
+
if self.cpu is not None:
|
|
152
|
+
_validate_positive_number(self.cpu, field_name="resources.cpu")
|
|
153
|
+
if self.memory is not None:
|
|
154
|
+
match = _MEMORY_PATTERN.fullmatch(self.memory)
|
|
155
|
+
if match is None or float(match.group("amount")) <= 0:
|
|
156
|
+
msg = "resources.memory must be a positive quantity"
|
|
157
|
+
raise ValueError(msg)
|
|
158
|
+
if self.gpu_count is not None and (
|
|
159
|
+
isinstance(self.gpu_count, bool)
|
|
160
|
+
or not isinstance(self.gpu_count, int) # pyright: ignore[reportUnnecessaryIsInstance]
|
|
161
|
+
or self.gpu_count <= 0
|
|
162
|
+
):
|
|
163
|
+
msg = "resources.gpu_count must be a positive integer"
|
|
164
|
+
raise ValueError(msg)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class OpenShellProviderConfig:
|
|
169
|
+
"""Immutable provider settings shared by lifecycle components."""
|
|
170
|
+
|
|
171
|
+
workspace: str = "default"
|
|
172
|
+
sandbox_name: str | None = None
|
|
173
|
+
policy: Policy = None
|
|
174
|
+
command: Sequence[str] | None = field(default=None, repr=False)
|
|
175
|
+
service_port: int = 8000
|
|
176
|
+
service_name: str = ""
|
|
177
|
+
startup_timeout_s: float = 120.0
|
|
178
|
+
deletion_timeout_s: float = 60.0
|
|
179
|
+
health_poll_interval_s: float = 0.5
|
|
180
|
+
health_request_timeout_s: float = 2.0
|
|
181
|
+
gateway: str | None = None
|
|
182
|
+
keep_sandbox: bool = False
|
|
183
|
+
restricted_alpha_cleanup_acknowledged: bool = False
|
|
184
|
+
labels: Mapping[str, str] = field(default_factory=dict[str, str])
|
|
185
|
+
providers: Sequence[str] = field(default_factory=tuple[str, ...])
|
|
186
|
+
resources: OpenShellResources | None = None
|
|
187
|
+
|
|
188
|
+
def __post_init__(self) -> None:
|
|
189
|
+
"""Validate settings and detach mutable values supplied by callers."""
|
|
190
|
+
if self.command is not None:
|
|
191
|
+
object.__setattr__(self, "command", validate_command(self.command))
|
|
192
|
+
if not self.workspace.strip():
|
|
193
|
+
msg = "workspace must not be empty"
|
|
194
|
+
raise ValueError(msg)
|
|
195
|
+
if self.sandbox_name is not None:
|
|
196
|
+
_validate_name(
|
|
197
|
+
self.sandbox_name,
|
|
198
|
+
field_name="sandbox_name",
|
|
199
|
+
max_length=MAX_SANDBOX_NAME_LENGTH,
|
|
200
|
+
)
|
|
201
|
+
_validate_service_name(self.service_name)
|
|
202
|
+
if (
|
|
203
|
+
isinstance(self.service_port, bool)
|
|
204
|
+
or not isinstance(self.service_port, int) # pyright: ignore[reportUnnecessaryIsInstance]
|
|
205
|
+
or self.service_port < 1
|
|
206
|
+
or self.service_port > _MAX_PORT
|
|
207
|
+
):
|
|
208
|
+
msg = f"service_port must be an integer from 1 through {_MAX_PORT}"
|
|
209
|
+
raise ValueError(msg)
|
|
210
|
+
_validate_positive_number(
|
|
211
|
+
self.startup_timeout_s, field_name="startup_timeout_s"
|
|
212
|
+
)
|
|
213
|
+
_validate_positive_number(
|
|
214
|
+
self.deletion_timeout_s, field_name="deletion_timeout_s"
|
|
215
|
+
)
|
|
216
|
+
_validate_positive_number(
|
|
217
|
+
self.health_poll_interval_s, field_name="health_poll_interval_s"
|
|
218
|
+
)
|
|
219
|
+
_validate_positive_number(
|
|
220
|
+
self.health_request_timeout_s, field_name="health_request_timeout_s"
|
|
221
|
+
)
|
|
222
|
+
if self.gateway is not None and not self.gateway.strip():
|
|
223
|
+
msg = "gateway must not be empty"
|
|
224
|
+
raise ValueError(msg)
|
|
225
|
+
if not isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - Validate untyped callers.
|
|
226
|
+
self.restricted_alpha_cleanup_acknowledged, bool
|
|
227
|
+
):
|
|
228
|
+
msg = "restricted_alpha_cleanup_acknowledged must be a boolean"
|
|
229
|
+
raise TypeError(msg)
|
|
230
|
+
|
|
231
|
+
labels = dict(self.labels)
|
|
232
|
+
_validate_labels(labels)
|
|
233
|
+
object.__setattr__(self, "labels", MappingProxyType(labels))
|
|
234
|
+
|
|
235
|
+
if isinstance(self.providers, str):
|
|
236
|
+
msg = "providers must be a sequence of provider names"
|
|
237
|
+
raise TypeError(msg)
|
|
238
|
+
providers = tuple(self.providers)
|
|
239
|
+
if any(
|
|
240
|
+
not isinstance(provider, str) # pyright: ignore[reportUnnecessaryIsInstance]
|
|
241
|
+
or not provider.strip()
|
|
242
|
+
for provider in providers
|
|
243
|
+
):
|
|
244
|
+
msg = "providers must contain non-empty strings"
|
|
245
|
+
raise ValueError(msg)
|
|
246
|
+
object.__setattr__(self, "providers", providers)
|
|
247
|
+
|
|
248
|
+
if isinstance(self.policy, Mapping):
|
|
249
|
+
object.__setattr__(self, "policy", _PolicySnapshot(self.policy))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Provider-specific exceptions with stable public meanings."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OpenShellProviderError(RuntimeError):
|
|
5
|
+
"""Base class for provider failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenShellConnectionError(OpenShellProviderError):
|
|
9
|
+
"""Raised when the provider cannot communicate with OpenShell."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SandboxCreationError(OpenShellProviderError):
|
|
13
|
+
"""Raised when sandbox creation fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SandboxReadinessError(OpenShellProviderError):
|
|
17
|
+
"""Raised when an OpenShell sandbox does not become ready."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenEnvReadinessTimeout(OpenShellProviderError): # noqa: N818
|
|
21
|
+
"""Raised when the OpenEnv health endpoint does not become ready."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ServiceAccessError(OpenShellProviderError):
|
|
25
|
+
"""Raised when TLS rejects access the OpenEnv client cannot provide.
|
|
26
|
+
|
|
27
|
+
Examples are an untrusted route certificate or a required TLS client
|
|
28
|
+
certificate. Retrying cannot help.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SandboxDeletionError(OpenShellProviderError):
|
|
33
|
+
"""Raised when an owned sandbox cannot be deleted."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PolicyConfigurationError(OpenShellProviderError):
|
|
37
|
+
"""Raised when an explicit policy cannot be loaded or validated."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Lifecycle state and audit metadata models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class ProviderState:
|
|
15
|
+
"""Mutable state for the provider's single owned sandbox."""
|
|
16
|
+
|
|
17
|
+
sandbox_name: str | None = None
|
|
18
|
+
sandbox_id: str | None = None
|
|
19
|
+
image: str | None = None
|
|
20
|
+
base_url: str | None = None
|
|
21
|
+
created: bool = False
|
|
22
|
+
ready: bool = False
|
|
23
|
+
deleted: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class OpenShellRunMetadata:
|
|
28
|
+
"""Non-secret provenance for one OpenEnv run."""
|
|
29
|
+
|
|
30
|
+
sandbox_name: str
|
|
31
|
+
sandbox_id: str | None
|
|
32
|
+
workspace: str
|
|
33
|
+
image: str
|
|
34
|
+
service_url: str
|
|
35
|
+
policy_digest: str | None
|
|
36
|
+
created_at: datetime
|
|
37
|
+
|
|
38
|
+
openenv_provider_version: str | None = None
|
|
39
|
+
openshell_version: str | None = None
|
|
40
|
+
openenv_version: str | None = None
|
|
41
|
+
ready_at: datetime | None = None
|
|
42
|
+
deleted_at: datetime | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def package_version(distribution: str) -> str | None:
|
|
46
|
+
"""Read installed distribution provenance without importing runtime SDKs."""
|
|
47
|
+
try:
|
|
48
|
+
return version(distribution)
|
|
49
|
+
except PackageNotFoundError:
|
|
50
|
+
return None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Fail-closed loading of explicit policies without gateway access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, cast
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from openenv_openshell._adapter import ensure_sdk
|
|
14
|
+
from openenv_openshell.errors import PolicyConfigurationError
|
|
15
|
+
|
|
16
|
+
_INVALID = "Invalid explicit policy; use supported OpenShell 0.1.2 policy fields."
|
|
17
|
+
_MAX_BYTES = 1024 * 1024
|
|
18
|
+
_MAX_DEPTH = 64
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Hashable, Iterator
|
|
22
|
+
|
|
23
|
+
from yaml.events import Event
|
|
24
|
+
from yaml.nodes import MappingNode
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def policy_digest(policy: Mapping[str, object]) -> str:
|
|
28
|
+
"""Hash normalized SDK fields as sorted, compact UTF-8 JSON.
|
|
29
|
+
|
|
30
|
+
Preserve list order; only mapping order and authored schema spellings are
|
|
31
|
+
normalized. The caller must pass the validated output of load_policy.
|
|
32
|
+
"""
|
|
33
|
+
canonical = json.dumps(
|
|
34
|
+
dict(policy), sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
35
|
+
).encode("utf-8")
|
|
36
|
+
return sha256(canonical).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _PolicyLoader(yaml.SafeLoader):
|
|
40
|
+
"""Reject duplicate keys and merge keys instead of silently overriding."""
|
|
41
|
+
|
|
42
|
+
def construct_mapping(
|
|
43
|
+
self,
|
|
44
|
+
node: MappingNode,
|
|
45
|
+
deep: bool = False, # noqa: FBT001, FBT002 - PyYAML override.
|
|
46
|
+
) -> dict[Hashable, object]:
|
|
47
|
+
keys: set[str] = set()
|
|
48
|
+
for key_node, _ in node.value:
|
|
49
|
+
key: object = self.construct_object(key_node, deep=deep) # pyright: ignore[reportUnknownMemberType] - PyYAML stub lacks node type.
|
|
50
|
+
if not isinstance(key, str) or key in keys or key == "<<":
|
|
51
|
+
raise PolicyConfigurationError(_INVALID)
|
|
52
|
+
keys.add(key)
|
|
53
|
+
return cast("dict[Hashable, object]", super().construct_mapping(node, deep))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _detach(value: object, depth: int = 0) -> object:
|
|
57
|
+
if depth > _MAX_DEPTH:
|
|
58
|
+
raise PolicyConfigurationError(_INVALID)
|
|
59
|
+
if isinstance(value, Mapping):
|
|
60
|
+
result: dict[str, object] = {}
|
|
61
|
+
for key, child in cast("Mapping[object, object]", value).items():
|
|
62
|
+
if not isinstance(key, str):
|
|
63
|
+
raise PolicyConfigurationError(_INVALID)
|
|
64
|
+
result[key] = _detach(child, depth + 1)
|
|
65
|
+
return dict(sorted(result.items()))
|
|
66
|
+
if isinstance(value, list):
|
|
67
|
+
return [_detach(child, depth + 1) for child in cast("list[object]", value)]
|
|
68
|
+
if isinstance(value, (str, bool, int)):
|
|
69
|
+
return value
|
|
70
|
+
raise PolicyConfigurationError(_INVALID)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_policy(source: object) -> dict[str, object]:
|
|
74
|
+
"""Load a YAML path or mapping and return detached normalized SDK fields.
|
|
75
|
+
|
|
76
|
+
None is not an explicit policy. Image/default policy selection belongs to
|
|
77
|
+
the caller and must never be used to recover from an explicit-policy error.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
if isinstance(source, (str, Path)):
|
|
81
|
+
with Path(source).open("rb") as stream:
|
|
82
|
+
data = stream.read(_MAX_BYTES + 1)
|
|
83
|
+
if len(data) > _MAX_BYTES:
|
|
84
|
+
raise PolicyConfigurationError(_INVALID)
|
|
85
|
+
# Reject aliases/anchors: no recursive or exponentially expanded YAML.
|
|
86
|
+
events = cast("Iterator[Event]", yaml.parse(data)) # pyright: ignore[reportUnknownMemberType] - PyYAML parse return is untyped.
|
|
87
|
+
for event in events:
|
|
88
|
+
if getattr(event, "anchor", None) is not None:
|
|
89
|
+
raise PolicyConfigurationError(_INVALID)
|
|
90
|
+
source = yaml.load(data, Loader=_PolicyLoader) # noqa: S506 - SafeLoader subclass.
|
|
91
|
+
detached = _detach(source)
|
|
92
|
+
if not isinstance(detached, dict):
|
|
93
|
+
raise PolicyConfigurationError(_INVALID)
|
|
94
|
+
policy = cast("dict[str, object]", detached)
|
|
95
|
+
if type(policy.get("version")) is not int or policy["version"] != 1:
|
|
96
|
+
raise PolicyConfigurationError(_INVALID)
|
|
97
|
+
if "filesystem_policy" in policy:
|
|
98
|
+
if "filesystem" in policy:
|
|
99
|
+
raise PolicyConfigurationError(_INVALID)
|
|
100
|
+
policy["filesystem"] = policy.pop("filesystem_policy")
|
|
101
|
+
ensure_sdk()
|
|
102
|
+
from openenv_openshell._sdk import ( # noqa: PLC0415 - Guarded lazy model boundary.
|
|
103
|
+
normalize_policy,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return cast("dict[str, object]", _detach(normalize_policy(policy)))
|
|
107
|
+
except (OSError, UnicodeError, yaml.YAMLError, RecursionError):
|
|
108
|
+
raise PolicyConfigurationError(_INVALID) from None
|