isolattice 0.1.0__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.
Files changed (51) hide show
  1. isolattice/__init__.py +205 -0
  2. isolattice/_artifacts.py +219 -0
  3. isolattice/_async.py +117 -0
  4. isolattice/backend.py +294 -0
  5. isolattice/backends/__init__.py +19 -0
  6. isolattice/backends/_bootstrap.py +123 -0
  7. isolattice/backends/_cli.py +304 -0
  8. isolattice/backends/_kubernetes_network.py +191 -0
  9. isolattice/backends/_linux_helper.py +514 -0
  10. isolattice/backends/_macos_helper.py +281 -0
  11. isolattice/backends/_mounts.py +90 -0
  12. isolattice/backends/_native.py +129 -0
  13. isolattice/backends/_podman_log_filter.py +77 -0
  14. isolattice/backends/_windows_helper.py +504 -0
  15. isolattice/backends/docker.py +1622 -0
  16. isolattice/backends/kubernetes.py +1930 -0
  17. isolattice/backends/linux.py +937 -0
  18. isolattice/backends/macos.py +925 -0
  19. isolattice/backends/podman.py +2347 -0
  20. isolattice/backends/windows.py +986 -0
  21. isolattice/backends/windows_appcontainer.py +1004 -0
  22. isolattice/commands.py +64 -0
  23. isolattice/configuration.py +316 -0
  24. isolattice/contracts.py +120 -0
  25. isolattice/errors.py +56 -0
  26. isolattice/events.py +117 -0
  27. isolattice/models.py +941 -0
  28. isolattice/observability.py +666 -0
  29. isolattice/otel.py +229 -0
  30. isolattice/py.typed +0 -0
  31. isolattice/quotas.py +227 -0
  32. isolattice/remote/__init__.py +32 -0
  33. isolattice/remote/_attach.py +129 -0
  34. isolattice/remote/_journal.py +216 -0
  35. isolattice/remote/_placement.py +138 -0
  36. isolattice/remote/_wire.py +428 -0
  37. isolattice/remote/client.py +846 -0
  38. isolattice/remote/server.py +1148 -0
  39. isolattice/resolution.py +223 -0
  40. isolattice/runtime.py +1646 -0
  41. isolattice/s3.py +565 -0
  42. isolattice/shutdown.py +151 -0
  43. isolattice/signing.py +136 -0
  44. isolattice/storage.py +436 -0
  45. isolattice/supervision.py +439 -0
  46. isolattice/termination.py +224 -0
  47. isolattice/testing.py +351 -0
  48. isolattice-0.1.0.dist-info/METADATA +332 -0
  49. isolattice-0.1.0.dist-info/RECORD +51 -0
  50. isolattice-0.1.0.dist-info/WHEEL +4 -0
  51. isolattice-0.1.0.dist-info/licenses/LICENSE +201 -0
