saferun-cli 0.0.2__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. saferun/__init__.py +3 -0
  2. saferun/__main__.py +5 -0
  3. saferun/cli.py +533 -0
  4. saferun/dockerdiag.py +65 -0
  5. saferun/doctor.py +159 -0
  6. saferun/engine/__init__.py +7 -0
  7. saferun/engine/analyzer.py +30 -0
  8. saferun/engine/context.py +27 -0
  9. saferun/engine/engine.py +100 -0
  10. saferun/engine/model.py +146 -0
  11. saferun/engine/node.py +145 -0
  12. saferun/engine/osv.py +291 -0
  13. saferun/engine/osvdb.py +367 -0
  14. saferun/engine/paths.py +41 -0
  15. saferun/engine/pysyntax.py +135 -0
  16. saferun/engine/rules/python.yml +103 -0
  17. saferun/engine/semgrep.py +173 -0
  18. saferun/engine/tools.py +167 -0
  19. saferun/engine/verdict.py +47 -0
  20. saferun/hostos/__init__.py +188 -0
  21. saferun/hostos/_win.py +316 -0
  22. saferun/hostos/acl.py +172 -0
  23. saferun/hostos/fsops.py +41 -0
  24. saferun/hostos/process.py +82 -0
  25. saferun/hostos/winfs.py +164 -0
  26. saferun/runcmd.py +139 -0
  27. saferun/sandbox/__init__.py +11 -0
  28. saferun/sandbox/analyzer.py +192 -0
  29. saferun/sandbox/attestation.py +110 -0
  30. saferun/sandbox/config.py +121 -0
  31. saferun/sandbox/controls.py +104 -0
  32. saferun/sandbox/copy.py +338 -0
  33. saferun/sandbox/copy_windows.py +281 -0
  34. saferun/sandbox/cosign_tool.py +134 -0
  35. saferun/sandbox/docker.py +167 -0
  36. saferun/sandbox/images.py +110 -0
  37. saferun/sandbox/profiles.py +40 -0
  38. saferun/sandbox/reaper.py +127 -0
  39. saferun/sandbox/runner.py +268 -0
  40. saferun/sandbox/secrets.py +91 -0
  41. saferun/sandbox/signature.py +117 -0
  42. saferun/sandbox/verify.py +75 -0
  43. saferun/sandbox/workspace.py +92 -0
  44. saferun/setup.py +559 -0
  45. saferun/ui.py +133 -0
  46. saferun_cli-0.0.2.dist-info/METADATA +136 -0
  47. saferun_cli-0.0.2.dist-info/RECORD +51 -0
  48. saferun_cli-0.0.2.dist-info/WHEEL +5 -0
  49. saferun_cli-0.0.2.dist-info/entry_points.txt +2 -0
  50. saferun_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
  51. saferun_cli-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,281 @@
