Navigation
Copy page

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 eval acts 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:

sh
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:

sh
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:

nix
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:

sh
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:

sh
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:

sh
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:

nix
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:

sh
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:

nix
sandbox.denyEnv = [ "AWS_SECRET_ACCESS_KEY" "GH_TOKEN" ];

By hand, add an --unsetenv per variable to the same bwrap invocation:

sh
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/read is coarse, so any readable secret is readable. denyRead masks 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.