Skip to content

Sandbox untrusted environment builds - #495

Open
tylerpotts wants to merge 26 commits into
mainfrom
feat/sandbox-builds
Open

Sandbox untrusted environment builds#495
tylerpotts wants to merge 26 commits into
mainfrom
feat/sandbox-builds

Conversation

@tylerpotts

@tylerpotts tylerpotts commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Reference Issues or PRs

Part of #445

Companion work: #465 fixed the pixi argument injection, and nebari-dev/nebi-pack#45 hardens the Helm chart. This PR addresses the core finding those two did not: untrusted build code executing next to credentials and other tenants' files.

What does this implement/fix?

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds a feature)
  • Breaking change (fix or feature that would cause existing features not to work as expected)
  • Documentation Update
  • Code style update (formatting, renaming)
  • Refactoring (no functional changes, no API changes)
  • Build related changes
  • Other (please describe):

Summary

Environment builds execute untrusted code: a pixi.toml can declare source or build backends that run arbitrary commands during a solve or install. Today those commands inherit the full server environment and can reach every tenant's files. This PR closes both halves at the process level.

Environment scrubbing, in every mode on every platform. Build subprocesses receive only an allowlist (PATH, HOME, TMPDIR, locale, TLS trust, proxy variables). NEBI_DATABASE_DSN, NEBI_AUTH_JWT_SECRET, queue addresses, and registry credentials never reach build code. Previously no exec.Cmd in internal/executor or internal/pkgmgr set cmd.Env at all.

Landlock confinement, on Linux. A hidden nebi sandbox-exec subcommand applies a Landlock ruleset to itself and then syscall.Execs the real build command, so pixi and everything it spawns inherit the confinement. A build can read and write only its own workspace directory, read a specific set of system paths, and open TCP connections only to configured ports. The database and queue become unreachable from build code even though they share the pod network.

Confinement needs no extra privileges. It works with runAsNonRoot, all capabilities dropped, and no user namespaces, which is what makes it compatible with the chart hardening in nebari-dev/nebi-pack#45.

Configuration

sandbox:
  mode: strict            # strict | permissive | off
  allowed_ports: [80, 443]
  build_timeout: 30m

NEBI_SANDBOX_MODE, NEBI_SANDBOX_ALLOWED_PORTS, NEBI_SANDBOX_BUILD_TIMEOUT. Mode defaults to strict in team mode and off in local/desktop mode.

mode when the kernel cannot confine
strict the job fails with a message naming NEBI_SANDBOX_MODE
permissive a warning goes to the build log, the build runs unconfined
off nothing is attempted; environment scrubbing still applies

Kernel requirements

Landlock ABI kernel what you get
v1 5.13+ filesystem confinement, but cross-directory renames fail with EXDEV
v2 5.19+ adds refer, which package managers need to stage downloads. Practical minimum
v4 6.7+ adds TCP restriction. Feature complete

Below v4 the network is unrestricted with a warning rather than a build failure, even in strict mode.

Two operator traps, both documented

  • allowed_ports: [] denies all TCP, it does not mean unrestricted. Note that the environment variable cannot express this: viper treats an empty env var as unset, so NEBI_SANDBOX_ALLOWED_PORTS="" silently yields the default. Use the YAML form.
  • Upgrading an existing team deployment starts enforcing confinement and the build timeout where neither existed. Builds on kernels older than 5.13 will fail until the operator sets permissive or off.

Notable things found while building this

