Security Model and Sandboxing
On this page
kli draws a hard line between two questions that are easy to confuse. What is an agent allowed to do? is the authority question, and kli answers it precisely through the capability lattice. What can the kli process touch on this machine? is the containment question, and kli does not answer it at all. The process runs with your privileges, and so does everything it does on your behalf.
kli enforces authority — which capabilities an agent may exercise — not containment. By default nothing kli runs is isolated from the host: a granted shell command, file write, or
evalacts with the full privileges of the kli process. For autonomous or untrusted use, run kli inside your own confinement (bwrap, a container, or a VM). The sandbox is the boundary; kli is not.
Authority is not containment
Authority is decidable and kli enforces it. Every gated tool asks the current
subject whether it holds the matching capability before it acts, and a capability
the subject lacks is denied. You shape that authority up front through the
capabilities array — see Capabilities and fault
barriers for how the subject
decides, Restrict what kli can do for the
steps, and Capabilities for the full vocabulary.
Containment is a different mechanism living in a different place: the operating
system, not kli. A capability decides whether the bash tool may run a command;
it does not and cannot decide what that command, once running, may read or write
on disk or send over the network. A shell granted process/exec runs curl,
make, and rm with the full reach of the kli process. Narrowing the capability
set reduces which tools the agent can reach; it never shrinks the blast radius
of the ones it can. That second job belongs to a sandbox you put around the whole
process.
Why the boundary is the process, not the tool
It is tempting to want a sandbox bolted onto the bash tool alone — confine
shell-outs and leave the rest. That boundary is theatre. The eval tool runs
Common Lisp inside the live image, and the file tools (read, write, edit)
act on the host filesystem directly; both wield the process's full authority
without ever spawning a subprocess. A wrapper around bash would confine
shell-outs while write and eval kept unrestricted host access — a boundary
with a hole exactly where it matters.
The one boundary that contains every tool at once is the process boundary. A jail
around the kli process contains bash, eval, and the file tools together,
because all three draw on the same process privileges. So kli builds no per-tool
sandbox and instead makes whole-process confinement easy to stand up around it.
See the authority a run will hold
Before you size a sandbox, see exactly what a run could do inside it. kli
--print-authority resolves the subject a session would hold — the configured
capabilities under the resolved profile — and prints its atoms and constraints,
then exits without reading a prompt or running an agent:
kli --print-authority
It defaults to the same profile -p uses; --profile <name> inspects another.
The headless attenuation flags apply here too, so you can preview a narrowed run:
--read-only drops file/write, file/edit, and process/exec; --no-bash
drops process/exec. Add --json for one machine-readable object:
kli --print-authority --read-only --json
The report tells you whether the run is universal (every capability), bounded to a listed set, or holds nothing — the information you need to decide how tight the surrounding confinement must be.
Confine the kli process
The recipes below all do the same thing: bind the working directory writable, mount the rest of the filesystem read-only, drop into isolated namespaces, and run kli inside. They differ only in the mechanism your platform already has.
The Nix sandbox option
If you build kli through the flake's producer, confinement is one option. It wraps a fixed store entrypoint, so the wrapper lives outside the binary it confines — nothing the model or a repo-local setting can switch off:
programs.kli = {
enable = true;
sandbox = {
network = true; # set false to unshare the network namespace
writablePaths = [ "${config.home.homeDirectory}/.cache/kli" ];
denyRead = [ "${config.home.homeDirectory}/.aws" ];
denyEnv = [ "AWS_SECRET_ACCESS_KEY" ];
};
};
The same sandbox set is accepted by mkConfiguredKli in a dev shell and by the
NixOS module. The wrapper binds $PWD writable and --chdirs into it at run time,
--ro-binds / for everything else, mounts a private /dev, /proc, and
/tmp, and sets --unshare-pid --unshare-ipc (bwrap drops the ability to gain
new privileges on its own — there is no --no-new-privs flag). writablePaths
adds extra writable binds; network = false adds --unshare-net; denyRead
masks paths and denyEnv unsets environment variables (both below).
bwrap
The same confinement by hand, for any install:
bwrap \
--ro-bind / / \
--dev /dev --proc /proc --tmpfs /tmp \
--unshare-pid --unshare-ipc \
--bind "$PWD" "$PWD" --chdir "$PWD" \
-- kli
Append --unshare-net to cut the network (read the network note below first).
Docker or Podman
Run kli from an image that has it installed, mounting only the project:
docker run --rm -it \
-v "$PWD:/work" -w /work \
kli-image kli
The container is the filesystem boundary; nothing outside the mount is visible.
Add --network none to cut the network.
systemd-run
Wrap a single transient unit with systemd's own sandboxing:
systemd-run --user --pty \
-p ProtectSystem=strict \
-p ReadWritePaths="$PWD" \
kli
ProtectSystem=strict makes the filesystem read-only except ReadWritePaths; add
PrivateNetwork=yes to cut the network. Leave the path holding kli's config and
credentials readable, or the model API client cannot authenticate.
A dev container
A .devcontainer that runs kli inside the container makes the container the
boundary for every session opened in it, with the container runtime governing what
the workspace can reach.
Hide secret files from the agent
A capability cannot hide one file: file/read is all-or-nothing, so a session that
can read the project can read a secret sitting in it. The place to hide a specific
path is the mount namespace, where the mask covers bash, read, and eval
uniformly because it is in the kernel, not a per-tool filter.
With the Nix option, list the paths under denyRead. A file there reads as empty,
a directory reads as empty:
sandbox.denyRead = [
"${config.home.homeDirectory}/.aws"
"${config.home.homeDirectory}/.config/gh"
];
By hand, overlay the same masks after the read-only root — a file with /dev/null,
a directory with a tmpfs. Launched from a repo root, this hides a project-local
secret and your cloud credentials:
bwrap \
--ro-bind / / \
--dev /dev --proc /proc --tmpfs /tmp \
--unshare-pid --unshare-ipc \
--bind "$PWD" "$PWD" --chdir "$PWD" \
--bind /dev/null "$PWD/.envrc.local" \
--tmpfs "$HOME/.aws" \
-- kli
This is the at-rest boundary: it takes the secret off disk. Its in-environment
twin is denyEnv, which keeps named variables out of every tool's environment
(next section). denyRead masks files; denyEnv unsets variables — both act at
the process boundary, so both cover bash, the file tools, and eval at once.
Keep secret variables out of the environment
A secret often lives in the environment, not just on disk: a token exported into
the shell that launched kli is inherited by every shell-out. kli does not filter
the environment per tool — eval can read any variable through posix-getenv,
and a shell command can read /proc/self/environ, so a bash-only scrub is
theatre the same way a bash-only filesystem jail is. The place to drop a variable
is the process boundary, where the unset covers bash, the file tools, and
eval together.
With the Nix option, name the variables under denyEnv. Each is unset before the
confined process starts:
sandbox.denyEnv = [ "AWS_SECRET_ACCESS_KEY" "GH_TOKEN" ];
By hand, add an --unsetenv per variable to the same bwrap invocation:
bwrap \
--ro-bind / / \
--dev /dev --proc /proc --tmpfs /tmp \
--unshare-pid --unshare-ipc \
--bind "$PWD" "$PWD" --chdir "$PWD" \
--unsetenv AWS_SECRET_ACCESS_KEY \
--unsetenv GH_TOKEN \
-- kli
denyEnv is a blocklist of names to remove, not an allowlist of names to keep: it
shrinks the inherited environment by the secrets you name and leaves the rest
intact, so command lookups and tool configuration still work.
Network is all or nothing
Whole-process confinement makes the network all-or-nothing, because kli's model API client shares the process with the tools. Cutting the network namespace cuts the API along with everything else, so net-off is usable only with a model that runs locally. There is no built-in per-destination filter: allowing the API while blocking exfiltration to elsewhere is a filtering proxy you run in front of kli, not a control kli provides. The headline protection of the easy sandbox is therefore filesystem and process isolation, not network policy.
Residual risks
A sandbox bounds the blast radius; it does not make autonomous execution safe. Name these and plan for them:
- Destruction inside the writable workspace. Nothing distinguishes
rm -rf .from legitimate work within the directory you bound writable. Version control and backups bound this; the sandbox does not. - Exfiltration over an allowed channel. With the network on, an agent can send data anywhere it can reach. A filtering proxy in front of kli bounds this; kli does not.
- Credential read-at-rest.
file/readis coarse, so any readable secret is readable.denyReadmasks the paths you name; a broader mount or LSM policy covers the ones you forget. - A self-sandbox would be widenable. A confinement the binary applied to itself could be loosened by injecting config. Keeping the wrapper external — a fixed entrypoint around the binary — is why the Nix option cannot be switched off from inside a session.
The throughline: kli is honest about being a non-provider of containment, tells you the exact authority a run will hold, and makes the real boundary — a jail around the whole process — easy to put in place. The sandbox is the boundary; kli is not.