# Security Model and Sandboxing


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](/kli/concepts/capabilities-and-fault-barriers) for how the subject
decides, [Restrict what kli can do](/kli/guides/restrict-what-kli-can-do) for the
steps, and [Capabilities](/kli/config/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 `--chdir`s into it at run time,
`--ro-bind`s `/` 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.
