Outsider Stage 0.5
실제 봉인된 Outsider Stage 0.5 개입 체인을 재생하고 검증합니다
주된 위협은 우연한 파괴입니다
The primary threat is accidental destruction — an agent that misreads a path and deletes the wrong folder — not a determined attacker. Network isolation is secondary and off by default; an AI CLI that
보안Go · ShellGitHub @fabiocicerchia온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
A jail for AI CLIs. azkaban confines a coding agent (Claude Code, aider, etc.) to a single project directory using bubblewrap and Landlock, so a misbehaving or hallucinating tool cannot read your ~/.ssh, exfiltrate credentials, or rm -rf your home.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A jail for AI CLIs. azkaban confines a coding agent (Claude Code, aider,
etc.) to a single project directory using bubblewrap
and Landlock, so a
misbehaving or hallucinating tool cannot read your ~/.ssh, exfiltrate
credentials, or rm -rf your home.
The primary threat is accidental destruction — an agent that misreads a path
and deletes the wrong folder — not a determined attacker. Network isolation is
secondary and off by default; an AI CLI that cannot reach the internet is not
much use. Everything here is arranged around that priority.README 전문 보기 ↗A jail for AI CLIs. azkaban confines a coding agent (Claude Code, aider,
etc.) to a single project directory using bubblewrap
and Landlock, so a
misbehaving or hallucinating tool cannot read your ~/.ssh, exfiltrate
credentials, or rm -rf your home.
The primary threat is accidental destruction — an agent that misreads a path and deletes the wrong folder — not a determined attacker. Network isolation is secondary and off by default; an AI CLI that cannot reach the internet is not much use. Everything here is arranged around that priority.
It is small and auditable: the whole security model fits in two files
(main.go, dockerproxy.go) you can read in one sitting.
Linux only, and it is a fence, not a vault. The default posture is arranged against a confused tool, not a hostile one: the network is shared, the terminal is shared, and the
KNOWN ESCAPE VECTORSblock at the bottom ofmain.golists what is deliberately left open. Without bubblewrap ≥ 0.11 and kernel ≥ 5.11 the write-discarding overlay is unavailable and writes are real — azkaban warns, and the warning matters.
One static binary, two roles. The outer role builds a bwrap command line from
the allowlists at the top of main.go; the inner role runs inside the jail
and adds the layer bwrap cannot:
azkaban [flags] [--] <command> [args...]
│
│ outer role — on the host
├─ parse flags, read ~/.config/azkaban/config
├─ build the bind lists (rwPaths, roPaths, roFreeze, maskPaths)
├─ --dry-run ? print the command and stop
└─ exec /usr/bin/bwrap ...
│
│ the mount view — subtraction, not permission
│ $HOME is a tmpfs; only allowlisted paths are bound back
│ credential stores inside them are masked with an empty file
│ ~/.config/azkaban is re-bound read-only AFTER the rw list
│
│ namespaces — pid, uts, ipc, cgroup (net only with --no-net)
│ the overlay — writes land in tmpfs and evaporate on exit
│ rlimits + a sibling cgroup, so a runaway write cannot OOM the
│ host through that tmpfs
│
└─ re-exec of the same binary: --landlock-exec
│
│ landlock — what may be OPENED, enforced by the kernel
│ even if a mount is somehow reachable
│ (+ the TCP port allowlist under --net-ports)
│
└─ exec <command> [args...]
--bind-docker / --bind-podman add one more: a filtering proxy in front of the
container socket, since a socket that can create a privileged container is a
hole straight through everything above.
Layers, not a single gate: the mount view decides what exists, Landlock decides what may be opened, and the two are enforced by different parts of the kernel. Numbered in full in the design doc.
More in docs/design.md.
Subtracts, rather than permits. $HOME becomes an empty tmpfs and only
the allowlisted paths are bound back, so a directory nobody thought about is
hidden by default rather than exposed by default.
Discards writes by default. The allowlisted $HOME directories are
writable through a throwaway overlay: the tool sees a working filesystem, and
the writes evaporate on exit. Caveat: this needs bubblewrap ≥ 0.11 and kernel
≥ 5.11, and state you wanted to keep is discarded too — that is what
--persist-path is for.
Masks credential stores that live inside directories bound wholesale.
~/.config holds API tokens; the overlay stops them being destroyed, not
read, so they are replaced with empty files.
Binds no container socket unless asked. --bind-docker / --bind-podman
put a filtering proxy in front of one, refusing --privileged, device
passthrough, escape-worthy capabilities, shared namespaces and binds of host
paths outside the project. Caveat: containerd is not offered at all — it
speaks gRPC, which the proxy cannot inspect.
Clears the environment but for a small allowlist, so an API key in the
host environment does not follow the tool in. Extend it with env NAME
lines in the config, or hand over everything with --keep-env.
Limits Docker to jail-owned resources. Container and exec operations use
only IDs created through that jail's proxy. Names resolve to recorded immutable
IDs and container lists exclude other workloads. Named volumes, volume-driver
options, VolumesFrom, links, joining existing networks, privileged exec,
global events and network management are denied; use
project bind mounts or anonymous volumes instead. Image and build caches remain
shared and the bind-mount symlink race is not eliminated. Use a dedicated
rootless daemon for hostile workloads or artifact isolation.
Caps file size, process count and file descriptors, and optionally total
memory with --mem-max. Caveat: RLIMIT_FSIZE is per-file, so a loop
creating many small files still fills the overlay; and a memory cap also
disables swap for the jail.
Shares the network by default, because an agent that cannot reach the
internet is not much use. --no-net isolates it; --net-ports allows
outbound TCP to named ports only, which does not touch UDP and therefore
does not touch DNS.
bwrap (bubblewrap) at /usr/bin/bwrap--bind-docker / --bind-podmanVerify the overlay is actually active before trusting it:
azkaban --dry-run | grep -c tmp-overlay # 0 means writes are real
git clone https://github.com/fabiocicerchia/azkaban.git
cd azkaban
make build # -> ./azkaban
make install # ...or into GOBIN (~/go/bin by default)
cd /path/to/project # NOT $HOME — azkaban refuses to make $HOME writable
azkaban claude # or: azkaban -- <any command> [args...]
Read the jail before you trust it. --dry-run prints the exact bwrap command
instead of running it (elided here — the real line is long, and that is the
point):
$ azkaban --dry-run -- sh -c 'echo hi'
azkaban: WARNING: this bwrap has no --tmp-overlay; falling back to real writes. Upgrade bubblewrap (>= 0.11) or pass --persist to silence this.
/usr/bin/bwrap --clearenv --setenv HOME /home/you --setenv PATH ... \
--ro-bind /usr /usr --ro-bind /etc /etc --dev /dev --proc /proc \
--tmpfs /tmp --tmpfs /run \
--ro-bind /tmp/azkaban-mask-503781593 /proc/kallsyms \
--tmpfs /home/you \
--ro-bind /home/you/.gitconfig /home/you/.gitconfig \
--bind /home/you/.cache /home/you/.cache \
--bind /home/you/.config /home/you/.config \
--ro-bind /home/you/.config/azkaban /home/you/.config/azkaban \
--bind /path/to/project /path/to/project --chdir /path/to/project \
--die-with-parent --unshare-pid --unshare-uts --unshare-ipc \
--hostname azkaban \
--setenv AZKABAN_LL_RO '...' --setenv AZKABAN_LL_RW '...' \
/tmp/.azkaban-self --landlock-exec -- sh -c 'echo hi'
--tmpfs /home/you is the whole model in one flag: the home directory is
emptied first, and every --bind after it is a deliberate exception.
The flag reference, verbatim:
$ azkaban --help
azkaban [flags] [--] <command> [args...]
--no-gpu do not bind GPU devices (/dev/nvidia*, /dev/dri)
--persist let writes to $HOME allowlist dirs really land on disk. Default
is a throwaway overlay: the tool sees them writable, but writes
and deletes evaporate on exit and cannot destroy real data.
--bind-docker bind the docker socket behind the filtering proxy (OFF by
default; containerd is not offered — gRPC, which the proxy
cannot inspect)
--bind-podman same, for podman's Docker-compatible REST socket
--unfiltered-container-socket
bind the socket with NO filtering at all. Says how to bind, not
which — pair it with --bind-docker or --bind-podman.
--display pass through X11/wayland/XAUTHORITY + the wayland/pulse sockets
from /run/user (OFF by default; ssh-agent, gpg-agent, dbus and
any rootless container socket in there stay hidden)
--ssh-agent forward the agent (+ known_hosts read-only) so git push over ssh
works. The jail talks to a FILTERING PROXY in the outer process
that forwards only "list keys" and "sign this" and refuses add,
remove, lock and extensions — so a tool inside can no longer
delete the keys you loaded or lock your host agent. The keys
stay on the host either way; the jail gets a signing oracle,
and that oracle still authenticates as you to every host they
open. OFF by default; ~/.ssh itself is never bound.
--ssh-agent-confirm
...and ask on the terminal before every signature. This is
"ssh-add -c" for a jail that cannot reach the host's prompt.
--ssh-agent-raw
...bind the REAL agent socket with no filter, the pre-proxy
behaviour. Anything in the jail can then add, remove or lock
your keys as well as sign with them.
--unix-socket PATH
bind ONE unix socket, and nothing around it. For "this tool may
reach Postgres at /tmp/.s.PGSQL.5432" without granting /tmp.
Repeatable; connect and bind are not distinguished. For every
run, use "unix-socket" lines in the config.
--unix-socket-dir DIR
same, for a directory whose socket names are generated at
runtime (PID-suffixed paths). Wider by exactly one directory,
which is why both exist. Repeatable.
--allow-userns permit nested user namespaces (needed by Chrome/Electron tools)
--no-net isolate the network in a new namespace (breaks internet access)
--net-ports L allow outbound TCP only to these ports (comma-separated), enforced
by landlock. Blocks localhost services and LAN scanning. UDP and
therefore DNS are unaffected. Needs the landlock stage.
--net-host H allow outbound traffic only to this host, through a CONNECT
proxy in the outer process. Repeatable; "*.example.com" covers
subdomains but not the bare domain. TLS is NOT intercepted —
the target is checked and raw bytes relayed. Sets HTTPS_PROXY
in the jail and narrows --net-ports to the proxy, so a client
that ignores the variable is refused by the kernel rather than
connecting directly. Needs the landlock stage. For every run,
use "net <host>" lines in the config.
--keep-env inherit the whole host environment (default: clear it and pass
only HOME/PATH/TERM/LANG/...; add more with "env NAME" in
~/.config/azkaban/config)
--mem-max SIZE cap total memory with a cgroup (e.g. 8G). The overlay writes to
tmpfs, i.e. RAM, and this is the only thing that bounds it. Off
by default: a cap also disables swap for the jail, so a workload
that would have paged out is killed instead.
--no-rlimits do not cap file size / process count (default caps them; the
overlay writes to RAM, so a runaway write can OOM the host)
--no-landlock skip the landlock stage
--ro PATH bind one extra path read-only, this run only. Repeatable.
--rw PATH same, writable (still overlaid unless --persist). Repeatable.
$HOME-relative; / and $HOME are refused; un-masks any credential
store named. For every run, use "ro"/"rw" lines in the config.
--persist-path PATH
exempt ONE path from the throwaway overlay: writes to it land on
the host, everything else still evaporates. For the file a tool
must keep across runs (a login token) without --persist making
the whole allowlist destroyable. Repeatable; name the file, not
its directory. For every run, use "persist" lines in the config.
--credential P allow the jail to use a host credential WITHOUT giving it the
secret: it talks plain HTTP to a loopback broker, which attaches
the real token and makes the TLS connection itself. "github" is
the only provider so far; add " write" to permit push, which the
default read-only policy refuses. Repeatable. For every run, use
"credential <provider>" lines in the config.
--rollback snapshot the writable $HOME roots either side of the run and
let writes land FOR REAL, so destruction becomes a diff to
review rather than a loss. An alternative to the default
throwaway overlay, not a layer on it. Review and undo with
"azkaban rollback show|restore".
--elevate let a denied READ be approved on the terminal instead of ending
the run. A seccomp supervisor in the outer process traps opens,
asks you about any path outside the allowlist, and hands back a
read-only descriptor it opened itself — so a grant is bounded by
your own permissions and cannot exceed them. Landlock stays the
floor: anything the supervisor does not answer is denied by the
kernel as usual. Writes are never elevated. OFF by default, and
rate-limited to 10 prompts/s so a tool in a loop cannot wear you
down. Needs the landlock stage.
--dry-run print the bwrap command instead of running it
--no-guidance do not describe the jail to the tool inside it. By default
/run/azkaban holds a read-only policy.json, a README, a Claude
Code PostToolUse hook and this binary, so a confused agent can
run "azkaban why --self" instead of guessing at an error.
--no-audit do not record this run. Every run is otherwise written as JSONL
to $XDG_STATE_HOME/azkaban/audit/ — the resolved policy, the
mode flags, every degradation, every docker-filter decision and
the exit code. "audit off" in the config turns it off for good.
-h, --help this help
azkaban why explain what the jail would do with one path, host or port,
without starting one. "azkaban why -h" for its flags.
--dry-run prints the whole resolved policy. azkaban why answers about one
thing:
$ azkaban why --path ~/.claude --op write
/home/you/.claude
ALLOWED (write)
mechanism: --overlay-src + --tmp-overlay (throwaway tmpfs upper layer)
matched: rwPaths .claude
survives: no — discarded when the jail exits
$ azkaban why --path ~/.ssh/id_rsa
/home/you/.ssh/id_rsa
ABSENT (read)
mechanism: --tmpfs /home/you
matched: default deny
ABSENT, not DENIED: ~/.ssh was never mounted, so a read fails as ENOENT
— and calling that a denial sends people hunting for a permission nobody can
grant. Add --json for tooling, or the run flags (--persist, --net-ports,
--ro, ...) to ask what the answer would be.
--self answers from inside the jail, off the read-only policy the jail
carries at /run/azkaban/. That is the variant that helps a confused agent,
because it runs at the moment the error happened — see
docs/design.md.
More in docs/getting-started.md.
azkaban: WARNING: this bwrap has no --tmp-overlay; falling back to real writes.
The default protection is gone: writes to the allowlisted $HOME directories
now land on disk. Upgrade bubblewrap to ≥ 0.11 (and the kernel to ≥ 5.11 for
unprivileged overlayfs), or pass --persist to say you meant it. Check with
azkaban --dry-run | grep -c tmp-overlay — 0 means writes are real.
azkaban: refusing to run: ... path contains a newline or NUL
A bind path with a newline in it would inject extra entries into the Landlock
allowlist, so it is refused rather than sanitised. Rename the directory.
azkaban: warning: resource cgroup unavailable (...); memory is NOT capped.
The cgroup v2 tree is not usable here — common inside another container. The
jail still runs, but the overlay writes to RAM with no cap. You did not ask for
one, so this is a warning.
azkaban: --mem-max 8G cannot be enforced: ...
The same condition, on a run that did ask for a cap — so it is refused rather
than warned about. A cap that silently does nothing is worse than no cap: the
flag looks like it worked while the jail runs unbounded. Delegate a cgroup v2
memory controller, or drop --mem-max.
azkaban: WARNING: this kernel allows TIOCSTI
The jail shares your terminal, so a tool inside it can push characters into
your shell's input queue. Close it host-wide:
sysctl -w dev.tty.legacy_tiocsti=0.
Can't find source path ... from bwrap.
A path in the allowlist that does not exist on this machine, usually a
dangling symlink under $HOME. Binds are filtered by os.Stat, which follows
symlinks for exactly this reason; a broken link is the case that slips through.
The tool inside cannot see a file it needs.
That is the design — $HOME is a tmpfs and everything is hidden unless it is
in a list. Add ro/rw lines to ~/.config/azkaban/config for a permanent
exception, or --ro/--rw for one run.
| Page | Covers |
|---|---|
| design.md | The five layers in detail, plus the security model and deliberate omissions |
| configuration.md | ~/.config/azkaban/config, credential masking, environment allowlist |
| containers.md | Docker/podman sockets and the filtering proxy |
| testing.md | How the suite proves containment without risking a real home |
The layers are documented ones; these are what they are built on.
--tmp-overlay flag the default posture depends on.user_namespaces(7)
— why this needs no privilege on the host, and what --allow-userns gives
back.TIOCSTI and dev.tty.legacy_tiocsti
— the terminal-injection vector the startup warning is about.Semantic Versioning, from Conventional Commits.
Any change to the bind lists, the bwrap flags, the Landlock ruleset or the socket filter is security-sensitive regardless of the number it ships under, and PRs that touch them are reviewed as such — see CONTRIBUTING.md.
See CONTRIBUTING.md — in particular the rules for PRs that touch the bind lists, since those are the security model. By participating you agree to the Code of Conduct.
Found a vulnerability? See SECURITY.md — please don't open a
public issue. Note that the KNOWN ESCAPE VECTORS block at the bottom of
main.go documents what is deliberately left open.
Apache 2.0 © Fabio Cicerchia
Prints the whole invocation with --dry-run. That output is the audit
trail, and it is meant to be read before it is trusted.