I run Claude Code and Codex in full YOLO mode. claude --dangerously-skip-permissions, no approval prompts, let it edit files and run commands inside whatever project I pointed it at. That is the whole appeal. The agent stays in flow and I am not clicking “allow” on a dialog every thirty seconds.

The problem is the word “project.” Nothing about --dangerously-skip-permissions actually confines the agent to the project directory. It runs as me. It can read my SOPS age key, my SSH keys, every other repo in ~/Github, my shell history, the Docker socket. The client’s own permission system is a suggestion living inside a process that already holds all of my authority.

I wanted a floor under that, enforced by the kernel rather than by the agent’s good behavior. That is what nono gives me.

The tools already sandbox, so why not just use that

Fair question, and I don’t want to strawman the built-in sandboxes, because they are real. Codex sandboxes every command it runs, bubblewrap plus seccomp on Linux and Seatbelt on macOS, defaulting to a workspace-write mode that confines writes to the project and turns network off. Claude Code has an opt-in sandboxed Bash tool built on the same OS primitives. Both are enforced by the kernel and sit outside the model, so a tool call cannot quietly switch them off. That part is good, and I use the same primitives.

Two things pushed me past them anyway.

The first is coverage. In their low-friction default modes, both sandboxes still let the agent read my entire home directory. Codex’s workspace-write confines writes and network but not reads. Claude’s own docs are blunt that the default “still allows reading credential files such as ~/.aws/credentials and ~/.ssh/.” So the agent is blocked from writing outside the project, while nothing stops it from reading my SOPS age key or a sibling repo’s secrets. I wanted reads outside the project denied by default, not just writes.

The second is that these are two different sandboxes with two different config models, and I run both agents (and a third). Claude’s is a per-Bash-tool setting with its own credentials block; Codex’s is a sandbox_mode plus a shell-environment policy. Keeping those in step across every tool and project, on two operating systems, is exactly the drift that Nix exists to kill. I wanted one policy and one boundary, not one per tool.

The tools themselves point in this direction. Codex’s docs say that when it runs inside a container that provides isolation, you should pass --sandbox danger-full-access “so Codex does not try to create a second sandbox layer.” Claude says to use --dangerously-skip-permissions only “in isolated environments like containers, VMs.” That is the exact shape I built, with nono as the outer boundary instead of a container: turn each tool’s inner sandbox off, and let one external layer mediate everything.

So why nono rather than the container the docs assume? I want the agent working on my real files with my real toolchain, not a copied-in checkout, and a plain container still hands the agent my actual credentials. Codex’s own devcontainer note warns that under full access “a malicious project can exfiltrate anything available inside the devcontainer, including Codex credentials.” nono gives me per-project capability profiles instead, and the part I care about most, credentials the agent never holds at all. More on that below.

What nono is

nono runs a command under a profile that describes exactly what it may touch. On Linux it uses Landlock for filesystem rules and a seccomp user-notification supervisor for the syscalls Landlock cannot see. The profile is data: which directories are readable, which are read-write, which Unix sockets, which network destinations, which environment variables get stripped before the process starts.

I settled on three lanes per agent.

claude and codex are the safe lane. The project directory is read-write, the agent’s own state under ~/.claude or ~/.codex is writable, the Nix store is readable so its runtime works, and almost nothing else is reachable. No host sockets, no SOPS material, no sibling projects, no ambient credentials. Inside that box the agent runs prompt-free, because the box is the boundary now instead of the prompt.

claude-with GRANT adds one named capability on top of the safe lane. claude-with blog grants write access to ~/Github/blog when I want the agent to publish a post (this one, in fact). claude-with nix hands over the Nix daemon socket after I type a confirmation. Grants compose, so claude-with nix blog is both.

host-claude is the escape hatch: raw, no nono at all, the agent back to running as unrestricted me. It is a separate command on purpose, so I cannot fat-finger my way into it while reaching for the safe lane.

All three are just fish aliases pointing at generated launcher scripts:

