The trust model for recipes, hooks, and registries; what the B-workstream security fixes actually changed.
Trust is a per-registry local decision, stored in
~/.config/sparkrun/registries.yaml as a boolean trusted: field on each
entry (see RegistryEntry.trusted in core/registry.py).
A recipe is trusted when any of the following holds (see
core/launcher.py:resolve_recipe_trust):
- The user passed
--truston the CLI (hidden flag, default off). - The recipe was loaded from a local path (no
source_registryrecorded — files passed on the CLI,./recipes/,~/.config/sparkrun/recipes/). - The recipe came from a registry whose
trustedflag istruein the user's localregistries.yaml.
A recipe is untrusted otherwise — typically a third-party registry the user
added via sparkrun registry add <url> without the --trust flag, or any
registry whose name cannot be resolved against the local registries.yaml.
-
Default registries: every entry shipped in
core/registry.py:FALLBACK_DEFAULT_REGISTRIESdeclarestrusted=Trueon the entry itself. All built-in defaults are first-party recipe sources, so all of them ship trusted — includingeugrandatlas, which are not bootstrap-discovery URLs.Trust is declared per entry, not derived from
BOOTSTRAP_REGISTRY_URLS(that list exists for bootstrap-time manifest discovery and deliberately differs).FALLBACK_DEFAULT_REGISTRIESis the single source of truth for "which registries ship trusted";_default_trusted_urls()exposes it to the migration below. -
Bootstrap manifest discovery: when
_init_defaults_from_manifestssuccessfully clones a bootstrap URL and reads its.sparkrun/registry.yaml, sparkrun marks the discovered entriestrusted=Truebecause they came in via the curated bootstrap path. The manifest YAML itself cannot grant trust — only the local decision (curated bootstrap URL list, explicit user opt-in) does. -
User-added registries:
sparkrun registry add <url>lands new entries withtrusted=False. Pass--trust(or runsparkrun registry trust <name>afterwards) to opt in. -
Migration: when an existing
registries.yamlpredates thetrustedfield, sparkrun performs a one-time migration on next load, marking entries whose (normalized) URL matches a registry that ships trusted —_default_trusted_urls()— and leaving the resttrusted=False. Comparison strips a trailing/and.git, sinceeugr's default URL carries no.gitsuffix while the others do.Deriving this from the default list rather than
BOOTSTRAP_REGISTRY_URLSis deliberate: otherwise marking a registry trusted would reach only fresh installs, and anyone upgrading from a pre-trust config would silently keep it untrusted. A user who has already migrated keeps whatever theirregistries.yamlsays — re-trusting a registry their own config marks untrusted is not a decision the migration makes for them.
| Command | Effect |
|---|---|
sparkrun registry add <url> |
Add registries from a manifest (lands trusted=False) |
sparkrun registry add --trust <url> |
Add and immediately mark trusted=True |
sparkrun registry trust <name> |
Flip an existing registry to trusted=True |
sparkrun registry untrust <name> |
Flip back to trusted=False |
sparkrun registry list |
Includes a Trusted column |
sparkrun registry show <name> |
Includes a Trusted: line |
Three hook surfaces consult the trust flag (all in
orchestration/hooks.py:_confirm_hook_execution):
| Hook | Where it runs | Trust behavior |
|---|---|---|
pre_exec |
Inside the head container, before the serve command. | Trusted: runs. Untrusted: interactive confirmation prompt. |
post_exec |
Inside the head container, after the port is healthy. | Trusted: runs. Untrusted: interactive confirmation prompt. |
post_commands |
On the control machine, after the port is healthy. | Trusted: runs. Untrusted: interactive confirmation prompt. |
launcher.py:launch_inference computes recipe_trusted once and passes it to
runtime.run(...) (which gates pre_exec) and to
post_launch_lifecycle(trust=...) (which gates post_exec + post_commands).
The same recipe gets the same answer for every surface.
core/launcher.py:_enforce_recipe_mount_trust refuses these
container-escape surfaces for an untrusted recipe, at the single launch
choke point:
executor_configprivilege keys (_TRUST_GATED_EXECUTOR_KEYS):privileged,cap_add,security_opt,devices,user,volumes. Each maps to adocker runflag that defeats the rootless hardening or exposes host state, and each sits above the executor's rootlessapply_runtime_adjustmentslayer in the resolution chain — so a recipe setting them would otherwise win over the hardening.- Executor selection (
_TRUSTED_DEFAULT_EXECUTORS): restricted todocker. The rootless, namespaced container is the sandbox that justifies running a registry/URL recipe's servecommandwithout a prompt;localruns it natively viasetsid bash -candk8swedges it intokubectl run, either of which is arbitrary host code execution. - The undocumented
cluster_configlaunch overrides (resolved_model_path/remote_cache_dir/local_cache_dir), which identity-mount a host directory and repoint the serve argument at it.
Innocuous resource knobs are deliberately not gated: shm_size, ipc,
network, memory_limit, ulimit, restart_policy, auto_remove,
labels.
utils/shell.py:assert_safe_mount_source applies regardless of trust:
the host root, the Docker control socket, SSH keys, and kernel
pseudo-filesystems are refused outright, even for a trusted recipe. It
validates the literal path shape (absolute, no .., not under a forbidden
subtree) rather than trusting control-machine realpath, because the mount
happens on a remote host whose symlink layout differs.
Once a recipe is trusted — local path, a registry marked trusted,
sparkrun registry trust, or --trust — all of the above become available
to it with no second prompt for cap_add: SYS_ADMIN.
Adding and trusting a third-party registry implies trusting its recipes' privileged fields. If you don't trust a registry, don't trust it.
core/registry.py:_validate_git_url accepts only four URL schemes for
sparkrun registry add and the default-registry clone path:
https://...git@host:org/repossh://...file://...
Anything else (e.g. http://, ext::, --upload-pack=...) is rejected before
git clone is invoked, preventing argument-injection through URL parsing.
core/registry.py:RESERVED_NAME_PREFIXES (arena, spark-arena,
sparkarena, sparkrun, official, ...) may only be used by URLs hosted
under approved GitHub orgs (spark-arena, scitrera, eugr, dbotwinick,
raphaelamorim). validate_registry_name() enforces this — preventing
third-party repositories from impersonating an official source by claiming a
look-alike name.
Registry names and asset subpaths arrive from .sparkrun/registry.yaml
manifests in remote repositories (sparkrun registry add <url>, bootstrap
manifest discovery), and both are then turned into real filesystem paths. Two
distinct primitives result if they are not contained:
- A name is used verbatim as a directory under the registry cache root
(
RegistryManager._cache_diriscache_root / name). An escaping name (../…,a/b) resolves outside that root, and_link_registry_to_sharedgoes on toshutil.rmtreea cache dir that is not a link — so this is a delete primitive, not merely an untidy path. A name matching the_url_<hash>form reserved by_clone_dir_for_urlis the same hazard aimed at the shared clone its siblings on that URL depend on. - A subpath is resolved against the registry's cache dir
(
asset_diris_cache_dir(name) / subpath) and handed togit sparse-checkout set. An escaping subpath makesiter_asset_filesrgloba directory outside the clone, andfind_recipethen offers whatever YAML it finds there as a runnable recipe — a read primitive that feeds the recipe loader.
Three validators in core/registry.py contain this. Both charsets require the
first character of every path component to be alphanumeric, which rules out
./.., dotfiles, a leading - (which git would read as an option) and the
_url_ prefix in one rule:
| Function | Guards |
|---|---|
assert_safe_registry_name(name) |
non-empty, ≤100 chars, [A-Za-z0-9][A-Za-z0-9._-]* |
assert_safe_registry_subpath(subpath, field=…) |
relative, no backslash, every /-segment in the same charset; empty means "asset kind not declared" |
assert_safe_registry_entry(entry) |
the single chokepoint — name plus all four fields in SUBPATH_FIELDS |
Enforcement points, and why each behaves differently:
validate_registry_name()runsassert_safe_registry_namefirst, so an unsafe name is rejected on containment grounds before the namespace rule is consulted —../sparkrun-xdoes not start with a reserved prefix, so the namespace check alone would pass it.add_registry()additionally runsassert_safe_registry_entry, sincevalidate_registry_nameonly sees the name and this is the public programmatic entry point._discover_manifest_entries()validates every declared entry and drops unsafe ones with a warning, keeping the rest (per-entry partial success, matching the per-URL behavior of_init_defaults_from_manifests). A manifest with nothing left raises, so a wholly hostile manifest is never reported as a successful no-op add._load_registries_from_file()skips unsafe entries with a warning rather than raising. This is deliberately narrower than the enclosingexceptin_load_registries, which discards the file and reverts to the shipped defaults: one bad entry — a hand-edit, a merge, a manifest read by an older build with no charset check — must not take the user's other registries with it. The namespace check is deliberately not applied on load, since it gates adding a registry and would otherwise invalidate an existing config retroactively.
Manifest discovery clones blob-filtered and sparse (--filter=blob:none --sparse + sparse-checkout set .sparkrun): only the manifest is ever read, so
the recipe trees are never fetched. A failed sparse-checkout raises rather than
being reported as "no manifest found", so a clone whose manifest directory was
never materialized cannot be mistaken for a repo that declares nothing.
utils/shell.py is the canonical place for shell-string assembly:
quote()— wrapsshlex.quote(). Every command string interpolation passes through this.validate_unix_username(user)— used before anysudoers/ sudo script interpolation incli/_setup/. Rejects strings outside POSIX usernames so installer scripts can't be steered into arbitrary file paths.b64_encode_cmd()/b64_wrap_bash()— base64 wrappers for serve commands that contain embedded newlines, single quotes, or unicode.
orchestration/transfer.py:_run_delegated_copy validates both ends of
delegated rsync transfers:
source_hostis matched against the validated host list before any SSH invocation runs against it.destis rejected when the resolved path escapes the cache root (basic traversal containment).
runtimes/trtllm.py no longer relaxes SSH host-key checking inside the rsh
wrapper for MPI multi-node. Operators are expected to seed known_hosts via
sparkrun setup ssh-mesh (or equivalent) — strict checking now applies to
every leg.
cli/_setup/_sudo.py + _phases.py + _uninstall.py call
validate_unix_username() on every sudo_user value before it is interpolated
into sudoers fragments or script bodies. Combined with the auth_proxy CORS
tightening (limited to AUTH_PROXY_BASE, no wildcards) and the removal of
prefix token logging from debug paths, the setup surface no longer trusts
operator-supplied identifiers verbatim.
The OAuth callback CORS allowlist is restricted to AUTH_PROXY_BASE. Token
prefixes (the first N chars of a bearer token) are no longer emitted in debug
logs.
When adding a third-party registry:
- Inspect
recipes/*.yamlforpre_exec,post_exec,post_commands,executor_config.cap_add,devices,security_opt. - Confirm the registry URL matches one of the approved schemes
(
https://...,git@...,ssh://...,file://...). - Run untrusted recipes with
--dry-runfirst; the interactive trust prompt makes the per-launch posture explicit. - Use
--trustonly when you've reviewed the recipe and intend to run its privileged content.