This repo's Nix flake and flake templates provide a convenient way to create contained development environments for AI agents. These agents run in a per-project confined shell, with outbound network traffic denied by default.
This project makes no security gaurantees. It provides a defence-in-depth layer, but limitations and vulnerabilities still remain. Use it as one mitigation among several. You are responsible for the code your agent executes.
- It is designed to mitigate: accidental exfiltration of
$HOME/.ssh, browser cookie stores, and keychain dumps; supply-chaincurl | shshell-outs to arbitrary internet hosts; package post-install scripts reading paths unrelated to the project, etc. - It is not designed to mitigate: a kernel-level escape (bubblewrap or
Seatbelt CVEs); prompt-injection that convinces the agent to use allowed
hosts maliciously (e.g. exfil via an allowlisted GitHub Gist), or the user
deliberately exposing hosts with
--allow *. - Known platform limitations/quirks (see How it works):
- macOS cannot
ping <whitelisted-ip>: the Seatbelt profile language rejects per-IP rules so the userspace proxy only covers TCP viaCONNECT/SOCKS5. Non-proxy-aware UDP and raw sockets fail closed. See ADR-0003. - On macOS,
--wl-adddoes not update a running session — changes take effect on the next launch. Linux updates live transient units.
- macOS cannot
Two prerequisites apply on every platform: install Nix, and enable flakes. The following are host specific platform setup instructions:
- NixOS → docs/nixos.md
- macOS with nix-darwin → docs/macos.md
- Non-NixOS Linux (Ubuntu, Debian, Fedora) → docs/non-nixos-linux.md
Status badges reflect the latest main functional-CI run.
| OS | Version | Status |
|---|---|---|
| NixOS | 26.05 | |
| macOS (nix-darwin) | macOS 15 & 26 / nix-darwin 26.05 | |
| Ubuntu | 26.04 | |
| Debian | 13 | |
| Fedora | 44 |
Other systemd-based Linux distributions with cgroup v2 should work —
setup-linux probes for what it needs and reports what is missing.
Please file an issue if --check passes on your distro but the wrapper
fails at runtime. See docs/testing.md for more information.
Once setup, you can run a jailed agent directly with:
nix run github:pete3n/nix-slop-dev#claudeNOTE: Running the application directly from the repo limits features and project customization options. It is intended for a quick emphemeral session with an agent in a jailed environment.
To get the full benefit from nix-slop-dev, clone a development environment template and customize it for your needs:
nix flake init -t github:pete3n/nix-slop-dev#claude-code-nvim-dev
nix developThe project ships the following tools. A full reference can be found at: docs/usage.md.
Apps (Can be directly run with: nix run github:pete3n/nix-slop-dev#):
claude— launch Claude Code inside a Slop Env, using the current directory's basename as project identity and library-bundled defaults forCLAUDE.md, rules, and skills.pi— same zero-touch entry point for Pi (earendil-works/pi)opencode— same zero-touch entry point for opencode (sst/opencode)
Zero-touch entry point allows:
-
Network allows:
api.anthropic.complatform.claude.com2607:6bc0::/32(Linux only)- Everything else denied.
-
Filesystem allows:
- current working directory (read/write)
~/.local/state/claude/projects/<projectname>~/.local/state/claude/shared/.credentials.json~/.cache~/.npm~/.local/share/claude-code- everything else in
$HOMEis hidden.
-
jail-shell— open an interactive bash shell inside the same jail without starting an agent. Useful for inspecting what the agent will see, or running a build manually under the same confinement.- Network: no per-invocation allows. Only the persistent whitelist
(
sandboxed --wl-add …) applies. - Filesystem: identical to the Apps above.
- Network: no per-invocation allows. Only the persistent whitelist
(
-
setup-linux— diagnose and configure host prerequisites for Linux distros (sudoers, auditd, AppArmor, etc.).
Full default-access matrix and how to extend it: docs/usage.md.
Packages (nix build github:pete3n/nix-slop-dev#):
sandboxed— the wrapper that creates the Sandbox (and on macOS also the Jail via Seatbelt). See docs/usage.md for its flag reference.sandbox-proxy(macOS only) — the userspace HTTPCONNECT/ SOCKS5 proxy that enforces the Host Whitelist on macOS. It is created automatically bysandboxed, and is compiled from the Go source underpackages/sandbox-proxy/(onemain.goplus in-tree subpackages —whitelist/,connect/,socks/,listener/,denial/). Thego.modhas norequireblock: imports are pure stdlib, andvendorHash = nullindefault.nixenforces that an accidental external dependency becomes a build break rather than a silent fetch. The only build-time externals are Go itself andbuildGoModule, both pinned by the flake'snixpkgsinput.prereq-guidance(Linux only) — helper that prints distro-aware setup advice from a template'sshellHookwhen prerequisites are missing.hunk— re-export of modem-dev/hunk (thehunkdiff-review tool), pinned via the flake'shunkinput so templates and the outer dev-shell consume one version. The package output also carries thehunk-reviewskill, merged into each template's skills bundle at build time.worktrunk— re-export of worktrunk (thewtgit-worktree workflow CLI) fromnixpkgs-unstable
Templates (nix flake init -t github:pete3n/nix-slop-dev#):
Templates are the primary mechanism for customising the Slop Env. Each template is a standalone Nix flake you own. Edit context, rules, skills, project packages, and Jail combinators as needed for your project.
The template's flake.nix declares one
Nix dev shell:
devShells.${system}.default = slop.mkShell {
projectName = "my-project";
agentMdFile = ./slop-env/claude-config/CLAUDE.md;
rulesDir = ./slop-env/claude-config/rules;
skillsDir = skills; # checked-in skills + hunk-review, merged at build time
projectPkgs = [ hunk worktrunk ]; # shipped by default; append your own from pkgs
projectEnv = { FOO = "bar"; }; # Set env vars to pass.
};Every template's flake.nix already wires hunk and worktrunk (the wt
worktree-workflow CLI) into projectPkgs and merges hunk's hunk-review
skill into skillsDir, so a freshly-initialised template ships with diff
review and worktree management on both the dev shell and the jailed agent.
The agent and localAi arguments (selecting a non-Claude Agent Profile and
local ollama provider(s) — including a multi-endpoint coordinator→workers
topology over loopback) are covered under Local AI and in
docs/usage.md.
In the example above, nix develop enters the shell; the shellHook runs
prereq checks and puts claude / jail-shell on PATH wired to the project's
customised Slop Env. Dev shells are Nix's standard pattern for reproducible
per-project tool environments — this project layers a Slop Env on top
so the shell's tools are also jailed.
The template's slop-env/claude-config/ tree carries:
CLAUDE.md— base context the agent always sees.rules/*.md— universal policy files concatenated ontoCLAUDE.mdat jail-build time.skills/— agent skills in Matt Pocock's skills format: each subdirectory is one skill (aSKILL.mdplus optional helper files) the agent can invoke on demand. The default template shipsdiagnose,grill-with-docs,handoff,improve-codebase-architecture,prototype,tdd,teach,to-issues,to-prd,triage,worktrunk,wt-switch-create, andzoom-out. At build time thehunk-reviewskill from thehunkpackage is merged into the same bundle (so it stays version-locked to the installedhunk), giving a freshly-initialised template diff-review and worktree-workflow skills out of the box. Add, remove, or replace freely. The whole tree is bind-mounted read-only into the Slop Env at~/.local/state/claude/projects/<projectName>/skills/.
Available templates:
claude-code— the default Slop Env as a standalone flake, intended for project-scope editing of combinators,CLAUDE.md, rules, and skills.claude-code-nvim-dev— same pluslua-language-server, Neovim plugin-dev tooling, and headless test plumbing.pi-agent— Slop Env preconfigured for Pi (earendil-works/pi) instead of Claude Code, with alocalAioption for local ollama provider(s) and coordinator→workers orchestration. Same Sandbox / Jail guarantees; see ADR-0009 for the Agent Profile abstraction behind it.opencode— Slop Env preconfigured for opencode (sst/opencode) instead of Claude Code, with alocalAioption for local ollama provider(s) and coordinator→workers orchestration. Same Sandbox / Jail guarantees; see ADR-0010 for the zero-touch divergence behind it.
Customisation recipes for combinators, project packages, and env-var forwarding live in docs/usage.md.
Host-config modules:
nixosModules.sandboxed— installssandboxedon NixOS with the sudoers and auditd integration the wrapper needs.darwinModules.sandboxed— installssandboxedon nix-darwin. Thin by design: Seatbelt is daemonless and unprivileged, so no sudoers or audit machinery is required.
Library (nix-slop-dev.lib):
lib.slopEnv pkgs— returns{ defaults; jail; mkBins; mkShell; }. Called by every template and by the zero-touch apps to compose a Slop Env.lib.jail pkgs(macOS) — Seatbelt combinator library used to build the macOS Jail. Mirrors upstream jail-nix's combinator surface so templates can write cross-platform combinator code.
A Jail hides /tmp and most of $HOME, so a jailed agent cannot drop a
file where you can see it by default. Every Slop Env — both the zero-touch
apps and the templates, on Linux and macOS — provisions two host-visible
per-project directories to bridge that boundary:
- Scratch (
$TMPDIR) — throwaway space for the agent's temporary and intermediate files. Inspect it freely, but treat it as disposable. - Exchange — the deliberate two-way handoff channel: drop inputs in for
the agent to ingest, collect outputs (e.g. handoff documents) it leaves
for you. Persists across agent runs. Surfaced to the agent under an
Agent-Profile-specific env var:
CLAUDE_EXCHANGE_DIR,OPENCODE_EXCHANGE_DIR, orPI_EXCHANGE_DIR.
The shell prints the Exchange path when you enter the Slop Env. Both live
outside the project working tree and are scoped per projectName, not
shared across projects. See CONTEXT.md for the precise
definitions and docs/usage.md for the per-agent paths.
A single Slop Env can declare several authentication identities — Accounts — and run agents under different ones simultaneously without their credentials or sessions clobbering each other (see ADR-0014, and CONTEXT.md for the term). An Account is one authentication identity: either an OAuth subscription login or an API key. It is orthogonal to the Agent Profile (which agent — Claude, Pi, opencode) and to projectName (which project). This pass covers the Claude Agent Profile on Linux only.
The contract is two slop.mkShell arguments (the same arguments mkBins accepts):
accounts— a closed registry of Accounts. Shape:accounts = { <name> = { type = "oauth" | "apikey"; keyFile = "<runtime path>"; }; }.keyFileis only fortype = "apikey", where it is required (a runtime path such as/run/agenix/<secret>— the path, never the secret, is baked; omitting it on anapikeyAccount is an eval error,attribute 'keyFile' missing);oauthAccounts have nokeyFile. Account names are constrained to[A-Za-z0-9._-]+and validated at eval/build time (so a name can never smuggle shell metacharacters, the sed delimiter, or a path separator — an unsafe name fails evaluation, not launch; pinned by tests/account-name-validation.nix).defaultAccount— optional string; the project default Account. Defaults tonull.
Both default to the no-Account path (accounts = {}, defaultAccount = null), which reproduces today's single shared-credential behaviour byte-for-byte (credentials at ~/.local/state/claude/shared/.credentials.json). Nothing changes for existing users who do not declare accounts.
Selection precedence (per run): NIX_SLOP_DEV_ACCOUNT override → else defaultAccount → else (registry non-empty, neither set) refuse with error: no Account selected. Set NIX_SLOP_DEV_ACCOUNT or a defaultAccount (known Accounts: …).
Deny-by-default — declaring a non-empty accounts opts the Slop Env into an account-required regime, mirroring the Jail/Sandbox posture:
- An override naming an Account not in the registry refuses to launch before the Jail starts, non-zero, with
error: Account <name> is not declared in this Slop Env (known Accounts: …). Refusing to launch.(pinned by tests/account-launcher.nix). - Declaring
accountswithout a concreteprojectName(the zero-touch/apps path) is refused at eval time — that launcher resolves only theprojectNameplaceholder and would otherwise emit a broken, unsubstituted launcher; Accounts require a template/mkShell/mkBinsprojectName(pinned by tests/account-zerotouch-refused.nix). - For an
apikeyAccount, the launcher reads thekeyFileat invocation and exportsANTHROPIC_API_KEYscoped to the single jailed exec only (forwarded via the Sandbox's-e ANTHROPIC_API_KEY) — never a global/parent-shell export, and the key never enters the/nix/store. An unreadablekeyFilefails closed before exec witherror: Account <name> keyFile <path> is not readable.(pinned by tests/account-apikey.nix and tests/account-apikey-unreadable.nix).
Storage layout (Linux): credentials are stored per-Account and reused across projects, while session/config state is keyed per Account-and-project (so two Accounts on one project never clobber each other's .claude.json or sessions):
~/.local/state/claude/accounts/<acct>/.credentials.json # per-Account, reused across projects
~/.local/state/claude/projects/<proj>/<acct>/ # per Account-and-project: sessions, config,
# plus the per-Account Scratch (tmp) and Exchange dirsA worked example, mirroring the system-keyed devShells.default nesting shown under Templates:
devShells.${system}.default = slop.mkShell {
projectName = "my-project"; # Accounts require a concrete projectName
agentMdFile = ./slop-env/claude-config/CLAUDE.md;
rulesDir = ./slop-env/claude-config/rules;
skillsDir = skills;
projectPkgs = [ hunk worktrunk ];
# ADR-0014 per-Account credential isolation (Linux only this pass).
accounts = {
acme = { type = "oauth"; }; # subscription login
globex = { type = "oauth"; }; # a second login
ci = { type = "apikey"; keyFile = "/run/agenix/<secret>"; }; # key, path only
};
defaultAccount = "acme"; # optional; the per-run override wins over it
};Platform asymmetry — Claude on Linux gets full OAuth + API-key multi-Account this pass. On macOS, declaring a non-empty accounts is refused at eval with slopEnv (darwin): per-Account credential isolation (ADR-0014) is not implemented on macOS in this pass; declare accounts only on Linux … (the keychain has a single slot for Claude OAuth, so file-based per-Account OAuth does not apply). For a cross-platform config, gate the argument on pkgs.stdenv.isLinux. On Linux, Accounts are honoured only by the Claude Agent Profile this pass: declaring accounts with the Pi or opencode profile is currently silently ignored — the registry is dropped, with no isolation and no error (a known gap until those profiles implement the same contract, unlike macOS, which refuses outright). Zero-touch apps keep single-credential behaviour.
A human walkthrough that doubles as the feature's acceptance test. Most scenarios map to a committed test that pins the expected result (cited inline); scenario 5 (the macOS refusal) is enforced at eval with no dedicated test. Never print real tokens — use placeholders (sk-ant-…, /run/agenix/<secret>) only.
- Two OAuth Accounts at once (the headline). With the example above, open two terminals and
nix developin each, then runNIX_SLOP_DEV_ACCOUNT=acme claudein one andNIX_SLOP_DEV_ACCOUNT=globex claudein the other. Expect distinctaccounts/{acme,globex}/.credentials.jsonand distinctprojects/my-project/{acme,globex}/session dirs; a/loginin one Account must not touch the other. (Pinned by tests/account-isolation.nix and tests/account-functional.nix.) - API-key Account. Declare
ci = { type = "apikey"; keyFile = "/run/agenix/<secret>"; }, thenNIX_SLOP_DEV_ACCOUNT=ci claude. Confirm the jailed agent seesANTHROPIC_API_KEYsourced from the file, that it is absent from the parent shell env, and that the key never appears in any/nix/storepath. (Pinned by tests/account-apikey.nix; the unreadable-keyFilefail-closed path by tests/account-apikey-unreadable.nix.) - Fail-closed on a typo.
NIX_SLOP_DEV_ACCOUNT=typo clauderefuses with theis not declared in this Slop Env … Refusing to launch.error, non-zero, before the Jail starts. (Pinned by tests/account-launcher.nix.) - Backward compatibility. Remove
accounts/defaultAccount(or leave them at their defaults) andclauderuns exactly as before, against~/.local/state/claude/shared/.credentials.json— byte-for-byte unchanged. (Pinned by the byte-equality baseline tests/template-claude-code-drv.nix againsttests/template-claude-code-drv.expected.) - macOS. On a Darwin system, declaring a non-empty
accountsfails evaluation with theslopEnv (darwin): … not implemented on macOS …message — Linux-only this pass.
Point the Pi or opencode Agent Profile at one or more local model servers instead of a cloud provider. Each is a Local AI Endpoint — a loopback Ollama server declared port-only: the Slop Env derives the provider URL http://127.0.0.1:<port>/v1 itself (one ollama-<name> provider per endpoint), so a config can never aim the agent off the loopback interface (ADR-0012). The Claude Agent Profile takes no local-AI path. This section is a summary; the full recipe and complete option reference live in docs/usage.md, and the design rationale in ADR-0011.
The option follows the NixOS-module idiom — localAi = { enable; settings; } — so enable = false (or omitting localAi) leaves a fully-written example inert and emits no local AI byte-for-byte. Each settings.endpoints entry becomes one provider:
devShells.${system}.default = slop.mkShell {
projectName = "my-project";
agent = slop.profiles.pi; # or slop.profiles.opencode
localAi = {
enable = true; # master switch; enable = false keeps settings inert
settings.endpoints = [
{
name = "big"; # → provider ollama-big
port = 11435; # → derived http://127.0.0.1:11435/v1
coordinator = true; # launch model; delegates to the workers below
models = [ { id = "qwen3-coder:latest"; reasoning = true; } ];
}
{
name = "fast"; # → provider ollama-fast, a worker subagent
port = 11434;
role = "Quick edits and small refactors"; # the worker's description
models = [ { id = "qwen3:8b"; reasoning = true; } ];
}
];
};
};The SSH tunnel is yours to set up. Forward each remote (or other-host) Ollama to a distinct loopback port before launching — the tunnel is out-of-band, so the agent only ever sees 127.0.0.1:<port>. A warn-only liveness probe on shell entry reports each endpoint's reachability without ever blocking, so you can start the tunnel whenever:
ssh -N -L 11434:localhost:11434 -L 11435:localhost:11434 gpubox &Confirm a model is actually pulled on the server with:
curl -s localhost:11435/api/tags | jq '.models[].name'Offline guarantee, precisely. Launch with the local launcher (pl → pi-local, ocl → opencode-local): it adds no Sandbox egress allows and disables the model-fetch. The guarantee is no agent-initiated egress — not no data leaves the machine. Loopback is intentionally not network-confined by the Sandbox, so if you have tunnelled a remote endpoint, prompt data travels that tunnel by your explicit ssh -L choice (ADR-0012).
Coordinator→workers (B2). Mark exactly one endpoint coordinator = true to make it the launch model and turn every other endpoint into a worker it delegates scoped subtasks to. opencode's path is stable (native mode = "subagent"); pi's is experimental — a vendored subagent extension (ADR-0013). Use default = true instead of coordinator to pick a launch model without generating any workers. Launch-model precedence is coordinator → a single default = true endpoint → the built-in anthropic model; declaring more than one coordinator or default is an eval error.
- Jail — the filesystem-confinement boundary built per project template
(e.g.
jailed-claude). The agent sees a curated view: the project directory, a writable config dir, and nothing else. Enforced by bubblewrap on Linux and by Seatbelt's filesystem rules on macOS. - Sandbox — the network-confinement boundary: a per-invocation host
whitelist plus violation alerting. Created by the
sandboxedwrapper. Enforced by a systemd transient unit on Linux and by a userspace proxy pinned to loopback by Seatbelt on macOS. - Slop Env — the dev environment a user enters when running an agent or
jail-shell: one Slop Env = one Jail + one Sandbox. - Account — one authentication identity an agent runs under (an OAuth
subscription login or an API key), selected per run. Orthogonal to the Agent
Profile (which agent) and
projectName(which project): the same agent can run under different Accounts, and one Account is reused across projects. Declared in a closed Nix registry; see Multiple accounts and ADR-0014.
Full glossary: CONTEXT.md.
The Sandbox runs the agent inside a systemd-run --user --pty
transient unit with IPAddressDeny=any plus per-invocation
IPAddressAllow= properties for resolved whitelist entries. Enforcement is
the kernel's cgroup-v2 eBPF filter; auditd records denied connect()
syscalls so --log can surface violations.
The Jail is built by jail-nix
combinators that wrap bubblewrap: a fresh mount namespace, the project
directory bind-mounted, the agent config is created via write-text,
and /usr/bin/env as the single host binary in the new mount tree.
Hardening sits in nixosModules.sandboxed — see
docs/nixos.md for the module options.
macOS has no systemd and no bubblewrap, so both boundaries land on
Seatbelt (sandbox-exec profiles). Seatbelt is daemonless and runs as
the calling user.
The Jail uses (deny default) with a curated read+write prelude
(dyld cache, /System/Library, ICU, timezone data), then layers
additive (allow file-read*/file-write* (subpath …)) rules per
combinator. Paths outside the allow list are invisible.
process-exec is opt-in per path so the agent cannot reach osascript,
launchctl, defaults, etc. just because they exist on the host. See
ADR-0004.
The Sandbox cannot live in SBPL alone: the parser structurally
rejects per-IP rules (spike 07 / ADR-0003).
Instead the wrapper spawns a userspace proxy (HTTP CONNECT + SOCKS5)
that reads the host whitelist, and pins the agent's outbound socket
authority to that proxy's loopback port via Seatbelt. The agent sees
HTTPS_PROXY / HTTP_PROXY / ALL_PROXY pointing at 127.0.0.1:<port>;
anything that ignores the env vars hits Seatbelt's deny and fails closed.
Violations surface from both the unified log (Seatbelt denials) and the
proxy's own log (whitelist misses).
Module reference: docs/macos.md.
The enforcement mechanisms are identical to NixOS — same systemd
IPAddressDeny/Allow, same bubblewrap Jail, same auditd violation
recording. What differs is host configuration: outside NixOS there is no
declarative module, so the setup-linux app diagnoses prerequisites
(systemd ≥ 235, cgroup v2, auditd, sudoers drop-in, AppArmor on Ubuntu
24.04) and writes them with the host's absolute tool paths.
The wrapper detects NixOS at runtime and switches between embedded Nix
store paths (NixOS) and bare tool names resolved via sudo's secure_path
(elsewhere). See ADR-0002
for why store-pinning everywhere would break on flake update.
Setup walkthrough: docs/non-nixos-linux.md.
The originally-planned multi-agent templates have shipped: pi-agent
(ADR-0009) and opencode
(ADR-0010), both
delivering the same Sandbox / Jail guarantees as the default Claude Code
template.
Per-Account credential isolation has also shipped for Claude Code on Linux (ADR-0014) — declare multiple Accounts and run agents under a different Account each at the same time. Still deferred: the same per-Account contract for the Pi and opencode profiles, and OAuth multi-Account on macOS (blocked on the system keychain's single credential slot). Track further work via repo issues; contributions welcome.