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/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """coldrun: fetch, vet and sandbox any downloaded script in one command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("coldrun")
9
+ except PackageNotFoundError: # pragma: no cover - only when run from a bare checkout
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = ["__version__"]
coldrun/cli.py ADDED
@@ -0,0 +1,53 @@
1
+ """Typer application for the ``coldrun`` command.
2
+
3
+ This module wires command-line options to the pipeline and holds no logic of
4
+ its own. Commands are added here as the milestones land.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from coldrun import __version__
13
+ from coldrun import doctor as doctor_mod
14
+
15
+ app = typer.Typer(
16
+ name="coldrun",
17
+ help="Fetch, vet and sandbox any downloaded script in one command.",
18
+ no_args_is_help=True,
19
+ add_completion=False,
20
+ rich_markup_mode="rich",
21
+ )
22
+
23
+
24
+ def _version_callback(value: bool) -> None:
25
+ if value:
26
+ typer.echo(f"coldrun {__version__}")
27
+ raise typer.Exit()
28
+
29
+
30
+ @app.callback()
31
+ def main(
32
+ version: bool = typer.Option(
33
+ False,
34
+ "--version",
35
+ "-V",
36
+ help="Show the version and exit.",
37
+ callback=_version_callback,
38
+ is_eager=True,
39
+ ),
40
+ ) -> None:
41
+ """Fetch, vet and sandbox any downloaded script in one command."""
42
+
43
+
44
+ @app.command()
45
+ def doctor() -> None:
46
+ """Check bwrap, podman, uv and kernel support, and say how to fix what is missing."""
47
+ results = doctor_mod.run_checks()
48
+ doctor_mod.render(results, Console(highlight=False))
49
+ raise typer.Exit(code=1 if doctor_mod.has_failures(results) else 0)
50
+
51
+
52
+ if __name__ == "__main__": # pragma: no cover
53
+ app()
coldrun/config.py ADDED
@@ -0,0 +1,29 @@
1
+ """Where coldrun keeps its files on the host.
2
+
3
+ Only paths live here for now; the TOML config file arrives when there are
4
+ settings worth persisting. Both directories honour the XDG variables.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ APP = "coldrun"
13
+
14
+
15
+ def config_dir() -> Path:
16
+ """``$XDG_CONFIG_HOME/coldrun`` or ``~/.config/coldrun``."""
17
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
18
+ return Path(base) / APP
19
+
20
+
21
+ def cache_dir() -> Path:
22
+ """``$XDG_CACHE_HOME/coldrun`` or ``~/.cache/coldrun``."""
23
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
24
+ return Path(base) / APP
25
+
26
+
27
+ def quarantine_root() -> Path:
28
+ """Parent of every quarantine directory, under the cache."""
29
+ return cache_dir() / "quarantine"
coldrun/detect.py ADDED
@@ -0,0 +1,223 @@
1
+ """Pick a runner from shebang, extension and file magic.
2
+
3
+ The language knowledge lives in the runners: each one scores a file through
4
+ ``Runner.detect``. This module supplies the cheap, shared signals runners
5
+ build on, asks every registered runner, and turns the scores into one choice
6
+ or one clear error. Only bytes are inspected; nothing is executed or imported.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shlex
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from coldrun.runners.base import Runner
19
+ from coldrun.runners.registry import Registry
20
+
21
+ #: A runner must be at least this sure before coldrun runs anything.
22
+ THRESHOLD = 0.5
23
+ _SNIFF_BYTES = 512
24
+ _SHEBANG_MAX = 256
25
+
26
+
27
+ class FileKind(str, Enum):
28
+ """Coarse shape of a file, from its first bytes."""
29
+
30
+ SCRIPT = "script" # has a shebang
31
+ TEXT = "text" # readable text without a shebang
32
+ ELF = "elf"
33
+ PE = "pe"
34
+ MACHO = "macho"
35
+ ZIP = "zip"
36
+ GZIP = "gzip"
37
+ XZ = "xz"
38
+ BZIP2 = "bzip2"
39
+ ZSTD = "zstd"
40
+ BINARY = "binary" # other non-text
41
+ EMPTY = "empty"
42
+
43
+
44
+ _MAGIC: tuple[tuple[bytes, FileKind], ...] = (
45
+ (b"\x7fELF", FileKind.ELF),
46
+ (b"MZ", FileKind.PE),
47
+ (b"\xcf\xfa\xed\xfe", FileKind.MACHO),
48
+ (b"\xfe\xed\xfa\xcf", FileKind.MACHO),
49
+ (b"\xca\xfe\xba\xbe", FileKind.MACHO),
50
+ (b"PK\x03\x04", FileKind.ZIP),
51
+ (b"\x1f\x8b", FileKind.GZIP),
52
+ (b"\xfd7zXZ\x00", FileKind.XZ),
53
+ (b"BZh", FileKind.BZIP2),
54
+ (b"\x28\xb5\x2f\xfd", FileKind.ZSTD),
55
+ )
56
+ _TEXT_BYTES = bytes(range(0x20, 0x7F)) + b"\t\n\r\x0b\x0c\x1b"
57
+ _ASCII_LIMIT = 0x80
58
+ _BINARY_RATIO = 0.1
59
+
60
+
61
+ class DetectError(Exception):
62
+ """No runner could be chosen.
63
+
64
+ Args:
65
+ what: Why, in one sentence.
66
+ hint: What the user can do, usually ``--lang``.
67
+ candidates: Runner names and scores that were considered.
68
+ """
69
+
70
+ def __init__(self, what: str, hint: str, candidates: list[tuple[str, float]]) -> None:
71
+ """Keep the parts separate for the report."""
72
+ super().__init__(f"{what} {hint}")
73
+ self.what = what
74
+ self.hint = hint
75
+ self.candidates = candidates
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class Signals:
80
+ """The shared evidence about one file."""
81
+
82
+ kind: FileKind
83
+ shebang: str | None
84
+ interpreter: str | None
85
+ extension: str
86
+
87
+ @property
88
+ def is_text(self) -> bool:
89
+ """True for scripts and plain text."""
90
+ return self.kind in {FileKind.SCRIPT, FileKind.TEXT}
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class Detection:
95
+ """The chosen runner and why."""
96
+
97
+ runner: Runner
98
+ confidence: float
99
+ signals: Signals
100
+ forced: bool = False
101
+ candidates: list[tuple[str, float]] = field(default_factory=list)
102
+
103
+ @property
104
+ def reasons(self) -> list[str]:
105
+ """Human-readable evidence for the report."""
106
+ out: list[str] = []
107
+ if self.forced:
108
+ out.append(f"--lang {self.runner.name}")
109
+ if self.signals.shebang:
110
+ out.append(f"shebang {self.signals.shebang}")
111
+ if self.signals.extension:
112
+ out.append(f"extension {self.signals.extension}")
113
+ if not self.signals.is_text:
114
+ out.append(f"magic {self.signals.kind.value}")
115
+ return out
116
+
117
+
118
+ def sniff_kind(path: Path) -> FileKind:
119
+ """Classify a file from its first bytes."""
120
+ with path.open("rb") as fh:
121
+ head = fh.read(_SNIFF_BYTES)
122
+ if not head:
123
+ return FileKind.EMPTY
124
+ for magic, kind in _MAGIC:
125
+ if head.startswith(magic):
126
+ return kind
127
+ if head.startswith(b"#!"):
128
+ return FileKind.SCRIPT
129
+ if b"\x00" in head:
130
+ return FileKind.BINARY
131
+ sample = head.translate(None, _TEXT_BYTES)
132
+ # Allow UTF-8 multibyte sequences; anything with lots of control bytes is binary.
133
+ control = sum(1 for b in sample if b < _ASCII_LIMIT)
134
+ return FileKind.BINARY if control > len(head) * _BINARY_RATIO else FileKind.TEXT
135
+
136
+
137
+ def read_shebang(path: Path) -> str | None:
138
+ """Return the shebang line without ``#!`` and trailing whitespace, or ``None``."""
139
+ with path.open("rb") as fh:
140
+ head = fh.read(_SHEBANG_MAX)
141
+ if not head.startswith(b"#!"):
142
+ return None
143
+ line = head[2:].split(b"\n", 1)[0].strip()
144
+ try:
145
+ return line.decode("utf-8")
146
+ except UnicodeDecodeError:
147
+ return None
148
+
149
+
150
+ def interpreter_from_shebang(shebang: str) -> str | None:
151
+ """Name of the interpreter a shebang asks for, such as ``python3`` or ``bash``.
152
+
153
+ Handles ``/usr/bin/env name``, ``/usr/bin/env -S name args`` and plain
154
+ ``/bin/name``. Returns ``None`` for an unparsable line.
155
+ """
156
+ try:
157
+ parts = shlex.split(shebang)
158
+ except ValueError:
159
+ return None
160
+ if not parts:
161
+ return None
162
+ if Path(parts[0]).name == "env":
163
+ rest = [p for p in parts[1:] if not p.startswith("-") and "=" not in p]
164
+ return Path(rest[0]).name if rest else None
165
+ return Path(parts[0]).name
166
+
167
+
168
+ def signals_for(path: Path) -> Signals:
169
+ """Gather the shared evidence for ``path``."""
170
+ kind = sniff_kind(path)
171
+ shebang = read_shebang(path) if kind == FileKind.SCRIPT else None
172
+ return Signals(
173
+ kind=kind,
174
+ shebang=shebang,
175
+ interpreter=interpreter_from_shebang(shebang) if shebang else None,
176
+ extension=path.suffix.lower(),
177
+ )
178
+
179
+
180
+ def detect(path: Path, registry: Registry, lang: str | None = None) -> Detection:
181
+ """Choose the runner for ``path``.
182
+
183
+ Args:
184
+ path: The quarantined file.
185
+ registry: Where runners come from.
186
+ lang: ``--lang`` override; skips scoring and takes that runner.
187
+
188
+ Raises:
189
+ DetectError: When nothing is confident enough, or ``lang`` is unknown.
190
+ """
191
+ signals = signals_for(path)
192
+ if lang is not None:
193
+ try:
194
+ runner = registry.get(lang)
195
+ except LookupError as exc:
196
+ raise DetectError(
197
+ str(exc) + ".",
198
+ "Pick one of the available runners with --lang.",
199
+ [],
200
+ ) from exc
201
+ return Detection(runner=runner, confidence=1.0, signals=signals, forced=True)
202
+
203
+ scored: list[tuple[float, Runner]] = []
204
+ for runner in registry.all():
205
+ score = float(runner.detect(path))
206
+ scored.append((min(max(score, 0.0), 1.0), runner))
207
+ scored.sort(key=lambda s: s[0], reverse=True)
208
+ candidates = [(r.name, s) for s, r in scored]
209
+ if not scored or scored[0][0] < THRESHOLD:
210
+ what = f"Could not tell what kind of script {path.name} is ({signals.kind.value}"
211
+ what += f", shebang {signals.shebang}" if signals.shebang else ""
212
+ what += ")."
213
+ names = ", ".join(registry.names()) or "none installed"
214
+ raise DetectError(what, f"Force a runner with --lang NAME. Available: {names}.", candidates)
215
+ best, runner = scored[0]
216
+ if len(scored) > 1 and scored[1][0] == best:
217
+ tied = ", ".join(r.name for s, r in scored if s == best)
218
+ raise DetectError(
219
+ f"{path.name} matches more than one runner equally ({tied}).",
220
+ "Choose one with --lang NAME.",
221
+ candidates,
222
+ )
223
+ return Detection(runner=runner, confidence=best, signals=signals, candidates=candidates)
coldrun/doctor.py ADDED
@@ -0,0 +1,208 @@
1
+ """``coldrun doctor``: check bwrap, podman, uv and kernel support.
2
+
3
+ Each check is a small function that returns a :class:`CheckResult` with the
4
+ fix for its failure, so the output tells the user exactly what to run next.
5
+ The probes are injectable for tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from typing import TYPE_CHECKING
17
+
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+ from coldrun.config import cache_dir
22
+ from coldrun.sandbox import bwrap as bw
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+ from pathlib import Path
27
+
28
+ from rich.console import Console
29
+
30
+ MIN_PYTHON = (3, 10)
31
+
32
+
33
+ class Status(str, Enum):
34
+ """Outcome of one check."""
35
+
36
+ PASS = "pass" # noqa: S105 - a status, not a password
37
+ WARN = "warn"
38
+ FAIL = "fail"
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class CheckResult:
43
+ """One line of doctor output."""
44
+
45
+ name: str
46
+ status: Status
47
+ detail: str
48
+ fix: str = ""
49
+
50
+
51
+ def _run_version(argv: list[str]) -> str | None:
52
+ try:
53
+ out = subprocess.run( # noqa: S603 - fixed argv of a known tool
54
+ argv, capture_output=True, text=True, check=False, timeout=10
55
+ )
56
+ except (OSError, subprocess.TimeoutExpired):
57
+ return None
58
+ line = (out.stdout or out.stderr).strip().splitlines()
59
+ return line[0] if line else None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Probes:
64
+ """Host lookups the checks depend on; tests replace them."""
65
+
66
+ which: Callable[[str], str | None] = shutil.which
67
+ version_of: Callable[[list[str]], str | None] = _run_version
68
+ userns_problem: Callable[[], tuple[str, str] | None] = bw.userns_problem
69
+ tiocsti_blocked: Callable[[], bool] = bw.tiocsti_is_blocked
70
+ python_version: tuple[int, int] = sys.version_info[:2]
71
+ kernel: str = platform.release()
72
+ cache: Path | None = None
73
+
74
+
75
+ def check_bwrap(p: Probes) -> CheckResult:
76
+ """Bubblewrap is installed."""
77
+ path = p.which("bwrap")
78
+ if path is None:
79
+ return CheckResult(
80
+ "bubblewrap",
81
+ Status.FAIL,
82
+ "not installed",
83
+ "sudo pacman -S bubblewrap (Arch) or sudo apt install bubblewrap (Debian, Kali)",
84
+ )
85
+ ver = p.version_of([path, "--version"]) or "version unknown"
86
+ return CheckResult("bubblewrap", Status.PASS, f"{path} ({ver})")
87
+
88
+
89
+ def check_userns(p: Probes) -> CheckResult:
90
+ """Unprivileged user namespaces are allowed."""
91
+ problem = p.userns_problem()
92
+ if problem is None:
93
+ return CheckResult("user namespaces", Status.PASS, "unprivileged user namespaces allowed")
94
+ return CheckResult("user namespaces", Status.FAIL, problem[0], problem[1])
95
+
96
+
97
+ def check_tiocsti(p: Probes) -> CheckResult:
98
+ """The kernel blocks TIOCSTI so interactive scripts can keep the terminal."""
99
+ if p.tiocsti_blocked():
100
+ return CheckResult("TIOCSTI", Status.PASS, "blocked by the kernel; TTY passthrough is safe")
101
+ return CheckResult(
102
+ "TIOCSTI",
103
+ Status.WARN,
104
+ "legacy TIOCSTI allowed; interactive scripts run in a new session (no job control)",
105
+ "sudo sysctl -w dev.tty.legacy_tiocsti=0 (kernel 6.2 or newer)",
106
+ )
107
+
108
+
109
+ def check_podman(p: Probes) -> CheckResult:
110
+ """Podman is optional until a runner needs a full image."""
111
+ path = p.which("podman")
112
+ if path is None:
113
+ return CheckResult(
114
+ "podman",
115
+ Status.WARN,
116
+ "not installed; only needed for Python 2, Node, Go and Ruby runners (M5)",
117
+ "sudo pacman -S podman or sudo apt install podman",
118
+ )
119
+ ver = p.version_of([path, "--version"]) or "version unknown"
120
+ return CheckResult("podman", Status.PASS, f"{path} ({ver})")
121
+
122
+
123
+ def check_uv(p: Probes) -> CheckResult:
124
+ """Uv installs Python dependencies inside the sandbox."""
125
+ path = p.which("uv")
126
+ if path is None:
127
+ return CheckResult(
128
+ "uv",
129
+ Status.WARN,
130
+ "not installed; needed by the Python runner (M2)",
131
+ "curl -LsSf https://astral.sh/uv/install.sh | sh or sudo pacman -S uv",
132
+ )
133
+ ver = p.version_of([path, "--version"]) or "version unknown"
134
+ return CheckResult("uv", Status.PASS, f"{path} ({ver})")
135
+
136
+
137
+ def check_python(p: Probes) -> CheckResult:
138
+ """Python is new enough for coldrun itself."""
139
+ ver = ".".join(map(str, p.python_version))
140
+ if p.python_version < MIN_PYTHON:
141
+ return CheckResult(
142
+ "python", Status.FAIL, f"{ver} is too old", "coldrun needs Python 3.10 or newer"
143
+ )
144
+ return CheckResult("python", Status.PASS, f"{ver} ({p.kernel})")
145
+
146
+
147
+ def check_cache(p: Probes) -> CheckResult:
148
+ """The cache directory can be created and written."""
149
+ path = p.cache if p.cache is not None else cache_dir()
150
+ try:
151
+ path.mkdir(parents=True, exist_ok=True)
152
+ probe = path / ".doctor-write-test"
153
+ probe.write_text("ok")
154
+ probe.unlink()
155
+ except OSError as exc:
156
+ return CheckResult(
157
+ "cache", Status.FAIL, f"{path}: {exc.strerror}", f"chown or chmod {path}"
158
+ )
159
+ return CheckResult("cache", Status.PASS, str(path))
160
+
161
+
162
+ CHECKS: tuple[Callable[[Probes], CheckResult], ...] = (
163
+ check_python,
164
+ check_bwrap,
165
+ check_userns,
166
+ check_tiocsti,
167
+ check_podman,
168
+ check_uv,
169
+ check_cache,
170
+ )
171
+
172
+
173
+ def run_checks(probes: Probes | None = None) -> list[CheckResult]:
174
+ """Run every check and return the results in display order."""
175
+ p = probes or Probes()
176
+ return [check(p) for check in CHECKS]
177
+
178
+
179
+ def has_failures(results: list[CheckResult]) -> bool:
180
+ """True when at least one check failed outright."""
181
+ return any(r.status is Status.FAIL for r in results)
182
+
183
+
184
+ _STYLE = {Status.PASS: "green", Status.WARN: "yellow", Status.FAIL: "bold red"}
185
+
186
+
187
+ def render(results: list[CheckResult], console: Console) -> None:
188
+ """Print the doctor table."""
189
+ table = Table(show_header=True, header_style="bold", box=None, pad_edge=False, padding=(0, 1))
190
+ table.add_column("check", no_wrap=True)
191
+ table.add_column("status", no_wrap=True)
192
+ table.add_column("detail")
193
+ for r in results:
194
+ detail = Text(r.detail)
195
+ if r.fix:
196
+ detail.append("\nfix: ", style="bold")
197
+ detail.append(r.fix)
198
+ table.add_row(r.name, Text(r.status.value, style=_STYLE[r.status]), detail)
199
+ console.print(table)
200
+ if has_failures(results):
201
+ console.print(
202
+ Text(
203
+ "coldrun cannot sandbox scripts until the failures above are fixed.",
204
+ style="bold red",
205
+ )
206
+ )
207
+ else:
208
+ console.print(Text("ready to run.", style="green"))
@@ -0,0 +1,82 @@
1
+ """Fetchers that place a source into a quarantine directory without executing it.
2
+
3
+ :func:`fetch` is the only entry point the pipeline uses. It picks the first
4
+ fetcher whose :meth:`Fetcher.matches` accepts the spec, gives it a fresh
5
+ quarantine directory, then seals the result with a hash and ``source.json``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import urllib.parse
12
+ from typing import TYPE_CHECKING
13
+
14
+ from coldrun.config import quarantine_root
15
+ from coldrun.fetch.base import (
16
+ MAX_SOURCE_BYTES,
17
+ Fetched,
18
+ Fetcher,
19
+ FetchError,
20
+ new_quarantine,
21
+ seal,
22
+ )
23
+ from coldrun.fetch.local import LocalFetcher
24
+ from coldrun.fetch.url import UrlFetcher
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Sequence
28
+ from pathlib import Path
29
+
30
+ __all__ = [
31
+ "MAX_SOURCE_BYTES",
32
+ "FetchError",
33
+ "Fetched",
34
+ "Fetcher",
35
+ "LocalFetcher",
36
+ "UrlFetcher",
37
+ "default_fetchers",
38
+ "fetch",
39
+ ]
40
+
41
+
42
+ def default_fetchers() -> list[Fetcher]:
43
+ """Fetchers in the order they are tried. URL forms first, paths last."""
44
+ return [UrlFetcher(), LocalFetcher()]
45
+
46
+
47
+ def _pick(spec: str, fetchers: Sequence[Fetcher]) -> Fetcher:
48
+ for f in fetchers:
49
+ if f.matches(spec):
50
+ return f
51
+ scheme = urllib.parse.urlsplit(spec).scheme or "that"
52
+ raise FetchError(
53
+ f"No fetcher understands {spec!r} ({scheme} is not supported).",
54
+ "Use a local path, an http(s) URL, or a GitHub blob URL.",
55
+ )
56
+
57
+
58
+ def fetch(
59
+ spec: str, root: Path | None = None, fetchers: Sequence[Fetcher] | None = None
60
+ ) -> Fetched:
61
+ """Fetch ``spec`` into a new quarantine directory.
62
+
63
+ Args:
64
+ spec: Local path or URL as the user typed it.
65
+ root: Parent for quarantine directories; defaults to the cache.
66
+ fetchers: Override the fetcher list; tests use this.
67
+
68
+ Returns:
69
+ The sealed :class:`Fetched` record.
70
+
71
+ Raises:
72
+ FetchError: With what failed and what to try next. The quarantine
73
+ directory is removed on failure.
74
+ """
75
+ fetcher = _pick(spec, fetchers if fetchers is not None else default_fetchers())
76
+ qdir = new_quarantine(root if root is not None else quarantine_root())
77
+ try:
78
+ file = fetcher.fetch(spec, qdir)
79
+ return seal(fetcher.kind, spec, file)
80
+ except BaseException:
81
+ shutil.rmtree(qdir, ignore_errors=True)
82
+ raise