coldrun 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.
coldrun/fetch/base.py ADDED
@@ -0,0 +1,137 @@
1
+ """Shared pieces of the fetch step: errors, the fetched-source record and the quarantine.
2
+
3
+ A quarantine directory holds exactly one fetched source plus a ``source.json``
4
+ describing where it came from. It is created mode 0700 and nothing in it is
5
+ ever imported or executed by the host.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import stat
14
+ import tempfile
15
+ from dataclasses import asdict, dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Protocol
19
+
20
+ #: Largest script or archive coldrun will fetch, in bytes.
21
+ MAX_SOURCE_BYTES = 64 * 1024 * 1024
22
+ _FILENAME_OK = re.compile(r"[^A-Za-z0-9._+-]+")
23
+ _HASH_PREFIX = 16
24
+
25
+
26
+ class FetchError(Exception):
27
+ """A source could not be fetched.
28
+
29
+ Args:
30
+ what: What failed, in one sentence.
31
+ hint: The next command or action the user should try.
32
+ """
33
+
34
+ def __init__(self, what: str, hint: str) -> None:
35
+ """Keep both halves so the report can show them separately."""
36
+ super().__init__(f"{what} {hint}".strip())
37
+ self.what = what
38
+ self.hint = hint
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Fetched:
43
+ """A source that now sits in quarantine.
44
+
45
+ Attributes:
46
+ kind: Fetcher that produced it, such as ``file`` or ``url``.
47
+ origin: The spec as the user gave it.
48
+ path: The quarantined file.
49
+ sha256: Hex digest of the file contents.
50
+ size: Size in bytes.
51
+ fetched_at: UTC timestamp in ISO 8601.
52
+ """
53
+
54
+ kind: str
55
+ origin: str
56
+ path: Path
57
+ sha256: str
58
+ size: int
59
+ fetched_at: str
60
+
61
+ @property
62
+ def quarantine(self) -> Path:
63
+ """The directory holding the file and its ``source.json``."""
64
+ return self.path.parent
65
+
66
+ def to_json(self) -> str:
67
+ """Serialise for ``source.json``."""
68
+ data = asdict(self)
69
+ data["path"] = str(self.path)
70
+ return json.dumps(data, indent=2, sort_keys=True) + "\n"
71
+
72
+
73
+ class Fetcher(Protocol):
74
+ """One way of obtaining a source."""
75
+
76
+ kind: str
77
+
78
+ def matches(self, spec: str) -> bool:
79
+ """True when this fetcher understands ``spec``."""
80
+ ...
81
+
82
+ def fetch(self, spec: str, dest: Path) -> Path:
83
+ """Place the source inside ``dest`` and return the file path.
84
+
85
+ ``dest`` already exists, is empty and is private to this fetch.
86
+ Raise :class:`FetchError` on any failure.
87
+ """
88
+ ...
89
+
90
+
91
+ def safe_filename(name: str, default: str = "script") -> str:
92
+ """Reduce an arbitrary name to something safe to create in quarantine."""
93
+ base = Path(name).name
94
+ cleaned = _FILENAME_OK.sub("_", base).strip("._")
95
+ if not cleaned or cleaned in {".", ".."}:
96
+ return default
97
+ return cleaned[:128]
98
+
99
+
100
+ def sha256_of(path: Path) -> str:
101
+ """Hex SHA-256 of a file, streamed."""
102
+ digest = hashlib.sha256()
103
+ with path.open("rb") as fh:
104
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
105
+ digest.update(chunk)
106
+ return digest.hexdigest()
107
+
108
+
109
+ def new_quarantine(root: Path) -> Path:
110
+ """Create a fresh private directory under ``root`` and return it."""
111
+ root.mkdir(parents=True, exist_ok=True)
112
+ root.chmod(stat.S_IRWXU)
113
+ path = Path(tempfile.mkdtemp(prefix="q-", dir=root))
114
+ path.chmod(stat.S_IRWXU)
115
+ return path
116
+
117
+
118
+ def seal(kind: str, origin: str, file: Path) -> Fetched:
119
+ """Hash the fetched file, make it read-only and write ``source.json``."""
120
+ size = file.stat().st_size
121
+ if size > MAX_SOURCE_BYTES:
122
+ file.unlink()
123
+ raise FetchError(
124
+ f"{origin} is {size} bytes, above the {MAX_SOURCE_BYTES} byte limit.",
125
+ "Fetch a smaller artefact or extract the one script you need.",
126
+ )
127
+ file.chmod(stat.S_IRUSR)
128
+ record = Fetched(
129
+ kind=kind,
130
+ origin=origin,
131
+ path=file,
132
+ sha256=sha256_of(file),
133
+ size=size,
134
+ fetched_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
135
+ )
136
+ (file.parent / "source.json").write_text(record.to_json())
137
+ return record
coldrun/fetch/local.py ADDED
@@ -0,0 +1,46 @@
1
+ """Fetch a script that is already on disk by copying it into quarantine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from coldrun.fetch.base import FetchError, safe_filename
9
+
10
+
11
+ class LocalFetcher:
12
+ """Copies a regular file. Directories and archives come later."""
13
+
14
+ kind = "file"
15
+
16
+ def matches(self, spec: str) -> bool:
17
+ """Anything that is not a URL scheme is treated as a path."""
18
+ return "://" not in spec
19
+
20
+ def fetch(self, spec: str, dest: Path) -> Path:
21
+ """Copy ``spec`` into ``dest`` without following it anywhere surprising."""
22
+ src = Path(spec).expanduser()
23
+ if not src.exists():
24
+ raise FetchError(
25
+ f"{spec} does not exist.",
26
+ "Check the path, or pass a URL such as https://… to fetch it.",
27
+ )
28
+ if src.is_dir():
29
+ raise FetchError(
30
+ f"{spec} is a directory.",
31
+ "Point coldrun at the script inside it; repos arrive with the GitHub fetcher.",
32
+ )
33
+ resolved = src.resolve()
34
+ if not resolved.is_file():
35
+ raise FetchError(
36
+ f"{spec} is not a regular file.",
37
+ "coldrun fetches scripts and archives only.",
38
+ )
39
+ target = dest / safe_filename(src.name)
40
+ try:
41
+ shutil.copyfile(resolved, target, follow_symlinks=False)
42
+ except OSError as exc:
43
+ raise FetchError(
44
+ f"Could not read {spec}: {exc.strerror}.", "Check permissions."
45
+ ) from exc
46
+ return target
coldrun/fetch/url.py ADDED
@@ -0,0 +1,136 @@
1
+ """Fetch a script from a plain http or https URL.
2
+
3
+ Downloads stream to disk with a size cap. Redirects are followed only to
4
+ http or https, never to ``file://`` or anything else that could reach the
5
+ host, and the GitHub ``blob`` and gist page forms are rewritten to their raw
6
+ equivalents so the user can paste what the browser shows.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from coldrun import __version__
18
+ from coldrun.fetch.base import MAX_SOURCE_BYTES, FetchError, safe_filename
19
+
20
+ if TYPE_CHECKING:
21
+ from pathlib import Path
22
+
23
+ TIMEOUT_SECONDS = 30
24
+ MAX_REDIRECTS = 5
25
+ _CHUNK = 256 * 1024
26
+ _GITHUB_BLOB = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)$")
27
+ _GITHUB_RAW = re.compile(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$")
28
+
29
+
30
+ class _SafeRedirects(urllib.request.HTTPRedirectHandler):
31
+ """Refuse redirects that leave http or https, and cap how many we follow.
32
+
33
+ The check runs before urllib's own handling so the user sees why the
34
+ fetch stopped instead of a bare ``HTTP 302``.
35
+ """
36
+
37
+ def http_error_302(
38
+ self,
39
+ req: urllib.request.Request,
40
+ fp: Any, # noqa: ANN401 - urllib's own signature
41
+ code: int,
42
+ msg: str,
43
+ headers: Any, # noqa: ANN401
44
+ ) -> Any: # noqa: ANN401
45
+ location = headers.get("location") or headers.get("uri") or ""
46
+ newurl = urllib.parse.urljoin(req.full_url, location)
47
+ scheme = urllib.parse.urlsplit(newurl).scheme
48
+ if scheme not in {"http", "https"}:
49
+ raise FetchError(
50
+ f"Redirect to {scheme}:// refused.",
51
+ "Only http and https destinations are followed.",
52
+ )
53
+ hops = sum(getattr(req, "redirect_dict", {}).values())
54
+ if hops >= MAX_REDIRECTS:
55
+ raise FetchError("Too many redirects.", "Fetch the final URL directly.")
56
+ return super().http_error_302(req, fp, code, msg, headers)
57
+
58
+ http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
59
+
60
+
61
+ def to_raw(url: str) -> str:
62
+ """Turn a GitHub ``blob`` page URL into its raw download URL."""
63
+ if m := _GITHUB_BLOB.match(url):
64
+ owner, repo, ref, path = m.groups()
65
+ return f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}"
66
+ return url
67
+
68
+
69
+ def filename_for(url: str) -> str:
70
+ """Pick the quarantine file name from the URL path."""
71
+ return safe_filename(urllib.parse.unquote(urllib.parse.urlsplit(url).path))
72
+
73
+
74
+ class UrlFetcher:
75
+ """Plain downloads. Repos, gists and exploit-db have their own fetchers."""
76
+
77
+ kind = "url"
78
+
79
+ def __init__(self, opener: urllib.request.OpenerDirector | None = None) -> None:
80
+ """Use ``opener`` for requests; tests inject one, callers leave it."""
81
+ self._opener = opener or urllib.request.build_opener(_SafeRedirects())
82
+
83
+ def matches(self, spec: str) -> bool:
84
+ """Accept http and https only; other schemes are refused by :func:`fetch`."""
85
+ return spec.startswith(("http://", "https://"))
86
+
87
+ def fetch(self, spec: str, dest: Path) -> Path:
88
+ """Download ``spec`` into ``dest`` with a size cap and a timeout."""
89
+ url = to_raw(spec)
90
+ target = dest / filename_for(url)
91
+ req = urllib.request.Request( # noqa: S310 - scheme checked by matches()
92
+ url, headers={"User-Agent": f"coldrun/{__version__}"}
93
+ )
94
+ try:
95
+ with self._opener.open(req, timeout=TIMEOUT_SECONDS) as resp:
96
+ declared = resp.headers.get("Content-Length")
97
+ if declared and int(declared) > MAX_SOURCE_BYTES:
98
+ raise FetchError(
99
+ f"{url} is {declared} bytes, above the {MAX_SOURCE_BYTES} byte limit.",
100
+ "Fetch a smaller artefact.",
101
+ )
102
+ self._stream(resp, target, url)
103
+ except urllib.error.HTTPError as exc:
104
+ target.unlink(missing_ok=True)
105
+ if 300 <= exc.code < 400: # noqa: PLR2004 - HTTP redirect class
106
+ # urllib gives up on loops and repeated hops with a 3xx error.
107
+ raise FetchError("Too many redirects.", "Fetch the final URL directly.") from exc
108
+ raise FetchError(
109
+ f"{url} returned HTTP {exc.code}.",
110
+ "Check the URL in a browser; for private repos use a local clone.",
111
+ ) from exc
112
+ except urllib.error.URLError as exc:
113
+ target.unlink(missing_ok=True)
114
+ raise FetchError(
115
+ f"Could not reach {url}: {exc.reason}.",
116
+ "Check the host name and your network, then retry.",
117
+ ) from exc
118
+ except TimeoutError as exc:
119
+ target.unlink(missing_ok=True)
120
+ raise FetchError(f"{url} timed out after {TIMEOUT_SECONDS}s.", "Retry later.") from exc
121
+ return target
122
+
123
+ @staticmethod
124
+ def _stream(resp: Any, target: Path, url: str) -> None: # noqa: ANN401 - http.client response
125
+ written = 0
126
+ with target.open("wb") as out:
127
+ while chunk := resp.read(_CHUNK):
128
+ written += len(chunk)
129
+ if written > MAX_SOURCE_BYTES:
130
+ out.close()
131
+ target.unlink(missing_ok=True)
132
+ raise FetchError(
133
+ f"{url} exceeded the {MAX_SOURCE_BYTES} byte limit while downloading.",
134
+ "Fetch a smaller artefact.",
135
+ )
136
+ out.write(chunk)
coldrun/pipeline.py ADDED
@@ -0,0 +1 @@
1
+ """The eight pipeline steps: fetch, detect, resolve, vet, decide, build, run, report."""
coldrun/py.typed ADDED
File without changes
coldrun/report.py ADDED
@@ -0,0 +1,195 @@
1
+ """Render what ran, what was blocked and the exit code.
2
+
3
+ Human output goes through Rich to stderr-safe consoles; the JSON form
4
+ arrives with the vetting milestone. Errors from any pipeline step are shown
5
+ the same way: what failed, then the next command to try.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import IntEnum
12
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
13
+
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ from coldrun.vet.findings import Severity
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+ from coldrun.detect import Detection
24
+ from coldrun.fetch.base import Fetched
25
+ from coldrun.runners.base import DepSet
26
+ from coldrun.vet.findings import Finding
27
+
28
+
29
+ class ExitCode(IntEnum):
30
+ """Exit codes coldrun uses for its own failures.
31
+
32
+ Anything else is the script's own exit code, passed through.
33
+ """
34
+
35
+ OK = 0
36
+ REFUSED = 100
37
+ SANDBOX_FAILED = 101
38
+ DEPS_FAILED = 102
39
+ USAGE = 2
40
+
41
+
42
+ @runtime_checkable
43
+ class UserFacingError(Protocol):
44
+ """Errors that carry a one-sentence cause and a next step."""
45
+
46
+ what: str
47
+ hint: str
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class Report:
52
+ """Everything the user should know about one run.
53
+
54
+ Fields are filled in as the pipeline advances, so a report rendered after
55
+ a failure shows how far it got.
56
+ """
57
+
58
+ source: Fetched | None = None
59
+ detection: Detection | None = None
60
+ deps: DepSet | None = None
61
+ findings: list[Finding] = field(default_factory=list)
62
+ verdict: str | None = None
63
+ backend: str | None = None
64
+ net: str = "none"
65
+ blocked: list[str] = field(default_factory=list)
66
+ exit_code: int | None = None
67
+ out_dir: str | None = None
68
+ wrote: list[str] = field(default_factory=list)
69
+
70
+ @property
71
+ def max_severity(self) -> Severity | None:
72
+ """Highest severity among the findings, or ``None``."""
73
+ if not self.findings:
74
+ return None
75
+ return max((f.severity for f in self.findings), key=lambda s: s.rank)
76
+
77
+
78
+ _SEVERITY_STYLE = {
79
+ Severity.INFO: "dim",
80
+ Severity.LOW: "green",
81
+ Severity.MEDIUM: "yellow",
82
+ Severity.HIGH: "bold red",
83
+ }
84
+
85
+
86
+ def default_blocked(net: str) -> list[str]:
87
+ """The protections every run gets, phrased for the report."""
88
+ blocked = [
89
+ "home directory, SSH keys, browser profiles, cloud credentials",
90
+ "Docker socket and D-Bus session",
91
+ "writes outside /work and /tmp",
92
+ "capabilities and privilege escalation",
93
+ ]
94
+ if net == "none":
95
+ blocked.insert(0, "all network access")
96
+ elif net == "allow":
97
+ blocked.insert(0, "network except --allow-host destinations")
98
+ return blocked
99
+
100
+
101
+ def make_console(*, stderr: bool = False, force_plain: bool = False) -> Console:
102
+ """A console for reports; ``force_plain`` disables colour and width tricks for tests."""
103
+ if force_plain:
104
+ return Console(stderr=stderr, color_system=None, width=100, highlight=False)
105
+ return Console(stderr=stderr, highlight=False)
106
+
107
+
108
+ def render(report: Report, console: Console) -> None:
109
+ """Print the run summary."""
110
+ table = Table(show_header=False, box=None, pad_edge=False, padding=(0, 1))
111
+ table.add_column("key", style="bold", no_wrap=True)
112
+ table.add_column("value")
113
+
114
+ if report.source is not None:
115
+ table.add_row("source", f"{report.source.origin} ({report.source.kind})")
116
+ table.add_row("sha256", report.source.sha256)
117
+ if report.detection is not None:
118
+ det = report.detection
119
+ why = ", ".join(det.reasons) or "runner score"
120
+ table.add_row("runner", f"{det.runner.name} ({det.confidence:.0%}: {why})")
121
+ if report.deps is not None:
122
+ table.add_row("dependencies", _format_deps(report.deps))
123
+ if report.findings:
124
+ table.add_row("findings", _format_findings(report.findings))
125
+ if report.verdict is not None:
126
+ table.add_row("verdict", report.verdict)
127
+ if report.backend is not None:
128
+ table.add_row("sandbox", f"{report.backend}, network {report.net}")
129
+ if report.blocked:
130
+ table.add_row("blocked", "\n".join(report.blocked))
131
+ if report.wrote:
132
+ where = f" -> {report.out_dir}" if report.out_dir else ""
133
+ table.add_row("wrote", "\n".join(report.wrote) + where)
134
+ if report.exit_code is not None:
135
+ table.add_row("exit code", _format_exit(report.exit_code))
136
+ console.print(table)
137
+
138
+
139
+ def render_error(err: BaseException, console: Console) -> None:
140
+ """Print a failure as *what failed* then *next: what to try*."""
141
+ if isinstance(err, UserFacingError):
142
+ console.print(Text(err.what, style="bold red"))
143
+ console.print(Text(f"next: {err.hint}", style="yellow"))
144
+ else:
145
+ console.print(Text(f"{type(err).__name__}: {err}", style="bold red"))
146
+ console.print(Text("next: rerun with COLDRUN_DEBUG=1 and open an issue.", style="yellow"))
147
+
148
+
149
+ def exit_code_for(err: BaseException) -> int:
150
+ """Map a pipeline error to the exit code the CLI should use."""
151
+ code = getattr(err, "exit_code", None)
152
+ if isinstance(code, int):
153
+ return code
154
+ by_name = {
155
+ "FetchError": ExitCode.USAGE,
156
+ "DetectError": ExitCode.USAGE,
157
+ "SandboxError": ExitCode.SANDBOX_FAILED,
158
+ "DepsError": ExitCode.DEPS_FAILED,
159
+ "RefusedError": ExitCode.REFUSED,
160
+ }
161
+ return int(by_name.get(type(err).__name__, 1))
162
+
163
+
164
+ def _format_deps(deps: DepSet) -> str:
165
+ parts: list[str] = []
166
+ if deps.interpreter:
167
+ parts.append(deps.interpreter)
168
+ if deps.packages:
169
+ eco = f"{deps.ecosystem}: " if deps.ecosystem else ""
170
+ parts.append(eco + ", ".join(deps.packages))
171
+ if deps.system_binaries:
172
+ parts.append("binaries: " + ", ".join(deps.system_binaries))
173
+ if not parts:
174
+ return "none"
175
+ return "; ".join(parts) + f" (from {deps.source})"
176
+
177
+
178
+ def _format_findings(findings: Sequence[Finding]) -> Text:
179
+ text = Text()
180
+ for i, f in enumerate(sorted(findings, key=lambda x: -x.severity.rank)):
181
+ if i:
182
+ text.append("\n")
183
+ text.append(f"{f.severity.value:<6}", style=_SEVERITY_STYLE[f.severity])
184
+ text.append(f" {f.rule_id} {f.location()}: {f.message}")
185
+ return text
186
+
187
+
188
+ def _format_exit(code: int) -> Text:
189
+ if code == 0:
190
+ return Text("0", style="green")
191
+ try:
192
+ label = ExitCode(code).name.lower().replace("_", " ")
193
+ except ValueError:
194
+ return Text(str(code), style="red")
195
+ return Text(f"{code} ({label})", style="red")
@@ -0,0 +1,15 @@
1
+ """Language runners: one module per language plus the shared base contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from coldrun.runners.base import DepSet, Runner, RunnerInfo
6
+ from coldrun.runners.registry import Registry, RunnerNotFoundError, default_registry
7
+
8
+ __all__ = [
9
+ "DepSet",
10
+ "Registry",
11
+ "Runner",
12
+ "RunnerInfo",
13
+ "RunnerNotFoundError",
14
+ "default_registry",
15
+ ]
@@ -0,0 +1,97 @@
1
+ """The contract every language runner implements.
2
+
3
+ A runner knows how to recognise a script, work out what it needs, and produce
4
+ the commands that install those needs and run the script. The commands are
5
+ executed by the sandbox, never by the runner itself, and never on the host.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
12
+
13
+ if TYPE_CHECKING:
14
+ from pathlib import Path
15
+
16
+ from coldrun.vet.findings import Finding
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class DepSet:
21
+ """Everything a script needs before it can run.
22
+
23
+ Attributes:
24
+ ecosystem: Package registry the packages come from, such as ``pypi``
25
+ or ``npm``. Empty when there are no packages.
26
+ packages: Package requirement strings as the ecosystem spells them,
27
+ for example ``requests>=2.31``. Sorted and de-duplicated.
28
+ system_binaries: Command names the script expects on ``PATH``.
29
+ interpreter: Interpreter requirement, such as ``python>=3.10`` or
30
+ ``bash``. ``None`` for prebuilt binaries.
31
+ source: Where the requirements came from, for the report: a manifest
32
+ file name or ``inferred``.
33
+ """
34
+
35
+ ecosystem: str = ""
36
+ packages: tuple[str, ...] = ()
37
+ system_binaries: tuple[str, ...] = ()
38
+ interpreter: str | None = None
39
+ source: str = "inferred"
40
+
41
+ def __post_init__(self) -> None:
42
+ """Normalise ordering so equal needs compare and hash equal."""
43
+ object.__setattr__(self, "packages", tuple(sorted(set(self.packages))))
44
+ object.__setattr__(self, "system_binaries", tuple(sorted(set(self.system_binaries))))
45
+
46
+ @property
47
+ def is_empty(self) -> bool:
48
+ """True when nothing has to be installed."""
49
+ return not self.packages and not self.system_binaries
50
+
51
+
52
+ @runtime_checkable
53
+ class Runner(Protocol):
54
+ """What a language plugin provides.
55
+
56
+ Runners are discovered through the ``coldrun.runners`` entry-point group.
57
+ Each entry point names a class with a no-argument constructor, or a
58
+ zero-argument factory, that yields an object satisfying this protocol.
59
+ """
60
+
61
+ name: str
62
+
63
+ def detect(self, path: Path) -> float:
64
+ """Return confidence from 0.0 to 1.0 that this runner owns ``path``.
65
+
66
+ Only inspect the bytes; never execute or import the file.
67
+ """
68
+ ...
69
+
70
+ def resolve_deps(self, path: Path) -> DepSet:
71
+ """Work out what ``path`` needs, from a manifest or from the code."""
72
+ ...
73
+
74
+ def install_cmd(self, deps: DepSet) -> list[str]:
75
+ """Argument list that installs ``deps``. Runs inside the sandbox.
76
+
77
+ Return an empty list when nothing has to be installed.
78
+ """
79
+ ...
80
+
81
+ def run_cmd(self, path: Path, args: list[str]) -> list[str]:
82
+ """Argument list that runs ``path`` with ``args``. Runs inside the sandbox."""
83
+ ...
84
+
85
+ def vet(self, path: Path, deps: DepSet) -> list[Finding]:
86
+ """Language-specific checks on the script and its dependencies."""
87
+ ...
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class RunnerInfo:
92
+ """A loaded runner together with where it came from."""
93
+
94
+ runner: Runner
95
+ entry_point: str
96
+ distribution: str | None = None
97
+ load_error: str | None = field(default=None, compare=False)