diff --git a/concepts/plugins.mdx b/concepts/plugins.mdx index 4046c76..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,12 +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. 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. + `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. Subcommand + extensions are a **beta** feature behind the `features.beta` flag. ## How plugins work @@ -31,17 +47,42 @@ 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 mechanisms let a plugin participate: + +| 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 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 @@ -81,6 +122,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 +162,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 +178,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 +501,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 +533,110 @@ 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). + +## 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 @@ -216,9 +652,17 @@ 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. 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 @@ -226,7 +670,12 @@ 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` +- [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 new file mode 100644 index 0000000..8fb5331 --- /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 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 +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..92de836 100644 --- a/llms.txt +++ b/llms.txt @@ -143,7 +143,8 @@ 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 ## Languages