What kgate run actually does
The previous post 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:
kgate init
kgate network off
cd ~/projinit 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.
Walking the launch from the config file toward the kernel.
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:
bwrapstarts with--unshare-netand--info-fd 3. It prints the sandboxed pid as JSON on that file descriptor.slirp4netnsattaches to that pid. The guest address is10.0.2.100. The DNS address inside the guest is10.0.2.3.- A script in the namespace waits until
10.0.2.100appears ontap0, then sets the output policy to drop. - DNS is allowed only to
10.0.2.3(and tofd00::3when IPv6 is enabled andip6tablesexists). Port 53 to the rest of the world stays closed. - The script resolves each allowed name inside the namespace. The addresses that come back are accepted. Everything else is rejected.
- The script
execs the command. The command is an argument, not text interpolated into the script.
The guest is 10.0.2.100. The resolver is 10.0.2.3. Names are looked up inside the namespace, then the filter allows those addresses and rejects the rest.
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."