flashnode 0.2.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.
@@ -0,0 +1,108 @@
1
+ """Tier-2 task runner: allowlisted container execution.
2
+
3
+ Same `run(payload, workdir, inputs) → outdir` interface as
4
+ `SubprocessRunner`; execution happens inside `docker run` with the AGENTS.md
5
+ security contract: image allowlist (fail closed, checked before any
6
+ subprocess), `--network none`, cpu/memory limits, read-only rootfs with the
7
+ work directory as the only writable mount, host-uid user mapping (no root in
8
+ the container's world), and a wall-clock timeout. The docker socket is used
9
+ *by the agent* to launch the task — it is never mounted into the task.
10
+
11
+ The task sees exactly one directory: its workdir bound at `/work`
12
+ (spec.json in, inputs under /work/inputs, outputs to /work/out) — so
13
+ spec.json is written with *container* paths, not host paths.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ from flashnode.executor.hardening import CONTAINER_WORKDIR, container_name, harden_args
23
+ from flashnode.executor.images import DEFAULT_ALLOWED_IMAGE_PREFIXES, image_is_allowed
24
+ from flashnode.executor.runner import DEFAULT_ALLOWED_MODULES, TaskExecutionError
25
+
26
+
27
+ class DockerRunner:
28
+ def __init__(
29
+ self,
30
+ allowed_images: frozenset[str] = frozenset(),
31
+ allowed_modules: frozenset[str] = DEFAULT_ALLOWED_MODULES,
32
+ cpus: float = 2.0,
33
+ memory_gb: float = 2.0,
34
+ timeout_seconds: float = 900.0,
35
+ allowed_image_prefixes: frozenset[str] = DEFAULT_ALLOWED_IMAGE_PREFIXES,
36
+ ):
37
+ # `allowed_images` now defaults to empty because the built-in
38
+ # namespace prefix is what a volunteer runs on. An empty exact list
39
+ # is no longer the "refusing to start" condition it once was — see
40
+ # executor/images.py for the two-knob model.
41
+ self.allowed_images = allowed_images
42
+ self.allowed_image_prefixes = allowed_image_prefixes
43
+ self.allowed_modules = allowed_modules
44
+ self.cpus = cpus
45
+ self.memory_gb = memory_gb
46
+ self.timeout_seconds = timeout_seconds
47
+
48
+ def run(self, payload: dict, workdir: Path, inputs: dict[str, Path]) -> Path:
49
+ module = payload.get("module", "")
50
+ if module not in self.allowed_modules:
51
+ raise TaskExecutionError(f"module {module!r} is not allowlisted — refusing to run")
52
+ image = payload.get("image")
53
+ if not image:
54
+ raise TaskExecutionError("payload carries no image — the docker runner requires one")
55
+ if not image_is_allowed(image, self.allowed_images, self.allowed_image_prefixes):
56
+ raise TaskExecutionError(f"image {image!r} is not allowlisted — refusing to run")
57
+
58
+ workdir = Path(workdir)
59
+ outdir = workdir / "out"
60
+ outdir.mkdir(parents=True, exist_ok=True)
61
+ (workdir / "spec.json").write_text(
62
+ json.dumps(
63
+ {
64
+ "task_id": payload.get("task_id", ""),
65
+ "params": payload.get("params", {}),
66
+ # container paths: the task only ever sees /work
67
+ "inputs": {
68
+ name: f"{CONTAINER_WORKDIR}/{Path(path).relative_to(workdir)}"
69
+ for name, path in inputs.items()
70
+ },
71
+ }
72
+ )
73
+ )
74
+
75
+ name = container_name(payload.get("task_id"))
76
+ argv = [
77
+ "docker", "run", "--rm", "--name", name,
78
+ *harden_args(workdir, cpus=self.cpus, memory_gb=self.memory_gb),
79
+ image,
80
+ "python", "-m", module,
81
+ "--spec", f"{CONTAINER_WORKDIR}/spec.json",
82
+ "--out", f"{CONTAINER_WORKDIR}/out",
83
+ ]
84
+ try:
85
+ proc = subprocess.run(
86
+ argv, capture_output=True, timeout=self.timeout_seconds, check=False
87
+ )
88
+ except subprocess.TimeoutExpired:
89
+ # subprocess.run's timeout kills the docker CLIENT process, not
90
+ # the daemon-side container — it keeps running unless we kill it
91
+ # by name ourselves (same fix as ArgvDockerRunner; the leak was
92
+ # the reason hardening.py's container_name() is shared at all).
93
+ try:
94
+ subprocess.run(["docker", "kill", name], capture_output=True, timeout=10, check=False)
95
+ except Exception:
96
+ pass
97
+ raise TaskExecutionError(f"task exceeded {self.timeout_seconds}s wall clock")
98
+ except OSError as exc:
99
+ # `docker` missing/removed mid-run raises FileNotFoundError here
100
+ # (a subclass of OSError). Degrade to a failed task, not a dead
101
+ # agent — execute_one only catches TaskExecutionError/LeaseLost.
102
+ raise TaskExecutionError(f"docker is unavailable: {exc}") from exc
103
+ if proc.returncode != 0:
104
+ tail = proc.stderr.decode(errors="replace")[-800:]
105
+ raise TaskExecutionError(f"task exited {proc.returncode}: {tail}")
106
+ if not (outdir / "metrics.json").is_file():
107
+ raise TaskExecutionError("task produced no metrics.json — nothing to commit")
108
+ return outdir
@@ -0,0 +1,141 @@
1
+ """The container security contract, in one place.
2
+
3
+ Every sandboxed runner builds its `docker run` flags here. Keeping this in a
4
+ single function is deliberate: two runners maintaining their own flag lists
5
+ drift, and the drift is invisible — the runner that quietly lost
6
+ `--cap-drop=ALL` still passes all its behavioural tests.
7
+
8
+ Changing this function changes the guarantee for ALL runners.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import sys
16
+ import uuid
17
+ from pathlib import Path, PureWindowsPath
18
+
19
+ CONTAINER_WORKDIR = "/work"
20
+
21
+ # Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*. We prefix
22
+ # with "flashnode-" (always alnum-first) so the sanitized task_id segment
23
+ # only has to avoid illegal characters, not worry about its own leading
24
+ # character.
25
+ _NAME_ILLEGAL = re.compile(r"[^A-Za-z0-9_.-]")
26
+
27
+
28
+ def container_name(task_id: object) -> str:
29
+ """A Docker-legal, collision-resistant name for this attempt's container.
30
+
31
+ Shared by every sandboxed runner (docker_runner, argv_runner) so a
32
+ timed-out `docker run` can be killed by the SAME name it was launched
33
+ with — the whole reason this lives in hardening.py rather than being
34
+ reimplemented per runner (AGENTS.md: a single security-contract seam,
35
+ not two copies that can drift).
36
+
37
+ task_id comes from an untrusted payload — sanitize it into the name
38
+ rather than interpolating it raw. A random suffix (not task_id alone)
39
+ guarantees uniqueness even when two concurrent attempts share a task_id
40
+ (e.g. a retried attempt racing a slow-to-expire prior one).
41
+ """
42
+ safe = _NAME_ILLEGAL.sub("-", str(task_id or ""))
43
+ suffix = uuid.uuid4().hex[:8]
44
+ return f"flashnode-{safe}-{suffix}" if safe else f"flashnode-{suffix}"
45
+
46
+
47
+ def _user_flag() -> list[str]:
48
+ """The `--user` flag, or nothing, depending on what the platform can
49
+ actually prove about identity.
50
+
51
+ - POSIX (`os.getuid`/`os.getgid` exist): pass the invoking user's real
52
+ uid:gid, exactly as before. Files written into the bind-mounted
53
+ workdir come back owned by the caller, not root.
54
+ - Windows: `os.getuid`/`os.getgid` don't exist, and Docker Desktop does
55
+ not map a host identity into the Linux container the way a native
56
+ Linux daemon does — there is no host uid to pass. Omitting `--user`
57
+ here is safe ONLY because every curated image
58
+ (flashml-cloud/images/*/Dockerfile) ends in a fixed non-root `USER`;
59
+ see that directory's README.md and flashml-cloud's
60
+ docs/superpowers/plans/2026-08-01-windows-hosts.md ("The trap at the
61
+ centre of this plan"). If any curated image ever regresses to
62
+ running as root, this omission silently runs strangers' code as
63
+ container root on every Windows host.
64
+ - Anything else: refuse. Security fields fail closed
65
+ (flashruntime/CLAUDE.md rule 3) — an unrecognised platform must not
66
+ silently produce a container running as an unknown, possibly root,
67
+ identity.
68
+ """
69
+ if hasattr(os, "getuid"):
70
+ return ["--user", f"{os.getuid()}:{os.getgid()}"]
71
+ if sys.platform == "win32":
72
+ return []
73
+ raise RuntimeError(
74
+ f"cannot determine a safe --user for platform {sys.platform!r}: "
75
+ "no os.getuid/os.getgid and not win32 — refusing to run "
76
+ "unprivileged-in-name-only"
77
+ )
78
+
79
+
80
+ def _bind_mount_source(workdir: Path) -> str:
81
+ """Render `workdir` as the source half of `-v src:dst`, in a form
82
+ Docker's flag parser won't misinterpret.
83
+
84
+ `-v` splits its argument on ':'. A POSIX path never contains one, so it
85
+ passes through byte-identical (this must not change — see the plan's
86
+ Global Constraints). A Windows path (`C:\\Users\\phong\\...`) contains
87
+ both backslashes AND a drive-letter colon, which is ambiguous with the
88
+ src:dst separator itself — that raw form is what breaks the split.
89
+
90
+ Detecting "is this a Windows path" by `isinstance(..., PureWindowsPath)`
91
+ rather than `sys.platform == "win32"` is deliberate: a real `Path`
92
+ passed in while running on Windows already IS a `WindowsPath`, a
93
+ `PureWindowsPath` subclass, so the same branch handles the live case
94
+ and lets tests exercise it on any host with a synthetic
95
+ `PureWindowsPath` — no `sys.platform` gate to bit-rot in CI.
96
+
97
+ Rewrites `C:\\Users\\phong\\work` to `/c/Users/phong/work`: lowercase
98
+ drive letter, forward slashes, exactly one ':' left in the whole
99
+ argument (the src:dst separator). This is the form Docker Desktop's
100
+ Windows CLI accepts for bind-mount sources.
101
+ """
102
+ if isinstance(workdir, PureWindowsPath):
103
+ drive = workdir.drive # e.g. "C:" — empty for a driveless path
104
+ if not drive:
105
+ raise ValueError(
106
+ f"Windows workdir {workdir!r} has no drive letter — cannot "
107
+ "build a Docker Desktop bind-mount source from it"
108
+ )
109
+ letter = drive.rstrip(":").lower()
110
+ rest = "/".join(workdir.parts[1:])
111
+ return f"/{letter}/{rest}" if rest else f"/{letter}"
112
+ return str(workdir)
113
+
114
+
115
+ def harden_args(
116
+ workdir: Path,
117
+ *,
118
+ cpus: float,
119
+ memory_gb: float,
120
+ pids_limit: int = 512,
121
+ ) -> list[str]:
122
+ """Docker flags common to every sandboxed task."""
123
+ return [
124
+ # the job never reaches the volunteer's LAN or the internet; the
125
+ # agent is the courier for inputs, outputs, and checkpoints
126
+ "--network", "none",
127
+ "--read-only",
128
+ "--tmpfs", "/tmp:rw,noexec,nosuid,size=256m",
129
+ *_user_flag(),
130
+ "--cap-drop=ALL",
131
+ "--security-opt=no-new-privileges",
132
+ f"--pids-limit={pids_limit}",
133
+ "--cpus", str(cpus),
134
+ # equal values: with a larger memory-swap the memory cap is
135
+ # bypassable by swapping
136
+ "--memory", f"{memory_gb}g",
137
+ "--memory-swap", f"{memory_gb}g",
138
+ "--ulimit", "nofile=1024:1024",
139
+ "-v", f"{_bind_mount_source(workdir)}:{CONTAINER_WORKDIR}",
140
+ "-w", CONTAINER_WORKDIR,
141
+ ]
@@ -0,0 +1,161 @@
1
+ """Which container images this host will run, and why it is a prefix.
2
+
3
+ A volunteer installs FlashNode and runs `flashnode work`. They do not build
4
+ an image, do not log in to a registry, and do not maintain a list of image
5
+ references — the built-in allowlist below is what makes that true. When the
6
+ cloud publishes a new curated image, hosts pick it up on their next task
7
+ with no action from their owner.
8
+
9
+ WHY A NAMESPACE PREFIX RATHER THAN EXACT REFERENCES
10
+ ---------------------------------------------------
11
+ Exact matching was the original design and it does not survive contact with
12
+ more than a handful of machines: every published image would strand every
13
+ host until its owner edited an env var, which in practice means the fleet
14
+ runs a mix of versions and security fixes never fully land.
15
+
16
+ The prefix is a namespace *we* control and publish to. It is genuinely
17
+ weaker than three fixed strings, and the honest reason that is acceptable
18
+ is this: the coordinator already tells a node which module to run, which
19
+ argv to execute, and which inputs to fetch. An attacker who could name an
20
+ arbitrary image has, by then, long since been able to name arbitrary work.
21
+ The image allowlist defends against a *confused* coordinator naming
22
+ something off-namespace — say `alpine` with a mounted socket — not against
23
+ a fully compromised one. Pinning exact tags buys nothing against the latter
24
+ while costing the whole fleet its ability to be updated.
25
+
26
+ See M1_DECISIONS.md D16.
27
+
28
+ WHAT THIS STILL REFUSES
29
+ -----------------------
30
+ - Anything outside the namespace, including near-misses that merely contain
31
+ it (`evil.example.com/ghcr.io/zolli-labs/flashml-x`).
32
+ - References with no explicit tag or digest. Docker would silently append
33
+ `:latest`, which is a floating pointer to whatever was pushed last — the
34
+ exact mutability the curated-image tags are immutable to avoid.
35
+ - Malformed references, rather than passing them to `docker run` and hoping
36
+ its parser agrees with ours about where the name ends.
37
+
38
+ TWO DIFFERENT KNOBS, TWO DIFFERENT DIRECTIONS
39
+ ---------------------------------------------
40
+ - ``FLASHNODE_ALLOWED_IMAGES`` (env, passed here as ``allowed_exact``) is
41
+ ADDITIVE. It is an operator/testing escape hatch — self-hosting the whole
42
+ stack, or an integration test running `python:3.11-alpine`. Anyone who can
43
+ set it can already run anything on that machine, so it grants nothing they
44
+ did not have.
45
+ - ``policy.json`` ``allowed_images`` is SUBTRACTIVE. That is the host
46
+ *owner's* safety policy and it intersects with whatever the above permits
47
+ (see flashnode/config): an owner can forbid more, never permit code the
48
+ build does not trust.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import re
54
+
55
+ __all__ = [
56
+ "DEFAULT_ALLOWED_IMAGE_PREFIXES",
57
+ "InvalidImageReference",
58
+ "image_is_allowed",
59
+ "split_reference",
60
+ ]
61
+
62
+ #: Namespace the cloud publishes curated task images to. Must stay in step
63
+ #: with REGISTRY_PREFIX in flashml-cloud's apps/api/flashml_cloud_api/
64
+ #: images.py — the e2e suite asserts every reference that API can emit is
65
+ #: accepted here, because this seam has broken four times before and neither
66
+ #: repo's own tests can see it (M1_DECISIONS.md D12).
67
+ DEFAULT_ALLOWED_IMAGE_PREFIXES = frozenset({"ghcr.io/zolli-labs/flashml-"})
68
+
69
+ # Deliberately stricter than the distribution spec. A reference we cannot
70
+ # confidently parse is one we cannot confidently authorize, and every
71
+ # reference we actually need is a plain lowercase path — so the safe reading
72
+ # is to reject the exotic rest rather than approximate it.
73
+ # Structure: an optional `host[:port]/`, then one or more lowercase path
74
+ # components.
75
+ #
76
+ # The leading group exists for the port. A private registry is reached as
77
+ # `registry.example:5000/team/img:2.0`, and while split_reference() gets the
78
+ # tag boundary right (only a ':' after the final '/' can start a tag), the
79
+ # name it hands back still carries that ':5000' — so a name pattern with no
80
+ # room for a port would reject every self-hosted registry as malformed.
81
+ #
82
+ # The trailing path group is `*`, not `+`: a single-component name like
83
+ # `python` is valid Docker Hub shorthand, and an owner who explicitly
84
+ # allowlists one has named something real. It can never match a namespace
85
+ # prefix (those all contain '/'), so permitting the shape costs nothing.
86
+ _NAME_RE = re.compile(
87
+ r"^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]{1,5})?/)?"
88
+ r"[a-z0-9]+(?:[._-][a-z0-9]+)*"
89
+ r"(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$"
90
+ )
91
+ _TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$")
92
+ _DIGEST_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
93
+
94
+
95
+ class InvalidImageReference(ValueError):
96
+ """A reference that could not be parsed, so could not be authorized."""
97
+
98
+
99
+ def split_reference(image: str) -> tuple[str, str]:
100
+ """Split ``image`` into ``(name, tag_or_digest)``.
101
+
102
+ Raises ``InvalidImageReference`` for anything malformed or lacking an
103
+ explicit version. Splitting is done here, once, rather than at each call
104
+ site: authorizing the *whole* string by prefix would let a crafted tag
105
+ smuggle the namespace past the check, so every caller must be comparing
106
+ the same parsed name this function returns.
107
+ """
108
+ if not isinstance(image, str) or not image or image.strip() != image:
109
+ raise InvalidImageReference(f"not a usable image reference: {image!r}")
110
+ if any(c.isspace() or ord(c) < 32 for c in image):
111
+ raise InvalidImageReference(f"image reference contains whitespace or control characters: {image!r}")
112
+
113
+ # Digest form wins: `name@sha256:...` has no tag to strip afterwards.
114
+ if "@" in image:
115
+ name, _, digest = image.partition("@")
116
+ version = digest
117
+ if not _DIGEST_RE.match(digest):
118
+ raise InvalidImageReference(f"unsupported digest in {image!r} (want sha256:<64 hex>)")
119
+ else:
120
+ # A tag cannot contain '/', so only a ':' after the final '/' can be
121
+ # a tag separator — anything earlier is a registry port.
122
+ head, sep, tail = image.rpartition(":")
123
+ if sep and "/" not in tail:
124
+ name, version = head, tail
125
+ if not _TAG_RE.match(version):
126
+ raise InvalidImageReference(f"malformed tag in {image!r}")
127
+ else:
128
+ # No explicit version. Docker would default to `:latest`; we
129
+ # refuse instead, because a floating tag means the image a host
130
+ # runs is not the image the cloud validated the job against.
131
+ raise InvalidImageReference(
132
+ f"image {image!r} has no explicit tag or digest — refusing to "
133
+ "let docker default it to :latest"
134
+ )
135
+
136
+ if ".." in name or not _NAME_RE.match(name):
137
+ raise InvalidImageReference(f"malformed image name in {image!r}")
138
+ return name, version
139
+
140
+
141
+ def image_is_allowed(
142
+ image: str,
143
+ allowed_exact: frozenset[str] = frozenset(),
144
+ allowed_prefixes: frozenset[str] = DEFAULT_ALLOWED_IMAGE_PREFIXES,
145
+ ) -> bool:
146
+ """Whether this host will run ``image``.
147
+
148
+ Fails closed: an unparseable reference is not allowed, never an
149
+ exception the caller might treat as an unrelated failure.
150
+
151
+ ``allowed_exact`` is the host owner's explicit list (env var or
152
+ policy.json) and is matched against the FULL reference — an owner naming
153
+ one exact pinned image means that image, not its namespace.
154
+ """
155
+ try:
156
+ name, _version = split_reference(image)
157
+ except InvalidImageReference:
158
+ return False
159
+ if image in allowed_exact:
160
+ return True
161
+ return any(name.startswith(prefix) for prefix in allowed_prefixes)