fitting closely and comfortably Β· marked by cordiality and secure privacy Β· offering safe concealment Β· a small private room in a pub
A sandbox for running an untrusted code on modern Linux machines. As
seamless experience as possible. It works without root, without any daemon,
without an installation. Static linked binary written in Go, which
- reads the policy - aka profiles
- builds a cli arguments for
bwrap, which confines the access to the filesystem - provides a proxy or wrappers for well known services including
ssh-agentorpodmansocket, ensuringsshorpodman/dockerCLI can be safely used from the sandbox. - can use a private network namespace via
pasta - supports running Claude Code via
@claudeprofile
echo "hello" > hello
snug ~/src/myproject
# sandbox hides and protects the filesystem
π snug:~/src/myproject> ls ~/.ssh
ls: cannot access '/home/you/.ssh': No such file or directory
π snug:~/src/myproject$ echo "hello" > ../hello
bash: ../hello: Read-only file system
π snug:~/projects/plainsof/cv/snug$
# yet allow a write access to cwd by default
π snug:~/src/myproject$ cat hello
hello
π snug:~/src/myproject$ echo "hello from snug" > hello
hello from snugIn 2026? Every code
- LLM agents - the worst kind of such code. It runs autonomously, having a complete access to the system and the safeguards are just a prompt. They're ever changing blackox prone to prompt injections, manipulations and hallucinations.
- Supply chain dependencies - everything allowing post-install scripts (node, python, Ruby, ...) can get rogue and has been used to exfiltrate the production secrets in the past.
- A homework assigments, LLM code snippet, random post on web, curl | bash installing instructions and so
There is no tool I was aware about, which would match all points
- Easy to use -
bwrapis an excellent sandboxing tool on its own. Yet building the proper CLI invocation is anything than easy. - Sharing as much as environment with a regular system as possible.
- Easy to poke holes into the sandbox, so developer flow feels like being on unrestricted host system, rather than inside sealed sandbox.
- Must be compatible with
distrobox. - It should enable users to inspect what command is supposed to do.
Needs bubblewrap, and pasta (package passt) if you want networking.
make build
./bin/snug doctor # can this host run it?
./bin/snug --dry-run . # what will happen, before it happens
./bin/snug . # a shell in the sandboxUseful shapes:
snug ~/src/proj -- make test # run one command; its exit code propagates
snug -p @net ~/src/proj # ...with internet access
snug -p @git-ro -p @net ~/src/proj # ...and your git identity, read-only
snug -p @claude -p @net ~/src/proj # Claude Code, its credentials staged (not your host's)
snug -p @podman-socket -p @net ~/src/proj # run containers, via a filtering proxy
snug -p @podman-build -p @net ~/src/proj # ...and build images too
snug -p @tmp-shared ~/src/proj # a /tmp that survives, shared with future runs
snug --dry-run -p @net ~/src/proj # print the policy and the exact bwrap line; start nothingThe default is share nothing and then define a named minimal holes aka *profiles which make the sandbox less contained and more useful.
The base state is an empty tmpfs root, an empty network namespace, and an empty environment. Nothing is inherited. A profile is a named hole.
![TODO] the ./bin/snug --no-defaults . -- ./bin/snug doctor MAY work.
The profiles themselves are supposed to be easy
- They're declarative - no fancy programming language, no conditionals
- Order does not matter
- Profile can include others, but this is to enable a reusability
- Profile can never exclude or disable access
- Profile conflicts are hard fails. Some conflicts like the same path requested
as
roandrware coerced torw. - They're restricted to ascii alphanumeric characters, hyphen (-) and those starting
with
@aresnug's own builtin.
$ snug profile list # what exists
$ snug profile show @net # what one grants, and what it costs
$ snug profile tree # which profiles imply which
$ snug profile dot | dot -Tpng -o profiles.png| profile | grants |
|---|---|
@sys |
/usr plus the dozen /etc entries things actually need. |
@home |
$HOME as an empty tmpfs at the host path. Ephemeral. |
@cwd-rw |
The target directory, writable and persistent. |
@parent-ro |
The target's parent, read-only. |
@git-ro |
~/.config/git and ~/.gitconfig, read-only. |
@tmp-shared |
A per-project host directory as /tmp. Survives the sandbox. |
@net |
Internet access. Host loopback unreachable. |
@net-anon |
As @net, but the sandbox does not learn your LAN address. |
@net-host |
Dangerous. Shares the host network namespace. Needs --i-know. |
@claude |
Claude Code: binary and skills read-only, credentials staged as writable copies. |
@podman-socket |
Run containers, via a filtering proxy over a per-sandbox engine. |
@podman-build |
As @podman-socket, plus podman build with a filtered option set. |
Those are implicitly used unless --no-defaults are specified and makes
the CLI a bit shorter to type. This REPLACES the builtin defaults.
# ~/.config/snug/config.toml β preferences, never grants
defaults = ["@sys", "@home", "@cwd-rw", "@parent-ro"]Write your own in ~/.config/snug/profiles.d/srv-rw.toml:
[profile.srv-rw]
description = "The directories my build reaches outside the project."
include = ["@net"] # enable networking
rw = ["/srv/project", "/opt/cache/project"]Then snug -p srv-rw ~/src/proj will mount /srv/project and
/opt/cache/project as read-write and enable the networking.
Each sandbox gets its own network namespace with a pasta helper. Egress is
unrestricted; the host's 127.0.0.1 is not merely blocked but not
expressible β the sandbox's loopback is a different loopback.
That namespace also isolates abstract AF_UNIX sockets, which is what keeps X11 and D-Bus out for free. Filesystem sandboxing does nothing about those; there is no path to not-mount.
Offline is the absence of the @net profile, not a setting β so it cannot be
switched back on by adding something.
Hostβsandbox publishing is off, and opening it means naming the ports yourself:
[profile.myports]
include = ["@net"]
publish = [3000, 8080] # bound to the host's 127.0.0.1 only, never the LANThere is deliberately no "publish whatever the sandbox binds": that would let the
sandbox choose what appears on your loopback, and a prompt-injected agent could
squat 127.0.0.1:8080 ahead of your own dev server. A @net-publish profile
that did exactly that used to ship, and it never forwarded a single port β see
internal/profile/profiles/base.toml for why.
Provisional. This section documents behaviour that landed ahead of its documentation. The measured version is
.claude/design/CONTAINER-CLIENT.md.
The engine behind @podman-socket is podman, rootless, one per sandbox. What
snug filters is the docker-compatible schema β which podman itself serves β
so the client you run inside can be anything that speaks it. CONTAINER_HOST and
DOCKER_HOST both point at snug's proxy.
In practice that means docker is the client to use inside a sandbox:
docker pull alpine && docker run --rm alpine echo hi # works
podman-remote ps # works, read-only onlypodman-remote speaks podman's native libpod API, which snug refuses for any
request carrying a body it would have to inspect β so it inspects fine and
cannot run or pull. There is no flag that changes this.
The shim. /usr/bin/podman may be a symlink to distrobox-host-exec,
which forwards to an engine outside the container. That is not distrobox's
default β it is a choice someone made on that machine, for usability β so snug
detects it rather than assuming it. From inside a sandbox the forwarding cannot
work, and podman's own error for it names neither the cause nor a fix. Where snug
finds such a shim it stages its own podman at /run/snug/bin/podman
and puts that directory on PATH ahead of /usr/bin. It forwards the
subcommands docker can serve, byte-for-byte, and refuses the rest in its own
voice:
$ podman pod ps
snug: stub refuses 'podman pod' -- pods are a podman-only grouping; docker has
no equivalent command.
Nothing is hidden: /usr/bin/podman is untouched and still runs by absolute
path, /run/snug/bin is read-only from inside, and a podman provided by a
profile's path still wins over snug's. It appears only when a podman profile
is selected β a default snug <dir> has no stub and an unchanged PATH.
Known rough edges, measured and not yet fixed:
docker runexits 0 but prints nothing β the container's stdout is not relayed back. Plaindockerbehaves the same, so this is the proxy, not the stub.docker buildneeds the classic builder; snug setsDOCKER_BUILDKIT=0for you, because BuildKit bypasses the filter entirely rather than being filtered.docker run -pis refused: published ports land on the engine's side of the boundary today. ENGINE-NETNS.md is the fix.docker cpis refused, deliberately β the engine resolves that path outside the sandbox as your user. Usedocker exec C tar -cf - β¦instead.- On an SELinux host,
-vneeds:z(docker run -v "$PWD:/w:z" β¦). That is rootless podman, not snug.
The first and most important aspect is that it prevents a filesystem access. So
no ~/.ssh, no ~/.aws, no browser or desktop keyring, no tokens, no
~/Documents or ~/.bashrc access. By a design it prevents an access to Wayland,
systemd, PulseAudio, X11 or any other sockets, which can be used for a sandbox escape.
| defended | how |
|---|---|
~/.ssh, ~/.aws, ~/.gnupg, keyrings, browser profiles |
never mounted |
| your other projects | never mounted |
host services on 127.0.0.1 |
private netns |
| X11 keylogging, D-Bus, the desktop session | not mounted; netns-scoped |
host persistence (.bashrc, autostart, cron) |
$HOME is an ephemeral tmpfs |
Kernel zero days - the security perimeter is a Linux itself, so escape by exploit is possible. Run the VM if expects more strict isolation though.
VERIFY.md constains a set of instructions for humans to test the
sandbox.
The project also keeps an in-house red team (.claude/agents/redteam.md) whose
job is to escape. It runs before every milestone lands, and it keeps earning its
keep.
- a host-environment leak readable at
/proc/1/environ - a masking rule that covered one of two spellings
- a seccomp filter that was requested but never installed
- a directory on stdin that bypassed every mount grant
- a
clone3call that created a nested user namespace - a
--secretsource that climbed out of the build context with..and read an arbitrary host file. - and so on
This is alpha status - while the basic concept feels solid, more real world usage are needed.
The builtin profiles, their dependencies, CLI or an ability to attach to an existing sandbox - all of this may be refined in the near future.
- how to properly deal with secrets
- podman network
- environment variable handling
- tighten the podman build
- better defined identities - requires secrets to be final
VERIFY.md is the hands-on checklist: run it rather than trusting
this file.
Design and research material is not user documentation and lives apart, under
.claude/design/, beside the agents that work from it:
INDEX.md |
Architecture, threat model, the policy model, roadmap |
PSEUDOFS-AUDIT.md |
What /proc, /sys and /dev expose, measured |
PARAMETERISED-PROFILES.md |
A deferred design, and why |
SECRETS.md |
What snug does with credentials, and what it should |
CLAUDE.md |
Working agreement: invariants, and hard-won facts about this environment |
TODO.md |
What is deferred, and known gaps between docs and code |
MIT licensed. Linux with unprivileged user namespaces, bubblewrap, Go 1.26+ to build,
pasta for networking. Works inside distrobox and other containers β nested
user namespaces are fine. snug doctor tells you where you stand, and names the
exact sysctl when something is missing.