isolattice/__init__.py ADDED
@@ -0,0 +1,205 @@
1
+ """Async sandbox execution with explicit backend selection and verified policies."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .backend import Backend, BackendSession, Boundary
6
+ from .backends import (
7
+ DockerBackend,
8
+ KubernetesBackend,
9
+ LinuxNamespaceBackend,
10
+ MacOSSeatbeltBackend,
11
+ PodmanBackend,
12
+ WindowsAppContainerBackend,
13
+ WindowsJobBackend,
14
+ )
15
+ from .commands import CommandRunner, SubprocessRunner
16
+ from .configuration import (
17
+ BaselinePolicyValidator,
18
+ ConfigurationProvider,
19
+ MappingConfigurationProvider,
20
+ PolicyValidator,
21
+ TomlConfigurationProvider,
22
+ policy_from_mapping,
23
+ )
24
+ from .contracts import CONTRACT_VERSION, ExtensionPoint, extension_points, load_trusted_plugin
25
+ from .errors import (
26
+ SandboxArtifactError,
27
+ SandboxBackendError,
28
+ SandboxCapabilityError,
29
+ SandboxError,
30
+ SandboxExecutionError,
31
+ SandboxQuotaError,
32
+ SandboxStateError,
33
+ SandboxTerminationError,
34
+ SandboxTimeoutError,
35
+ TerminationVerificationError,
36
+ )
37
+ from .events import EventKind, EventSink, LoggingEventSink, SandboxEvent
38
+ from .models import (
39
+ DISABLEABLE_CAPABILITIES,
40
+ ArtifactKind,
41
+ ArtifactManifest,
42
+ ArtifactSignature,
43
+ BackendReport,
44
+ BindMount,
45
+ Capability,
46
+ CapabilityEvidence,
47
+ CapabilityState,
48
+ EgressRule,
49
+ ExecutionRequest,
50
+ ExecutionResult,
51
+ NetworkAccess,
52
+ OutputChunk,
53
+ ResourceBudget,
54
+ ResourceUsage,
55
+ SandboxPolicy,
56
+ TerminationPolicy,
57
+ TerminationReport,
58
+ TransportProtocol,
59
+ inherit_environment,
60
+ validate_artifact_key,
61
+ )
62
+ from .observability import (
63
+ AuditLogError,
64
+ AuditLogEventSink,
65
+ AuditLogSummary,
66
+ FanOutEventSink,
67
+ InstanceDiagnostics,
68
+ RecordingEventSink,
69
+ RedactingEventSink,
70
+ Redactor,
71
+ RegexRedactor,
72
+ SandboxDiagnostics,
73
+ SandboxHealth,
74
+ SecretRedactor,
75
+ verify_audit_log,
76
+ )
77
+ from .quotas import QuotaResourceController, ResourceClaim, ResourceController
78
+ from .resolution import (
79
+ BackendResolver,
80
+ Candidate,
81
+ CapabilityResolver,
82
+ policy_compatibility,
83
+ select_backend,
84
+ )
85
+ from .runtime import ExecutionHandle, Sandbox, SandboxInstance
86
+ from .s3 import S3ArtifactStore, S3Credentials
87
+ from .shutdown import ShutdownSignals, default_shutdown_signals, shutdown_on_signals
88
+ from .storage import ArtifactSigner, ArtifactStore, ArtifactVerifier, LocalArtifactStore
89
+ from .supervision import (
90
+ FailedAttempt,
91
+ RestartPolicy,
92
+ SandboxPool,
93
+ SupervisedResult,
94
+ run_supervised,
95
+ )
96
+ from .termination import BoundaryState, Observation, TerminationDriver, terminate_boundary
97
+
98
+ try:
99
+ __version__ = version("isolattice")
100
+ except PackageNotFoundError: # pragma: no cover - only when run from an uninstalled tree
101
+ __version__ = "0.0.0"
102
+
103
+ __all__ = [
104
+ "CONTRACT_VERSION",
105
+ "DISABLEABLE_CAPABILITIES",
106
+ "ArtifactKind",
107
+ "ArtifactManifest",
108
+ "ArtifactSignature",
109
+ "ArtifactSigner",
110
+ "ArtifactStore",
111
+ "ArtifactVerifier",
112
+ "AuditLogError",
113
+ "AuditLogEventSink",
114
+ "AuditLogSummary",
115
+ "Backend",
116
+ "BackendReport",
117
+ "BackendResolver",
118
+ "BackendSession",
119
+ "BaselinePolicyValidator",
120
+ "BindMount",
121
+ "Boundary",
122
+ "BoundaryState",
123
+ "Candidate",
124
+ "Capability",
125
+ "CapabilityEvidence",
126
+ "CapabilityResolver",
127
+ "CapabilityState",
128
+ "CommandRunner",
129
+ "ConfigurationProvider",
130
+ "DockerBackend",
131
+ "EgressRule",
132
+ "EventKind",
133
+ "EventSink",
134
+ "ExecutionHandle",
135
+ "ExecutionRequest",
136
+ "ExecutionResult",
137
+ "ExtensionPoint",
138
+ "FailedAttempt",
139
+ "FanOutEventSink",
140
+ "InstanceDiagnostics",
141
+ "KubernetesBackend",
142
+ "LinuxNamespaceBackend",
143
+ "LocalArtifactStore",
144
+ "LoggingEventSink",
145
+ "MacOSSeatbeltBackend",
146
+ "MappingConfigurationProvider",
147
+ "NetworkAccess",
148
+ "Observation",
149
+ "OutputChunk",
150
+ "PodmanBackend",
151
+ "PolicyValidator",
152
+ "QuotaResourceController",
153
+ "RecordingEventSink",
154
+ "RedactingEventSink",
155
+ "Redactor",
156
+ "RegexRedactor",
157
+ "ResourceBudget",
158
+ "ResourceClaim",
159
+ "ResourceController",
160
+ "ResourceUsage",
161
+ "RestartPolicy",
162
+ "S3ArtifactStore",
163
+ "S3Credentials",
164
+ "Sandbox",
165
+ "SandboxArtifactError",
166
+ "SandboxBackendError",
167
+ "SandboxCapabilityError",
168
+ "SandboxDiagnostics",
169
+ "SandboxError",
170
+ "SandboxEvent",
171
+ "SandboxExecutionError",
172
+ "SandboxHealth",
173
+ "SandboxInstance",
174
+ "SandboxPolicy",
175
+ "SandboxPool",
176
+ "SandboxQuotaError",
177
+ "SandboxStateError",
178
+ "SandboxTerminationError",
179
+ "SandboxTimeoutError",
180
+ "SecretRedactor",
181
+ "ShutdownSignals",
182
+ "SubprocessRunner",
183
+ "SupervisedResult",
184
+ "TerminationDriver",
185
+ "TerminationPolicy",
186
+ "TerminationReport",
187
+ "TerminationVerificationError",
188
+ "TomlConfigurationProvider",
189
+ "TransportProtocol",
190
+ "WindowsAppContainerBackend",
191
+ "WindowsJobBackend",
192
+ "__version__",
193
+ "default_shutdown_signals",
194
+ "extension_points",
195
+ "inherit_environment",
196
+ "load_trusted_plugin",
197
+ "policy_compatibility",
198
+ "policy_from_mapping",
199
+ "run_supervised",
200
+ "select_backend",
201
+ "shutdown_on_signals",
202
+ "terminate_boundary",
203
+ "validate_artifact_key",
204
+ "verify_audit_log",
205
+ ]
@@ -0,0 +1,219 @@
1
+ """Integrity manifests and safe-archive validation for snapshots and checkpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import datetime
7
+ import hashlib
8
+ import re
9
+ import shutil
10
+ import sys
11
+ import tarfile
12
+ import tempfile
13
+ import time
14
+ from pathlib import Path, PurePosixPath
15
+
16
+ from .errors import SandboxArtifactError
17
+ from .models import ArtifactKind, ArtifactManifest
18
+
19
+ _READ_SIZE = 1024 * 1024
20
+ _DRIVE = re.compile(r"[A-Za-z]:")
21
+ _REMOVE_RETRY_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8)
22
+ WORKSPACE_FORMAT = "tar"
23
+ ROOTFS_FORMAT = "rootfs-tar"
24
+ CHECKPOINT_FORMAT = "backend-checkpoint"
25
+
26
+
27
+ async def stage(parent: Path | None) -> Path:
28
+ """Create a private staging directory for one artifact operation."""
29
+ return Path(await asyncio.to_thread(tempfile.mkdtemp, prefix="isolattice-", dir=parent))
30
+
31
+
32
+ def _ignore_missing(_function: object, _path: str, error: BaseException) -> None:
33
+ # A concurrent or cancelled earlier removal already deleted this entry.
34
+ if not isinstance(error, FileNotFoundError):
35
+ raise error
36
+
37
+
38
+ def _rmtree_if_present(directory: Path) -> None:
39
+ shutil.rmtree(directory, onexc=_ignore_missing)
40
+
41
+
42
+ def _remove_tree(directory: Path) -> None:
43
+ # Retry transient failures a bounded number of times, then surface the failure.
44
+ # On Windows, scanners and indexers briefly hold just-deleted files open, leaving
45
+ # them delete-pending so removing the parent directory fails. On BSDs a concurrent
46
+ # removal of the same tree can report EAGAIN.
47
+ transient: tuple[type[OSError], ...] = (
48
+ (BlockingIOError, PermissionError) if sys.platform == "win32" else (BlockingIOError,)
49
+ )
50
+ for delay in _REMOVE_RETRY_DELAYS:
51
+ try:
52
+ _rmtree_if_present(directory)
53
+ except transient:
54
+ time.sleep(delay)
55
+ else:
56
+ return
57
+ _rmtree_if_present(directory)
58
+
59
+
60
+ async def unstage(directory: Path) -> None:
61
+ """Remove a staging directory; archives may contain workload memory."""
62
+ await asyncio.to_thread(_remove_tree, directory)
63
+
64
+
65
+ def _digest(path: Path) -> tuple[str, int]:
66
+ hasher = hashlib.sha256()
67
+ size = 0
68
+ with path.open("rb") as file:
69
+ while block := file.read(_READ_SIZE):
70
+ hasher.update(block)
71
+ size += len(block)
72
+ return hasher.hexdigest(), size
73
+
74
+
75
+ async def seal(
76
+ kind: ArtifactKind, backend: str, archive_format: str, archive: Path, key: str
77
+ ) -> ArtifactManifest:
78
+ """Hash a completed archive and describe it as the artifact ``key``."""
79
+ digest, size = await asyncio.to_thread(_digest, archive)
80
+ return ArtifactManifest(
81
+ kind=kind,
82
+ backend=backend,
83
+ format=archive_format,
84
+ archive=key,
85
+ sha256=digest,
86
+ size=size,
87
+ created_at=datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
88
+ )
89
+
90
+
91
+ async def verify(
92
+ archive: Path,
93
+ manifest: ArtifactManifest,
94
+ *,
95
+ key: str,
96
+ kind: ArtifactKind,
97
+ backend: str,
98
+ archive_format: str,
99
+ max_bytes: int,
100
+ ) -> None:
101
+ """Check a fetched archive against its manifest without trusting the store."""
102
+ if manifest.archive != key:
103
+ raise SandboxArtifactError("artifact manifest names a different key")
104
+ if manifest.kind is not kind:
105
+ raise SandboxArtifactError(f"artifact is a {manifest.kind.value}, not a {kind.value}")
106
+ if manifest.backend != backend:
107
+ raise SandboxArtifactError(
108
+ f"artifact was produced by backend {manifest.backend!r}, not {backend!r}"
109
+ )
110
+ if manifest.format != archive_format:
111
+ raise SandboxArtifactError(f"artifact format {manifest.format!r} is not {archive_format!r}")
112
+ if manifest.size > max_bytes:
113
+ raise SandboxArtifactError("artifact exceeds the configured restore size limit")
114
+ try:
115
+ digest, size = await asyncio.to_thread(_digest, archive)
116
+ except OSError as error:
117
+ raise SandboxArtifactError("fetched artifact archive is unreadable") from error
118
+ if size != manifest.size or digest != manifest.sha256:
119
+ raise SandboxArtifactError("artifact archive does not match its integrity manifest")
120
+
121
+
122
+ def _validate_workspace_tar(archive: Path, max_members: int, max_bytes: int) -> None:
123
+ members = 0
124
+ total = 0
125
+ try:
126
+ with tarfile.open(archive, mode="r:") as bundle:
127
+ for member in bundle:
128
+ members += 1
129
+ if members > max_members:
130
+ raise SandboxArtifactError("workspace archive has too many members")
131
+ if not (member.isreg() or member.isdir() or member.issym() or member.islnk()):
132
+ raise SandboxArtifactError(
133
+ f"workspace archive member {member.name!r} is not a regular file, "
134
+ "directory or link"
135
+ )
136
+ # data_filter rewrites absolute names; backend extractors may not, so
137
+ # reject them outright instead of relying on the rewrite.
138
+ name = PurePosixPath(member.name)
139
+ if (
140
+ name.is_absolute()
141
+ or ".." in name.parts
142
+ or "\\" in member.name
143
+ or _DRIVE.match(member.name) is not None
144
+ ):
145
+ raise SandboxArtifactError(
146
+ f"workspace archive member {member.name!r} is unsafe: "
147
+ "absolute or traversing name"
148
+ )
149
+ try:
150
+ tarfile.data_filter(member, "/isolattice-workspace")
151
+ except tarfile.FilterError as error:
152
+ raise SandboxArtifactError(
153
+ f"workspace archive member {member.name!r} is unsafe: {error}"
154
+ ) from error
155
+ total += member.size
156
+ if total > max_bytes:
157
+ raise SandboxArtifactError("workspace archive expands beyond its size limit")
158
+ except (tarfile.TarError, OSError) as error:
159
+ raise SandboxArtifactError("workspace archive is not a valid uncompressed tar") from error
160
+
161
+
162
+ async def validate_workspace_tar(archive: Path, *, max_members: int, max_bytes: int) -> None:
163
+ """Reject absolute, traversing, special, link-escaping or oversized members."""
164
+ await asyncio.to_thread(_validate_workspace_tar, archive, max_members, max_bytes)
165
+
166
+
167
+ def _unsafe_name(name: str) -> bool:
168
+ path = PurePosixPath(name)
169
+ return (
170
+ not name
171
+ or path.is_absolute()
172
+ or ".." in path.parts
173
+ or "\\" in name
174
+ or _DRIVE.match(name) is not None
175
+ )
176
+
177
+
178
+ def _validate_rootfs_tar(archive: Path, max_members: int, max_bytes: int) -> None:
179
+ members = 0
180
+ total = 0
181
+ seen: set[str] = set()
182
+ try:
183
+ with tarfile.open(archive, mode="r:") as bundle:
184
+ for member in bundle:
185
+ members += 1
186
+ if members > max_members:
187
+ raise SandboxArtifactError("container snapshot has too many members")
188
+ if not (member.isreg() or member.isdir() or member.issym() or member.islnk()):
189
+ raise SandboxArtifactError(
190
+ f"container snapshot member {member.name!r} is a device or special file"
191
+ )
192
+ if _unsafe_name(member.name):
193
+ raise SandboxArtifactError(
194
+ f"container snapshot member {member.name!r} has an absolute or "
195
+ "traversing name"
196
+ )
197
+ # Symbolic links resolve inside the container root; hard links must
198
+ # name an earlier member so the engine never links outside the archive.
199
+ if member.islnk() and (
200
+ _unsafe_name(member.linkname)
201
+ or PurePosixPath(member.linkname).as_posix() not in seen
202
+ ):
203
+ raise SandboxArtifactError(
204
+ f"container snapshot hard link {member.name!r} names no earlier member"
205
+ )
206
+ seen.add(PurePosixPath(member.name).as_posix())
207
+ total += member.size
208
+ if total > max_bytes:
209
+ raise SandboxArtifactError("container snapshot expands beyond its size limit")
210
+ except (tarfile.TarError, OSError) as error:
211
+ raise SandboxArtifactError("container snapshot is not a valid uncompressed tar") from error
212
+
213
+
214
+ async def validate_rootfs_tar(archive: Path, *, max_members: int, max_bytes: int) -> None:
215
+ """Reject special files, unsafe names, dangling hard links and oversized archives.
216
+
217
+ The archive is handed to the container engine and never extracted on the host.
218
+ """
219
+ await asyncio.to_thread(_validate_rootfs_tar, archive, max_members, max_bytes)
isolattice/_async.py ADDED
@@ -0,0 +1,117 @@
1
+ """Helpers for owned tasks whose cleanup must survive repeated cancellation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from dataclasses import dataclass
8
+ from typing import NoReturn
9
+
10
+ from .errors import SandboxBackendError
11
+
12
+ _EXIT_POLL_INTERVAL = 0.1
13
+ _ATTACH_EXIT_GRACE = 2.0
14
+ _ATTACH_REAP_TIMEOUT = 5.0
15
+
16
+
17
+ async def root_exit(process: asyncio.subprocess.Process) -> int:
18
+ """Return the exit code of ``process`` once it exits, even if its pipes stay open."""
19
+ # Python 3.12 resolves wait() only after every pipe closes, so descendants holding
20
+ # inherited pipes would hide the root's exit; returncode is set at exit everywhere.
21
+ waiter = asyncio.ensure_future(process.wait())
22
+ try:
23
+ while True:
24
+ done, _ = await asyncio.wait((waiter,), timeout=_EXIT_POLL_INTERVAL)
25
+ if done:
26
+ return waiter.result()
27
+ if (code := process.returncode) is not None:
28
+ return code
29
+ finally:
30
+ if not waiter.done():
31
+ waiter.cancel()
32
+
33
+
34
+ async def reap_attach(process: asyncio.subprocess.Process) -> None:
35
+ """Wait briefly for an attach client to exit, then kill and reap it."""
36
+ if process.returncode is not None:
37
+ return
38
+ try:
39
+ async with asyncio.timeout(_ATTACH_EXIT_GRACE):
40
+ await process.wait()
41
+ return
42
+ except TimeoutError:
43
+ with contextlib.suppress(ProcessLookupError):
44
+ process.kill()
45
+ try:
46
+ async with asyncio.timeout(_ATTACH_REAP_TIMEOUT):
47
+ await process.wait()
48
+ except TimeoutError as error:
49
+ raise SandboxBackendError("the owned backend attach client could not be reaped") from error
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class Settled[T]:
54
+ value: T | None
55
+ error: BaseException | None
56
+ interrupted: asyncio.CancelledError | None
57
+
58
+
59
+ async def discard_remaining(reader: asyncio.StreamReader) -> bool:
60
+ discarded = False
61
+ while await reader.read(65536):
62
+ discarded = True
63
+ await asyncio.sleep(0)
64
+ return discarded
65
+
66
+
67
+ async def settle[T](task: asyncio.Task[T]) -> Settled[T]:
68
+ interrupted: asyncio.CancelledError | None = None
69
+ while not task.done():
70
+ try:
71
+ await asyncio.shield(task)
72
+ except asyncio.CancelledError as error:
73
+ if not task.done() and interrupted is None:
74
+ interrupted = error
75
+ except BaseException:
76
+ break
77
+ try:
78
+ return Settled(task.result(), None, interrupted)
79
+ except BaseException as error:
80
+ return Settled(None, error, interrupted)
81
+
82
+
83
+ async def wait_until(task: asyncio.Task[object], deadline: float) -> asyncio.CancelledError | None:
84
+ """Wait for ``task`` until a loop-time deadline without cancelling it.
85
+
86
+ Caller cancellation is absorbed and returned so bounded cleanup still completes.
87
+ """
88
+ interrupted: asyncio.CancelledError | None = None
89
+ loop = asyncio.get_running_loop()
90
+ while not task.done():
91
+ remaining = deadline - loop.time()
92
+ if remaining <= 0:
93
+ break
94
+ try:
95
+ await asyncio.wait((task,), timeout=remaining)
96
+ except asyncio.CancelledError as error:
97
+ if interrupted is None:
98
+ interrupted = error
99
+ return interrupted
100
+
101
+
102
+ def raise_combined(message: str, *errors: BaseException | None) -> None:
103
+ present = [error for error in errors if error is not None]
104
+ if len(present) == 1:
105
+ raise present[0]
106
+ if present:
107
+ raise BaseExceptionGroup(message, present)
108
+
109
+
110
+ def raise_with_cleanup(
111
+ message: str, failure: BaseException, *cleanup: BaseException | None
112
+ ) -> NoReturn:
113
+ """Raise ``failure``, grouped with any cleanup errors that also occurred."""
114
+ present = [error for error in cleanup if error is not None]
115
+ if present:
116
+ raise BaseExceptionGroup(message, [failure, *present])
117
+ raise failure