codeterrain 0.3.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.
codeterrain/sandbox.py ADDED
@@ -0,0 +1,565 @@
1
+ """The only place codeterrain starts a subprocess that imports or runs the target's code.
2
+
3
+ Importing an application can call AWS (or any network service) at import time, so every such
4
+ subprocess runs with:
5
+
6
+ - an environment built from an allowlist (PATH, locale and temp variables, plus the Windows
7
+ basics), never the parent's whole environment; the config's ``import_env`` and the opt-in
8
+ ``pass_env`` are layered on top;
9
+ - the home and per-user data directories (``HOME``, ``USERPROFILE``, ``APPDATA``,
10
+ ``LOCALAPPDATA``, ``XDG_*``) pointed at an empty directory in the run dir, so SDKs that look for
11
+ credential files there find none; only user-level ``pass_env`` restores them. Code that reads
12
+ the real home by absolute path, or asks an OS credential store, is not stopped;
13
+ - git told to read no system or global config and to use no credential helper, so a ``git`` the
14
+ target starts is not authenticated with the user's token (Git Credential Manager is set in the
15
+ system config by Git for Windows);
16
+ - dummy AWS keys, and the shared credentials/config files pointed at a path that does not exist;
17
+ neither config key can bring an ``AWS_*`` credential back;
18
+ - every ``*_proxy`` variable dropped and ``NO_PROXY=*``; ``PYTEST_ADDOPTS`` and ``PYTEST_PLUGINS``
19
+ dropped;
20
+ - a best-effort network guard (``codeterrain._guard``): sockets reach loopback only, any other
21
+ connect, send or name lookup raises. The extractor is started through a bootstrap that installs
22
+ the guard, asserts it is active (exit 3 otherwise) and only then runs the extractor module;
23
+ - results written to a JSON file named by ``CODETERRAIN_RESULT``, never parsed from stdout.
24
+
25
+ The package is copied into a fresh per-run directory under the system temp dir (never the cache
26
+ dir, so the child is not told where the cache is) that goes first on ``PYTHONPATH``; the host's
27
+ own site-packages is never exposed to the target interpreter. The child's stdout and stderr go to
28
+ files in that directory and every read back is capped. On timeout the whole process tree is
29
+ killed; after a normal exit the processes the child left behind are killed too (a job object on
30
+ Windows, the process group on POSIX).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import shutil
38
+ import signal
39
+ import subprocess
40
+ import sys
41
+ import tempfile
42
+ import time
43
+ from collections.abc import Callable
44
+ from dataclasses import dataclass
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ from codeterrain.config import Config
49
+ from codeterrain.executables import resolve_executable
50
+
51
+ DUMMY_CREDENTIAL = "codeterrain-dummy"
52
+ RESULT_ENV = "CODETERRAIN_RESULT"
53
+ GUARD_MARKER = "codeterrain sandbox"
54
+ DEFAULT_TIMEOUT = 1800
55
+ #: The largest result document read back from a child.
56
+ MAX_RESULT_BYTES = 256 * 1024 * 1024
57
+ #: Seconds to wait for a killed process tree to go.
58
+ KILL_GRACE = 10
59
+
60
+ GUARD_FAILED = "codeterrain sandbox: network guard not active"
61
+ GUARD_EXIT = 3
62
+ RUN_DIR_ENV = "CODETERRAIN_RUN_DIR"
63
+ RESPECT_ADDOPTS_ENV = "CODETERRAIN_RESPECT_ADDOPTS"
64
+
65
+ SITECUSTOMIZE = """\
66
+ try:
67
+ import codeterrain._guard as _guard
68
+
69
+ _guard.install()
70
+ except Exception:
71
+ pass
72
+ """
73
+
74
+ BOOTSTRAP = r"""\
75
+ import os, sys
76
+
77
+
78
+ def _fail(reason):
79
+ sys.stderr.write("codeterrain sandbox: network guard not active (%s)\n" % reason)
80
+ sys.exit(3)
81
+
82
+
83
+ try:
84
+ run_dir = os.environ["CODETERRAIN_RUN_DIR"]
85
+ sys.path.insert(0, run_dir)
86
+ import codeterrain._guard as guard
87
+
88
+ here = os.path.realpath(os.path.dirname(guard.__file__))
89
+ root = os.path.realpath(run_dir)
90
+ try:
91
+ inside = os.path.commonpath([here, root]) == root
92
+ except ValueError:
93
+ inside = False
94
+ if not inside:
95
+ _fail("guard module loaded from %s" % here)
96
+ guard.install()
97
+ guard.use_selector_event_loop()
98
+ import socket
99
+
100
+ if socket.socket.connect is not guard.connect:
101
+ _fail("socket.socket.connect was not replaced")
102
+ if not guard.proactor_guarded():
103
+ _fail("asyncio proactor was not wrapped")
104
+ except SystemExit:
105
+ raise
106
+ except BaseException as exc:
107
+ _fail("%s: %s" % (type(exc).__name__, exc))
108
+
109
+ import runpy
110
+
111
+ mode, target, rest = sys.argv[1], sys.argv[2], sys.argv[3:]
112
+ if mode == "-m":
113
+ sys.argv = [target] + rest
114
+ runpy.run_module(target, run_name="__main__", alter_sys=True)
115
+ else:
116
+ sys.argv = ["-c"] + rest
117
+ exec(compile(target, "<string>", "exec"), {"__name__": "__main__"})
118
+ """
119
+
120
+ # Always allowed through from the parent environment.
121
+ ENV_ALLOWLIST = frozenset({"PATH", "LANG", "LC_ALL", "TMPDIR", "TEMP", "TMP"})
122
+ WINDOWS_ENV_ALLOWLIST = frozenset(
123
+ {
124
+ "SYSTEMROOT",
125
+ "SYSTEMDRIVE",
126
+ "WINDIR",
127
+ "COMSPEC",
128
+ "PATHEXT",
129
+ "NUMBER_OF_PROCESSORS",
130
+ "PROCESSOR_ARCHITECTURE",
131
+ "USERNAME",
132
+ "PROGRAMDATA",
133
+ "PROGRAMFILES",
134
+ "PROGRAMFILES(X86)",
135
+ }
136
+ )
137
+ # The home and per-user data directories, pointed at an empty directory in the run dir so the
138
+ # child sees no credential store (gh, gcloud, az, git, netrc, keyring files); user-level
139
+ # ``pass_env`` can hand any of them through on purpose.
140
+ HOME_DIRS = {
141
+ "HOME": "home",
142
+ "USERPROFILE": "home",
143
+ "APPDATA": "home/AppData/Roaming",
144
+ "LOCALAPPDATA": "home/AppData/Local",
145
+ "XDG_CONFIG_HOME": "home/.config",
146
+ "XDG_CACHE_HOME": "home/.cache",
147
+ "XDG_DATA_HOME": "home/.local/share",
148
+ }
149
+ # git reads no system or global config and has no credential helper, so a git the target starts
150
+ # cannot use the user's stored token. An empty ``credential.helper`` resets the helper list, and
151
+ # the command-line scope (``GIT_CONFIG_COUNT``) is read last. User-level ``pass_env`` can override.
152
+ GIT_SANDBOX_ENV = {
153
+ "GIT_CONFIG_NOSYSTEM": "1",
154
+ "GIT_TERMINAL_PROMPT": "0",
155
+ "GCM_INTERACTIVE": "never",
156
+ "GIT_CONFIG_COUNT": "1",
157
+ "GIT_CONFIG_KEY_0": "credential.helper",
158
+ "GIT_CONFIG_VALUE_0": "",
159
+ }
160
+ # Never reach the target, whichever config key asks for them.
161
+ ENV_ALWAYS_DROPPED = frozenset({"PYTEST_ADDOPTS", "PYTEST_PLUGINS"})
162
+ WINDOWS = os.name == "nt"
163
+
164
+
165
+ class SandboxError(RuntimeError):
166
+ """The sandboxed subprocess could not be run to completion."""
167
+
168
+
169
+ @dataclass
170
+ class SandboxResult:
171
+ returncode: int
172
+ stdout: str
173
+ stderr: str
174
+ result: Any | None
175
+
176
+
177
+ def credential_env(run_dir: Path, region: str) -> dict[str, str]:
178
+ """The AWS environment every sandboxed process gets: dummy keys, no files, no metadata."""
179
+ return {
180
+ "AWS_ACCESS_KEY_ID": DUMMY_CREDENTIAL,
181
+ "AWS_SECRET_ACCESS_KEY": DUMMY_CREDENTIAL,
182
+ "AWS_SHARED_CREDENTIALS_FILE": str(run_dir / "no-such-credentials"),
183
+ "AWS_CONFIG_FILE": str(run_dir / "no-such-config"),
184
+ "AWS_EC2_METADATA_DISABLED": "true",
185
+ "AWS_DEFAULT_REGION": region,
186
+ }
187
+
188
+
189
+ #: Shorter values are not redacted: they hold no meaningful secret, and replacing a value such as
190
+ #: ``test`` or ``1`` would corrupt every path and traceback that contains it.
191
+ MIN_REDACTED_LENGTH = 6
192
+ REDACTED = "[REDACTED]"
193
+ #: A run of this many characters or more from the start or the end of a longer value is redacted
194
+ #: too: libraries print a long value shortened (pydantic keeps both ends around ``...``).
195
+ MIN_FRAGMENT_LENGTH = 12
196
+
197
+
198
+ def _end_fragments(text: str, secret: str) -> list[tuple[int, int]]:
199
+ """Spans of ``text`` that are a prefix or a suffix of ``secret`` of at least
200
+ ``MIN_FRAGMENT_LENGTH`` characters, each grown as far as it keeps matching."""
201
+ n = MIN_FRAGMENT_LENGTH
202
+ spans = []
203
+ start = text.find(secret[:n])
204
+ while start != -1:
205
+ end = start + n
206
+ while end - start < len(secret) and end < len(text) and text[end] == secret[end - start]:
207
+ end += 1
208
+ spans.append((start, end))
209
+ start = text.find(secret[:n], end)
210
+ stop = text.find(secret[-n:])
211
+ while stop != -1:
212
+ begin, k = stop, len(secret) - n
213
+ while k > 0 and begin > 0 and text[begin - 1] == secret[k - 1]:
214
+ begin, k = begin - 1, k - 1
215
+ spans.append((begin, stop + n))
216
+ stop = text.find(secret[-n:], stop + 1)
217
+ return spans
218
+
219
+
220
+ def _replace_spans(text: str, spans: list[tuple[int, int]]) -> str:
221
+ out, last = [], 0
222
+ for begin, end in sorted(spans):
223
+ if end <= last:
224
+ continue
225
+ out.append(text[last:max(begin, last)])
226
+ out.append(REDACTED if begin >= last else "")
227
+ last = end
228
+ return "".join(out) + text[last:]
229
+
230
+
231
+ def redactor(config: Config, source: dict[str, str] | None = None) -> Callable[[str], str]:
232
+ """A function that replaces every ``import_env`` value, every ``pass_env`` value present in
233
+ the parent environment and the dummy credential with ``[REDACTED]``, and a long value's
234
+ leading and trailing fragments too (``MIN_FRAGMENT_LENGTH``). Child text (errors, tracebacks,
235
+ output tails, node ids, route fields) goes through it before it is cached or printed."""
236
+ env = os.environ if source is None else source
237
+ values = {*config.import_env.values(), DUMMY_CREDENTIAL}
238
+ values.update(env[name] for name in config.pass_env if name in env)
239
+ secrets = sorted((v for v in values if len(v) >= MIN_REDACTED_LENGTH), key=len, reverse=True)
240
+
241
+ def redact(text: str) -> str:
242
+ for secret in secrets:
243
+ text = text.replace(secret, REDACTED)
244
+ spans = [
245
+ span
246
+ for secret in secrets
247
+ if len(secret) > MIN_FRAGMENT_LENGTH
248
+ for span in _end_fragments(text, secret)
249
+ ]
250
+ return _replace_spans(text, spans) if spans else text
251
+
252
+ return redact
253
+
254
+
255
+ def redact_document(value: Any, redact: Callable[[str], str]) -> Any:
256
+ """``value`` with every string in it, object keys included, passed through ``redact``; other
257
+ types are kept. A child document can carry optional or plugin fields the core never reads."""
258
+ if isinstance(value, str):
259
+ return redact(value)
260
+ if isinstance(value, list):
261
+ return [redact_document(v, redact) for v in value]
262
+ if isinstance(value, dict):
263
+ return {redact(k) if isinstance(k, str) else k: redact_document(v, redact)
264
+ for k, v in value.items()}
265
+ return value
266
+
267
+
268
+ def _allowed(name: str) -> bool:
269
+ if name in ENV_ALLOWLIST:
270
+ return True
271
+ return WINDOWS and name.upper() in WINDOWS_ENV_ALLOWLIST
272
+
273
+
274
+ def _is_proxy(name: str) -> bool:
275
+ return name.lower().endswith("_proxy")
276
+
277
+
278
+ def build_env(
279
+ config: Config,
280
+ run_dir: Path,
281
+ result_path: Path,
282
+ base: dict[str, str] | None = None,
283
+ ) -> dict[str, str]:
284
+ source = os.environ if base is None else base
285
+ env = {k: v for k, v in source.items() if _allowed(k)}
286
+ aws = credential_env(run_dir, config.region)
287
+ env.update(aws)
288
+ env.update(config.import_env)
289
+ env.update({name: str(run_dir / rel) for name, rel in HOME_DIRS.items()})
290
+ env.update(GIT_SANDBOX_ENV, GIT_CONFIG_GLOBAL=str(run_dir / "no-such-gitconfig"))
291
+ for name in config.pass_env:
292
+ upper = name.upper()
293
+ if name in source and (not upper.startswith("AWS_") or upper == "AWS_DEFAULT_REGION"):
294
+ env[name] = source[name]
295
+ # import_env and pass_env may tune the region but can never bring real credentials back.
296
+ env.update({k: v for k, v in aws.items() if k != "AWS_DEFAULT_REGION"})
297
+ for name in list(env):
298
+ if _is_proxy(name) or name.upper() in ENV_ALWAYS_DROPPED:
299
+ del env[name]
300
+ env.update(
301
+ {
302
+ "NO_PROXY": "*",
303
+ "no_proxy": "*",
304
+ "PYTHONPATH": os.pathsep.join(p for p in (str(run_dir), env.get("PYTHONPATH")) if p),
305
+ "PYTHONDONTWRITEBYTECODE": "1",
306
+ "COVERAGE_FILE": str(run_dir / ".coverage"),
307
+ RESULT_ENV: str(result_path),
308
+ RUN_DIR_ENV: str(run_dir),
309
+ "CODETERRAIN_ROOT": config.root.as_posix(),
310
+ }
311
+ )
312
+ if config.respect_addopts:
313
+ env[RESPECT_ADDOPTS_ENV] = "1"
314
+ return env
315
+
316
+
317
+ def _tail(text: str, limit: int = 4000) -> str:
318
+ return text if len(text) <= limit else "..." + text[-limit:]
319
+
320
+
321
+ def _read_tail(path: Path, limit: int = 4000) -> str:
322
+ """The last ``limit`` characters of a child's output file, read without loading all of it."""
323
+ try:
324
+ with open(path, "rb") as handle:
325
+ size = handle.seek(0, os.SEEK_END)
326
+ handle.seek(max(0, size - 4 * limit))
327
+ data = handle.read()
328
+ except OSError:
329
+ return ""
330
+ text = data.decode("utf-8", errors="replace")
331
+ return "..." + text[-limit:] if len(data) < size else _tail(text, limit)
332
+
333
+
334
+ def _warn(message: str) -> None:
335
+ print(f"warning: {message}", file=sys.stderr)
336
+
337
+
338
+ class _Job:
339
+ """Windows only: a job object holding the child and everything it starts, orphans included.
340
+
341
+ ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` makes closing it kill every process still in it. The
342
+ child is assigned straight after it starts, before its interpreter has run any target code.
343
+ """
344
+
345
+ def __init__(self, proc: subprocess.Popen[bytes]) -> None:
346
+ import ctypes
347
+ from ctypes import wintypes
348
+
349
+ class _Basic(ctypes.Structure):
350
+ _fields_ = [
351
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
352
+ ("PerJobUserTimeLimit", ctypes.c_int64),
353
+ ("LimitFlags", wintypes.DWORD),
354
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
355
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
356
+ ("ActiveProcessLimit", wintypes.DWORD),
357
+ ("Affinity", ctypes.c_size_t),
358
+ ("PriorityClass", wintypes.DWORD),
359
+ ("SchedulingClass", wintypes.DWORD),
360
+ ]
361
+
362
+ class _Extended(ctypes.Structure):
363
+ _fields_ = [
364
+ ("BasicLimitInformation", _Basic),
365
+ ("IoInfo", ctypes.c_uint64 * 6),
366
+ ("ProcessMemoryLimit", ctypes.c_size_t),
367
+ ("JobMemoryLimit", ctypes.c_size_t),
368
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
369
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
370
+ ]
371
+
372
+ k32 = ctypes.WinDLL("kernel32", use_last_error=True)
373
+ k32.CreateJobObjectW.restype = wintypes.HANDLE
374
+ k32.CreateJobObjectW.argtypes = (wintypes.LPVOID, wintypes.LPCWSTR)
375
+ k32.SetInformationJobObject.argtypes = (
376
+ wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD
377
+ )
378
+ k32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
379
+ k32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
380
+ k32.CloseHandle.argtypes = (wintypes.HANDLE,)
381
+ self._k32 = k32
382
+ self.handle = k32.CreateJobObjectW(None, None)
383
+ if not self.handle:
384
+ raise OSError(ctypes.get_last_error(), "CreateJobObjectW failed")
385
+ info = _Extended()
386
+ info.BasicLimitInformation.LimitFlags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
387
+ ok = k32.SetInformationJobObject(self.handle, 9, ctypes.byref(info), ctypes.sizeof(info))
388
+ if ok:
389
+ ok = k32.AssignProcessToJobObject(self.handle, int(proc._handle)) # type: ignore[attr-defined]
390
+ if not ok:
391
+ error = ctypes.get_last_error()
392
+ k32.CloseHandle(self.handle)
393
+ raise OSError(error, "cannot put the child in a job object")
394
+
395
+ def close(self) -> None:
396
+ """Kill every process left in the job and release it."""
397
+ if self.handle:
398
+ self._k32.TerminateJobObject(self.handle, 1)
399
+ self._k32.CloseHandle(self.handle)
400
+ self.handle = None
401
+
402
+
403
+ def _new_job(proc: subprocess.Popen[bytes]) -> _Job | None:
404
+ try:
405
+ return _Job(proc)
406
+ except OSError as exc:
407
+ _warn(f"processes the sandboxed child starts may outlive it: {exc}")
408
+ return None
409
+
410
+
411
+ def _kill_tree(proc: subprocess.Popen[bytes], job: _Job | None = None) -> None:
412
+ """Kill the child and everything it started, then wait a bounded time for it to go.
413
+
414
+ Windows: the job object when there is one, else ``taskkill /T /F`` by its absolute path under
415
+ ``%SystemRoot%``. POSIX: the child leads its own session (``start_new_session``), so the whole
416
+ process group is killed. On POSIX a grandchild that started its own session survives.
417
+ """
418
+ if job is not None:
419
+ job.close()
420
+ elif WINDOWS:
421
+ system_root = os.environ.get("SYSTEMROOT") or r"C:\Windows"
422
+ taskkill = Path(system_root) / "System32" / "taskkill.exe"
423
+ try:
424
+ subprocess.run(
425
+ [str(taskkill), "/T", "/F", "/PID", str(proc.pid)],
426
+ stdin=subprocess.DEVNULL,
427
+ stdout=subprocess.DEVNULL,
428
+ stderr=subprocess.DEVNULL,
429
+ timeout=KILL_GRACE,
430
+ check=False,
431
+ )
432
+ except (OSError, subprocess.SubprocessError) as exc:
433
+ _warn(f"could not kill the sandboxed process tree: {exc}")
434
+ else:
435
+ _kill_group(proc.pid)
436
+ proc.kill()
437
+ try:
438
+ proc.wait(timeout=KILL_GRACE)
439
+ except subprocess.TimeoutExpired:
440
+ pass
441
+
442
+
443
+ def _kill_group(pid: int) -> None:
444
+ """POSIX only: kill the child's process group, stragglers included; gone is fine."""
445
+ try:
446
+ os.killpg(pid, signal.SIGKILL)
447
+ except (ProcessLookupError, PermissionError):
448
+ pass
449
+
450
+
451
+ def _remove_run_dir(run_dir: Path) -> None:
452
+ """Remove the run dir; on Windows a just-killed process can hold its files for a moment."""
453
+ for _ in range(10):
454
+ shutil.rmtree(run_dir, ignore_errors=True)
455
+ if not run_dir.exists():
456
+ return
457
+ time.sleep(0.2)
458
+ _warn(f"could not remove the sandbox run directory {run_dir}")
459
+
460
+
461
+ def _read_result(path: Path) -> Any | None:
462
+ if not path.is_file():
463
+ return None
464
+ size = path.stat().st_size
465
+ if size > MAX_RESULT_BYTES:
466
+ raise SandboxError(f"result file is {size} bytes, over the {MAX_RESULT_BYTES} byte cap")
467
+ try:
468
+ return json.loads(path.read_bytes())
469
+ except (ValueError, RecursionError) as exc:
470
+ raise SandboxError(f"unreadable result file: {type(exc).__name__}: {exc}") from exc
471
+
472
+
473
+ def _stage(run_dir: Path) -> None:
474
+ """Fill the per-run directory: a copy of this package and the guard sitecustomize."""
475
+ package = Path(__file__).resolve().parent
476
+ shutil.copytree(package, run_dir / package.name, ignore=shutil.ignore_patterns("__pycache__"))
477
+ (run_dir / "sitecustomize.py").write_bytes(SITECUSTOMIZE.encode("utf-8"))
478
+ for rel in set(HOME_DIRS.values()):
479
+ (run_dir / rel).mkdir(parents=True, exist_ok=True)
480
+
481
+
482
+ def run_python(
483
+ python: str | os.PathLike[str],
484
+ args: list[str],
485
+ config: Config,
486
+ cache_dir: Path,
487
+ timeout: int = DEFAULT_TIMEOUT,
488
+ ) -> SandboxResult:
489
+ """Run ``python *args`` in the target root under the sandbox and return its JSON result.
490
+
491
+ ``args`` starts with ``-m module`` or ``-c code``; both run through the guard bootstrap. The
492
+ run directory is a fresh ``tempfile.mkdtemp`` (``cache_dir`` is not used for it, so the child
493
+ never learns the cache path); it is removed afterwards.
494
+ """
495
+ if args and args[0] not in ("-m", "-c"):
496
+ raise SandboxError(f"unsupported interpreter arguments: {' '.join(args[:2])}")
497
+ try:
498
+ exe = resolve_executable(python, untrusted=config.root)
499
+ except FileNotFoundError as exc:
500
+ raise SandboxError(f"cannot start {python}: {exc}") from exc
501
+ run_dir = Path(tempfile.mkdtemp(prefix="codeterrain-run-"))
502
+ result_path = run_dir / "result.json"
503
+ out_path, err_path = run_dir / "stdout.txt", run_dir / "stderr.txt"
504
+ try:
505
+ _stage(run_dir)
506
+ with open(out_path, "wb") as out, open(err_path, "wb") as err:
507
+ try:
508
+ proc = subprocess.Popen(
509
+ [exe, "-c", BOOTSTRAP, *args],
510
+ cwd=config.root,
511
+ env=build_env(config, run_dir, result_path),
512
+ stdin=subprocess.DEVNULL,
513
+ stdout=out,
514
+ stderr=err,
515
+ start_new_session=not WINDOWS,
516
+ )
517
+ except OSError as exc:
518
+ raise SandboxError(f"cannot start {python}: {exc}") from exc
519
+ job = _new_job(proc) if WINDOWS else None
520
+ try:
521
+ returncode = proc.wait(timeout=timeout)
522
+ except subprocess.TimeoutExpired as exc:
523
+ _kill_tree(proc, job)
524
+ raise SandboxError(
525
+ f"timed out after {timeout}s: {python} {' '.join(args)}"
526
+ ) from exc
527
+ finally:
528
+ if job is not None:
529
+ job.close()
530
+ elif not WINDOWS:
531
+ _kill_group(proc.pid)
532
+ redact = redactor(config)
533
+ stdout, stderr = redact(_read_tail(out_path)), redact(_read_tail(err_path))
534
+ if returncode == GUARD_EXIT and GUARD_FAILED in stderr:
535
+ raise SandboxError(stderr.strip().splitlines()[-1])
536
+ return SandboxResult(returncode, stdout, stderr, _read_result(result_path))
537
+ finally:
538
+ _remove_run_dir(run_dir)
539
+
540
+
541
+ VERSION_PROBE = (
542
+ "import json, os, sys\n"
543
+ "from importlib import metadata\n"
544
+ "def v(n):\n"
545
+ " try:\n"
546
+ " return metadata.version(n)\n"
547
+ " except metadata.PackageNotFoundError:\n"
548
+ " return None\n"
549
+ "sites = [p for p in sys.path\n"
550
+ " if os.path.basename(p) in ('site-packages', 'dist-packages') and os.path.isdir(p)]\n"
551
+ "dists = sorted({n for p in sites for n in os.listdir(p) if n.endswith('.dist-info')})\n"
552
+ "out = {'python': sys.version.split()[0], 'pytest': v('pytest'), 'fastapi': v('fastapi'),\n"
553
+ " 'prefix': sys.prefix, 'sites': sorted(sites), 'dists': dists}\n"
554
+ "open(os.environ['CODETERRAIN_RESULT'], 'w', encoding='utf-8').write(json.dumps(out))\n"
555
+ )
556
+
557
+
558
+ def probe_versions(
559
+ python: str | os.PathLike[str], config: Config, cache_dir: Path
560
+ ) -> dict[str, Any]:
561
+ """Python, pytest and fastapi versions, the prefix, site dirs and installed distributions."""
562
+ result = run_python(python, ["-c", VERSION_PROBE], config, cache_dir, timeout=60)
563
+ if not isinstance(result.result, dict):
564
+ raise SandboxError(f"version probe failed: {result.stderr.strip()[-300:]}")
565
+ return result.result