Three of these were caught only because the implementation was verified against a real kernel rather than reasoned about:

  • Landlock denies reparenting in every domain unless refer is granted, and refer can only be granted in the domain that handles it. A filesystem ruleset followed by a stacked network ruleset therefore re-denies renames regardless of what the filesystem domain allowed. Package managers rename staged downloads into their cache constantly, so this would have broken every Linux build. Confine is now a single combined ruleset with a v4 → v2 → v1 ladder. Note for anyone testing this: GNU mv falls back to copy-and-unlink on EXDEV and reports success, so only a raw rename(2) exposes it.
  • Three service-layer pixi list calls bypassed the executor entirely (syncPackagesFromDisk, SyncPackagesFromWorkspace, CreateVersionSnapshot), running against user-controlled workspaces with the full server environment. Closed by adding ListPackages to the Executor interface, which also fixes a pre-existing bug where those paths ignored config.PackageManager.PixiPath.
  • Granting read on /etc would have re-exposed the secrets the allowlist removes, since nebi searches /etc/nebi/ for config.yaml and both database.dsn and auth.jwt_secret are file-loadable. Only the TLS trust stores and specific resolver files are granted.
  • config.package_manager.pixi_path was being ignored by package listing and version snapshots, which resolved pixi from PATH instead and would silently auto-install one if absent. Routing those through the executor fixed it. This surfaced as an e2e test failure: that test only passed on main because the real pixi it accidentally invoked writes pixi.lock as a side effect of pixi list, which the version snapshot then read. The test was asserting on a bug.
  • Out-of-range ports were silently truncated by uint16(port), so 70000 would have opened port 4464 while the intended port stayed closed. Validated at both the config and ruleset layers.

Testing

  • Did you test the pull request locally?
  • Did you add new tests?

Unit tests cover the environment allowlist, argv construction, mode resolution and validation, HOME scoping on both sides of the active/off branch, setup-failure detection, and the Landlock rule assembly.

internal/sandbox/confine_test.go is the acceptance test issue 445 asks for. It runs a deliberately malicious build backend (testdata/probe) confined and asserts it cannot read the planted DSN from the environment, cannot read or write a sibling workspace, cannot read /etc outside the allowlist, and cannot connect to a stand-in database port, while a legitimate build in its own workspace succeeds and cross-subdirectory renames still work. Negative cases assert on the specific errno, so a missing file or broken binary cannot masquerade as containment.

The test is //go:build linux and skips when the kernel reports no Landlock support. It is confirmed enforcing in CI, not skipping: the run logs kernel Landlock ABI version 7 and all eight subtests pass, including the positive controls (a legitimate write and a cross-subdirectory rename succeed, and an allowed port connects) that stop the negative cases from passing vacuously.

Two bugs this work surfaced in existing code

Neither is fixed here, and both predate this branch.

A real race in the create-job API contract. internal/worker/worker.go sets the workspace status to ready before calling CreateVersionSnapshot. A client that polls until ready and immediately publishes can legitimately get 400 Workspace has no versions to publish. The 250ms poll interval in the e2e test hides it. Moving the snapshot above the status flip looks like a three-line fix.

Snapshot failures are swallowed. When CreateVersionSnapshot fails it only logs. The workspace still goes ready with zero versions, and the user learns nothing until a later publish fails with a message that does not name the real cause.

Separately, cmd/nebi's e2e suite still shells out to the real pixi in most tests, which makes it slow and dependent on PATH and the network.

Not in scope

These remain open under issue 445 and should keep it open after this merges:

  • A Kubernetes Job-per-build executor. That is infrastructure-level isolation and needs client-go, chart work, queue ack/retry, and log shipping.
  • Queue visibility-timeout and ack semantics. A worker dying mid-build still leaves a job running forever, which is pre-existing.
  • Per-workspace volumes in nebi-pack.
  • Killing the whole process group on timeout. exec.CommandContext signals only the direct child, so pixi's grandchildren can outlive a timeout. They stay Landlock-confined, so this is resource cleanup rather than containment, and the fix needs a platform split for the Windows desktop build.
  • Private conda channel credentials. Because HOME is redirected when the sandbox is active, $HOME/.rattler/credentials.json does not reach confined builds. Teams using authenticated channels need off today. The right fix is a per-job credential file scoped to the workspace's declared channels, never environment inheritance.

Documentation

config.yaml.example documents every key, and docs/docs/server-setup.md gains a "Build Sandbox" section covering the two layers, the mode and ABI tables, both operator traps, the per-workspace cache trade-off, and the known limitations.

Access-centered content checklist