1
+ """Windows copy engine: the same algorithm and guarantees-by-detection as copy.py, on Win32 handles (see hostos/winfs.py).
2
+
3
+ Three passes over the tree (pre-scan, verified copy, final validation) compare a signature per entry (type, attributes, 128-bit file id,
4
+ size, LastWrite and Change time, link count) and the name set of every directory; any replaced, added, removed or modified entry abandons
5
+ the whole copy (WorkspaceChangedError). On top of that, Windows lets us hold directories and files open with a share mode that forbids
6
+ rename / delete / write, which prevents what the POSIX engine can only detect (see winfs.py for exactly what is and is not prevented).
7
+
8
+ Not copied, and reported as skipped: symlinks and every other reparse point (junctions, mount points, cloud placeholders) and entries
9
+ with an alternate data stream other than Zone.Identifier. Withheld: files whose access list is narrower than the project root's
10
+ (or cannot be read), and files with a credential signature. Hard links with names outside the tree are skipped.
11
+
12
+ This module has no Windows-only imports: it runs against any `FsOps`, which is how its logic is tested on every platform.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import ntpath
17
+ import os
18
+ from collections import Counter
19
+
20
+ from ..hostos.fsops import EntryReplaced, FsOps, Info
21
+ from .config import (
22
+ SandboxError, SensitivePolicy, SkippedEntry, WithheldEntry, WorkspaceChangedError, WorkspaceLimits,
23
+ )
24
+ from .copy import CHUNK, CopyResult, _Budget
25
+ from .secrets import LEARN_MAX, Redactor, SignatureScanner
26
+
27
+
28
+ def _changed(path: str, what: str) -> WorkspaceChangedError:
29
+ return WorkspaceChangedError(
30
+ f"{path or '.'}: {what} while SafeRun was copying; the copy was abandoned (retry on a quiescent tree)")
31
+
32
+
33
+ class _Sink:
34
+ """Writes the copy under `dest` (a directory we own and created)."""
35
+
36
+ def __init__(self, dest: str, ops: FsOps):
37
+ self.dest, self.ops = dest, ops
38
+
39
+ def _p(self, rel: str) -> str:
40
+ p = os.path.join(self.dest, *rel.split("/"))
41
+ # Extended-length form. Not os.path.abspath(): Win32 normalisation turns a final component such as CON into the device path \\.\CON.
42
+ # `dest` is absolute and `rel` comes from a directory listing, so cleaning up the string is all that is needed.
43
+ return "\\\\?\\" + ntpath.normpath(p) if os.name == "nt" else p
44
+
45
+ def mkdir(self, rel: str) -> None:
46
+ os.mkdir(self._p(rel))
47
+
48
+ def write(self, rel, node, want: Info, budget: _Budget, redactor: Redactor, withhold_on_match: bool):
49
+ dest, scanner, kind = self._p(rel), SignatureScanner(), None
50
+ head: bytearray | None = bytearray()
51
+ written = read = 0
52
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0)
53
+ try:
54
+ fd = os.open(dest, flags, 0o644)
55
+ except FileExistsError:
56
+ raise SandboxError(f"{rel}: another entry with the same name (differing only in case) was already copied") from None
57
+ try:
58
+ while chunk := self.ops.read(node, CHUNK):
59
+ read += len(chunk)
60
+ budget.add_bytes(len(chunk))
61
+ kind = kind or scanner.feed(chunk)
62
+ if kind and withhold_on_match:
63
+ break
64
+ if head is not None:
65
+ head += chunk
66
+ head = head if len(head) <= LEARN_MAX else None
67
+ view = memoryview(chunk)
68
+ while view:
69
+ n = os.write(fd, view)
70
+ written += n
71
+ view = view[n:]
72
+ finally:
73
+ os.close(fd)
74
+ if kind and withhold_on_match:
75
+ os.unlink(dest)
76
+ return kind, 0
77
+ after = self.ops.refresh(node)
78
+ if read != want.size or after.sig != want.sig:
79
+ raise _changed(rel, "file was modified while being read")
80
+ if head is not None:
81
+ redactor.learn(bytes(head))
82
+ return None, written
83
+
84
+
85
+ class _Walker:
86
+ """One traversal. sink=None is the pre-scan (nothing is read or written)."""
87
+
88
+ def __init__(self, ops: FsOps, lim: WorkspaceLimits, policy: SensitivePolicy, sink: _Sink | None,
89
+ seen_links: Counter | None = None, expected: dict | None = None, expected_listings: dict | None = None):
90
+ self.ops, self.budget, self.policy, self.sink = ops, _Budget(lim), policy, sink
91
+ self.skipped: list[SkippedEntry] = []
92
+ self.withheld: list[WithheldEntry] = []
93
+ self.redactor = Redactor()
94
+ self.copied_files = self.copied_dirs = self.copied_bytes = 0
95
+ self.links: Counter = Counter()
96
+ self.seen_links = seen_links or Counter()
97
+ self.records: dict[str, tuple] = {}
98
+ self.listings: dict[str, frozenset] = {}
99
+ self.expected, self.expected_listings = expected, expected_listings
100
+
101
+ def _private(self, path: str, info: Info) -> bool:
102
+ if self.policy.honor_private_modes and path not in self.policy.allow_paths and info.private:
103
+ self.withheld.append(WithheldEntry(path, "private-mode"))
104
+ return True
105
+ return False
106
+
107
+ def _verify(self, path: str, want: tuple) -> None:
108
+ if self.expected is None:
109
+ self.records[path] = want
110
+ elif self.expected.get(path) != want:
111
+ raise _changed(path, "entry was replaced, added or modified")
112
+
113
+ def _list(self, d, rel: str) -> list[str]:
114
+ exp = None
115
+ if self.expected_listings is not None:
116
+ exp = self.expected_listings.get(rel)
117
+ if exp is None:
118
+ raise _changed(rel, "directory was not part of the pre-scan")
119
+ names = self.ops.listdir(d)
120
+ for name in names:
121
+ self.budget.entry()
122
+ if exp is not None and name not in exp:
123
+ raise _changed(f"{rel}/{name}" if rel else name, "entry was added")
124
+ if exp is not None:
125
+ if len(names) != len(exp):
126
+ raise _changed(rel, "an entry was removed")
127
+ else:
128
+ self.listings[rel] = frozenset(names)
129
+ return sorted(names)
130
+
131
+ def walk(self, d, rel: str, depth: int) -> None:
132
+ for name in self._list(d, rel):
133
+ path = f"{rel}/{name}" if rel else name
134
+ try:
135
+ info = self.ops.probe(d, name, True)
136
+ except FileNotFoundError:
137
+ raise _changed(path, "entry was removed") from None
138
+ except OSError:
139
+ self.skipped.append(SkippedEntry(path, "unreadable"))
140
+ continue
141
+ self._verify(path, info.sig)
142
+ if info.kind == "symlink":
143
+ self.skipped.append(SkippedEntry(path, "symlink"))
144
+ elif info.kind == "reparse":
145
+ self.skipped.append(SkippedEntry(path, "reparse-point"))
146
+ elif info.kind == "dir":
147
+ self._dir(d, name, path, info, depth)
148
+ elif info.kind == "file":
149
+ self._file(d, name, path, info)
150
+ else:
151
+ self.skipped.append(SkippedEntry(path, "not-regular-file"))
152
+
153
+ def _streams_skip(self, d, name: str, path: str) -> bool:
154
+ try:
155
+ extra = self.ops.streams(d, name)
156
+ except OSError:
157
+ self.skipped.append(SkippedEntry(path, "unreadable"))
158
+ return True
159
+ if extra:
160
+ self.skipped.append(SkippedEntry(path, "alternate-data-stream"))
161
+ return True
162
+ return False
163
+
164
+ def _open(self, opener, d, name: str, path: str, want: Info):
165
+ try:
166
+ node = opener(d, name)
167
+ except EntryReplaced:
168
+ raise _changed(path, "entry was replaced or moved") from None
169
+ except (FileNotFoundError, NotADirectoryError):
170
+ raise _changed(path, "entry was replaced or removed") from None
171
+ except OSError:
172
+ self.skipped.append(SkippedEntry(path, "unreadable"))
173
+ return None
174
+ if node.info.sig != want.sig:
175
+ self.ops.close(node)
176
+ raise _changed(path, "entry was replaced or modified")
177
+ return node
178
+
179
+ def _dir(self, d, name, path, info: Info, depth):
180
+ self.budget.depth(depth + 1)
181
+ if self._private(path, info) or self._streams_skip(d, name, path): # same in both passes: such a directory is never entered
182
+ return
183
+ node = self._open(self.ops.open_dir, d, name, path, info)
184
+ if node is None:
185
+ return
186
+ try:
187
+ if self.sink:
188
+ self.sink.mkdir(path)
189
+ self.copied_dirs += 1
190
+ self.walk(node, path, depth + 1)
191
+ finally:
192
+ self.ops.close(node)
193
+
194
+ def _file(self, d, name, path, info: Info):
195
+ if self.sink is None: # pre-scan
196
+ if info.nlink > 1:
197
+ self.links[info.ident] += 1
198
+ if not (self.policy.honor_private_modes and path not in self.policy.allow_paths and info.private):
199
+ self.budget.add_bytes(info.size)
200
+ return
201
+ if self._private(path, info): # never opened, never read
202
+ return
203
+ if self._streams_skip(d, name, path):
204
+ return
205
+ node = self._open(self.ops.open_file, d, name, path, info)
206
+ if node is None:
207
+ return
208
+ try:
209
+ if node.info.nlink > 1 and self.seen_links[node.info.ident] != node.info.nlink:
210
+ self.skipped.append(SkippedEntry(path, "hardlink-external"))
211
+ return
212
+ kind, kept = self.sink.write(path, node, info, self.budget, self.redactor,
213
+ withhold_on_match=path not in self.policy.allow_paths)
214
+ if kind:
215
+ self.withheld.append(WithheldEntry(path, kind))
216
+ else:
217
+ self.copied_files += 1
218
+ self.copied_bytes += kept
219
+ finally:
220
+ self.ops.close(node)
221
+
222
+
223
+ def _validate_tree(ops: FsOps, d, rel: str, records: dict, listings: dict, budget: _Budget) -> None:
224
+ """Final pass: everything below `d` must still be exactly what the pre-scan recorded."""
225
+ expected = listings[rel]
226
+ names = ops.listdir(d)
227
+ for name in names:
228
+ budget.entry()
229
+ if name not in expected:
230
+ raise _changed(f"{rel}/{name}" if rel else name, "entry was added (final check)")
231
+ if len(names) != len(expected):
232
+ raise _changed(rel, "an entry was removed (final check)")
233
+ for name in sorted(names):
234
+ path = f"{rel}/{name}" if rel else name
235
+ try:
236
+ info = ops.probe(d, name, False)
237
+ except FileNotFoundError:
238
+ raise _changed(path, "entry was removed (final check)") from None
239
+ except OSError:
240
+ if path in records:
241
+ raise _changed(path, "entry became unreadable (final check)") from None
242
+ continue
243
+ if records.get(path) != info.sig:
244
+ raise _changed(path, "entry was replaced or modified (final check)")
245
+ if info.kind == "dir" and path in listings:
246
+ try:
247
+ node = ops.open_dir(d, name)
248
+ except OSError:
249
+ raise _changed(path, "directory was replaced or removed (final check)") from None
250
+ try:
251
+ if node.info.sig != records[path]:
252
+ raise _changed(path, "directory was replaced or modified (final check)")
253
+ _validate_tree(ops, node, path, records, listings, budget)
254
+ finally:
255
+ ops.close(node)
256
+
257
+
258
+ def copy_tree(src: str, dest: str, lim: WorkspaceLimits, policy: SensitivePolicy | None = None, ops: FsOps | None = None) -> CopyResult:
259
+ """Same contract as copy.copy_tree."""
260
+ policy = policy or SensitivePolicy()
261
+ if ops is None:
262
+ from ..hostos.winfs import WinOps
263
+ ops = WinOps()
264
+ try:
265
+ root = ops.open_root(src)
266
+ except EntryReplaced as e:
267
+ raise _changed("", str(e)) from None
268
+ try:
269
+ pre = _Walker(ops, lim, policy, None)
270
+ pre.records[""] = root.info.sig
271
+ pre.walk(root, "", 0)
272
+ w = _Walker(ops, lim, policy, _Sink(dest, ops), pre.links, pre.records, pre.listings)
273
+ if ops.refresh(root).sig != pre.records[""]:
274
+ raise _changed("", "the project root was modified")
275
+ w.walk(root, "", 0)
276
+ if ops.refresh(root).sig != pre.records[""]:
277
+ raise _changed("", "the project root was modified (final check)")
278
+ _validate_tree(ops, root, "", pre.records, pre.listings, _Budget(lim))
279
+ return CopyResult(w.skipped, w.withheld, w.budget.files, w.copied_files, w.copied_dirs, w.copied_bytes, w.redactor)
280
+ finally:
281
+ ops.close(root)
@@ -0,0 +1,134 @@
1
+ """A pinned, hash-verified copy of cosign that SafeRun fetches itself, so users do not have to install it.
2
+
3
+ Trust model: the expected SHA-256 of every supported cosign binary is written in THIS file (taken from the upstream release's
4
+ `cosign_checksums.txt`, v2.4.3, the version the release workflow uses). A download is written to a temporary file in the private
5
+ tool directory, hashed, and only a file whose SHA-256 equals the pin is made executable and moved into place. The file is hashed
6
+ again every time it is looked up, so a binary that was modified later is ignored (and re-fetched by `saferun init`). Nothing else is
7
+ trusted: not the redirect target, not the file name, not the HTTP headers. HTTPS only, size-capped.
8
+ What this does NOT give you: it trusts the hashes in this reviewed source file, and the GitHub release that they were copied from.
9
+ The cosign binary then verifies the analyzer image's signature exactly as before (signature.py); the identity check is unchanged.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import os
15
+ import platform
16
+ import sys
17
+ import tempfile
18
+ import urllib.request
19
+ from pathlib import Path
20
+
21
+ from .. import hostos
22
+ from .config import SandboxError
23
+
24
+ COSIGN_VERSION = "2.4.3"
25
+ BASE_URL = f"https://github.com/sigstore/cosign/releases/download/v{COSIGN_VERSION}"
26
+ MAX_BYTES = 200 * 1024 * 1024
27
+ # (system, machine) -> (release asset, sha256). Source: cosign_checksums.txt of release v2.4.3.
28
+ ASSETS = {
29
+ ("linux", "x86_64"): ("cosign-linux-amd64", "caaad125acef1cb81d58dcdc454a1e429d09a750d1e9e2b3ed1aed8964454708"),
30
+ ("linux", "aarch64"): ("cosign-linux-arm64", "bd0f9763bca54de88699c3656ade2f39c9a1c7a2916ff35601caf23a79be0629"),
31
+ ("darwin", "x86_64"): ("cosign-darwin-amd64", "98a3bfd691f42c6a5b721880116f89210d8fdff61cc0224cd3ef2f8e55a466fb"),
32
+ ("darwin", "arm64"): ("cosign-darwin-arm64", "edfc761b27ced77f0f9ca288ff4fac7caa898e1e9db38f4dfdf72160cdf8e638"),
33
+ ("windows", "x86_64"): ("cosign-windows-amd64.exe", "a2ac24e197111c9430cb2a98f10a641164381afb83df036504868e4ea5720800"),
34
+ }
35
+ _MACHINE_ALIASES = {"amd64": "x86_64", "x64": "x86_64", "arm64": "aarch64"}
36
+
37
+
38
+ class CosignFetchError(SandboxError):
39
+ """The pinned cosign could not be obtained or did not match its pinned hash. Nothing was installed."""
40
+
41
+
42
+ def platform_key(system: str | None = None, machine: str | None = None) -> tuple[str, str]:
43
+ s = (system or platform.system()).lower()
44
+ m = (machine or platform.machine()).lower()
45
+ if s == "darwin":
46
+ m = {"amd64": "x86_64", "x64": "x86_64"}.get(m, m) # Apple silicon reports arm64
47
+ else:
48
+ m = _MACHINE_ALIASES.get(m, m)
49
+ return s, m
50
+
51
+
52
+ def asset_for(system: str | None = None, machine: str | None = None) -> tuple[str, str]:
53
+ key = platform_key(system, machine)
54
+ if key not in ASSETS:
55
+ raise CosignFetchError(f"no pinned cosign build for {key[0]}/{key[1]}; install cosign {COSIGN_VERSION}+ yourself "
56
+ "(https://docs.sigstore.dev/cosign/system_config/installation/) and put it on PATH")
57
+ return ASSETS[key]
58
+
59
+
60
+ def tool_dir() -> Path:
61
+ return hostos.user_data_base() / "saferun" / "bin"
62
+
63
+
64
+ def managed_path(system: str | None = None) -> Path:
65
+ exe = ".exe" if (system or sys.platform).lower().startswith(("win", "windows")) else ""
66
+ return tool_dir() / f"cosign-{COSIGN_VERSION}{exe}"
67
+
68
+
69
+ def _sha256(path: Path) -> str:
70
+ h = hashlib.sha256()
71
+ with open(path, "rb") as f:
72
+ while chunk := f.read(1 << 20):
73
+ h.update(chunk)
74
+ return h.hexdigest()
75
+
76
+
77
+ def managed_cosign() -> str | None:
78
+ """Absolute path of the managed cosign if it exists, is a regular file (no symlink) and still has its pinned hash; else None. Never raises."""
79
+ try:
80
+ want = asset_for()[1]
81
+ p = managed_path()
82
+ if p.is_symlink() or not p.is_file() or _sha256(p) != want:
83
+ return None
84
+ return str(p.resolve())
85
+ except (OSError, SandboxError):
86
+ return None
87
+
88
+
89
+ def ensure_cosign(*, opener=None, progress=None) -> str:
90
+ """Return the managed cosign, downloading and verifying it first if needed. Raises CosignFetchError (nothing is left behind)."""
91
+ have = managed_cosign()
92
+ if have:
93
+ return have
94
+ name, want = asset_for()
95
+ url = f"{BASE_URL}/{name}"
96
+ if not url.startswith("https://"):
97
+ raise CosignFetchError("cosign must be downloaded over https")
98
+ opener = opener or urllib.request.urlopen
99
+ d = tool_dir()
100
+ if d.is_symlink():
101
+ raise CosignFetchError(f"refusing to use a symlink as the tool directory: {d}")
102
+ try:
103
+ hostos.ensure_private_dir(d.parent) # <data>/saferun also holds the analyzer lock, which requires it to be owner-only
104
+ hostos.ensure_private_dir(d)
105
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".cosign-")
106
+ except OSError as e:
107
+ raise CosignFetchError(f"cannot prepare {d}: {e}") from None
108
+ try:
109
+ h, size = hashlib.sha256(), 0
110
+ with os.fdopen(fd, "wb") as out, opener(url, timeout=120) as resp: # noqa: S310 - https enforced above
111
+ while chunk := resp.read(1 << 20):
112
+ size += len(chunk)
113
+ if size > MAX_BYTES:
114
+ raise CosignFetchError(f"download exceeds {MAX_BYTES} bytes; refused")
115
+ h.update(chunk)
116
+ out.write(chunk)
117
+ if progress:
118
+ progress("cosign", size, None)
119
+ if h.hexdigest() != want:
120
+ raise CosignFetchError("the downloaded cosign does not match its pinned SHA-256; it was discarded and NOT installed")
121
+ if not hostos.IS_WINDOWS:
122
+ os.chmod(tmp, 0o700)
123
+ os.replace(tmp, managed_path())
124
+ except CosignFetchError:
125
+ raise
126
+ except OSError as e: # URLError, timeouts and disk errors are all OSError subclasses
127
+ raise CosignFetchError(f"could not download cosign from {url}: {e}") from None
128
+ finally:
129
+ if os.path.exists(tmp):
130
+ os.unlink(tmp)
131
+ got = managed_cosign()
132
+ if not got:
133
+ raise CosignFetchError("cosign was installed but could not be verified afterwards")
134
+ return got
@@ -0,0 +1,167 @@
1
+ """Thin, defensive wrappers over the docker CLI. Never uses a shell; args are lists.
2
+
3
+ Everything Docker returns is treated as untrusted input: unexpected output becomes a
4
+ SandboxError, never a raw exception and never a silent "success".
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+
14
+ from .. import hostos
15
+ from .config import CONTAINER_LABEL, ControlStatus, SandboxError
16
+ from .controls import REQUIRED_DAEMON, daemon_controls
17
+ from .images import digest_of, repo_digests
18
+
19
+ DEFAULT_TIMEOUT = 30 # seconds per docker CLI call (module attribute so tests can shorten it)
20
+ # Plain image reference only: must not start with "-" (would be parsed as a docker option).
21
+ _IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:@+-]{0,254}$")
22
+ _IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
23
+
24
+
25
+ def docker_bin() -> str:
26
+ """Absolute path of the docker executable (PATH is trusted; see PROJECT.md)."""
27
+ path = shutil.which("docker")
28
+ if not path:
29
+ raise SandboxError("docker executable not found on PATH")
30
+ return os.path.abspath(path)
31
+
32
+
33
+ def docker(*args: str, timeout: float | None = None) -> subprocess.CompletedProcess:
34
+ try:
35
+ return subprocess.run(
36
+ [docker_bin(), *args], capture_output=True, text=True, errors="replace",
37
+ timeout=DEFAULT_TIMEOUT if timeout is None else timeout, stdin=subprocess.DEVNULL,
38
+ )
39
+ except (OSError, subprocess.TimeoutExpired) as e:
40
+ raise SandboxError(f"docker {args[0] if args else ''} failed: {e}") from e
41
+
42
+
43
+ def validate_image(image: str) -> str:
44
+ if not isinstance(image, str) or not _IMAGE_RE.match(image):
45
+ raise SandboxError(f"invalid image reference: {image!r}")
46
+ return image
47
+
48
+
49
+ def check_local_daemon() -> None:
50
+ """Refuse a remote daemon: the workspace is bind-mounted by host path, so a remote
51
+ DOCKER_HOST / context would resolve that path on another machine."""
52
+ host = os.environ.get("DOCKER_HOST")
53
+ if host is None:
54
+ r = docker("context", "inspect", "--format", "{{.Endpoints.docker.Host}}")
55
+ if r.returncode != 0:
56
+ raise SandboxError(f"cannot determine the docker endpoint: {r.stderr.strip()}")
57
+ host = r.stdout.strip()
58
+ if not hostos.docker_endpoint_is_local(host):
59
+ only = "local named pipes (npipe:////./pipe/...)" if hostos.IS_WINDOWS else "unix:// sockets"
60
+ raise SandboxError(f"refusing non-local docker endpoint {host!r} (only {only} are supported)")
61
+
62
+
63
+ def daemon_info() -> dict:
64
+ r = docker("info", "--format", "{{json .}}")
65
+ if r.returncode != 0:
66
+ raise SandboxError(f"docker daemon unavailable: {r.stderr.strip()}")
67
+ try:
68
+ info = json.loads(r.stdout)
69
+ except ValueError as e:
70
+ raise SandboxError(f"unexpected `docker info` output: {e}") from e
71
+ if not isinstance(info, dict):
72
+ raise SandboxError("unexpected `docker info` output: not an object")
73
+ return info
74
+
75
+
76
+ def resolve_image(image: str) -> str:
77
+ """Image ID (sha256:...) of a *local* image. SafeRun never pulls implicitly."""
78
+ r = docker("image", "inspect", "--format", "{{.Id}}", validate_image(image))
79
+ if r.returncode != 0:
80
+ raise SandboxError(f"image {image!r} not present locally (SafeRun never pulls implicitly)")
81
+ image_id = r.stdout.strip()
82
+ if not _IMAGE_ID_RE.match(image_id):
83
+ raise SandboxError(f"unexpected image id from docker: {image_id!r}")
84
+ pinned = digest_of(image)
85
+ if pinned is not None:
86
+ verify_pinned_digest(image, pinned)
87
+ return image_id
88
+
89
+
90
+ def verify_pinned_digest(image: str, digest: str) -> None:
91
+ """Fail closed unless the local image behind `image` really carries the pinned `digest`.
92
+
93
+ Docker resolves `repo:tag@sha256:...` by digest, so a retagged image should not match at all; this second,
94
+ independent check on what Docker reports (RepoDigests) guards against a daemon that resolves differently.
95
+ """
96
+ r = docker("image", "inspect", "--format", "{{json .RepoDigests}}", validate_image(image))
97
+ if r.returncode != 0:
98
+ raise SandboxError(f"image {image!r} not present locally (SafeRun never pulls implicitly)")
99
+ try:
100
+ have = repo_digests(r.stdout.strip())
101
+ except ValueError as e:
102
+ raise SandboxError(f"cannot verify the digest of image {image!r}: {e}") from e
103
+ if digest not in have:
104
+ raise SandboxError(f"image digest mismatch for {image!r}: pinned {digest}, local image has {have or 'no digest'}")
105
+
106
+
107
+ def preflight(image: str, allow_degraded: frozenset[str] = frozenset()) -> tuple[str, list[ControlStatus]]:
108
+ """Fail closed before anything is created. Returns (image_id, daemon control statuses).
109
+
110
+ A required daemon control that is missing raises, unless the caller explicitly lists it in
111
+ `allow_degraded`; allowed degradations stay visible in the returned statuses.
112
+ """
113
+ validate_image(image)
114
+ check_local_daemon()
115
+ info = daemon_info()
116
+ # The sandbox images are Linux images: a daemon in Windows-containers mode (or any other OS type) cannot run them as specified.
117
+ if info.get("OSType") != "linux":
118
+ raise SandboxError(f"the docker daemon runs {info.get('OSType')!r} containers; SafeRun needs Linux containers"
119
+ + (" (Docker Desktop: right-click the tray icon and choose 'Switch to Linux containers')" if hostos.IS_WINDOWS else ""))
120
+ statuses = daemon_controls(info)
121
+ for s in statuses:
122
+ if s.name in REQUIRED_DAEMON and s.state != "verified" and s.name not in allow_degraded:
123
+ raise SandboxError(f"required control {s.name} is {s.state}: {s.detail}")
124
+ return resolve_image(image), statuses
125
+
126
+
127
+ def check_available(image: str) -> None:
128
+ """Raise SandboxError unless Docker is usable for SafeRun with `image` (used by the test setup)."""
129
+ preflight(image)
130
+
131
+
132
+ def container_exists(name: str) -> bool:
133
+ """True/False for a definite answer; SandboxError if Docker could not tell us."""
134
+ r = docker("inspect", "--type", "container", name)
135
+ if r.returncode == 0:
136
+ return True
137
+ if "no such" in r.stderr.lower():
138
+ return False
139
+ raise SandboxError(f"cannot determine whether container {name} exists: {r.stderr.strip()}")
140
+
141
+
142
+ def inspect(name: str) -> dict:
143
+ """`docker inspect` of a container; {} if it does not exist; SandboxError on odd output."""
144
+ r = docker("inspect", "--type", "container", name)
145
+ if r.returncode != 0:
146
+ if "no such" in r.stderr.lower():
147
+ return {}
148
+ raise SandboxError(f"docker inspect {name} failed: {r.stderr.strip()}")
149
+ try:
150
+ data = json.loads(r.stdout)
151
+ info = data[0]
152
+ except (ValueError, IndexError, KeyError, TypeError) as e:
153
+ raise SandboxError(f"unexpected `docker inspect` output for {name}: {e}") from e
154
+ if not isinstance(info, dict):
155
+ raise SandboxError(f"unexpected `docker inspect` output for {name}: not an object")
156
+ return info
157
+
158
+
159
+ def force_remove(name: str) -> bool:
160
+ """Kill and remove a container; True only if Docker confirms it is gone."""
161
+ docker("rm", "-f", "-v", name, timeout=60)
162
+ return not container_exists(name)
163
+
164
+
165
+ def leftover_containers() -> list[str]:
166
+ r = docker("ps", "-a", "-q", "--filter", f"label={CONTAINER_LABEL}")
167
+ return r.stdout.split()