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