Text styling

  • The content is written with plain language (where relevant).
  • If there are headers, they use the proper header tags (with only one level-one header: H1 or # in markdown).
  • All links describe where they link to (for example, check the Nebari website).
  • This content adheres to the Nebari style guides.

Non-text content

  • All content is represented as text (for example, images need alt text, and videos need captions or descriptive transcripts).
  • If there are emojis, there are not more than three in a row.
  • Don't use flashing GIFs or videos.
  • If the content were to be read as plain text, it still makes sense, and no information is missing.

Design for the core isolation fix of #445: scrubbed subprocess
environments plus Landlock-based per-job filesystem and network
confinement for untrusted pixi builds.
11 tasks covering config, the sandbox package, the Landlock shim,
executor and pkgmgr integration, worker timeouts, the #445 containment
acceptance test, and operator docs.
Add Confine(), which applies a Landlock ruleset to the calling process so
that it is inherited across execve by the build command.

Also narrows the read-only path set: /etc is no longer granted wholesale.
config.go searches /etc/nebi/ for config.yaml, and both database.dsn and
auth.jwt_secret are file-loadable, so a readable /etc handed build code the
credentials the environment allowlist exists to strip. Only the TLS trust
stores and a short list of resolver/user-lookup files are granted now,
emitted as a new --allow-ro-file flag.

Filesystem confinement is attempted at ABI v2 before falling back to v1 so
the workspace can carry the "refer" right; without it Landlock denies
renaming a file between two workspace subdirectories, which package
managers do when staging a download into their cache.
The server rewrites build argv to "nebi sandbox-exec ... -- <real argv>";
this makes that subcommand exist. It confines itself with Landlock and then
execve's the real command, exiting 125 when the sandbox cannot be
established so the parent can tell a broken sandbox from a failed build.

syscall.Exec does not exist on Windows and cmd/nebi builds for
windows/amd64, so the exec step is split into _unix and _windows files.
The uint16 narrowing in Confine relied on config validation, but the shim
parses --allow-port itself, so 70000 would have wrapped to 4464 and opened
a port nobody named.
RestrictNet was skipped for an empty port list, so "allowed_ports: []"
silently granted unrestricted network access instead of the fully offline
build it reads as. The network rules are now always applied; zero rules
under a v4 ruleset that handles bind and connect denies all TCP.

Doing that exposed a second bug. The filesystem and network restrictions
were two stacked Landlock domains, and Landlock denies reparenting a file
across directories by default in every domain, with "refer" grantable only
inside the domain that handles it. The stacked network domain therefore
re-denied refer and cross-subdirectory renames in the workspace failed with
EXDEV, which is exactly what package managers do when moving a staged
download into their cache. Both halves now go into a single ruleset, tried
at ABI v4, then v2 (filesystem plus refer, network unrestricted), then v1.

Missing TCP support is reported as ErrNetworkUnrestricted and warns rather
than failing, including in strict mode, since the filesystem is confined.
The setup-failure hint no longer asserts the kernel is at fault.

Verified on kernel 6.17: combined ruleset preserves renames and truncation,
denies /etc/nebi/config.yaml, denies writes outside the workspace, allows
connect only to listed ports, and denies all TCP when the list is empty.
Command rewrote HOME to <workspace>/.nebi-home and created both job-scoped
directories in every mode, including off. That gained nothing there, since
an unconfined build can already reach the real HOME, and cost two real
things.

For source == "local" workspaces GetWorkspacePath returns the user's own
project directory, so the desktop app would have started dropping a
multi-GB conda cache into people's project folders, untracked by any
gitignore. And pixi and rattler read $HOME/.rattler/credentials.json and
$HOME/.pixi, so the rewrite broke private-channel authentication. Local and
desktop mode default to off, so both landed on exactly the users who opted
out of sandboxing.

Off mode now passes the parent's HOME and TMPDIR through the allowlist
untouched and creates neither directory. Strict and permissive keep the
job-scoped behaviour, since per-workspace cache isolation is the point.
…ve modes

Runner.IsSetupFailure only reports exit code 125 as a sandbox failure when
the sandbox is actually active. In off mode the shim never runs, so 125 can
only be the build tool's own exit code, and reporting it as "build sandbox
setup failed" sent operators after a sandbox that was never involved. The
executor call sites now go through the method; the package-level function
stays for callers with no runner in scope.

NewRunner also skips the os.Executable lookup in off mode. selfPath is only
read when building the shim argv, which off mode never does, so a
resolution failure there stopped the executor from constructing, and the
server from booting, over a value nothing would have used.
@netlify

netlify Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploy Preview for nebi-docs ready!

Name Link
🔨 Latest commit 07ba1c2
🔍 Latest deploy log https://app.netlify.com/projects/nebi-docs/deploys/6a764aabd649ba000790e23c
😎 Deploy Preview https://deploy-preview-495--nebi-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

NewRunner trusted os.Executable() blindly. When the running program is not
the nebi binary the failure was catastrophic and silent: every build
re-execs that program with "sandbox-exec ..." prepended, and Go's flag
package stops parsing at the first non-flag argument, so a Go test binary
does not reject the subcommand. It ignores it and runs its whole suite
again, starting another server that does the same. That is a fork bomb, and
it is what has been killing Backend / Test at the five minute mark with no
output.

An active-mode Runner now proves its target implements the shim before
accepting it, by running "sandbox-exec --check": a fast path that exits 0
without applying a ruleset or exec'ing anything. Once at construction, not
per command, and the error names the resolved path. Measured on the e2e
binary with team-mode defaults: 0.31s to a legible startup failure naming
/tmp/e2e_nofix2.test, no leftover processes.

Two details the probe needs to be safe. It sets NEBI_SANDBOX_SHIM_PROBE, and
any process that reaches the probe with that set refuses to probe further,
which caps an accidental chain at depth one; a real shim exits at --check
long before it could build a Runner. And it sets WaitDelay, because killing
the target on timeout does not kill what the target already spawned, and
those grandchildren hold the output pipe open. Without it a 30 second target
blocked the probe for the full 30 seconds and the timeout bought nothing.

A non-empty selfPath stays trusted: it is an in-package escape hatch for
tests that never re-exec, and production passes "".
TestMain never set NEBI_MODE, so config.Load defaulted to team mode and
resolved sandbox.mode to strict. The e2e binary is not the nebi binary, so
it cannot re-exec itself as the shim. Set the mode explicitly instead of
relying on a default that means something else here.

Survey of the other places a test builds an executor from config: only
cmd/nebi/e2e_test.go and cmd/nebi/bundle_api_e2e_test.go reach
config.Load, both via server.Run. The latter sets NEBI_MODE=local, which
resolves the sandbox to off, and now also inherits this setting. Every
other site (internal/api, internal/service, internal/executor) builds a
config.Config literal, where Sandbox.Mode is "" and NewLocalExecutor maps
that to off, so none of them can construct an active Runner.
The local-mode bundle tests pointed NEBI_PACKAGE_MANAGER_PIXI_PATH at
/usr/bin/true, so `pixi lock` in the create job produced no pixi.lock.
TestE2E_BundlePublishViaAPI_LocalMode nevertheless passed, because the
package-listing path did not honor the configured pixi path: it called
pkgmgr.New("pixi"), which resolves the real pixi from PATH (installing
one if absent). Real `pixi list` writes pixi.lock as a side effect of
resolving the manifest, and that accidental file is what let the create
job's version snapshot succeed and the workspace be publishable.

Routing package listing through the executor made the configured path
authoritative, so the stub is now actually used, no lockfile is ever
produced, the snapshot fails with "failed to read pixi.lock" and publish
returns "Workspace has no versions to publish".

Honoring the configured binary is the correct behavior, so fix the test
rather than the product: give it a pixi stand-in that reproduces the one
side effect the server depends on, `pixi lock` writing pixi.lock. It
writes the file only when absent so a workspace seeded from an imported
bundle keeps the lockfile it shipped with, and only for `lock` so that
the `--version` probe, which runs in the server's working directory,
stays inert. This also removes the suite's hidden dependency on a real
pixi being installed.
These are local agent workflow artifacts, not project documentation.
The package comment now points at the issue instead of the removed spec.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant