From 1fa1eca17b4bc9ec9c34c6b91baab4fd5229945a Mon Sep 17 00:00:00 2001 From: Daniel Sauble Date: Tue, 1 Sep 2026 13:48:35 -0700 Subject: [PATCH 1/3] docs(concepts): document the session-wrap plugin hook Extend the plugins page with the one lifecycle hook the scoped prototype ships: session-wrap. Covers the hook tree, the typed top-level [plugin-hooks] section and its two-way binding checks, consent (top-level-only declarations, the auto-activation prompt that defaults to No), the hook protocol (context file and the five environment variables), the session-wrap rules (exec-and-never-return, re-entry marker, nesting and in-place refusal), and the store-path dev loop. Messages are quoted from the flox/flox daniel/session-wrap-hook branch; further extension points stay deferred. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PPagejDEz6zqZRXJQZp4Zr --- concepts/plugins.mdx | 358 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 344 insertions(+), 14 deletions(-) diff --git a/concepts/plugins.mdx b/concepts/plugins.mdx index 4046c76..98cea92 100644 --- a/concepts/plugins.mdx +++ b/concepts/plugins.mdx @@ -16,12 +16,18 @@ 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 a [lifecycle hook](#lifecycle-hooks), a plugin can +also wrap the whole session under its control, which is how +[sandboxing](/concepts/sandboxing) works. 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; the + [`session-wrap` hook](#lifecycle-hooks) additionally requires `"1.16.0"` + and a feature flag, and is currently prototype-only. ## How plugins work @@ -31,17 +37,38 @@ 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. 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. + - optionally, a `session-wrap` hook executable under `etc/flox/hooks/`, + which lets the plugin run the entire activation session under its + control. See [Lifecycle hooks](#lifecycle-hooks). 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. +A hook executable receives the same table through a context file instead, +since it runs 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. Two extension points let a plugin participate: + +| Lifecycle phase | Extension point | Payload | Availability | +|---|---|---|---| +| Activation: before the session starts | [`session-wrap`](#session-wrap) — run 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 | + +Further extension points for the other phases were prototyped on the +flox/flox branch `prototype/sandbox-plugins` and are deferred until +something ships that needs them. ## Installing and configuring a plugin @@ -81,6 +108,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. +A plugin that uses the [`session-wrap` hook](#lifecycle-hooks) needs 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 @@ -114,7 +148,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 a plugin that + declares a [`session-wrap` hook](#lifecycle-hooks) the alignment is + mandatory: the `[plugin-hooks]` value, the package's 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 +164,278 @@ 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, a + `[plugin-hooks]` declaration is ignored with a warning and the + activation proceeds unwrapped — so an environment that declares a hook + stays usable for teammates who haven't opted in. + + +`profile.d` scripts cover one moment in the lifecycle: environment setup +at activation start. A lifecycle hook 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. One hook exists today: +[`session-wrap`](#session-wrap), which runs before the activation +session starts. + +With the feature flag off, the warning names the flag to enable: + +```text +Ignored [plugin-hooks] because the 'plugin_hooks' feature is not enabled. +Enable it with 'flox config --set features.plugin_hooks true'. +``` + +### The hook tree + +```text +/ +├── etc/profile.d/0900_.sh # existing: activation env setup +└── etc/flox/hooks/ + └── session-wrap.d/ # executable +``` + +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 +the hook file after the plugin (``) is a requirement, not +tidiness. + +### Declaring hooks: `[plugin-hooks]` + +A hook doesn't run just because a package ships it. The environment's +manifest must opt in, through a typed, top-level section with a single +key: + +```toml +[plugin-hooks] +session-wrap = "plugin-openshell" # a string, not a list: at most one wrapper +``` + +The value names a plugin — the install ID of a package that must ship +`etc/flox/hooks/session-wrap.d/`. 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. + +At activation, Flox verifies the binding in both directions. Each of the +following fails the activation with the message shown: + +- The declared hook file is missing — the plugin isn't installed, or its + package doesn't ship the hook: + + ```text + Plugin '' declares a session-wrap hook but the environment provides none. + Expected an executable at . + Ensure the plugin package is installed and provides the hook, or remove the [plugin-hooks] declaration. + ``` + +- A hook file exists, but no installed package has the declared install + ID: + + ```text + Plugin '' is declared in [plugin-hooks] but not installed in this environment. + Add a package with install id '' to [install] before declaring its hooks. + ``` + +- The hook file is shipped by a *different* package than the declared + plugin's — a look-alike package shadowing a plugin's name: + + ```text + The session-wrap hook for plugin '' is provided by a different package. + Hooks must be shipped by the declared plugin's own package. + Remove the conflicting package or fix the [plugin-hooks] declaration. + ``` + +- The hook file isn't executable: + + ```text + The session-wrap hook for plugin '' is not executable. + The plugin package must ship etc/flox/hooks/session-wrap.d/ with the executable bit set. + ``` + +In the other direction, a shipped hook that isn't declared is ignored +with a warning naming the fix: + +```text +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 one specific power a `profile.d` script +doesn't have: **session capture** — a `session-wrap` hook execs your +terminal session under code the plugin controls. `profile.d` scripts +have no such power, so they stay undeclared. + +### 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 notice naming the include: + + ```text + Ignored [plugin-hooks] declared by included environment ''. + Declare plugin hooks in this environment's manifest to enable them. + ``` + + 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 hook, restate the + declaration in your own manifest. +- **[Auto-activation](/concepts/auto-activation) asks first.** A + directory whose environment declares a session wrapper is never + activated in place by the prompt hook. Instead, entering it prompts + before handing over the session, and the default is No: + + ```text + Enter ''? Activation hands this session to plugin ''. [y/N] + ``` + + Only `y` or `yes` accepts; bare Enter declines. The answer is + remembered for the current shell visit — leaving the directory clears + it, and re-entering asks again. The prompt appears even for + directories you've allowed with `flox activate allow`, since a prior + allow may predate the wrap declaration; an unregistered directory + prompts only while `auto_activate` is `prompt`, and a denied one never + does. Accepting runs `flox activate --dir ` as a foreground + session rather than the usual in-place activation: when the session + exits you are back in your original shell, with nothing activated. On + fish and tcsh, or without a terminal, no prompt is shown — a notice + points at running `flox activate` yourself instead: + + ```text + Run 'flox activate --dir ' to enter this environment (activation is handled by plugin ''). + ``` + +### The hook protocol + +Flox writes a JSON context file readable only by you (mode `0600`, in +Flox's temporary directory) and invokes the hook with five environment +variables: + +- `FLOX_HOOK_CTX` — path to the context file +- `FLOX_HOOK` — the hook kind, `session-wrap` +- `FLOX_PLUGIN_NAME` — the plugin whose hook is being invoked +- `FLOX_BIN` — the invoking `flox` binary, for hooks that need to run + Flox commands themselves (an image bake with `flox containerize`, say) +- `FLOX_HOOK_JQ` — a `jq` bundled with Flox, so shell-scripted hooks can + parse the context without depending on one + +The context is versioned (`ctx_version`, currently `1`) and carries +`plugin_table` — the plugin's own `[plugins.]` table as verbatim +JSON, or `null` when the manifest has no table for it. This is how a +hook executable reads its configuration: it runs outside the activation +shell, so the `flox_plugin_data` function isn't available to it. + +Hooks are language-agnostic — a hook with real logic can be a compiled +binary shipped in the package; simple ones stay shell. The hook inherits +your environment, working directory, and stdio, and runs before any +activation setup — which on macOS can mean bash 3.2 for a shell hook, so +keep it compatible. + +### session-wrap + +The hook runs the entire activation session under the plugin's control. +Flox dispatches it during `flox activate`, after the environment is +locked, built, and rendered (hooks are discovered in the rendered +environment), immediately before the session would start. The hook +composes whatever boundary it implements — an OS sandbox, a container — +and **execs the activation inside it; on success it never returns**. The +hook replaces the `flox` process, so its exit status becomes the +activation's: a hook that can't hand off must exit non-zero and say why +on stderr. There is no "decline and continue unwrapped" path — an +environment that declares a wrapper either activates wrapped or not at +all. + +The context a `session-wrap` hook receives: + +| Field | Meaning | +|---|---| +| `ctx_version` | Context schema version, `1` | +| `dot_flox_path` | Absolute path to the environment's `.flox` directory | +| `env_name` | The environment's name | +| `activation_mode` | `dev` or `run` | +| `rendered_env` | Store path of the rendered environment | +| `lockfile_path` | Path to the environment's lockfile | +| `plugin_table` | The plugin's `[plugins.]` table, or `null` | +| `invocation_type` | How `flox activate` was invoked, with its payload | +| `stdin_is_tty`, `stdout_is_tty` | Whether stdin and stdout are terminals | +| `inner_argv` | The host-side argv to re-exec under a boundary | +| `wrap_scope` | The re-entry marker value (see below) | + +The context gives a wrapper two ways to re-enter the activation. +`inner_argv` is the `flox activate` invocation itself — an argv starting +with the absolute path of the `flox` binary — sufficient for a boundary +that shares the host filesystem and can simply re-exec `flox` under a +wrapper process. `invocation_type` is the structured form of how you +invoked activation — `"interactive"`, `{"shellcommand": ""}` +for `-c`, or `{"execcommand": ["", "", ...]}` for +`-- ` — from which a container boundary composes its 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 exports + `_FLOX_SESSION_WRAPPED=` on the wrapped process. When the + same environment re-activates inside its own boundary, the marker + matches and Flox skips the wrap; activating a *different* wrapping + environment inside it is an error, because nested boundaries are + unsupported: + + ```text + Cannot activate this environment inside another environment's session-wrap boundary. + Exit the wrapped session first, then run 'flox activate' again. + ``` + +- **In-place activation is refused.** `eval "$(flox activate)"` cannot + hand your current shell to a wrapper: + + ```text + Cannot activate in-place an environment that declares a session-wrap plugin. + An 'eval "$(flox activate)"' cannot hand the current shell to plugin ''. + Run 'flox activate' to enter a wrapped session instead. + ``` + +- **Ephemeral activations skip the wrap.** The ephemeral activation + that `flox services start` and `flox services restart` perform to + launch a new process-compose instance is never wrapped. +- Stdio is inherited but not guaranteed to be a terminal — + `flox activate -- cmd | tee` reaches the hook with stdout a pipe. A + hook that wants to prompt must check the tty state the context + provides and talk to the terminal directly (`/dev/tty` or stderr), + never stdout. + +### 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 @@ -178,6 +487,15 @@ The trace answers the questions that come up while writing a plugin: into an issue or capture it in CI logs. +A `session-wrap` hook has a different debugging surface, since it runs +outside the traced activation script: + +- Pass `-vv` (debug-level logging) and Flox logs the dispatch — + `exec'ing session-wrap hook` — with the plugin name and the resolved + hook path. +- The hook inherits your terminal, so anything it writes to stderr + reaches you directly. + ## Plugin data in composed environments When one environment [includes](/concepts/composition) another, and both @@ -201,6 +519,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 +declaration is dropped, as described in +[Consent and composition](#consent-and-composition). + ## Beyond secrets Secrets retrieval fits `[plugins]` well because "environment variable name @@ -216,9 +538,15 @@ 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 the `session-wrap` hook the space widens from configuration to +behavior: a [sandbox plugin](/concepts/sandboxing) runs the whole +session inside an isolation boundary — an ordinary installable package, +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 hook — is entirely up to its +author. ## Further reading @@ -226,6 +554,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) — the OpenShell plugin, the first + consumer of the `session-wrap` hook - [Activating environments](/concepts/activation) — where `profile.d` scripts run relative to `hook` and `profile` - [Composing environments](/concepts/composition) — how `include` merges From 110cbd5567095ab7975423d8736140a36c75edec Mon Sep 17 00:00:00 2001 From: Daniel Sauble Date: Tue, 1 Sep 2026 13:48:35 -0700 Subject: [PATCH 2/3] docs(concepts): add a sandboxing page for the OpenShell plugin Sandboxing is a plugin built on the session-wrap hook, not a CLI feature. The page walks the declare / consent / enforce pattern, the security properties it rests on, and the OpenShell plugin as its only current implementation: what the hook does, requirements, the manifest policy table, image caching, and limitations. Adds the page to the Concepts nav and regenerates llms.txt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PPagejDEz6zqZRXJQZp4Zr --- concepts/sandboxing.mdx | 251 ++++++++++++++++++++++++++++++++++++++++ docs.json | 1 + llms.txt | 1 + 3 files changed, 253 insertions(+) create mode 100644 concepts/sandboxing.mdx diff --git a/concepts/sandboxing.mdx b/concepts/sandboxing.mdx new file mode 100644 index 0000000..5656469 --- /dev/null +++ b/concepts/sandboxing.mdx @@ -0,0 +1,251 @@ +--- +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 +[plugin](/concepts/plugins) built on the plugin framework's +[`session-wrap` hook](/concepts/plugins#session-wrap). Flox core provides +one generic extension point (wrap the session); a sandbox is an ordinary +installable package that uses it. The same pattern as +[secrets management](/concepts/secrets-management) — a class of problem +solved by a class of plugin — applied to isolation. The first such +plugin is [OpenShell](#openshell). + + + Sandboxing is a **prototype**. It requires a development build of Flox + from the flox/flox branch + [`daniel/session-wrap-hook`](https://github.com/flox/flox/tree/daniel/session-wrap-hook), + a manifest `schema-version` of `"1.16.0"`, and the + `features.plugin_hooks` flag enabled + (`flox config --set features.plugin_hooks true`). The OpenShell plugin + is not yet published to the Flox Catalog — it is built from the + flox-plugins branch + [`daniel/openshell-plugin`](https://github.com/flox/flox-plugins/tree/daniel/openshell-plugin) + and installed by store path. Expect the details below to change. + + +## The sandboxed activation pattern + +```mermaid +flowchart TD + A["Manifest declares the 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 the sandbox plugin (by store path, +while the package is unpublished — see the warning above) and declares +it in the typed, top-level `[plugin-hooks]` section, with the 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 with a notice naming the include, 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, the declaration is 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) + +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. A +hook that can't build its boundary exits non-zero instead, and the +activation fails with it. + +## 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 exits non-zero, + failing the activation rather than silently degrading +- **No sandbox code in Flox core** — the backend is a package you can + read, pin, replace, or write yourself + +## OpenShell + +`plugin-openshell` runs the session inside an +[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandbox with +**deny-by-default network egress**, enforced at layer 7. At activation, +its `session-wrap` hook: + +1. bakes the environment into a Docker image with `flox containerize`; +2. layers OpenShell's guest requirements on top — the `sandbox` user, + writable home and runtime directories, `/bin/sh`, and trusted `ip` + and `nsenter` binaries from a tools environment bundled in the + plugin package; +3. compiles the manifest's `[[plugins.plugin-openshell.network]]` grants + into an OpenShell policy; +4. execs `openshell sandbox create`, which runs the image's entrypoint + — the environment's own activation — under OpenShell's supervisor. + +The session runs as the unprivileged `sandbox` user with only the +project directory bind-mounted, read-write, at its host path. The rest +of the host filesystem, your home directory included, does not exist +inside the boundary, and no network endpoint is reachable unless the +manifest grants it. The sandbox deletes itself when the session exits. + +### Requirements + +- The OpenShell CLI, 0.0.62 or later, on `PATH` (validated against + 0.0.82) +- Docker — the CLI and a running daemon +- A reachable OpenShell gateway (`openshell status` must succeed) using + the Docker compute driver with `enable_bind_mounts = true` + +The hook checks each of these before doing anything else and fails the +activation with a pointer at the missing piece. + +### Installing and configuring the plugin + +Build the package from the `openshell` directory of the flox-plugins +checkout, then install it into the environment by store path, keeping +the default install ID `plugin-openshell`: + +```console +$ flox build plugin-openshell +$ cd /path/to/project +$ flox install /nix/store/...-plugin-openshell-0.1.0 +``` + +Then declare the wrapper and its policy: + +```toml +[plugin-hooks] +session-wrap = "plugin-openshell" + +[plugins.plugin-openshell] +autobake = true # bake without prompting (default: prompt on a tty, fail otherwise) +# allow-stale = true # run an existing image after env changes instead of rebaking +# image = "ref:tag" # use this image verbatim; disables baking + +[[plugins.plugin-openshell.network]] +endpoint = "api.github.com:443" # required, : +access = "read-only" # read-only | read-write | full (default: full) +protocol = "rest" # rest | websocket | graphql | mcp | json-rpc (default: rest) +binary = "curl" # install ID[/exe] or absolute guest path +``` + +No `[[plugins.plugin-openshell.network]]` entries means no egress at +all. Grants are scoped per endpoint, access mode, protocol, and +requesting binary. `binary` names a package in `[install]`, resolved +through the lockfile to its store path for the guest system (or an +absolute path inside the guest). In practice every rule needs one: +OpenShell 0.0.8x runs its policy engine in binary-identity mode, and +endpoint grants without a `binary` are denied. + +Each `[plugins.plugin-openshell]` setting has an environment-variable +override for CI: `FLOX_PLUGIN_OPENSHELL_AUTOBAKE`, +`FLOX_PLUGIN_OPENSHELL_ALLOW_STALE`, and `FLOX_PLUGIN_OPENSHELL_IMAGE`. + +### Images and caching + +The image is tagged with a digest of the lockfile with the plugin's own +footprint stripped — its `[plugin-hooks]` declaration, its +`[plugins.plugin-openshell]` table, and its `[install]` entry — so +policy edits and plugin upgrades never invalidate the image; only real +environment changes do. The compiled policy is written to +`.flox/cache/plugins/plugin-openshell/openshell-policy.yaml` on every +activation and handed to OpenShell at launch, which is why network +grants apply on the next activation without a rebake. + +When no image exists for the current digest, the hook prompts to bake +on a terminal and fails otherwise; `autobake = true` bakes without +asking, and `allow-stale = true` runs an existing image from a previous +digest instead. The images are the cache: after a rebake the plugin +removes superseded `-openshell` tags (keeping the current digest +and `latest`), and the base `flox containerize` images are never +removed. + +### Limitations + +- **No `flox` CLI inside the guest.** The session runs the environment's + activation, but `flox` commands and `[services]` are unavailable + in-session. +- **Host environment variables are not forwarded.** The guest sees the + image's baked configuration, not your shell's variables. +- **In-place activation is refused.** `eval "$(flox activate)"` cannot + be wrapped, as with any session wrapper. +- **Other store-path installs fail the bake.** A host-only store path in + `[install]` — other than the plugin itself, which is stripped — cannot + be realized for the guest. +- **Backslashes in `-- ` arguments are stripped** somewhere in + OpenShell's `sandbox create … -- ` transport (observed with + 0.0.82: `curl -w '%{http_code}\n'` arrives as `%{http_code}n`). Avoid + backslash escapes in `flox activate -- ` arguments; a quoted + `bash -c` string is unaffected in practice for common cases. +- **Validated on macOS only**, with a local gateway. The Linux host leg + has not been exercised. + +## Writing your own session boundary + +There is nothing privileged about the OpenShell plugin — it is a +directory in [flox-plugins](https://github.com/flox/flox-plugins) +containing a Flox build environment, one executable at +`etc/flox/hooks/session-wrap.d/plugin-openshell`, a small pre-locked +tools environment the guest image needs, 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 — 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 `session-wrap` contract +- [flox-plugins repository](https://github.com/flox/flox-plugins) — the + OpenShell plugin package and its README +- [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 hook extends 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 From d035c8b8e16343ec341b2d0a7a90b6266ea84d1d Mon Sep 17 00:00:00 2001 From: Daniel Sauble Date: Tue, 1 Sep 2026 16:03:23 -0700 Subject: [PATCH 3/3] docs(concepts): document subcommand extensions on the plugins page The page's goal is to describe every way to extend Flox, and until now it covered only environment plugins. Frame the two mechanisms up front (environment plugins take part in an environment's lifecycle; subcommand extensions add `flox ` commands, installed per user), keep the lifecycle table for environment plugins and say why extensions sit outside it, and add a Subcommand extensions section: dispatch order and bookkeeping variables, reserved names, install/list/remove, the flox-extension.toml manifest, and how a plugin and an extension combine into one project. Facts follow the beta extension subsystem shipped in Flox 1.14.1 behind features.beta. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PPagejDEz6zqZRXJQZp4Zr --- concepts/plugins.mdx | 141 ++++++++++++++++++++++++++++++++++++---- concepts/sandboxing.mdx | 2 +- llms.txt | 2 +- 3 files changed, 132 insertions(+), 13 deletions(-) diff --git a/concepts/plugins.mdx b/concepts/plugins.mdx index 98cea92..b396380 100644 --- a/concepts/plugins.mdx +++ b/concepts/plugins.mdx @@ -1,6 +1,6 @@ --- title: "Plugins" -description: "Package reusable, manifest-configured behavior as an installable component" +description: "Extend Flox with environment plugins and subcommand extensions" --- [Secrets management](/concepts/secrets-management) shows a pattern: an @@ -16,18 +16,28 @@ 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. And a plugin isn't limited to running a script at -activation — through a [lifecycle hook](#lifecycle-hooks), a plugin can -also wrap the whole session under its control, which is how -[sandboxing](/concepts/sandboxing) works. See -[Beyond secrets](#beyond-secrets) for other examples. +it for anything. See [Beyond secrets](#beyond-secrets) for other +examples. + +There are two ways to extend Flox: + +- **Environment plugins** are packages installed into an environment that + take part in its lifecycle — a `profile.d` script at activation, or a + [`session-wrap` hook](#lifecycle-hooks) around the whole session, which + is how [sandboxing](/concepts/sandboxing) works — and are configured per + environment through the manifest. Most of this page is about them. +- **[Subcommand extensions](#subcommand-extensions)** add commands to the + `flox` CLI itself: an executable `flox-` becomes `flox `. + They are installed per user, belong to no environment, and run only + when you invoke them. 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; the [`session-wrap` hook](#lifecycle-hooks) additionally requires `"1.16.0"` - and a feature flag, and is currently prototype-only. + and a feature flag, and is currently prototype-only. Subcommand + extensions are a **beta** feature behind the `features.beta` flag. ## How plugins work @@ -59,17 +69,21 @@ package as one. A Flox environment moves through phases — it's created and edited, locked and built, activated, attached to by additional shells, and eventually -deactivated. Two extension points let a plugin participate: +deactivated. Two mechanisms let a plugin participate: -| Lifecycle phase | Extension point | Payload | Availability | +| Lifecycle phase | Mechanism | Payload | Availability | |---|---|---|---| | Activation: before the session starts | [`session-wrap`](#session-wrap) — run 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 | -Further extension points for the other phases were prototyped on the +Further hooks for the other phases were prototyped on the flox/flox branch `prototype/sandbox-plugins` and are deferred until something ships that needs them. +[Subcommand extensions](#subcommand-extensions) sit outside this table: +they extend the CLI rather than an environment, so there is no phase at +which Flox dispatches them — you run them. + ## Installing and configuring a plugin Installing a plugin is the same as installing any package, plus one step: @@ -523,6 +537,106 @@ key you want to keep in the including environment's table. declaration is dropped, as described in [Consent and composition](#consent-and-composition). +## Subcommand extensions + + + Subcommand extensions are a **beta** feature, available since Flox 1.14.1 + behind a feature flag: `flox config --set features.beta true`, or export + `FLOX_FEATURES_BETA=true`. While in beta, `flox extension` is hidden from + `flox --help`, and extensions can only be installed from a local + directory. + + +Environment plugins extend what an *environment* does. Subcommand +extensions extend what the *`flox` command* does: an executable named +`flox-` becomes `flox `, the way `git-` becomes +`git `. An extension is installed per user rather than per +environment, and Flox never runs one on its own — it runs when you invoke +it. (These are unrelated to the [IDE extensions](/install-flox/ide-extensions) +that integrate editors and coding agents with Flox.) + +### How dispatch works + +When `flox ` doesn't match a built-in subcommand, Flox looks for an +executable `flox-` — first in its managed extensions directory +(`flox-/flox-` under `$XDG_DATA_HOME/flox/extensions/`, +typically `~/.local/share/flox/extensions/`), then on `PATH` — and +replaces itself with it. Everything after the name is passed through verbatim, the +extension inherits your environment, and Flox adds three variables: + +| Variable | Value | +|---|---| +| `FLOX_EXTENSION_NAME` | The extension's name | +| `FLOX_EXTENSION_PATH` | The managed install directory, or the executable itself when it was found on `PATH` | +| `FLOX_BIN` | The `flox` binary that dispatched it, for calling back into Flox | + +Built-in command names are reserved: dispatch never fires for them, and +installing a `flox-install` is refused up front rather than leaving you +with an extension that can never run. Global options +placed before the name (`flox -v hello`) are dropped rather than +forwarded; anything after the name belongs to the extension. + +### Installing, listing, and removing + +```console +$ cd ~/src +$ flox extension install --from-path ./flox-hello +✔ Installed flox-hello -> /home/me/.local/share/flox/extensions/flox-hello +$ flox hello world +Hello from hello +args: world +$ flox extension list +NAME PATH +hello /home/me/src/flox-hello +$ flox extension remove hello +✔ Removed flox-hello +``` + +`flox extension install .` installs the current directory. Reinstalling +with `--force` is how an extension updates; `remove` deletes the install +directory and any state kept inside it. + +### Writing an extension + +An extension is a directory — conventionally named `flox-`, which +is how the name is derived when there is no manifest — containing an +executable `flox-`, written in any language. An optional +`flox-extension.toml` at the source root names it explicitly: + +```toml +schema = "1" + +[extension] +name = "hello" +description = "Says hello" +``` + +`name` is lowercase (`[a-z0-9][a-z0-9_-]*`) and must match the directory +when both are present; `description` is recorded but not yet shown by +`flox extension list`. Only the executable and `flox-extension.toml` are +copied on install; anything else in the source directory is left behind, +so keep an extension to one self-contained executable. The canonical +reference is [flox-hello-local](https://github.com/flox/flox-hello-local): +clone it and install from the working tree, and it becomes +`flox hello-local`. + +### Combining a plugin and an extension + +An environment plugin runs at activation but has no command surface of +its own; an extension has a command surface but no place in the +activation. Take the `vault-secrets` plugin above: it exports secrets +during `flox activate`, and a failed lookup surfaces only as an aborted +activation. A `flox-vault-secrets` extension, installed once per user, +gives it a command: `flox vault-secrets check` runs +`"$FLOX_BIN" list --config` to read the current environment's manifest, +pulls out the `[plugins.vault-secrets]` table, and reports which +references resolve before you activate. Invoked inside an activated +shell, the extension also inherits `FLOX_ENV_PROJECT` and +`FLOX_ENV_CACHE`, so it can read any state the plugin leaves under its +cache directory (by convention `$FLOX_ENV_CACHE/plugins//`). +Today that is two installs: `flox install` for the package, +`flox extension install` for the command. + ## Beyond secrets Secrets retrieval fits `[plugins]` well because "environment variable name @@ -546,7 +660,9 @@ 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 hook — is entirely up to its -author. +author. And when a plugin needs a command of its own — to review state, +grant access, or trigger work — a +[subcommand extension](#subcommand-extensions) provides one. ## Further reading @@ -558,5 +674,8 @@ author. consumer of the `session-wrap` hook - [Activating environments](/concepts/activation) — where `profile.d` scripts run relative to `hook` and `profile` +- [flox-hello-local](https://github.com/flox/flox-hello-local) — the + reference subcommand extension, and the in-tree + [extension guides](https://github.com/flox/flox/tree/main/cli/flox/src/beta/extensions/docs) - [Composing environments](/concepts/composition) — how `include` merges manifests diff --git a/concepts/sandboxing.mdx b/concepts/sandboxing.mdx index 5656469..8fb5331 100644 --- a/concepts/sandboxing.mdx +++ b/concepts/sandboxing.mdx @@ -13,7 +13,7 @@ credentials, and can talk to any host on the network. what it can touch. In Flox, sandboxing is not a CLI feature — it is a [plugin](/concepts/plugins) built on the plugin framework's [`session-wrap` hook](/concepts/plugins#session-wrap). Flox core provides -one generic extension point (wrap the session); a sandbox is an ordinary +one generic hook (wrap the session); a sandbox is an ordinary installable package that uses it. The same pattern as [secrets management](/concepts/secrets-management) — a class of problem solved by a class of plugin — applied to isolation. The first such diff --git a/llms.txt b/llms.txt index 39d8507..92de836 100644 --- a/llms.txt +++ b/llms.txt @@ -143,7 +143,7 @@ Key terms: - [Catalog imports](https://flox.dev/docs/concepts/catalog-imports.md): Import packages from external catalogs for Nix expression builds - [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 +- [Plugins](https://flox.dev/docs/concepts/plugins.md): Extend Flox with environment plugins and subcommand extensions - [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