programs.fish.shellAliases = {
  codex = "${config.home.profileDirectory}/bin/safe-codex";
  claude = "${config.home.profileDirectory}/bin/safe-claude";
  claude-with = "${config.home.profileDirectory}/bin/claude-with";
};

Declaring it in Nix

The whole setup is a Home Manager module. nono itself is a pinned binary; packages/nono-bin.nix fetches the release tarball and autoPatchelfs it for NixOS:

version = "0.68.0";
src = fetchurl {
  url = "https://github.com/nolabs-ai/nono/releases/download/v${version}/nono-v${version}-${release.target}.tar.gz";
  inherit (release) hash;
};

The profiles are the part I care about, and they are not hand-written JSON. A Nix function builds each one, so I describe the boundary in the language I already use for the rest of my system, and Home Manager renders the JSON into ~/.config/nono/profiles. A trimmed version of the base profile:

groups.include = commonGroups; # node/python/rust runtimes, git config, etc.
# Landlock does not mediate AF_UNIX connect(2). nono's pathname supervisor
# makes sockets default-deny, so Docker/Podman, Nix, and the desktop-session
# bus cannot become ambient escape hatches.
linux.af_unix_mediation = "pathname";

That af_unix_mediation = "pathname" line is the one to notice. Landlock has no say over connect() to a Unix socket, so on its own the agent could still reach the Docker socket, the Nix daemon, or the user D-Bus, any of which is a way out of the box. nono makes AF_UNIX default-deny through its seccomp supervisor, and each lane allowlists only the sockets it actually needs. The safe lane needs none.

Because it is all generated, a new grant is a few lines of Nix and a rebuild, not a JSON file I copy-paste and forget to keep in sync.

Credentials the agent never holds

My favorite piece is the credential mediation. For registered projects, the agent’s git and gh are wrapped so that pushes and API calls go through nono’s own proxy. nono injects a scoped, short-lived phantom token at connect time; the real PAT never lands in the agent’s environment, its argv, or its config. The agent pushes a branch and opens a pull request, and at no point did it hold a credential that could do anything else.

So even in the safe lane the agent has working Git against exactly the repos it should, and a leaked session transcript or a prompt-injected tool call has nothing durable to steal.

The nix grant, and a bug that proved the box is real

claude-with nix gives the agent the Nix daemon socket. On my machine that is effectively root, since my user is a trusted Nix user, which is why it sits behind a typed confirm rather than a plain grant. When I first wired it up it half-worked, and chasing down why was the most satisfying hour of the week.

connect() to the daemon socket failed about half the time with a bare EPERM. Not a policy denial (those come back as EACCES with a logged reason), just a silent permission error. Running the same command under strace made it pass every time, which is the signature of a timing bug rather than a rules bug.

It was nono’s own rate limiter. The supervisor holds a token bucket, burst of 5 and refill of 10 per second, and it spends a token on every AF_UNIX notification before it even decides whether to allow the connect. So my allowlisted connections to the daemon socket were competing for tokens with all the ambient AF_UNIX chatter a process on NixOS generates: NSS lookups against nscd, /dev/log, D-Bus. Under any real load the bucket empties, and allowed connects get denied along with everything else.

A minimal repro pinned it down. A tight loop of 40 connects to the granted socket let exactly 5 through (the burst) and denied 35, and nono’s debug log held exactly 35 “rate limited” lines, one per failure. Space the same 40 connects 150ms apart and all of them succeed. I wrote it up and filed it upstream as nono #1420.

The reason I could find that at all is that nono is real kernel machinery with its own enforcement path, low enough in the stack to have a rate limiter that a flood of syscalls can exhaust. The bug was a good reminder of what I actually bought: mediation that sits under the agent, not beside it.

That is the Linux story. The same config also runs on my Mac mini through nono’s Seatbelt backend, with enough macOS-specific quirks around TCC, the credential proxy, and socket binding to deserve its own writeup. That one is coming later.