# What kgate run actually does

> One command, from the config file to the kernel. What is refused before exec, why a home-directory write is still a launch in the log, and how a Linux host allow list is a filter in a network namespace.

- URL: https://avikmukherjee.com/blog/what-kgate-run-does
- Author: Avik Mukherjee
- Published: 2026-10-26
- Updated: 2026-10-26
- Tags: macos, linux, sandbox, seatbelt, bubblewrap, operating-systems

import { RunWalk, SlirpFilter } from '@/components/blog/SandboxBackends'

The [previous post](/blog/seatbelt-and-bubblewrap) is about the two tools. Seatbelt is a macOS kernel policy from 2007. bubblewrap is a Linux mount tool from 2016. Neither was built for a launcher.

This one is the launch. `kgate run -- git status`, typed in a project directory, on a machine whose network mode is one the kernel will actually install.

## Ask the backend before the process exists

`kgate doctor` does not start your program. It asks the backend for this operating system whether the current policy can be enforced.

On this Mac the answers are short.

| Config | What doctor prints | Exit |
|---|---|---|
| `network: restricted` | `BLOCKED`. A per-host list cannot be installed. `sandbox-exec` only accepts `*` or `localhost`. | 1 |
| `network: open` | `DEGRADED`. The filesystem box is still there. The network box is not. | 0 |
| `network: off` | `READY`. The localhost rule from the previous post can be installed. | 0 |
| `sandbox-exec` missing | `BLOCKED`. | 1 |

The restricted sentence is the same limit the launch will hit. Doctor and the profile builder share it, so they cannot drift into two different stories about what the kernel accepts.

On Linux the table is different, because the kernel is different. Missing `bwrap` is `BLOCKED`. `network: open` is `DEGRADED` for the same reason as on a Mac. `network: off` is `--unshare-net`, and that is `READY` without any extra tools. A host allow list is `READY` only when `slirp4netns` and `iptables` are on the machine. If either is missing, doctor says `BLOCKED` and the launch will not start. An empty allow list with `restricted` is the same as network off: a network namespace with nothing in it.

I run doctor before I point an agent at a tree. A blocked doctor means the config is a wish. The process should not find that out halfway through a command.

## Three commands, one path

The setup on this Mac is:

```bash
kgate init
kgate network off
cd ~/proj
```

`init` writes `~/.kgate/config.yaml`. The default network is `restricted`, which doctor will reject here, so the next command sets the mode the kernel can enforce. A project `.kgate.yaml` may only make that smaller. It can add a read or exec deny. It can drop a host or a path. It cannot add one. A one-shot exception is a flag on that invocation, `--allow-path` or `--allow-net`, typed by the person at the terminal.

Then the command.

<RunWalk />

`git status` is the ordinary case. The working directory is the project, so the launch is willing to start. `git` is not on the deny list. The backend builds the argv. An audit line with kind `launch` is written first. If that write fails, the process does not start. Then `sandbox-exec` or `bwrap` runs, with the project as its current directory, and git's exit code comes back.

`curl` stops earlier. The default policy denies it by name, and also denies a copy that is the same file. The check reads the command line. A shell is not a way around it: `sh -c curl` is looked at too, and so is a one-line interpreter script. The audit kind is `block`. There is no `launch` line, because nothing was executed. The kernel never saw the process.

`touch` of a file in `$HOME` is the case that used to confuse me. `touch` is allowed to start. The audit line says `launch`. On macOS the write then fails with `EPERM`, `Operation not permitted`, and the real file is not created. On Linux the home directory is an empty tmpfs, so the write can succeed and still never touch the real home. The file dies with the sandbox. I do not turn either outcome into a second log line. The log records that the process was allowed to exist.

A launch whose working directory is `$HOME` does not get that far. The working directory is always in scope. If it is your home directory, the scope is your home directory, and the sandbox is a costume. That launch is refused before either backend runs.

## The same policy, two argv lists

Doctor and the launch call the same backend. On a Mac that backend returns a `sandbox-exec` profile. On Linux it returns a `bwrap` argument list, plus the extra files a host allow list needs. The config key has one name. The thing the kernel installs does not.

| You asked for | macOS | Linux |
|---|---|---|
| Hide `$HOME` | A read deny and a write deny on the home directory. The write deny is repeated after the temp allow, so `/private/tmp` cannot reopen home. | `--tmpfs $HOME`, after the read-only `/`. |
| Use the project | An allow for that directory, later than the denies. | `--bind $PWD $PWD`, after the tmpfs, or the project disappears with home. |
| Hide one secret file | A read deny on that path. | `--ro-bind /dev/null` on that path. |
| Network off | Deny outbound, then allow `localhost:*` only. | `--unshare-net`. |
| A list of hosts | Error. Do not start. | A network namespace, `slirp4netns`, and a filter. |

The profile order and the mount order are the whole mechanism. Both were in the previous post. What I want to add here is the row that is not the same shape at all.

## A host list is a second process

`--unshare-net` is network off. The namespace has no route to the host. That part is bubblewrap alone.

A list of names is not. bubblewrap will not filter by hostname. The construction rootless containers use, and the one this launcher builds but I have not executed on this Mac, is:

1. `bwrap` starts with `--unshare-net` and `--info-fd 3`. It prints the sandboxed pid as JSON on that file descriptor.
2. `slirp4netns` attaches to that pid. The guest address is `10.0.2.100`. The DNS address inside the guest is `10.0.2.3`.
3. A script in the namespace waits until `10.0.2.100` appears on `tap0`, then sets the output policy to drop.
4. DNS is allowed only to `10.0.2.3` (and to `fd00::3` when IPv6 is enabled and `ip6tables` exists). Port 53 to the rest of the world stays closed.
5. The script resolves each allowed name *inside* the namespace. The addresses that come back are accepted. Everything else is rejected.
6. The script `exec`s the command. The command is an argument, not text interpolated into the script.

<SlirpFilter />

The resolve has to happen in the namespace. A name resolved on the host can be a different address by the time the guest connects, and the filter would be allowing the wrong one. A name that resolves to no IPv4 address aborts the launch. The command does not run on a half-built filter.

I have unit tests for the argument list, the script, and the resolv file. I do not have a packet capture. This machine is a Mac. `bwrap` is not here. Treat the diagram as the shape of the filter, and the first post's `sandbox-exec` measurements as the ones I actually ran.

## What an agent is allowed to see

The model does not type `kgate`. A harness wraps the shell, so the command the model emits is already behind the `--`. If remembering the prefix is the model's job, skipping the prefix is also the model's job.

What comes back is the program's stdout and stderr, with a few known secret prefixes replaced, and the exit code. The mask is a user-space filter on the pipe. Seatbelt does not know what an API key looks like. Neither does bubblewrap. A line that never reaches the pipe is not masked. A token with no break long enough to overflow the buffer can still be split. The kernel box is not that filter, and the filter is not the kernel box.

A denied command looks like a normal failure to the harness: non-zero exit, a message, no stdout from the program. A kernel denial on macOS looks like the program ran and then failed. A write into the Linux tmpfs looks like success. The log is how I tell the first of those apart after the fact. `block` means it never started. `launch` means it did.

## What I would still not claim

A per-host allow list on macOS is still impossible. `network: off` and `network: open` are the modes this Mac can enforce. Open means the network box is gone. Doctor says so, and then it returns success, because the filesystem box is still installed and pretending otherwise would be a nicer report than the machine.

The Linux filter above is designed and checked as text. It is not a measurement of packets. When I can run it on a Linux machine, the thing to verify is the one the Mac already taught me to distrust: a name that fails, and a raw IP that succeeds, are not the same as "network off."
