diff --git a/concepts/plugins.mdx b/concepts/plugins.mdx index 4046c76..4617937 100644 --- a/concepts/plugins.mdx +++ b/concepts/plugins.mdx @@ -16,12 +16,20 @@ logic; they only need to supply the configuration. Secrets retrieval is the use case that motivated plugins, and this page anchors on it. But `[plugins]` itself is general-purpose: Flox stores whatever data you put there without interpreting it, so a plugin can use -it for anything. See [Beyond secrets](#beyond-secrets) for other examples. +it for anything. And a plugin isn't limited to running a script at +activation — through [lifecycle hooks](#lifecycle-hooks), a plugin can +participate in the whole life of an environment: wrapping the session, +injecting variables into every attaching shell, running a daemon for the +activation's lifetime, or cleaning up at teardown. +[Sandboxing](/concepts/sandboxing) is the flagship consumer of those +hooks. See [Beyond secrets](#beyond-secrets) for other examples. Plugins are **experimental** and under active development. Expect much of what this page describes to change in future releases. Plugins require a - `schema-version` of `"1.14.0"` or higher in the manifest. + `schema-version` of `"1.14.0"` or higher in the manifest; [lifecycle + hooks](#lifecycle-hooks) additionally require `"1.16.0"` and a feature + flag, and are currently prototype-only. ## How plugins work @@ -31,17 +39,43 @@ A plugin has two halves: - **Configuration** lives in the manifest, under `[plugins.]`. Flox treats it as opaque data — any keys, any values — and stores it without validating its shape. -- **Behavior** lives in a package. It ships a script in its output's - `etc/profile.d/` directory — the standard way packages hook into shell - setup — and Flox sources every installed package's `profile.d` scripts - before running your manifest's `hook.on-activate`. See [Activating - environments](/concepts/activation#activation-flow) for where this fits - in the activation timeline. +- **Behavior** lives in a package, at well-known paths inside its output: + - a script in `etc/profile.d/`, sourced during activation — the + standard way packages hook into shell setup, and the only payload most + plugins need. See [Activating + environments](/concepts/activation#activation-flow) for where this + fits in the activation timeline. + - optionally, executables and scripts under `etc/flox/hooks/`, the + [hook tree](#the-hook-tree), which let a plugin participate in other + phases of the environment's lifecycle. A plugin's `profile.d` script reads its own configuration with the `flox_plugin_data` shell function, which Flox provides during activation. -Nothing else ties a package to a plugin — it's a naming convention, not a -manifest field that marks a package as one. +Hook executables receive the same table through a context file instead, +since they run outside the activation shell. Nothing else ties a package +to a plugin — it's a naming convention, not a manifest field that marks a +package as one. + +## The environment lifecycle + +A Flox environment moves through phases — it's created and edited, locked +and built, activated, attached to by additional shells, and eventually +deactivated. Each extension point below is a place where a plugin can +participate. The `profile.d` convention covers the most common need +(setup at activation start); the rest are [lifecycle +hooks](#lifecycle-hooks). + +| Lifecycle phase | Extension point | Payload | Availability | +|---|---|---|---| +| Activation: before the session starts | [`session-wrap`](#session-wrap) — exec the entire session under the plugin's control | executable | prototype | +| Activation: environment setup | `profile.d` script — runs before `hook.on-activate` | sourced script | flox 1.14.0 | +| Activation start and every shell attach | [`env`](#env) — contribute environment variables to every shell | executable | prototype | +| While the environment is active | [`sidecar`](#sidecar) — a supervised process with the activation's lifetime | executable | prototype | +| Deactivation | [`on-deactivate.d`](#on-deactivated) — teardown script, the package counterpart of the manifest's `hook.on-deactivate` | sourced script | prototype | + +Extension points for the remaining phases (init, lock, push/pull, +containerize, services) are named in the design but deliberately not +built until something needs them. ## Installing and configuring a plugin @@ -81,6 +115,13 @@ script that lets `flox_plugin_data`'s failure propagate aborts activation; one that checks for it explicitly can warn and continue instead. See [Writing a plugin](#writing-a-plugin) for both patterns. +Plugins that use [lifecycle hooks](#lifecycle-hooks) need one more piece: +a declaration in the [`[plugin-hooks]`](#declaring-hooks-plugin-hooks) +section. Unlike `[plugins.]` data, hook participation *is* +cross-referenced — a declaration without a matching installed package +fails the activation, and a shipped hook without a declaration is +ignored with a warning. + ## Writing a plugin Any package can be a plugin. What makes it one is a `profile.d` script that @@ -91,7 +132,7 @@ _data="$(flox_plugin_data vault-secrets)" # {"GH_TOKEN":"secret/github-work",. while IFS= read -r name; do path="$("${_jq:-jq}" -r --arg n "$name" '.[$n]' <<< "$_data")" - export "$name=$(vault kv get -field=value "secret/$path")" + export "$name=$(vault kv get -field=value "$path")" done < <("${_jq:-jq}" -r 'keys[]' <<< "$_data") ``` @@ -114,7 +155,10 @@ A few conventions to follow when naming and scoping a plugin: - **Name it after your package.** The plugin name doesn't have to match the package's install ID or `pkg-path`, but matching `pkg-path` makes the - connection obvious to anyone reading the manifest. + connection obvious to anyone reading the manifest. For plugins that + declare [lifecycle hooks](#lifecycle-hooks) the alignment is mandatory: + the `[plugin-hooks]` declaration, the install ID, and the shipped hook + filename must all carry the same name. - **Read only your own table.** Nothing stops a script from reading the whole manifest, but Flox won't enforce that boundary for you — stick to `[plugins.]`. @@ -127,6 +171,253 @@ The same script runs during `flox build` too, so `[build]` commands can read your plugin's exported variables — not just interactive and `flox activate -- ` sessions. +## Lifecycle hooks + + + Lifecycle hooks are a **prototype**: they exist on a development branch + of Flox, not in any release. They require a manifest `schema-version` + of `"1.16.0"` and an explicit feature flag: + `flox config --set features.plugin_hooks true` (or export + `FLOX_FEATURES_PLUGIN_HOOKS=true`). With the flag off, `[plugin-hooks]` + declarations are ignored with a warning and the activation proceeds + normally — so an environment that declares hooks stays usable for + teammates who haven't opted in. + + +`profile.d` scripts cover one moment in the lifecycle: environment setup +at activation start. Lifecycle hooks let a plugin participate everywhere +else. Each hook kind is a file at a well-known path inside the plugin +package, discovered in the rendered environment and dispatched by Flox at +the right moment. + +### The hook tree + +``` +/ +├── etc/profile.d/1000_.sh # existing: activation env setup +└── etc/flox/hooks/ + ├── session-wrap.d/ # executable + ├── env.d/ # executable + ├── sidecar.d/ # executable + └── on-deactivate.d/1000_.sh # sourced +``` + +Per-plugin files inside per-hook directories merge across packages +exactly like `profile.d` does. One caveat is load-bearing: two packages +shipping an identical leaf filename is a hard build failure, so naming +hook files after the plugin (``, or +`1000_.sh` for sourced scripts) is a requirement, not +tidiness. + +### Declaring hooks: `[plugin-hooks]` + +Executable hooks don't run just because a package ships them. The +environment's manifest must opt in, through a typed, top-level section: + +```toml +[plugin-hooks] +session-wrap = "plugin-openshell" # at most one — a string, not a list +env = ["plugin-libsandbox"] # zero or more +sidecar = ["plugin-libsandbox"] # zero or more +``` + +Each value names a plugin — the install ID of a package that must ship +the matching hook file. At activation, Flox verifies the binding in both +directions: + +- A declared plugin that isn't installed, that doesn't ship the declared + hook, whose hook file isn't executable, or whose hook file is actually + shipped by a *different* package (shadowing a plugin's name) is an + activation error. +- A shipped hook that isn't declared is ignored with a warning naming the + fix: `Ignored session-wrap hook '' shipped by an installed + package. Declare it under [plugin-hooks] in the manifest to enable it.` + +Why the declaration exists at all: installing any package already +concedes code execution at activation — every package's `profile.d` +script runs with your privileges. The declaration is not a code-execution +boundary. What it gates is three specific powers a `profile.d` script +doesn't have: + +- **session capture** — a `session-wrap` hook execs your terminal session + under code the plugin controls; +- **per-attach injection** — an `env` hook writes into every shell's + environment, for the activation's lifetime; +- **supervised lifetime** — a `sidecar` hook gets a daemon that Flox + keeps alive alongside the activation. + +`profile.d` and `on-deactivate.d` scripts have none of those powers, so +they stay undeclared. + +Unknown keys in `[plugin-hooks]` fail at parse time, and `session-wrap` +is typed as a single string, so two wrappers are unrepresentable in one +manifest. + +### Consent and composition + +Declaring a session wrapper means "activating this environment hands the +session to that plugin". Flox makes sure that's always something *you* +wrote, and something you agree to: + +- **Only the top-level manifest's `[plugin-hooks]` section is + effective.** When one environment [includes](/concepts/composition) + another, an included manifest's `[plugin-hooks]` section is dropped + during composition, with a message naming the include. Plugin *data* + tables flow through includes; hook *participation* does not — a + declaration can never arrive from a manifest you didn't author. To + enable an included environment's plugin hooks, restate the declaration + in your own manifest. +- **[Auto-activation](/concepts/auto-activation) asks first.** Entering a + directory whose environment declares a session wrapper prompts before + handing over the session, and the default is No: + + ``` + Enter ''? Activation hands this session to plugin ''. [y/N] + ``` + + Bare Enter declines; declining is remembered for the rest of the shell + session (cleared when you leave the directory). The prompt appears on + every entry, even for directories you've allowed with + `flox activate allow` — a prior allow may predate the wrap + declaration — and accepting starts a foreground session rather than + the usual in-place activation. On fish and tcsh, or without a + terminal, no prompt is shown — a notice points at running + `flox activate` yourself instead. + +### The hook protocol + +Executable hooks share one invocation contract. Flox writes a JSON +context file readable only by you (mode `0600` for `session-wrap` and +`env` hooks; the sidecar's context lives inside its private `0700` +runtime directory) and invokes the hook with: + +- `FLOX_HOOK_CTX` — path to the context file +- `FLOX_HOOK` — the hook kind: `session-wrap`, `env`, or `sidecar` +- `FLOX_PLUGIN_NAME` — the plugin whose hook is being invoked +- `FLOX_HOOK_JQ` — a guaranteed `jq`, so shell-scripted hooks can parse + the context without depending on one +- `FLOX_BIN` — the invoking `flox` binary (`session-wrap` only) + +The context is versioned (`ctx_version`) and its fields vary by hook +kind, but every hook receives `plugin_table` — its own +`[plugins.]` table as verbatim JSON. This is how hook executables +read their configuration: they run outside the activation shell, so the +`flox_plugin_data` function isn't available to them. + +Hooks are language-agnostic — a hook with real logic can be a compiled +binary shipped in the package; simple ones stay shell. Shell hooks run +with the invoking user's environment before any activation setup, which +on macOS can mean bash 3.2 — keep them compatible. + +### session-wrap + +The marquee hook: it runs the entire activation session under the +plugin's control. Flox dispatches it during `flox activate`, after the +environment is locked, built, and rendered, immediately before the +session would start. The hook composes whatever boundary it implements — +an OS sandbox, a container, a remote hand-off — and **execs the +activation inside it; on success it never returns**. A hook that returns +instead fails the activation: an environment that declares a wrapper +either activates wrapped or not at all. + +The context gives a wrapper two ways to re-enter the activation: +`inner_argv`, a host-side argv sufficient for same-filesystem boundaries +that re-exec `flox` under a wrapper process, and `invocation_type`, the +structured form of how the user invoked activation (interactive, `-c` +shell string, or `-- cmd` argv), from which container and remote +boundaries compose their own in-boundary command. + +Rules Flox enforces around the wrap: + +- **One wrapper per manifest**, structurally (see the schema above). +- **Re-entry is detected, nesting is refused.** The hook marks the + wrapped process with a scope value from its context + (`_FLOX_SESSION_WRAPPED`); re-activating the same environment inside + its own boundary skips the wrap, while activating a *different* + wrapping environment inside it is an error. +- **In-place activation is refused.** `eval "$(flox activate)"` cannot + hand your current shell to a wrapper. +- Stdio is inherited but not guaranteed to be a terminal — a hook that + wants to prompt must check the tty state the context provides and talk + to the terminal directly, never stdout. + +### env + +An executable that contributes environment variables — at activation +start and again at every shell [attach](/concepts/activation#attaching), +which is the seam `profile.d` scripts don't reach (attaching shells +replay the recorded activation environment rather than re-running +setup). The hook prints a JSON object of variables on stdout: + +```json +{ "MY_VAR": "value", "LD_PRELOAD": "/nix/store/.../lib/mediate.so" } +``` + +Contract essentials: + +- Runs on every attach, so it must be **fast and idempotent** — check + before appending to path-like variables. +- Multiple declared `env` hooks run in lexical plugin-name order, + last-writer-wins; their contributions are reapplied to each shell and + win over values set by `profile.d` scripts or the user's hooks. +- **Fail-closed**: a non-zero exit or malformed output fails the + activation or attach. This is a declared control surface, not + best-effort decoration. +- `_FLOX_`-prefixed variables are rejected — Flox's own control state + can't be forged through this channel. + +### sidecar + +A long-running process with the activation's lifetime, supervised by the +same Flox process that supervises [services](/concepts/services). The +generic form of "my plugin needs a daemon": a policy broker, a proxy, a +watcher. + +Supervision contract: + +- Spawned at activation start with the hook context plus a private + runtime directory (mode `0700`, for sockets) beside the services + socket. **Spawn failure fails the activation.** +- A crash mid-activation is logged and non-fatal; there is no automatic + restart. Design plugins to fail closed on a dead sidecar. +- At teardown the sidecar is terminated (SIGTERM, a grace period, then + SIGKILL) and its runtime directory removed — after services shut down, + before `on-deactivate.d` scripts run. +- Its stdio is detached; a sidecar that needs to log writes its own + files, conventionally under the plugin's cache directory. + +### on-deactivate.d + +The package counterpart of the manifest's `hook.on-deactivate`: shell +scripts at `etc/flox/hooks/on-deactivate.d/*.sh`, sourced in filename +order when the last activation of the environment ends, before the +user's own `hook.on-deactivate`. They run with the activation-end +environment replayed, `flox_plugin_data` available, output going to the +activation's log, and failures swallowed — teardown always continues. + +Like `hook.on-deactivate`, these scripts don't run when the environment +is torn down uncleanly (a killed supervisor, a removed state directory, +containers), so they suit janitorial cleanup — caches, scratch state — +not anything correctness depends on. + +### Writing and testing a hook + +Hooks are testable without publishing anything. Build the plugin package +(a `[build]` target whose output ships the hook tree), install it into a +test environment by store path, declare it, and activate: + +```console +$ flox build plugin-myname +$ cd ../test-env +$ flox install /nix/store/...-plugin-myname-0.0.1 +$ flox edit # add [plugin-hooks] declaring your hook +$ FLOX_FEATURES_PLUGIN_HOOKS=true flox activate +``` + +The cache directory blessed for plugin state is +`/.flox/cache/plugins//` — it survives across +activations and is not committed. + ## Debugging a plugin Activation runs plugin scripts silently. When one doesn't do what you @@ -150,11 +441,12 @@ The trace answers the questions that come up while writing a plugin: - **Did my script run, and when?** Each `+ source` line appears in filename order — Flox's own setup scripts first, then plugin scripts. - If no `profile.d` lines appear at all, either the environment was - already active somewhere and this activation + If no `profile.d` lines appear at all, the environment was already + active somewhere and this activation [attached](/concepts/activation#attaching) instead of re-running - setup — exit the other activation first — or the environment is in - `run` mode, which skips package `profile.d` scripts entirely. + setup — exit the other activation first. If only Flox's own `0100` + script appears, the environment is in `run` mode, which skips + package `profile.d` scripts entirely. - **What data did it receive?** Drop the `grep` and the trace shows every command inside your script as it executes, including what `flox_plugin_data` printed: @@ -178,6 +470,19 @@ The trace answers the questions that come up while writing a plugin: into an issue or capture it in CI logs. +Lifecycle hooks have a different debugging surface, since they run +outside the traced activation script: + +- Verbose mode logs each dispatch (`exec'ing session-wrap hook`, + `running env hook`) with the resolved hook path. +- A `session-wrap` hook inherits your terminal, so anything it writes to + stderr reaches you directly. +- An `env` hook's stderr is not currently captured anywhere — have the + hook write diagnostics to a file while developing it. +- Sidecar lifecycle events (spawned, exited, terminated) and + `on-deactivate.d` script output land in the activation's log + directory. + ## Plugin data in composed environments When one environment [includes](/concepts/composition) another, and both @@ -201,6 +506,10 @@ partial, key-by-key merge could hand a plugin a table its author never intended. If you compose environments that share a plugin, restate every key you want to keep in the including environment's table. +`[plugin-hooks]` sections don't merge at all: an included environment's +declarations are dropped, as described in +[Consent and composition](#consent-and-composition). + ## Beyond secrets Secrets retrieval fits `[plugins]` well because "environment variable name @@ -216,9 +525,16 @@ equally: - Inject build-time metadata, like a license key or an internal registry URL, that a package needs to configure itself correctly. -Flox doesn't distinguish these from a secrets plugin. `[plugins]` is -free-form storage plus a convention for reading it; what a given plugin -does with its table is entirely up to its author. +With lifecycle hooks the space widens from configuration to behavior: +[sandbox plugins](/concepts/sandboxing) wrap the whole session under an +isolation boundary, inject policy into every shell, and run enforcement +daemons — all as ordinary installable packages, with no sandbox-specific +code in Flox itself. + +Flox doesn't distinguish any of these from a secrets plugin. `[plugins]` +is free-form storage plus a convention for reading it; what a given +plugin does with its table — and with its hooks — is entirely up to its +author. ## Further reading @@ -226,6 +542,8 @@ does with its table is entirely up to its author. section - [Secrets management](/concepts/secrets-management) — the hand-written pattern a secrets plugin packages up +- [Sandboxing](/concepts/sandboxing) — sandbox plugins, the flagship + consumers of lifecycle hooks - [Activating environments](/concepts/activation) — where `profile.d` scripts run relative to `hook` and `profile` - [Composing environments](/concepts/composition) — how `include` merges diff --git a/concepts/sandboxing.mdx b/concepts/sandboxing.mdx new file mode 100644 index 0000000..16666c1 --- /dev/null +++ b/concepts/sandboxing.mdx @@ -0,0 +1,612 @@ +--- +title: "Sandboxing" +description: "Isolating Flox environments and agent sessions using sandbox plugins" +--- + +A growing share of what runs inside a developer environment isn't typed +by a developer: coding agents, build tools, and scripts pulled from the +ecosystem all execute with your full privileges. By default an activated +environment can read `~/.ssh`, your browser profiles, and your cloud +credentials, and can talk to any host on the network. + +**Sandboxing** runs the activated session inside a boundary that limits +what it can touch. In Flox, sandboxing is not a CLI feature — it is a +family of [plugins](/concepts/plugins) built on the plugin framework's +[lifecycle hooks](/concepts/plugins#lifecycle-hooks). Flox core provides +generic extension points (wrap the session, inject variables, run a +supervised daemon); each sandbox backend is an ordinary installable +package that uses them. The same pattern as +[secrets management](/concepts/secrets-management) — a class of problem +solved by a class of plugin — applied to isolation. + + + Sandbox plugins are a **prototype**. They require a development build + of Flox with lifecycle-hook support, a manifest `schema-version` of + `"1.16.0"`, and the `features.plugin_hooks` flag enabled + (`flox config --set features.plugin_hooks true`), and the + plugin packages are not yet published to the Flox Catalog — they are + built from the [flox-plugins](https://github.com/flox/flox-plugins) + repository and installed by store path. Expect the details below to + change. + + +## The sandboxed activation pattern + +```mermaid +flowchart TD + A["Manifest declares a sandbox plugin in [plugin-hooks]"] --> B["flox activate locks, builds, and renders the environment"] + B --> C["Flox verifies the declaration against the installed plugin"] + C --> D["The plugin's session-wrap hook builds its boundary"] + D --> E["The hook execs the activation inside the boundary"] + E --> F["The whole session runs sandboxed until exit"] +``` + +The pattern has three phases: + +### 1. Declare (in the manifest) + +The environment's author installs a sandbox plugin (by store path, +while the packages are unpublished — see the warning above) and +declares it in the typed, top-level `[plugin-hooks]` section, with any +policy the plugin supports in its own `[plugins.]` table: + +```toml +[plugin-hooks] +session-wrap = "plugin-openshell" + +[[plugins.plugin-openshell.network]] +endpoint = "api.github.com:443" +access = "read-only" +binary = "curl" +``` + +The manifest carries the *policy* — which plugin wraps the session, what +the session may reach — versioned with the project like any other +manifest content. Policy edits take effect on the next activation. + +### 2. Consent (at activation) + +Handing a terminal session to third-party code is gated on the +environment's own author and on the person activating: + +- Only the top-level manifest's `[plugin-hooks]` declaration counts — a + declaration arriving through [composition](/concepts/composition) is + dropped, so an included environment can never wrap your session. +- [Auto-activation](/concepts/auto-activation) prompts before entering a + wrapping environment, defaulting to No. +- With the feature flag off, declarations are ignored with a warning and + activation proceeds unwrapped — teammates who haven't opted in aren't + locked out of a shared environment. + +### 3. Enforce (for the session's lifetime) + +For a session-boundary plugin, the hook execs the entire activation +under its boundary and never returns: there is no "decline the sandbox +and continue unwrapped" path. Every process in the session — your +shell, its children, anything an agent spawns — lives inside the +boundary until the session exits. + +Not every sandbox plugin completes phase 3 the same way: +[advisory mediation](#advisory-mediation-libsandbox) replaces the +boundary with per-access mediation armed through the `env` and +`sidecar` hooks, and the [hand-off plugins](#hand-off-sandboxes) stop +after compiling policy and generating launch artifacts, leaving the +launch — and with it the enforcement — to an operator on the vendor's +platform. + +## Key security properties + +- **Policy in the manifest, values nowhere** — the manifest declares + *what may be reached*, reviewable in a PR like any other change +- **Consent is structural** — declarations are typed, top-level, and + never inherited through includes; auto-activation asks first and + defaults to No +- **One wrapper per environment** — the schema makes a second + `session-wrap` declaration unrepresentable +- **Declaration is bound to the package** — the hook file must be + shipped by the declared plugin's own locked package; a look-alike + package shadowing a plugin's name is an activation error +- **Fail closed** — a wrapping environment activates wrapped or not at + all; a sandbox plugin that can't build its boundary fails the + activation rather than silently degrading +- **No sandbox code in Flox core** — every backend is a package you can + read, pin, replace, or write yourself + +## Three shapes of sandbox plugin + +Sandbox plugins currently come in three shapes: + +- **Session boundaries** use the `session-wrap` hook to exec the whole + session inside an isolation mechanism entered right from + `flox activate` — an OS-level sandbox (`plugin-host-native`, + `plugin-srt`) or a container (`plugin-oci`, `plugin-openshell`). + Strong containment for the wrapped session. +- **Hand-off generators** (the [hand-off plugins](#hand-off-sandboxes)) + use the same hook for runtimes flox can't enter by itself. They + compile the manifest's policy into the vendor's vocabulary and + generate launch artifacts, then deliberately stop the activation; + containment is delivered by the vendor's platform once an operator + completes the launch. +- **Advisory mediation** ([`plugin-libsandbox`](#advisory-mediation-libsandbox)) + uses the `env` and `sidecar` hooks to arm an in-process interposer + instead: the session stays on your host, in your shell, with file and + network access from cooperative tools mediated, audited, and — in + prompt mode — interactively grantable. Friction plus audit rather than + containment. + +## Session boundaries + +Each tab shows the manifest configuration for one boundary plugin and +what it does at activation. All of them refuse in-place activation +(`eval "$(flox activate)"` cannot be wrapped). The host-level wrappers +(host-native, srt) additionally skip re-wrapping when the same +environment re-activates inside its own boundary; the container +wrappers carry no flox CLI in the guest, so re-activation inside the +boundary does not arise. + + + + + Wraps the session with macOS's built-in `sandbox-exec` and a generated + Seatbelt profile — no third-party tooling at all. The policy denies + reading and writing your home directory (the files agents most often + leak: `~/.ssh`, browser profiles, cloud credentials) while re-allowing + the project itself, `~/.cache`, `~/.config`, `~/.local`, and Flox's + own state directories. `.env` files are denied even inside the + project. Everything outside the home directory is untouched, and + network egress is unrestricted. + + ```toml + [plugin-hooks] + session-wrap = "plugin-host-native" + ``` + + The policy is fixed in this version — there is no + `[plugins.plugin-host-native]` table yet. + + Because the boundary is the kernel's, it holds even for SIP-protected + system binaries that bypass advisory (preload-based) sandboxes. + + macOS only; the hook errors on other systems and points at + `plugin-srt` for a cross-platform boundary. + + + + Wraps the session with Anthropic's + [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) + (`srt`), which drives `sandbox-exec` on macOS and `bubblewrap` on + Linux and adds proxy-based network control. The generated settings + mirror the host-native deny-home policy and add **default-deny network + egress** with an empty allowlist. + + ```toml + [plugin-hooks] + session-wrap = "plugin-srt" + ``` + + The policy is fixed in this version — there is no + `[plugins.plugin-srt]` table yet, so anything needing network inside + the session fails until the policy grows an allowlist. + + Requires `srt` on `PATH` (`flox install sandbox-runtime`, or + `npm install -g @anthropic-ai/sandbox-runtime`; validated against + 0.0.71). One accepted delta from host-native: project `.env` files + stay readable, because srt's read rules have no pattern matching. + Validated end to end on macOS; the Linux leg is not yet exercised. + + + + Runs the session inside a Docker container of the environment. The + hook bakes an OCI image with `flox containerize` — cached under a + digest tag so unchanged environments never rebake — and execs + `docker run`. Only the project directory is mounted (read-write, at + its identical absolute path); the rest of the host filesystem, + including your home directory, does not exist inside the boundary. + + ```toml + [plugin-hooks] + session-wrap = "plugin-oci" + + [plugins.plugin-oci] + autobake = true # bake without prompting (default: prompt on a + # tty, fail otherwise) + # allow-stale = true # run an existing image after env changes + # image = "ref:tag" # use this image verbatim; disables baking + ``` + + The digest strips the plugin's own footprint from the lockfile before + hashing, so plugin configuration edits and plugin upgrades never + invalidate the image — only real environment changes do. + + Requires Docker (CLI and running daemon). Network egress is Docker's + unrestricted default — use `plugin-openshell` for policy-gated + egress. The guest carries no flox CLI (`flox list` and services are + unavailable in-session), and host environment variables are not + forwarded. + + + + Runs the session inside an NVIDIA OpenShell sandbox with + **deny-by-default L7 network egress**. The hook bakes the environment + into a Docker image (shared with `plugin-oci`), layers OpenShell's + guest requirements on top, compiles the manifest's network grants into + an OpenShell policy, and execs `openshell sandbox create` — the + session runs as an unprivileged sandbox user with only the project + bind-mounted. + + ```toml + [plugin-hooks] + session-wrap = "plugin-openshell" + + [plugins.plugin-openshell] + autobake = true + + [[plugins.plugin-openshell.network]] + endpoint = "api.github.com:443" # required, : + access = "read-only" # read-only | read-write | full + protocol = "rest" # rest|websocket|graphql|mcp|json-rpc + binary = "curl" # install id resolved via the lockfile + ``` + + No `[[...network]]` entries means no egress at all. Grants are scoped + per endpoint, access mode, protocol, and requesting binary — the + richest egress vocabulary of the current plugins, and the reference + the hand-off plugins' lossiness is measured against. Policy edits + apply on the next activation without rebaking the image. + + Requires the OpenShell CLI (0.0.62 or later), Docker, and a reachable + OpenShell gateway. Slated to be the first sandbox plugin released to + the Flox Catalog. + + + +## Advisory mediation: libsandbox + +`plugin-libsandbox` is the in-process alternative: no container, no +re-exec, no session hand-off. The shell you get is the shell you asked +for — with a libc interposer (`DYLD_INSERT_LIBRARIES` on macOS, +`LD_PRELOAD` on Linux) armed in every shell, mediating file and network +access outside the project from cooperative tools. It is the only +current plugin built on the `env` and `sidecar` hooks rather than +`session-wrap`: + +- the **env hook** composes the engine's policy environment (the preload + variable, allow-sets folded with saved grants, the mode) at activation + start and every attach; +- the **sidecar hook** runs a prompt broker for the activation's + lifetime, answering allow/deny verdicts and serving the review CLI. + +```toml +[plugin-hooks] +env = ["plugin-libsandbox"] +sidecar = ["plugin-libsandbox"] # only needed for prompt mode + +[plugins.plugin-libsandbox] +mode = "enforce" # off | warn | enforce | prompt +``` + +The modes: `warn` permits everything but records out-of-policy access to +an audit log (a dry run); `enforce` fails it with a permission error; +`prompt` denies and queues the access for live approval from a second +terminal. Grants and the audit trail persist as files under +`.flox/cache/plugins/plugin-libsandbox/`, and a companion `flox sandbox` +subcommand — a separately installed `flox-sandbox` executable shipped in +the same package, which flox dispatches as `flox sandbox` through an +experimental extension mechanism — lists, grants, revokes, and reviews +them: + +```console +$ flox sandbox audit # what did the session try outside policy? +$ flox sandbox allow ~/Notes # grant a path (immediate with a live + # prompt-mode broker; otherwise next activation) +``` + +Approval is deliberately out-of-band: the broker refuses grant requests +originating from inside the sandboxed session itself, so an agent cannot +approve its own access. + +Validated end to end on macOS; the Linux (`LD_PRELOAD`) leg builds from +the same sources but is not yet exercised. + + + Advisory means advisory: only libc entry points are mediated, so raw + syscalls and statically linked binaries bypass it, and on macOS + SIP-protected shells escape mediation for their own built-ins. The + target is an agent or build running cooperative tools — friction plus + an audit trail, not containment. Use a session boundary when you need + containment. + + +## Hand-off sandboxes + +Ten more plugins target sandboxes flox can't enter by itself: agent +platforms (Cursor, Devin), cloud sandboxes (Modal, E2B, Daytona, Vercel, +Ona), development platforms (Coder, Docker Sandboxes), and confidential +computing (Anjuna). Entering these needs something the hook can't do on +its own — a registry push, an account or license, special hardware, or +launching the vendor's own tool. (`plugin-coder` comes closest to a full +boundary: it runs its whole loop locally and is currently blocked at the +final workspace entry by a guest-image gap, rather than stopping by +design.) + +Each hand-off plugin does everything that *can* be done locally — runs +the preflights the runtime supports (vendor CLI and auth checks where +they exist; Ona, Devin, and Anjuna need neither, and their CLIs are at +most presence-probed to word the hand-off), bakes the environment image +where the runtime can consume one, compiles the manifest's +`[[plugins..network]]` grants into the runtime's own policy +vocabulary — then writes a launch artifact and **deliberately stops the +activation at the launch boundary** with the remaining operator steps. +Where a runtime's vocabulary can't express a grant, the plugin declares +the lossiness (refusing a grant it would have to widen, recording +scoping it can't enforce) rather than silently dropping policy. + + + + The whole loop on one machine: bakes a Docker image, pushes a + minimal Terraform template to a local Coder server, creates a + workspace from it, and execs the session over `coder ssh`. + Currently blocked at that last step — Coder's stock agent init + script needs coreutils the baked image doesn't carry — so the + workspace comes up but the session isn't entered. + + ```toml + [plugin-hooks] + session-wrap = "plugin-coder" + + [plugins.plugin-coder] + autobake = true + ``` + + Requires the Coder CLI (2.x), Docker, and a logged-in Coder server. + Network grants are declined outright — the local Docker provider + has no egress vocabulary. + + + Bakes an image, compiles grants into Modal's vocabulary + (`block_network` deny-all, or a TLS/443 domain allowlist), and + generates a complete Modal launch program under + `.flox/cache/plugins/plugin-modal/`. Stops at the launch boundary: + Modal ingests images by registry reference only, so the operator + pushes the image and runs `modal run` on the artifact. + + ```toml + [plugin-hooks] + session-wrap = "plugin-modal" + + [plugins.plugin-modal] + autobake = true + registry = "docker.io/myuser" + + [[plugins.plugin-modal.network]] + endpoint = "api.github.com:443" # 443 only + ``` + + Requires the Modal CLI (1.x, authenticated) and Docker. Grant + scoping beyond the domain (access, protocol, binary) is recorded + but not enforceable on Modal. + + + Preflights the `sbx` CLI (0.32+) and Docker, bakes an image, + compiles grants into the kit's HTTP/HTTPS domain allowlist, and + writes a sandbox kit manifest to + `.flox/cache/plugins/plugin-docker-sbx/spec.yaml`. Stops before + `sbx kit load`/`sbx run`: sbx's base-image contract (a non-root + `agent` user at uid 1000) isn't satisfied by the flox bake, so the + kit is a base for manual adaptation. + + ```toml + [plugin-hooks] + session-wrap = "plugin-docker-sbx" + + [[plugins.plugin-docker-sbx.network]] + endpoint = "api.github.com:443" # port 80 or 443 + ``` + + + Preflights the E2B CLI (1.x, authenticated) and Docker, bakes an + image, and writes `e2b.Dockerfile` and `e2b.toml` at the project + root (meant to be committed) with deny-by-default egress — E2B's + platform default is open, so the template sets + `allow_internet_access = false` explicitly. Stops with + push-and-`e2b template build` instructions. + + ```toml + [plugin-hooks] + session-wrap = "plugin-e2b" + + [plugins.plugin-e2b] + registry = "ghcr.io/acme" + + [[plugins.plugin-e2b.network]] + endpoint = "api.github.com:443" # port 80 or 443; host/SNI filtering + ``` + + + Preflights the Daytona CLI (0.9+, authenticated) and Docker, bakes + an image, compiles grants into Daytona's domain allowlist, and + writes a Python launch program that registers the image as a + snapshot and creates the sandbox. Stops at the launch boundary — + pushing the image and calling the Daytona API need credentials the + host can't supply automatically. + + ```toml + [plugin-hooks] + session-wrap = "plugin-daytona" + + [[plugins.plugin-daytona.network]] + endpoint = "api.github.com:443" + ``` + + No grants compiles to deny-all. CIDR-shaped grants are declined + (mutually exclusive with domain grants on Daytona). + + + Bakes an image and writes `.devcontainer/devcontainer.json` at the + project root (meant to be committed — Ona builds workspaces from + it), recording a compiled deny-by-default egress allowlist for the + operator to wire into Ona's enterprise network policy. Stops at the + launch boundary: opening the workspace needs an Ona account and + registry push. The Ona CLI is never executed. + + ```toml + [plugin-hooks] + session-wrap = "plugin-ona" + + [plugins.plugin-ona] + registry = "docker.io/myuser" + + [[plugins.plugin-ona.network]] + endpoint = "api.github.com:443" # 443 only + ``` + + + Devin boots snapshots from git-backed blueprints rather than OCI + images, so the hook bakes the image as the reproducible substrate, + compiles grants into a per-domain allowlist (no grants → explicit + deny-all), and writes the blueprint to + `.devin/blueprint.yaml` (committed to the repo). Stops naming the + two prerequisites: registry push and a Devin subscription. + + ```toml + [plugin-hooks] + session-wrap = "plugin-cognition-devin" + + [[plugins.plugin-cognition-devin.network]] + endpoint = "api.github.com:443" # 443 only + ``` + + + Targets Anjuna Security's enclave runtimes (AWS Nitro Enclaves, + AMD SEV-SNP, Intel SGX). Bakes an image and generates an + enclave-converter config carrying the compiled allowlist and an + attestation-binding note tying the enclave measurement to the flox + lockfile hash, plus a `build-enclave.sh` with the + `anjuna-nitro-cli` invocation. Stops at the launch boundary: the + enclave build needs a commercial license and TEE hardware. + + ```toml + [plugin-hooks] + session-wrap = "plugin-anjuna" + + [[plugins.plugin-anjuna.network]] + endpoint = "api.github.com:443" # 443 only + ``` + + + A policy compiler, not a launch wrapper: Cursor's agent CLI runs + its own host-kernel sandbox (Seatbelt on macOS, Landlock on Linux), + configured through settings files. The hook compiles network grants + into Cursor's project-scoped permission config at + `.cursor/cli.json` — web-fetch domain allowances plus a fixed deny + list for `.env*` and `*.key` files — then stops with instructions + to run `agent` in the project yourself. Nothing is baked; nothing + leaves the laptop. + + ```toml + [plugin-hooks] + session-wrap = "plugin-cursor" + + [[plugins.plugin-cursor.network]] + endpoint = "api.github.com:443" # WebFetch is HTTPS/443-shaped + ``` + + + Vercel Sandboxes boot fixed stock runtimes (Firecracker microVMs) + and can't ingest a baked image, so this is the bootstrap-shaped + wrapper: no Docker at all. The hook writes a bootstrap script that + installs Flox in-sandbox and activates the environment from + FloxHub, plus a Node launcher that creates the sandbox and streams + output. Stops at the launch boundary; the operator runs the + launcher. Requires the environment to be pushed to FloxHub first. + + ```toml + [plugin-hooks] + session-wrap = "plugin-vercel-sandbox" + + [plugins.plugin-vercel-sandbox] + runtime = "node24" # node22 | node24 | python3.13 + floxhub-ref = "/" # the pushed env the bootstrap activates + ``` + + Network grants are declined — the Vercel SDK has no per-sandbox + egress allowlist. + + + + + For the hand-off plugins with `autobake`, `allow-stale`, `image`, and + `registry` settings, the conventions match `plugin-oci`: prompt before + a bake on a tty (fail fast otherwise), and every setting has a + `FLOX_PLUGIN__*` environment-variable override for CI. + + +## Sandbox plugin reference + +| Plugin | Isolation | Network policy | Session runs | Notes | +| --- | --- | --- | --- | --- | +| `plugin-host-native` | macOS Seatbelt (kernel) | unrestricted | on the host, wrapped | deny-home incl. `.env`; macOS only | +| `plugin-srt` | Seatbelt / bubblewrap via srt | deny-all | on the host, wrapped | deny-home; macOS + Linux | +| `plugin-oci` | Docker container | unrestricted (Docker default) | in the container | project mounted at its host path | +| `plugin-openshell` | OpenShell (Docker) | deny-by-default L7 grants | in the container | per-endpoint/access/protocol/binary grants; first planned release | +| `plugin-libsandbox` | libc interposition (advisory) | TCP mediation w/ grants | on the host, unwrapped | `off\|warn\|enforce\|prompt`; `flox sandbox` review UI | +| `plugin-coder` | Coder workspace (Docker) | grants declined | workspace created; ssh entry blocked | self-hosted, whole loop local | +| `plugin-modal` | Modal cloud sandbox | 443 domain allowlist | hand-off (launcher generated) | registry push + `modal run` | +| `plugin-docker-sbx` | Docker Sandboxes microVM | 80/443 domain allowlist | hand-off (kit generated) | base-image contract needs adaptation | +| `plugin-e2b` | E2B cloud sandbox | 80/443 host allowlist | hand-off (template committed) | explicit deny-all default | +| `plugin-daytona` | Daytona cloud sandbox | domain allowlist | hand-off (launcher generated) | CIDR grants declined | +| `plugin-ona` | Ona cloud workspace | recorded, operator-wired | hand-off (devcontainer committed) | enterprise account required | +| `plugin-cognition-devin` | Devin snapshot | 443 domain allowlist | hand-off (blueprint committed) | subscription required | +| `plugin-anjuna` | TEE enclave | 443 allowlist, enclave-proxied | hand-off (enclave config) | license + TEE hardware | +| `plugin-cursor` | Cursor agent sandbox (local) | web-fetch domain allowances | operator runs `agent` | policy compiler only | +| `plugin-vercel-sandbox` | Firecracker microVM | none (grants declined) | hand-off (bootstrap + launcher) | activates from FloxHub | + +## Choosing a sandbox + +- **Quickest meaningful protection on a Mac:** `plugin-host-native` — + zero dependencies, kernel-enforced, home directory protected. +- **Cross-platform, network locked down too:** `plugin-srt`. +- **Full filesystem isolation:** `plugin-oci` — the host filesystem + simply isn't there. +- **Isolation plus controlled egress** (an agent that must reach + exactly your API and nothing else): `plugin-openshell`. +- **Observe first, contain later** — audit what a session reaches + outside the project, approve access interactively, keep your own + shell: `plugin-libsandbox`. +- **Your organization already runs on a hosted platform:** the matching + [hand-off plugin](#hand-off-sandboxes) compiles your manifest's + policy into that platform's vocabulary and generates the launch + artifacts. + +These compose with ordinary Flox workflows: because policy lives in the +manifest, a repository can ship a sandboxed-by-default environment, and +switching backends is a small manifest edit — swap the installed plugin +package, update the `[plugin-hooks]` declaration, and carry over the +plugin's `[plugins.]` policy table where it has one. + +## Writing your own sandbox plugin + +There is nothing privileged about the plugins above — each is a +directory in +[flox-plugins](https://github.com/flox/flox-plugins) containing a Flox +build environment, hook executables under `etc/flox/hooks/`, and a +README. A new backend is a new package: implement the +[`session-wrap` contract](/concepts/plugins#session-wrap) (read the +context file, build your boundary, exec the activation inside it) or the +[`env`/`sidecar` contracts](/concepts/plugins#env) for an advisory +design, and test it with a store-path install — no publishing required. +See [Lifecycle hooks](/concepts/plugins#lifecycle-hooks) for the full +protocol. + +## Further reading + +- [Plugins](/concepts/plugins) — the framework sandbox plugins are + built on, including the full lifecycle-hook contracts +- [flox-plugins repository](https://github.com/flox/flox-plugins) — the + sandbox plugin packages and per-plugin READMEs +- [Secrets management](/concepts/secrets-management) — the same + plugin-class pattern applied to secrets +- [Flox vs. containers](/concepts/flox-vs-containers) — where + container-based isolation fits relative to Flox environments +- [Activating environments](/concepts/activation) — the activation + timeline the hooks extend diff --git a/docs.json b/docs.json index dafc1ca..f5a4c67 100644 --- a/docs.json +++ b/docs.json @@ -137,6 +137,7 @@ "concepts/publishing", "concepts/secrets-management", "concepts/plugins", + "concepts/sandboxing", "concepts/flox-vs-containers" ] }, diff --git a/llms.txt b/llms.txt index 020acbf..39d8507 100644 --- a/llms.txt +++ b/llms.txt @@ -144,6 +144,7 @@ Key terms: - [Publishing](https://flox.dev/docs/concepts/publishing.md): Understanding how to publish packages with Flox - [Secrets management](https://flox.dev/docs/concepts/secrets-management.md): Managing secrets in Flox environments using just-in-time retrieval - [Plugins](https://flox.dev/docs/concepts/plugins.md): Package reusable, manifest-configured behavior as an installable component +- [Sandboxing](https://flox.dev/docs/concepts/sandboxing.md): Isolating Flox environments and agent sessions using sandbox plugins - [Flox vs. container workflows](https://flox.dev/docs/concepts/flox-vs-containers.md): Where Flox environments and container workflows differ, and how teams combine them ## Languages