hort
hort gives your coding agents a place to work that isn't your machine.
You run hort up fix-login inside a repository. hort cuts a new branch and a git worktree for it, starts a small container around that worktree, and drops you into a shell inside. From there you run whatever agent you like, with its permission prompts turned off, as many copies as you want. When you are done you review and commit from your own terminal on the host, and hort down fix-login throws the box away.
This book is the user documentation. If you are new, read it in order up to the quickstart; after that, jump to whatever you need.
What hort is
A sandbox is two things that live and die together: a git worktree on your host, on its own branch, and a container that mounts that worktree at /workdir. hort is the container runtime itself (it embeds one), so there is no daemon, no Docker and no image pulling. You bring a prepared root filesystem with your tools and agents already in it, and every sandbox layers a disposable writable copy on top of it.
The idea everything else follows from: the sandbox outlives every session inside it. A sandbox is a place. It comes up with hort up, it stays up until you run hort down, and in between you walk in and out of it with hort attach. Closing a terminal, dropping an SSH connection or an agent exiting ends a session, never the sandbox. That is what lets you run several agents side by side in one box and come back to them later.
What you get:
- Destruction stays in the box. The host filesystem does not exist inside. The real
.gitstays on the host, so the worst a bad command can do is wreck one uncommitted worktree. - Your environment comes with you. Dotfiles and credentials are mounted read-only, so your shell and editor config work inside.
- Several agents at once. Open as many sessions as you like in one sandbox, or run several sandboxes on separate branches.
- Nothing is lost by accident. hort never kills an idle sandbox.
hort lsshows every box with its age, idle time and whether its worktree holds uncommitted work. - An optional egress allowlist. Turn it on and a sandbox reaches only the hosts you name.
What hort is not
hort is deliberately small. It is not:
- An orchestrator. tmux (or your terminal) arranges panes and keeps sessions around; hort only makes sandboxes and sessions. See Running agents in parallel.
- A TUI or a GUI. Everything is a command. The only interactive prompts are first-run setup and a few yes/no confirmations.
- A diff viewer or merge tool. You review with
gitand your editor, on the host, while the sandbox is still alive. - A place to run git, by default. In the default mode git does not work inside a sandbox: the worktree points at your host repository, which the box does not have, and making that writable would let an agent rewrite history or delete branches in your real repository. The agent writes files; you commit them from the host. When you need the agent to commit and open pull requests itself, build the sandbox with
--git cloneand it gets a clone of its own, with your repository still never written from inside. See Git inside the sandbox. - An agent launcher or installer. hort starts no agent and installs none. Agents are baked into your rootfs, and you start them yourself.
- Armor against malicious repositories. hort contains destruction. It does not promise to stop a hostile repository from sending your data out; the allowlist raises the bar, it is not a wall. See the Security model.
- Tied to one agent. Claude Code, Aider, Codex CLI, Gemini CLI: whatever runs in a terminal runs in hort.
- Cross-platform. Linux only. A Linux VPS over SSH is a first-class case. macOS is out of scope.
The rule that follows: run hort on code you trust, with development credentials only, never production.
Where to go next
- Installation: build the binary and check your host with
hort doctor. - Preparing a rootfs: the base filesystem every sandbox runs.
- Quickstart: from nothing to an agent working in a sandbox, then review, commit and tear down.
- Working inside a hort sandbox: a short page to hand to the agent itself.
- Roadmap: what is planned and not available yet.
Installation
hort is a single binary written in Rust. You build it from source, put it on your PATH, and check that your host can run sandboxes with hort doctor.
Host requirements
hort runs on Linux only, as your normal unprivileged user. It needs no root and no daemon.
| Requirement | Needed for | If it is missing |
|---|---|---|
| Unprivileged user namespaces | Every sandbox | No sandbox can be built at all. |
pasta on PATH (the passt package) | Sandbox networking | hort up refuses to start. |
git on PATH | Every hort up, repository or not | hort up refuses to start. |
| Overlay filesystem usable inside a user namespace | Every sandbox root | A build fails when it mounts the root. |
libseccomp (the shared library) | Running the hort binary | The binary does not start. |
ip on PATH (the iproute2 package) | Only sandboxes with an egress allowlist | hort up refuses an allowlisted sandbox. |
cgroup v2 memory and cpu controllers delegated to your user | Only a resources ceiling | The ceiling is dropped with a warning; the sandbox still starts. |
| Landlock ABI 4 or later (Linux 6.7+) | One of the layers of an egress allowlist | The allowlist runs without that layer, with a warning. |
notify-send on PATH (libnotify) | Only desktop notifications | No notification is raised, with a warning. |
You also need a prepared rootfs directory: the base filesystem, with your agents inside, that every sandbox runs. That has its own page, Preparing a rootfs.
Building the binary
You need a Rust toolchain, version 1.89 or newer, and the libseccomp development package (libseccomp-devel on Fedora, libseccomp-dev on Debian and Ubuntu).
git clone https://github.com/johnsilverio/hort.git
cd hort
cargo build --release --locked
mkdir -p ~/.local/bin
install -m 0755 target/release/hort ~/.local/bin/hort
Make sure ~/.local/bin is on your PATH, then:
$ hort --help
The parsed command line: one subcommand and its flags
Usage: hort <COMMAND>
Commands:
up Build a sandbox and open a session in it
attach Open one more session in a running sandbox
ls List every sandbox with its reconciled state
down Tear a sandbox down in the mandatory order
prune Remove idle sandboxes and abrupt-death debris after confirming
config Ask what this host can do and write the global configuration
doctor Report what this host can do, changing nothing
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
The binary links dynamically against the C library and libseccomp, so copy it only to machines that have both.
Checking your host with hort doctor
hort doctor reads the host and prints one row per capability, changing nothing. Run it now, and again whenever you change something on the host.
$ hort doctor
host
user namespaces yes
pasta /usr/sbin/pasta
ip /usr/sbin/ip
cgroup memory delegated
cgroup pids delegated
cgroup cpu not delegated
a sandbox runs with no CPU ceiling. Add cpu to Delegate= in a systemd drop-in for user@.service.
cgroup cpuset not delegated
a sandbox cannot be pinned to a set of cores. Add cpuset to Delegate= in a systemd drop-in for user@.service.
landlock ABI 7
rootless overlayfs yes
notify-send /usr/sbin/notify-send
git yes
configuration
rootfs no rootfs configured — set "rootfs" to a prepared rootfs directory in .hort.json or ~/.config/hort/config.json
Under each thing the host lacks, doctor says what that costs and how to get it. The last row reports your configured rootfs with the same message hort up would print, or ready once it can carry a sandbox.
The exit status is 0 when the three hard requirements (user namespaces, pasta, git) are met and 1 otherwise, whatever the rootfs row says, so you can use it as a gate in a script:
hort doctor > /dev/null && echo "this host can build sandboxes"
See hort doctor for every row.
Fixing what doctor reports
User namespaces. Most distributions allow unprivileged user namespaces by default. If doctor says no, check the user.max_user_namespaces sysctl (it must be above zero). On Ubuntu 23.10 and later, AppArmor restricts them through the kernel.apparmor_restrict_unprivileged_userns sysctl; read your distribution's guidance before relaxing it.
pasta, git, ip, notify-send. Install the passt, git, iproute2 and libnotify packages of your distribution.
cgroup controllers. systemd delegates only memory and pids to users by default. A resources.cpus ceiling needs cpu as well. Add a drop-in and restart your user session:
sudo mkdir -p /etc/systemd/system/user@.service.d
printf '[Service]\nDelegate=cpu cpuset io memory pids\n' | sudo tee /etc/systemd/system/user@.service.d/delegate.conf
sudo systemctl daemon-reload
Then log out completely and back in (or reboot) and run hort doctor again. hort caps CPU time, never pins cores, so cpuset is reported but not used by any setting.
Landlock. The Landlock row matters only for egress allowlists. Below ABI 4 an allowlisted sandbox loses its kernel port restriction; the other layers still hold. See Networking and egress.
Where hort keeps things
| What | Where |
|---|---|
| Global configuration | ~/.config/hort/config.json (or $XDG_CONFIG_HOME/hort/config.json) |
| Per-sandbox records, overlays, worktrees, notify channels | ~/.local/state/hort/sandboxes/<name>/ (or under $XDG_STATE_HOME/hort) |
| Dependency caches | ~/.local/state/hort/cache/<project>/ |
| Container state, helper pid files and the per-sandbox log | $XDG_RUNTIME_DIR/hort/ (usually /run/user/<uid>/hort/), emptied on reboot |
Worktrees live under hort's state directory, not next to your repository, so nothing is added to your project folder.
Next
Prepare a rootfs, then follow the Quickstart.
Preparing a rootfs
Every sandbox runs on a prepared rootfs: an ordinary directory on your host holding a complete Linux root filesystem, with your shell, your tools and your agents already installed. hort never builds, pulls or installs anything. It mounts that directory as the read-only base of every sandbox and gives each sandbox its own disposable writable layer on top, so ten sandboxes share one copy on disk.
You prepare it once, and again whenever you want to change what is inside.
What a rootfs must provide
- A shell at
/bin/sh. Sessions drop into a shell, andhort uprefuses a rootfs without/bin/sh. - Tolerance of an arbitrary user. hort maps your own host user into the sandbox. Do not rely on a fixed
USERor uid; do not set one. - Your agents, baked in. Claude Code, Aider, Codex CLI, Gemini CLI: whatever you run must already be installed. hort installs nothing.
- A world-writable
/workdir. It is where hort binds the worktree. Create it withmkdir -p /workdir && chmod 1777 /workdir. hort refuses a rootfs whose/workdiris not world-writable. - No command that starts an agent. hort starts its own idle process as the container's first process. An image
CMDorENTRYPOINTis ignored.
Useful in practice, though not checked: ca-certificates (anything using TLS needs them), git only if tools you run expect the binary to exist (git commands still do not work inside, see Concepts), and the shell you use on the host if you want it inside (see the shell key).
Agents that refuse to run as root
Inside a sandbox your session is uid 0 of its own user namespace. On the host it is your own unprivileged user, and it holds no capabilities, so being "root" in there buys nothing on the machine. Some agents only look at the uid and refuse to run in their unrestricted mode. Claude Code is one:
--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons
Claude Code accepts that mode as root when IS_SANDBOX=1 is set. Set it in the rootfs, where the login shell reads it. An image ENV line does not survive the export, which keeps files and drops image settings.
- For
shandbashlogin shells:printf 'export IS_SANDBOX=1\n' > /etc/profile.d/hort-sandbox.sh - For fish:
mkdir -p /etc/fish/conf.d && printf 'set -gx IS_SANDBOX 1\n' > /etc/fish/conf.d/hort-sandbox.fish
Other agents have their own switch, or none; check each one's documentation.
Building one from a Dockerfile
A Dockerfile is a convenient way to describe the environment. The repository ships example.Dockerfile: a Node 22 Debian base with bash, git, curl, ca-certificates, Claude Code and IS_SANDBOX=1. Build it with podman, then export the container's filesystem into a directory:
podman build -t hort-devbox -f example.Dockerfile .
cid=$(podman create hort-devbox)
mkdir -p ~/.local/share/hort/devbox
podman export "$cid" | tar -x -C ~/.local/share/hort/devbox
podman rm "$cid"
chmod 1777 ~/.local/share/hort/devbox/workdir
The final chmod is not optional: extracting as a normal user drops the mode the image gave /workdir.
Then point hort at the directory, in ~/.config/hort/config.json or a project's .hort.json:
{
"rootfs": "~/.local/share/hort/devbox",
}
and check it:
$ hort doctor
...
configuration
rootfs ready
Use an absolute path or one starting with ~/. A relative path is read against whatever directory you run hort from.
debootstrap or umoci unpack produce a rootfs directory just as well. Whatever the tool, the result has to meet the five requirements above.
A minimal Alpine rootfs
For trying hort out without an agent, a few megabytes of Alpine are enough:
FROM docker.io/library/alpine:3.22
RUN apk add --no-cache ca-certificates iproute2
RUN mkdir -p /workdir && chmod 1777 /workdir
Build and export it exactly as above, into a directory of its own.
Changing a rootfs
A running sandbox uses its rootfs as the lower layer of a mounted overlay, and changing that layer under a live overlay is undefined. Never modify a rootfs directory that a live sandbox is using. Export the new version into a new directory (for example ~/.local/share/hort/devbox-2), point the configuration at it, and new sandboxes pick it up. Existing sandboxes keep running on the old one until you hort down them; then delete the old directory.
Also export into a directory of its own, never into a directory that already holds another rootfs.
What hort adds on top
You do not need to provide these; hort sets them up in every sandbox:
- A writable home at
/home/hortand a writable/tmp, both in memory and gone when the sandbox goes down, withHOMEand theXDG_*directories pointing into that home. - Your read-only dotfiles and credentials, placed under
/home/hortat the same relative path they have under your host home. /etc/resolv.confin an open-egress sandbox (and none under an allowlist). It is written into the sandbox's own layer and shadows whatever the rootfs carries.- The hostname, set to the sandbox name.
- For an agent with
notify.stopHook, the Claude Code settings file that reports completion.
The rootfs itself is never written to.
Quickstart
This walks through one full loop: build a sandbox for a task, run an agent in it, review and commit its work from the host, and tear the sandbox down. It assumes you have installed hort and prepared a rootfs at ~/.local/share/hort/devbox.
The example repository is ~/src/webapp and the task is called fix-login.
1. Point hort at your rootfs
The first time you run hort up on a terminal with no global configuration, hort opens a short setup dialogue, hort config, and writes ~/.config/hort/config.json for you. You can also write the file by hand. The only field hort cannot work out for itself is rootfs:
// ~/.config/hort/config.json
{
"rootfs": "~/.local/share/hort/devbox",
}
Check it:
$ hort doctor
...
configuration
rootfs ready
2. Commit what the agent should start from
A sandbox's worktree is cut from the repository's current HEAD. Uncommitted changes in your checkout do not come along. Commit (or stash) first:
cd ~/src/webapp
git status
git commit -am "wip before handing off"
3. Build the sandbox
hort up fix-login
hort has created a branch fix-login from HEAD, checked it out in a new worktree, started the container and opened a login shell inside it. What the prompt looks like is up to the shell configuration in your rootfs; the hostname is the sandbox name. You start in /workdir, which is the worktree:
pwd # /workdir
echo $HORT_SANDBOX # fix-login
echo $HOME # /home/hort, a home that lives in memory
Your host home does not exist in here. Nothing outside the worktree, the caches you declare and the read-only paths you configure is reachable from inside.
If you would rather get your prompt back and enter later, use hort up fix-login -d, then hort attach fix-login.
4. Run an agent
Start the agent the way you always do. The sandbox never starts one for you.
claude --dangerously-skip-permissions
The agent can write anywhere in /workdir, install packages, and delete whatever it likes. The worst it can do is wreck this worktree. For Claude Code to accept that flag inside a sandbox, your rootfs needs IS_SANDBOX=1 (see Agents that refuse to run as root), and its credentials have to be mounted; the recipe is in Configuration recipes.
5. Open more sessions
From another terminal, or another tmux pane:
hort attach fix-login
Each attach is a new shell in the same sandbox, seeing the same files. Run a second agent there, a test watcher, an editor. Closing a session never stops the sandbox; exiting the shell hort up opened doesn't either.
6. See what is running
$ hort ls
fix-login live 2 41m 3s 211ms 802us 45ns active fix-login dirty
The columns are name, state, sessions (processes running in the box besides its idle anchor), age, idle time (active while anything runs), branch, and whether the worktree has uncommitted changes. See hort ls.
7. Review and commit from the host
Git does not work inside a sandbox built the default way (see why, and clone mode for the sandbox that does have it). You review and commit on the host, against the worktree, while the sandbox is still running. The worktree lives at ~/.local/state/hort/sandboxes/<name>/worktree-<name>; inside the box, $HORT_WORKTREE prints that host path too.
cd ~/.local/state/hort/sandboxes/fix-login/worktree-fix-login
git status
git diff
git add -A
git commit -m "Fix login redirect loop"
That commit lands on the branch fix-login in your real repository. Merge it however you normally do:
cd ~/src/webapp
git merge fix-login
Not happy with something? Tell the agent and let it keep working; the sandbox is still there.
8. Tear it down
$ hort down fix-login
tear down sandbox 'fix-login' with open sessions? [y/N] y
down asks before it destroys something you cannot get back: when sessions are still open, and, in clone mode, when the box holds commits your repository does not have (-f skips both questions). It stops everything in the box, removes the container, deletes the worktree with any uncommitted changes, and removes hort's record. It prints nothing when it succeeds.
It keeps the branch and every commit on it. If you run hort up fix-login again later, hort notices the branch and offers to build on it:
$ hort up fix-login
branch 'fix-login' already exists (a 'hort down' keeps a sandbox's branch) — build sandbox 'fix-login' on it? [Y/n]
Once the branch is merged and you no longer need it, delete it on the host with git branch -d fix-login.
Where next
- Concepts: what exactly a sandbox, a session and a worktree are, and what each
lsstate means. - Running agents in parallel: several agents, several sandboxes, with tmux.
- Configuration recipes: dotfiles, credentials, caches, databases, an egress allowlist, notifications.
Concepts
hort has few moving parts. Knowing exactly what each one is makes everything else, from ls states to what down deletes, predictable.
Sandbox
A sandbox is a pair that lives and dies together:
- a git worktree on your host, on its own branch (or, in clone mode, a clone of your repository), and
- a container that mounts it at
/workdir.
It has a name you choose (hort up <name>), which is how every other command refers to it. It is born when hort up builds it and it dies only when you run hort down (or hort prune collects it). No timer ever stops it, however long it sits idle.
A sandbox is born empty. It runs no agent. It is a place you go into.
Anchor
Something has to keep a container alive while nobody is inside. hort starts sleep infinity as the container's first process, the anchor. It does nothing else. Its process is also how hort knows a sandbox is alive: the kernel's process table, not a file hort wrote, is the truth.
Nothing inside a sandbox can stop the anchor. The kernel discards signals sent to the first process of a pid namespace from inside that namespace, so kill -9 1 from a session does nothing. Only hort down on the host ends a sandbox.
Session
A session is a process that joins a running sandbox: a login shell in /workdir. hort up opens one (unless you pass -d) and every hort attach opens another. You can have as many as you like, all seeing the same files.
Sessions are independent of the sandbox. Exiting a shell, closing a terminal or losing an SSH connection ends that session only. The sandbox and every other session carry on, and hort attach gets you back in.
hort does not referee between sessions: two agents editing the same file in one sandbox can collide exactly as they would in two terminals on your machine. Every session runs as the same user in the same process namespace, so one session can also kill another's processes. See Security model.
When a session asks for a terminal, hort gives it a new terminal allocated inside the sandbox and relays it; your own terminal is never handed to the box. hort attach and hort up exit with the session's exit status, or 128 + signal if it was killed by a signal, so scripts can tell what happened inside.
Worktree and branch
In a git repository, hort up <name>:
- creates a new branch named
<name>from the repository's currentHEAD, - checks it out in a new worktree at
~/.local/state/hort/sandboxes/<name>/worktree-<name>, - mounts that worktree at
/workdir.
Because the worktree is cut from HEAD, uncommitted changes in your own checkout are not in it.
With --branch <existing> hort checks out a branch that already exists instead of creating one. git allows a branch in only one worktree at a time, so a branch you have checked out elsewhere (including your main checkout) is refused.
Committing in the worktree advances that branch in your real repository, which is the point. Every other branch, and all history not on this branch, stays out of the sandbox's reach.
Git is a host activity
The worktree's .git is a small file pointing at your repository's real .git directory by its host path, and that path does not exist inside the sandbox. So in this mode no git command works inside a sandbox: not status, not diff, not commit, even if git is installed in the rootfs. This is deliberate. Making git work inside by mounting your repository's .git into the box writable would let an unrestricted agent rewrite history or delete branches in your real repository, instead of being limited to one disposable worktree.
So the division of labour is: the agent writes files, you commit them from the host. Review with git diff in the worktree directory, commit there, while the sandbox is still running. $HORT_WORKTREE, set in every session, holds the host path of that directory.
When you need git inside, there is a second mode. hort up <name> --git clone gives the sandbox its own clone of the repository instead of a worktree, so an agent can commit, branch and open a pull request with its own tools. Your repository is still never written from inside: the clone borrows its history read-only and can only push to a remote you gave it credentials for. Worktrees stay the default. See Git inside the sandbox.
What down keeps
hort down <name> removes, in this order: the host-side helpers and every process in the box, the container, the worktree directory (with any uncommitted changes), and hort's record. It keeps:
- the branch
<name>and every commit on it, - your repository and every other branch,
- the project's dependency caches.
Anything not committed when you run down is gone. Commit first. In clone mode that means anything the agent did not push: the clone goes with the sandbox, and so does the pinned ref refs/hort/<name>/base your repository was holding for it. Because a clone keeps its commits to itself, down compares its tip against your repository first and asks before removing a sandbox holding work you never got back.
Because the branch stays, hort up <name> later finds it already there. On a terminal, hort offers to build the sandbox on that branch; without a terminal it refuses and prints the command that does it (hort up <name> --branch <name>). Delete a branch you no longer need with git branch -d <name> on the host (a flag to do it from down is planned).
Projects, and the mode without git
hort only builds a sandbox for a project. Starting from the directory you run it in, it walks up to the nearest directory that holds .hort.json, .devcontainer/devcontainer.json or .git. That directory is the project: its .hort.json is the project configuration. If there is none anywhere up the chain, hort up refuses, so that running it by accident in your downloads folder never mounts that folder into a box.
If the project is inside a git repository, you get the worktree and branch described above. If it is not (a folder marked only by a .hort.json or a devcontainer file), hort mounts the project folder itself at /workdir:
- there is no branch and no worktree, so
lsshows-for branch and dirty state; - the agent writes directly into your folder;
--branchis refused;hort downremoves the container and the record, and never your folder.
You keep container isolation but lose the disposable draft. Prefer a git repository for anything you care about.
States in hort ls
hort never trusts its own records about what is running. Every ls compares three sources, its records on disk, the processes the kernel is running and the worktrees on disk, and reports what it finds.
| State | What it means | What to do |
|---|---|---|
live | The anchor is running and the worktree is on disk. | Nothing. hort attach <name> to go in. |
orphaned | hort has a record, but the anchor is gone: the machine rebooted, or the process was killed. The worktree and its uncommitted work are still on disk. | hort up <name> brings it back on the same worktree, work intact. hort down <name> discards it. hort prune would also collect it. |
inconsistent | The anchor is running but the worktree directory was deleted on the host. | hort down <name>. What was in the worktree is gone. |
lost-record | A sandbox is running under that name but hort's record of it is gone (for example the state directory was deleted). | hort down <name>, which ls prints under the row. It stops the container and its helpers; see Troubleshooting for the worktree it cannot name. |
A live sandbox can also be half-built: the anchor runs but its networking is not standing, because the hort up that built it was interrupted or a helper process died. ls still says live. Run hort up <name> again: it rebuilds the network into the running sandbox without touching the sessions inside.
Idle and age
age is the time since hort up built the sandbox. idle is active while any process besides the anchor runs inside; otherwise it is the time since the most recent of: creation, the last hort attach, and the last completion an agent announced through notifications. hort does not scan the worktree for changes, so an agent quietly editing files in a session that is still open simply shows as active.
Idle time only informs you, and hort prune --idle if you ask for it. hort never stops a sandbox because it is idle: stopping one by mistake destroys uncommitted work, and leaving it running only costs memory.
Where things live
| Path | What |
|---|---|
~/.config/hort/config.json | Global configuration. |
<project>/.hort.json | Project configuration (or .devcontainer/devcontainer.json). |
~/.local/state/hort/sandboxes/<name>/metadata.json | hort's record of the sandbox. |
~/.local/state/hort/sandboxes/<name>/worktree-<name>/ | The worktree, or the clone in clone mode. |
~/.local/state/hort/sandboxes/<name>/overlay/ | The sandbox's disposable writable layer over the rootfs. |
~/.local/state/hort/sandboxes/<name>/notify/ | The notification channel, when configured. |
~/.local/state/hort/cache/<encoded project path>/ | The project's dependency caches, shared by all its sandboxes. |
$XDG_RUNTIME_DIR/hort/sandboxes/<name>/output.log | Log of the host-side helpers, including egress proxy decisions. |
XDG_CONFIG_HOME, XDG_STATE_HOME and XDG_RUNTIME_DIR move these the usual way. Everything under the runtime directory is discarded on reboot, which is exactly what a reboot does to running sandboxes.
Git inside the sandbox
A sandbox gets its /workdir in one of two ways, and the choice decides whether git works inside it.
worktree (the default) | clone | |
|---|---|---|
What /workdir is | a git worktree of your repository | a clone of your repository |
| git inside the sandbox | does not work | works |
| Who commits | you, on the host | the agent, inside |
| Who pushes | you, on the host | the agent, with a token you forward |
| Your repository written from inside | never | never |
| Disk | the checkout | the checkout, plus a small .git; history is borrowed, not copied |
Worktree mode is the default and is the right one when you review and commit the work yourself. Clone mode exists for the case where the agent has to finish the job on its own: commit, push and open a pull request without you in the loop.
In both modes your repository is safe from what happens inside the box. The difference is only how the work comes back.
Worktree mode
/workdir is a worktree on its own branch, and its .git is a pointer file naming your repository on the host, a path the sandbox does not have. So no git command works inside, by design, and you commit from the host while the sandbox runs. Concepts describes it in full.
Clone mode
Turning it on
Per sandbox:
hort up fix-login --git clone
Or for a project, in .hort.json (or globally in ~/.config/hort/config.json):
{
"git": "clone",
}
The flag beats the configuration, so a project configured for clone mode can still be built as a worktree with --git worktree, and the other way round.
In a project that is not a git repository there is nothing to clone. The flag is refused:
--git clone requires a git repository, but this project is not one
while the same mode coming from a configuration file only warns, and the sandbox is built the way a project without git always is, with the folder itself mounted:
warning: the configured 'clone' git mode needs a git repository and this project is not one, so the sandbox mounts the project folder itself
The difference is deliberate: a flag is a request for this one build, while a global configuration file covers every directory on your machine, and refusing there would break hort up in every folder that is not a repository.
What the sandbox gets
/workdir is a clone of your repository, with a real writable .git directory of its own. The branch rule is the same as in worktree mode: hort up <name> creates and checks out a branch named <name>, and --branch <existing> checks out one that already exists.
Inside, the clone has two remotes:
| Remote | Points at | Push |
|---|---|---|
origin | your repository's own origin, for example your GitHub remote | yes, this is where the agent's work goes |
hort-base | your repository on the host | no, fetch only |
If your repository has no remote, the clone has no origin and you add one yourself.
hort-base being fetch only is a guarantee and not tidiness. A plain git clone --shared can push a new branch straight into the repository it was cloned from, so hort gives that remote a push address that cannot resolve. A push through it fails and your repository is never written.
The clone does not copy your history. It borrows your repository's object store, mounted read-only inside the sandbox at /run/hort/objects, which is why a clone of a large repository costs about as much disk as a worktree does. Read-only is enforced by the kernel:
$ touch /run/hort/objects/anything
touch: cannot touch '/run/hort/objects/anything': Read-only file system
So the agent reads all of your history and rewrites none of it. After an agent commits inside a clone-mode sandbox, your repository's .git is byte for byte what it was.
The pinned ref
Because the clone borrows objects instead of copying them, a git gc in your repository could in principle delete an object the clone still needs. To prevent that, hort up writes a ref in your repository naming the commit the clone started from:
refs/hort/<name>/base
It is the one thing hort writes in your repository, it holds no work, and it is invisible to git branch. hort down does not remove it yet (see what is missing).
Sending the work back
The agent commits in the clone and pushes to origin, then opens a pull request. Two things have to be in place for that:
- Credentials inside the box. Forward a token the way the GitHub CLI recipe does. Use a fine-grained token scoped to the one repository, with branch protection on the remote as the backstop: the token lives inside a box with an agent you are not watching.
- Egress, if you use an allowlist. hort never adds a host to your allowlist by itself, so list
github.com(andapi.github.comforgh) yourself. Under open egress they are reachable already.
If you would rather not give the box a token at all, have the sandbox write a bundle and take that instead. The bundle has to be made inside the box, where the borrowed history resolves, and /workdir is on your host, so it lands where you can reach it:
hort run fix-login -- git -C /workdir bundle create /workdir/work.bundle fix-login
git fetch ~/.local/state/hort/sandboxes/fix-login/worktree-fix-login/work.bundle \
'fix-login:refs/hort/incoming'
Fetching from the clone directory does not work, for the same reason git run there does not (see below): it aborts with remote: aborting due to possible repository corruption on the remote side. The bundle is the way out.
Reading the clone from the host
Running git from the host inside the clone directory fails:
$ git -C ~/.local/state/hort/sandboxes/fix-login/worktree-fix-login status
error: unable to normalize alternate object path: /run/hort/objects
fatal: bad object HEAD
That is not damage. The clone records the address where it borrows objects, and that address exists inside the sandbox, not on your host. Ask the sandbox instead:
hort run fix-login -- git -C /workdir status
hort run fix-login -- git -C /workdir log --oneline -5
The same limit applies to fetching from that directory, which is why work comes back through a push or a bundle.
What is not there yet
Clone mode works, and one rough edge remains:
hort lsdoes not say which mode a sandbox was built in. Its dirty column also reads-for a clone, because that check asks your repository about a worktree and a clone is not one. The branch column is correct.
It is on the Roadmap.
What hort down does with a clone
down removes the clone along with the sandbox, and a commit that never left the box goes with it. So before it removes anything it compares the clone's tip against your repository and asks when your repository does not have it:
sandbox 'fix-login' holds commits the project repository does not have; tear it down anyway? [y/N]
Anything but y leaves the sandbox standing. hort down -f skips the question, and without a terminal down refuses rather than guess. See hort down.
It also deletes that sandbox's pinned ref refs/hort/<name>/base, the one your repository was holding so its gc would leave the clone's borrowed objects alone. The pins of your other clone-mode sandboxes stay where they are.
Changing the mode of a sandbox that exists
A sandbox is built one way or the other, and hort up refuses to finish a half-built one in the other mode:
sandbox 'fix-login' was built in clone git mode, not worktree — run 'hort down fix-login' first, or repeat it with 'hort up fix-login --git clone'
Take what you want out of it first, then tear it down and build again.
Which one to use
Use worktree mode when you are the one reviewing and committing, which is most of the time. It is the default, it has fewer moving parts, and nothing inside the box can run git at all.
Use clone mode when an agent has to deliver finished work by itself, typically several agents in parallel each opening its own pull request. The cost is that a token now lives inside the box, so scope it narrowly and protect the branches on the remote.
Running agents in parallel
hort gives you two ways to run agents side by side, and tmux (or any terminal multiplexer, or just several terminal tabs) to arrange them. hort itself never holds your terminal open for you, never detaches and reattaches a session, and has no layout of its own: that is the multiplexer's job, and it already does it well.
Two shapes of parallel
Several agents in one sandbox. Every hort attach opens another session in the same box, on the same worktree and branch. Use it when the agents are working on the same change: one writing a feature, one reviewing the diff as it grows, one running the tests in a loop.
One sandbox per task. Each hort up <name> gets its own branch, its own worktree and its own container. Agents in different sandboxes cannot see each other's files at all. Use it for independent tasks you want to review and merge separately.
You can mix them: three sandboxes, two sessions in one of them.
Conflicts are yours to manage. Two agents editing the same file in one sandbox can overwrite each other, exactly as two processes in one directory would on your machine. Two sandboxes never conflict on disk, but their branches can conflict when you merge.
With tmux
Build the sandboxes detached, then give each session its own pane:
cd ~/src/webapp
hort up fix-login -d
hort up search-api -d
tmux new-session -d -s webapp 'hort attach fix-login'
tmux split-window -h -t webapp 'hort attach fix-login'
tmux new-window -t webapp 'hort attach search-api'
tmux attach -t webapp
Now start an agent in each pane, for example claude --dangerously-skip-permissions in the first, aider in the second.
A pane whose session ends simply closes. Neither closing a pane nor killing the whole tmux server touches the sandboxes:
$ tmux kill-server
$ hort ls
fix-login live 0 1h 12m 3s 402ms 118us 20ns 3s 970ms 710us 293ns fix-login dirty
search-api live 0 1h 11m 58s 77ms 5us 301ns 1h 11m 58s 77ms 5us 301ns search-api clean
Reattach whenever you like, with a new pane and hort attach fix-login. The same holds over SSH: sandboxes on a remote machine keep running when the connection drops. Run tmux on the remote machine and your panes survive too.
Knowing which box a shell is in
Every session has two environment variables:
HORT_SANDBOX: the sandbox name, for examplefix-login.HORT_WORKTREE: the host path of the directory mounted at/workdir.
hort does not change your prompt. If your dotfiles are mounted into the sandbox, add a segment that shows HORT_SANDBOX when it is set. For fish:
# in config.fish or a function called from fish_prompt
if set -q HORT_SANDBOX
echo -n "[$HORT_SANDBOX] "
end
For bash:
[ -n "$HORT_SANDBOX" ] && PS1="[$HORT_SANDBOX] $PS1"
Inside a sandbox the hostname is also the sandbox name, so prompts that show \h already say where you are.
Keeping track
hort ls lists every sandbox on the machine, from every project, with its sessions, idle time and whether its worktree holds uncommitted work. A sandbox you forgot keeps running until you remove it; it costs memory, never work. Review and commit from the host, then hort down what you are finished with.
Notifications
If an agent can announce that it finished, hort can raise a desktop notification on the host when it does, so you do not have to watch every pane. Claude Code can; see Notifications.
Networking and egress
Every sandbox has its own network namespace, bridged to the host by pasta, which runs on the host as your user. What the sandbox can reach is decided by one configuration key, egress, and the databases you declare under network.
Open egress (the default)
With egress absent or true, the sandbox reaches whatever your host reaches. Nothing is filtered and no proxy runs. This is what most agents need out of the box: they talk to their model provider directly.
- Name resolution works: hort writes an
/etc/resolv.confinto the sandbox naming198.51.100.53, and pasta answers that address by forwarding the query to your host's resolver. - Services on the host's loopback are reachable at
127.0.0.1inside the sandbox. A development server or database listening on127.0.0.1:5432on your host answers on127.0.0.1:5432inside the box, with no configuration. - No proxy variables are set.
Open egress does not prevent exfiltration. A hostile repository could send data anywhere, including to services on your host's loopback. See the Security model.
An egress allowlist
{
"egress": { "allow": ["api.anthropic.com", "github.com", "*.githubusercontent.com"] },
}
With an allowlist the sandbox has no route to anything except two kinds of endpoint on its own loopback:
- an HTTPS proxy that hort runs on the host for this sandbox, which only tunnels to hosts on the list, and
- the databases you declared under
network.
Everything else fails: a raw connection to an IP address, UDP and QUIC, DNS queries, a host port you did not declare. There is no /etc/resolv.conf; the proxy resolves host names itself.
How entries match
- A bare name such as
github.commatches exactly that host and nothing else, not evenwww.github.com. - A
*.entry such as*.githubusercontent.commatches any subdomain (raw.githubusercontent.com) but not the name itself. List the apex separately if you need it. - Matching ignores case and a trailing dot.
notexample.comnever matches*.example.comorexample.com. - An entry must be a plain host name: no scheme, no port, no path.
"https://github.com"makeshort upfail withinvalid name.
"egress": false is an empty allowlist: no host is reachable through the proxy, while declared databases still are.
Tools must use the proxy
In every session of an allowlisted sandbox, hort sets:
HTTP_PROXY=http://127.0.0.1:<port> http_proxy=http://127.0.0.1:<port>
HTTPS_PROXY=http://127.0.0.1:<port> https_proxy=http://127.0.0.1:<port>
ALL_PROXY=http://127.0.0.1:<port> all_proxy=http://127.0.0.1:<port>
NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost
A tool that honours these variables works for allowed hosts. A tool that ignores them finds no route and fails. This is fail-closed by design.
The proxy only opens CONNECT tunnels, which is what clients use for https:// URLs. It checks the host named in the request against the list, then checks that the TLS handshake names the same host, and never decrypts anything. A plain http:// request sent to the proxy is refused, because cleartext names nothing the proxy can confirm.
NO_PROXY covers 127.0.0.1 and localhost so that database connections go straight to their forward. Some tools ignore NO_PROXY (BusyBox wget is one) and send even local requests to the proxy, which refuses them.
Reading the proxy log
Every decision the proxy makes is appended to the sandbox's log on the host:
grep -E '^(allowed|refused)' "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/hort/sandboxes/fix-login/output.log"
allowed api.anthropic.com
refused evil.test (not in the allowlist)
refused http://example.com/ (only CONNECT tunnels are proxied)
This makes a failing connection a one-look diagnosis:
- a
refused <host> (not in the allowlist)line: the allowlist blocked it; add the host if you trust it; - a
refused ... (only CONNECT tunnels are proxied)line: the tool sent a plain HTTP request; usehttps://, or for a local address make sure the tool honoursNO_PROXY; refused <host> (the connection asked for <other>): the TLS handshake named a different host than the request, which the proxy never allows;- no line at all: the tool ignored the proxy variables and tried to connect directly, which has no route.
What the agent cannot change
An allowlisted sandbox's network namespace is owned by hort, not by the sandbox. The agent is root only inside its own user namespace and holds no network privileges over the namespace it uses, so it cannot add a route, change addresses or remove the restriction:
# ip route add default via 10.0.0.1
RTNETLINK answers: Operation not permitted
On kernels with Landlock ABI 4 or later (Linux 6.7+), sessions are additionally restricted by the kernel to connecting only to the proxy's and the declared databases' ports. Below that, hort up warns:
warning: this kernel cannot restrict which ports a process connects to, so the egress allowlist of this sandbox runs without its kernel layer (Linux 6.7 or newer enforces it)
The other layers still hold. An allowlisted sandbox also needs ip (iproute2) on the host, to empty the namespace's route tables.
Changing the posture of a running sandbox
The network is wired when the sandbox is built. Editing egress in the configuration does not change a sandbox that is already running; hort down <name> and hort up <name> to rebuild it with the new posture. Commit what you want to keep first.
Databases
Declare each database your project needs under network:
{
"network": [
{ "mode": "host", "host": "127.0.0.1", "port": 5432 },
{ "mode": "network", "host": "192.168.1.20", "port": 6379 },
],
}
Inside the sandbox, every declared database is reached at 127.0.0.1:<port>, in both postures. Point your application's development configuration there.
hostis the address the database answers on, as seen from your host. For a database installed on the host that is127.0.0.1. For one in a container (for example Docker Compose), use the address and port it is published on.portis its port, and also the port the sandbox uses.mode("host"or"network") is informational; both are handled the same way. hort does not join Docker networks.
A database on the host's loopback is reached directly. For any other address, hort starts a small forwarder on the host listening on 127.0.0.1:<port> and relaying to the declared address, so that port must be free on your host's loopback.
Under an allowlist, only declared databases are reachable. Under open egress, services on the host loopback are reachable anyway, and declaring a database on another address is what makes it appear at 127.0.0.1:<port>.
Two databases declared on the same port at different addresses cannot both be reached, because inside the sandbox both would be 127.0.0.1:<port>. hort up refuses such a configuration before it builds or changes anything:
two databases are declared on port 5432 (10.0.0.5 and 10.0.0.6), and a sandbox can reach only one of them — remove one from "network" in your configuration or give it another port
Give every declared database its own port.
Only development databases and credentials belong in a sandbox. Never production.
Configuration recipes and tips
Worked setups for the things people configure first. Every key is described in full in the Configuration reference. Global settings go in ~/.config/hort/config.json; project settings in the project's .hort.json.
Dotfiles, read-only
Mount the configuration your shell and editor need. Paths under your home land at the same place under the sandbox home, /home/hort:
// ~/.config/hort/config.json
{
"rootfs": "~/.local/share/hort/devbox",
"mounts": {
"readOnly": ["~/.config/nvim", "~/.gitconfig", "~/.tmux.conf", "~/.bashrc"],
},
}
The programs themselves must be in the rootfs: mounting ~/.config/nvim does nothing for a rootfs without Neovim. If you want your usual shell as the session shell, install it in the rootfs; hort uses your host $SHELL automatically when the rootfs has the same path, or set shell.
Fish and a read-only config directory
Do not mount all of ~/.config/fish read-only. fish writes its universal variables to ~/.config/fish/fish_variables, and with the directory read-only every prompt prints:
error: Unable to open universal variable file '/home/hort/.config/fish/fish_variables': EROFS: Read-only file system
Mount the parts you edit instead, and leave the directory itself writable (it is in the sandbox's in-memory home):
{
"mounts": {
"readOnly": [
"~/.config/fish/config.fish",
"~/.config/fish/conf.d",
"~/.config/fish/functions",
"~/.config/fish/completions",
],
},
}
The same applies to any program that writes state next to its configuration: mount the files, not the directory. Note that hort config offers ~/.config/fish as a whole directory; pick the individual paths by hand instead.
Editing a mounted file while a sandbox runs
A read-only mount of a directory shows your edits inside running sandboxes immediately. A mount of a single file follows that file only while it is the same file on disk: many editors save by writing a new file and renaming it over the old one, and a running sandbox then keeps seeing the old content. New sandboxes see the new file. If you need live edits, mount the directory, or restart the sandbox.
Agent credentials
Claude Code
Claude Code keeps its login under ~/.claude. Mount it read-only and declare the completion hook:
// ~/.config/hort/config.json
{
"agents": [
{
"command": "claude --dangerously-skip-permissions",
"auth": { "readOnly": ["~/.claude"] },
"notify": { "stopHook": true },
},
],
}
Also required: IS_SANDBOX=1 in the rootfs, or Claude Code refuses its unrestricted mode because the session is uid 0 (see Preparing a rootfs).
Because the mount is read-only, nothing Claude Code writes under ~/.claude from inside the sandbox is saved, and whatever it writes elsewhere in the sandbox home is gone at hort down. It may therefore ask again for things it normally remembers, such as whether you trust the project folder.
Agents that take an API key
Forward the variable by name. Its value is read from your environment when the session opens and never written to disk by hort:
{
"agents": [
{ "command": "aider", "auth": { "env": ["OPENAI_API_KEY"] } },
{ "command": "gemini", "auth": { "env": ["GEMINI_API_KEY"] } },
],
}
export OPENAI_API_KEY=sk-... # in the terminal you run hort attach from
hort attach fix-login
If a variable is not set, the session opens without it and hort warns. Use development keys only: the agent can read anything you forward.
GitHub CLI without logging in each time
The gh command reads a token from GH_TOKEN (or GITHUB_TOKEN). Forward it the same way, so gh inside the sandbox is authenticated on every session with no gh auth login:
{
"agents": [
{ "command": "claude --dangerously-skip-permissions", "auth": { "env": ["GH_TOKEN"] } },
],
}
export GH_TOKEN=github_pat_... # in the terminal you run hort from
hort up review-pr
Use a fine-grained token, scoped to the one repository you are working on with only the permissions you need (typically Contents and Pull requests). The sandbox runs an agent you are not watching line by line, so the token is what bounds what it can do on the remote; branch protection on the remote is the backstop.
Under an egress allowlist the sandbox reaches nothing by default, so add github.com and api.github.com to the allowlist for gh to work. Under open egress they are reachable already.
In the default worktree mode git does not work inside the sandbox, so gh covers only what is API work: opening a pull request for a branch that is already on the remote, reading issues, gh api. To let the agent commit and push from inside, build the sandbox in clone mode; the same token then serves both.
Dependency caches
Keep installs across sandboxes of the same project:
// .hort.json
{
"cache": {
"dirs": [
"node_modules",
{ "name": "npm", "target": "~/.npm" },
{ "name": "pip", "target": "~/.cache/pip" },
{ "name": "cargo-registry", "target": "~/.cargo/registry" },
],
},
}
node_modules lands at /workdir/node_modules; the others under /home/hort. Every sandbox of this project shares the same directories, so two sandboxes running installs at the same moment write into the same cache. hort prune removes a project's caches once the project directory is gone, or with --force.
A resource ceiling
{
"resources": { "memory": "6g", "cpus": 4 },
}
Useful when agents run unattended: a runaway process hits the ceiling instead of your machine. cpus needs the cpu cgroup controller delegated to your user; hort doctor tells you, and Installation shows how.
A development database
A PostgreSQL installed on the host, and a Redis published by Docker Compose on another address:
// .hort.json
{
"network": [
{ "mode": "host", "host": "127.0.0.1", "port": 5432 },
{ "mode": "network", "host": "172.17.0.1", "port": 6379 },
],
}
Inside the sandbox, use 127.0.0.1:5432 and 127.0.0.1:6379. Each database needs its own port. Development data only. See Databases.
An egress allowlist
Start from what your agent and your package manager need, then add hosts as the proxy log shows them refused:
// .hort.json
{
"egress": {
"allow": [
"api.anthropic.com",
"registry.npmjs.org",
"github.com",
"*.githubusercontent.com",
],
},
}
Which hosts an agent contacts is up to that agent; check its documentation, then watch the log:
tail -f "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/hort/sandboxes/fix-login/output.log" | grep --line-buffered -E '^(allowed|refused)'
Rebuild the sandbox (hort down, hort up) after changing the list. Tools must honour HTTPS_PROXY; see Networking and egress.
Notifications
hort cannot tell when an agent finishes by watching processes: the agent returns to its prompt and nothing exits. The agent has to announce it. Claude Code can, through a Stop hook that hort installs for you.
You need:
"notify": { "stopHook": true }on the Claude Code entry inagents(as in the credentials recipe),notify-sendon the host (libnotify) and a desktop session that shows notifications,- optionally, a
notificationsblock to change the text:
{
"notifications": {
"sink": "desktop",
"message": "hort: '<name>' is done, come and review",
},
}
How it works: when at least one agent has stopHook enabled, hort up mounts a small channel directory at /run/hort/notify/ and writes /etc/claude-code/managed-settings.d/hort-notify.json into the sandbox, a Claude Code managed-settings file whose Stop hook appends one line of JSON to /run/hort/notify/events.jsonl. A small process on the host, started by hort up and stopped by hort down, raises your message each time that file grows. hort ls also counts the last completion as activity when computing idle time.
To test it without an agent, append a line from a session:
echo '{"ts":"","event":"stop","agent":"manual"}' >> /run/hort/notify/events.jsonl
Things to know:
-
The settings file only exists in sandboxes built after you enabled
stopHook. -
Claude Code documents that it merges every file in that managed-settings directory; its documentation does not say whether hooks from there run alongside the hooks in your own
~/.claudesettings. If your own Stop hooks stop firing inside a sandbox, this is the reason. -
If
notify-sendis missing, or the sink is anything butdesktop,hort upwarns and the sandbox starts without notifications. -
A notification that fails to show (for example no desktop session over SSH) is recorded in the sandbox's
output.log:hort: a completion of this sandbox was not raised: sandbox notifications failed: /usr/sbin/notify-send raised nothing: exit status: 1 -
Any other agent that can run a shell command when it finishes can append the same line. The channel is only mounted when some agent in the configuration has
stopHookenabled.
Showing the sandbox in your prompt
hort sets HORT_SANDBOX in every session and leaves drawing to your shell. With your prompt configuration mounted read-only, add a segment when the variable is set; examples for fish and bash are in Running agents in parallel.
Colors inside the sandbox
Sessions do not receive TERM or COLORTERM from your host terminal, so programs that decide on color from them (Claude Code among them) start without color. Until hort forwards them (planned), set defaults inside the sandbox from a shell configuration you mount into it. For fish:
if set -q HORT_SANDBOX
set -q TERM; or set -gx TERM xterm-256color
set -q COLORTERM; or set -gx COLORTERM truecolor
end
For bash or another POSIX shell:
if [ -n "$HORT_SANDBOX" ]; then
case "$TERM" in ""|dumb) export TERM=xterm-256color ;; esac
export COLORTERM="${COLORTERM:-truecolor}"
fi
The rootfs needs the terminfo entry for the TERM you choose (Debian's ncurses-base package carries xterm-256color).
Habits worth having
- Commit before
hort up. The worktree starts fromHEAD; uncommitted changes in your checkout are not in it. - Commit before
hort down. Uncommitted work in the worktree is deleted with it. - Name sandboxes after the task. The name becomes the branch.
- Run
hort lsnow and then. A forgotten sandbox costs memory until you remove it, anddirtymeans there is work nobody committed yet. - Keep production out. No production credentials, databases or tokens in any mount, variable or network entry.
Commands
| Command | What it does | Changes anything |
|---|---|---|
hort up <name> | Build a sandbox (or finish a half-built one) and open a session in it. | yes |
hort attach <name> | Open another session in a running sandbox. | records the attach time |
hort run <name> -- <cmd> | Run one command in a running sandbox with no terminal, exiting with its status. | records the attach time |
hort ls | List every sandbox with its state, sessions, age, idle time, branch and dirty state. | no |
hort down <name> | Tear a sandbox down: processes, container, worktree, record. | yes |
hort prune | Remove debris and, if asked, idle sandboxes, after confirming. | yes |
hort config | Answer a few questions and write the global configuration. | writes ~/.config/hort/config.json |
hort doctor | Report what this host can do. | no |
hort help and hort <command> --help print the built-in help. There is no --version flag.
Exit status
0: the command succeeded.1: hort refused or failed; the reason is printed on stderr as one line.- For
hort upwithout-d,hort attachandhort run, once the session has opened: the exit status of the session, or128 + signalif it was killed by a signal. A shell you leave withexit 3makes hort exit with 3. hort doctor:1when a hard requirement of the host is missing, even though the report is printed.
Output
Lists and reports (ls, prune, doctor) go to stdout. Errors and warning: lines go to stderr. Warnings never stop a command; they tell you something runs degraded.
Errors any command can print
Messages below that end in a detail come from the system and say what failed.
| Message | Meaning | What to do |
|---|---|---|
invalid name | A sandbox name was empty or contained /, or an egress.allow entry is not a plain host name. | Pick a name without /; write allowlist entries as example.com or *.example.com. |
state directory error: ... | hort could not read or write its own files under ~/.local/state/hort or determine your home directory. | Check permissions and free space on that path. |
working directory failed: ... | The directory you ran hort from could not be read (for example it was deleted). | cd somewhere that exists. |
corrupt metadata: ... | A sandbox record on disk cannot be read. | hort prune offers to remove corrupt records. |
git command failed: <operation>: <git's message> | A git operation hort ran failed; git's own message follows. | Read git's message; run the same operation yourself in the repository to see more. |
container runtime failed: ... | Building, joining or removing a container failed. | See Troubleshooting. |
sandbox networking failed: ... | Starting or stopping pasta, the proxy or a database forwarder failed. | See Troubleshooting. |
hort up
Build a sandbox and open a session in it.
hort up [OPTIONS] <NAME>
Arguments:
<NAME> The sandbox to build, which is also the branch it creates
Options:
--branch <BRANCH> Check out this existing branch instead of creating one named after the sandbox
--git <GIT> How the sandbox gets git into `/workdir`, overriding what the configuration declares: `worktree` or `clone`
-d, --detach Return to the prompt with the sandbox running instead of opening a session in it
-h, --help Print help
Examples
cd ~/src/webapp
hort up fix-login # new branch fix-login from HEAD, then a shell inside
hort up fix-login -d # same, but return to your prompt
hort up review --branch feature/search # build on an existing branch
hort up ship-it --git clone # give the box its own clone, so git works inside
What it does
- Reads the configuration (global, then project; see Configuration). On a terminal with no global configuration yet, it first runs the
hort configdialogue. - Checks that there is a project here: the nearest directory up from where you are that holds
.hort.json,.devcontainer/devcontainer.jsonor.git. - Checks the host and the rootfs, in this order: user namespaces,
pasta,git,ip(only for an egress allowlist), then the rootfs: configured, present, has/bin/sh, has the configuredshell, has a world-writable/workdir. - Checks the rest of the configuration that can be wrong: resource sizes, databases sharing a port, and caches aimed inside read-only mounts.
- Takes a per-name lock, so two
hort upof the same name cannot race. - Decides what to build. A sandbox of that name that is already fully running is refused. One that is half-built (an orphan, or a live box whose networking is gone) is completed instead of refused.
- Prepares
/workdir: in a git repository, a new branch<name>fromHEAD(or the--branchyou named) checked out at~/.local/state/hort/sandboxes/<name>/worktree-<name>, as a worktree or, in clone mode, as a clone of the repository. Without git, the project folder itself. - Records the sandbox on disk, before anything starts, so an interruption from here on leaves something
hort lscan see. - Starts the container with its anchor process, then its networking (pasta; the egress proxy for an allowlist; database forwarders).
- Starts the notification watcher, if an agent declares
notify.stopHook. If that fails, it warns and carries on. - Opens a session, unless
-d: exactly whathort attach <name>does.
If starting the container or its networking fails after the container is up, up undoes what it started (helpers, then container) and keeps the worktree and the record. The sandbox then shows as orphaned, and running hort up <name> again retries.
hort up <name> without -d behaves exactly like hort up <name> -d followed by hort attach <name>, including its exit status.
Building on a branch a down kept
hort down keeps the sandbox's branch. When you run hort up <name> and a branch <name> already exists and is not checked out anywhere, hort asks on a terminal:
branch 'fix-login' already exists (a 'hort down' keeps a sandbox's branch) — build sandbox 'fix-login' on it? [Y/n]
Enter (yes) builds the sandbox on that branch, exactly like --branch fix-login. Answering no, or running without a terminal, refuses with the ready command:
branch 'fix-login' already exists (a 'hort down' keeps a sandbox's branch) — run 'hort up fix-login --branch fix-login' to build the sandbox on it, or choose another name
Resuming a half-built sandbox
If a sandbox of that name is orphaned (its container died, for example after a reboot), hort up <name> builds a new container over the same worktree, with your uncommitted work intact, on whatever branch the worktree currently holds. If it is live but its networking is gone, hort up <name> restarts the networking without touching the container or the sessions inside. Neither prints anything on success.
Messages
Refusals before anything is built
| Message | Meaning and what to do |
|---|---|
'<path>' is not a project — run hort from a git repository, or add a .hort.json there to sandbox the directory itself | Neither this directory nor any above it has .git, .hort.json or .devcontainer/devcontainer.json. cd into your repository, or add a .hort.json (even {}) to the folder you want sandboxed. |
unprivileged user namespaces are disabled in this kernel — hort cannot create a sandbox | The kernel refuses unprivileged user namespaces. See Installation. |
pasta not found on PATH — hort needs it for sandbox networking | Install passt. |
git not found on PATH — hort needs it to prepare the sandbox worktree | Install git. It is needed even for projects that are not repositories. |
ip not found on PATH — hort needs iproute2 for allowlist egress | This project has an egress allowlist. Install iproute2. |
no rootfs configured — set "rootfs" to a prepared rootfs directory in .hort.json or ~/.config/hort/config.json | No layer sets rootfs. See Preparing a rootfs. |
rootfs directory '<path>' does not exist — prepare it first with podman export, debootstrap or umoci unpack | The configured path is not a directory. Check the path, or prepare the rootfs. |
rootfs '<path>' has no usable shell (expected /bin/sh) — the rootfs must provide one | The rootfs lacks /bin/sh. Often the export went into the wrong directory. |
shell '<shell>' not found in rootfs '<path>' — set "shell" to one the rootfs provides, or omit it | Your shell setting names a path the rootfs does not have. |
rootfs '<path>': /workdir is not writable by the mapped uid — make it world-writable | chmod 1777 <path>/workdir. Extracting an export as a normal user drops that mode. |
invalid config: <file>: <parser message> | A configuration file is not valid JSONC. The line and column are in the parser message. |
invalid config: resources.memory '<value>' is not a positive size like "4g" (an integer with an optional k, m, g or t suffix, base 1024) | Fix resources.memory. |
invalid config: resources.cpus <value> is not a positive number of cores | Fix resources.cpus. |
invalid name | The sandbox name is empty or contains /, or an egress.allow entry is not a plain host name. |
cache '<name>' targets '<target>', which does not exist inside the read-only mount '<source>' — point the cache elsewhere, or create it on the host first | A cache.dirs target falls inside a read-only mount whose host directory lacks that path. Create it on the host, or move the cache. |
two databases are declared on port <port> (<host> and <other>), and a sandbox can reach only one of them — remove one from "network" in your configuration or give it another port | Every declared database is 127.0.0.1:<port> inside, so two on one port cannot both be reached. Remove one or change a port. |
--branch requires a git repository, but this project is not one | --branch makes no sense for a project folder without git. |
--git clone requires a git repository, but this project is not one | There is nothing to clone in a project folder without git. The same mode set in a configuration file only warns and builds the sandbox without git. See Git inside the sandbox. |
another 'hort up <name>' is already in progress | Another hort up of this name holds the lock. Wait for it. The lock is released automatically if that process dies. |
Refusals about the name and the branch
| Message | Meaning and what to do |
|---|---|
a sandbox named '<name>' already exists (run 'hort attach <name>' to join it, or 'hort down <name>' first) | A sandbox with this name is already running. Also printed for a name ls shows as inconsistent or lost-record; for those, hort down <name> first. |
branch '<name>' already exists (a 'hort down' keeps a sandbox's branch) — run 'hort up <name> --branch <name>' to build the sandbox on it, or choose another name | See above. |
branch '<branch>' is already checked out in another worktree | git allows a branch in one worktree at a time. Switch that other checkout to a different branch, or pick another branch or name. |
branch '<branch>' does not exist; create it first or omit --branch to create a new branch named '<name>' | --branch names a branch that does not exist. |
sandbox '<name>' holds branch '<held>', not '<requested>' — run 'hort down <name>' first, then 'hort up <name> --branch <requested>' | You are resuming a half-built sandbox, whose worktree is on another branch than the --branch you gave. Commit what you need from its worktree first. |
sandbox '<name>' was built in <built> git mode, not <requested> — run 'hort down <name>' first, or repeat it with 'hort up <name> --git <built>' | You are resuming a half-built sandbox that was built in the other git mode. Finish it in the mode it has, or tear it down and build again. |
Failures while building
| Message | What to do |
|---|---|
git command failed: <step>: <git's message> | A git step of the build failed, and git's message says why; the step is named. For worktree add the usual cause is a repository with no commit yet (HEAD is empty), so make a first commit. |
container runtime failed: ... | See Troubleshooting. |
sandbox networking failed: ... | See Troubleshooting. |
Warnings (the sandbox still starts)
| Warning | Meaning |
|---|---|
warning: cgroup controller '<controller>' is not delegated to this user, so the <controller> ceiling is not enforced; add '<controller>' to Delegate= in a systemd drop-in for user@.service | Part of resources is not applied. |
warning: this kernel cannot restrict which ports a process connects to, so the egress allowlist of this sandbox runs without its kernel layer (Linux 6.7 or newer enforces it) | The allowlist holds, minus its Landlock layer. |
warning: the configured 'clone' git mode needs a git repository and this project is not one, so the sandbox mounts the project folder itself | Your configuration asks for clone mode and this project is not a repository, so the sandbox is built the way a project without git always is. |
warning: read-only mount '<path>' is not on this host, so the sandbox starts without it | A mounts.readOnly or auth.readOnly path does not exist. |
warning: notify-send is not on PATH, so no completion of this sandbox will be raised on the desktop (install libnotify to get it) | Notifications are configured but cannot be shown. |
warning: this build raises a completion on the desktop and nowhere else, so nothing will be raised on the '<sink>' this configuration asks for | notifications.sink names something other than desktop. |
warning: no completion of this sandbox will be raised, because its watcher could not be started: ... | The notification watcher failed to start. |
warning: devcontainer.json '<key>' is ignored: hort runs a prepared rootfs and never builds images | The project is configured by a devcontainer file; that key has no meaning for hort. |
When a session opens (without -d), the warnings of hort attach can follow.
hort attach
Open one more session in a running sandbox.
hort attach <NAME>
Arguments:
<NAME> The sandbox to join
Options:
-h, --help Print help
Examples
hort attach fix-login # a new shell in the sandbox
echo 'npm test' | hort attach fix-login # run commands without a terminal
What it does
attach never builds anything. For a running sandbox it opens a new session: a login shell, started in /workdir, inside the sandbox's namespaces. Any number of sessions can be open at once, and every one sees the same files.
It checks only that the kernel allows user namespaces. It does not need pasta, git or the rootfs directory, because the sandbox is already running.
The shell is the first of: the configured shell; your host $SHELL, if the rootfs has that same path; /bin/sh.
The environment of the session contains HORT_SANDBOX and HORT_WORKTREE, HOME=/home/hort and the XDG_* directories under it, a standard PATH, the variables your agents declare in auth.env, and, if the sandbox runs an egress proxy, the proxy variables. Nothing else is inherited from your host environment. The configuration is read again at every attach, so a changed auth.env or shell applies to the next session.
The terminal. When hort's standard input is a terminal, the session gets its own terminal allocated inside the sandbox, which hort relays to yours and resizes with your window. Your own terminal is never handed to the sandbox. When standard input is not a terminal (a pipe, a file, a script), the session runs on hort's standard streams instead, which is how you script it.
attach records the time, which hort ls uses for idle time.
Exit status
hort exits with the exit status of the session: the shell's own status when it exits, or 128 + signal when it is killed by a signal. So hort attach fix-login in a script tells you what the commands inside did, and an interrupted session is never reported as success.
$ echo 'exit 7' | hort attach fix-login; echo $?
7
Messages
| Message | Meaning and what to do |
|---|---|
no sandbox named '<name>' (run 'hort ls' to see what's alive) | hort has no record of that name. Check hort ls. A sandbox shown as lost-record also gets this: hort cannot join a sandbox it has no record of; hort down <name> it. |
sandbox '<name>' is not running (run 'hort up <name>' to start it, or 'hort prune' to clean up the stale record) | The sandbox is orphaned. hort up <name> brings it back on the same worktree. |
sandbox '<name>' is running but its container state is gone, so no session can join it (commit what you want to keep from <worktree> on the host, then run 'hort down <name>' and 'hort up <name> --branch <branch>') | The container runs, but the runtime's bookkeeping for it under $XDG_RUNTIME_DIR/hort was deleted, and hort does not rebuild it. Commit from the named worktree on the host, then do what the message says. |
sandbox '<name>' is running but its container state is gone, so no session can join it (run 'hort down <name>' and 'hort up <name>') | The same, for a project without git. down leaves your folder alone. |
unprivileged user namespaces are disabled in this kernel — hort cannot create a sandbox | See Installation. |
invalid config: ... | A configuration file does not parse. attach reads it for shell and auth.env. |
warning: environment variable '<VAR>' is not set on this host, so the session starts without it | A variable named in agents[].auth.env is not set in the terminal you ran attach from. export it and attach again. |
warning: devcontainer.json '<key>' is ignored: hort runs a prepared rootfs and never builds images | Printed whenever the configuration comes from a devcontainer file. |
container runtime failed: ... | Joining the sandbox failed. See Troubleshooting. |
hort run
Run one command in a running sandbox, with no terminal, and exit with its status.
hort run <NAME> -- <COMMAND>...
Arguments:
<NAME> The sandbox to run the command in
<COMMAND>... The command and its arguments, taken verbatim after `--`
Options:
-h, --help Print help
Examples
hort run fix-login -- npm test # run the tests, get their exit code
hort run fix-login -- ruff check . # lint inside the sandbox
hort run fix-login -- sh -c 'echo hi' # a shell one-liner
What it does
run is attach without a terminal. For a running sandbox it opens a new session that execs the command you named instead of a login shell, started in /workdir, inside the sandbox's namespaces. The command's own standard output and error come back on hort's, so a script or an orchestrator on your host can drive a box command by command and read each result.
Like attach, it never builds anything and checks only that the kernel allows user namespaces; it does not need pasta, git or the rootfs directory, because the sandbox is already running.
The environment of the session is the same one attach builds: HORT_SANDBOX and HORT_WORKTREE, HOME=/home/hort and the XDG_* directories under it, a standard PATH, the variables your agents declare in auth.env, and, if the sandbox runs an egress proxy, the proxy variables. Nothing else is inherited from your host environment.
No terminal is allocated. A command that ends is not a shell you type into, so run never asks the sandbox for a pty and never lends it yours. The command runs on hort's standard streams.
run records the time, the same as attach, so a box an orchestrator is driving command by command does not read as idle to hort prune --idle between two of them.
Exit status
hort exits with the exit status of the command: its own status when it exits, or 128 + signal when it is killed by a signal. So a command that failed inside the box is tellable from hort failing to open the box.
$ hort run fix-login -- sh -c 'exit 7'; echo $?
7
Messages
run reaches a live sandbox the same way attach does and refuses the same three states with the same messages: a name nothing knows, a sandbox that is not running, and a running sandbox whose container state is gone. See hort attach for each.
hort ls
List every sandbox on this machine with its state.
hort ls
Options:
-h, --help Print help
Output
One line per sandbox, from every project, with no header:
$ hort ls
fix-login live 2 41m 3s 211ms 802us 45ns active fix-login dirty
notes live 0 35s 73ms 521us 360ns 15s 535ms 72us 847ns - -
old-spike orphaned 0 2days 3h 4m 10s 5ms 1us 7ns 2days 2h 58m 1s 3ms 2us 9ns old-spike clean
ghost lost-record 0 - - - -
running with no record on disk; run 'hort down ghost' to stop its container and host-side helpers
The columns, separated by two spaces:
| # | Column | Meaning |
|---|---|---|
| 1 | name | The sandbox name. |
| 2 | state | live, orphaned, inconsistent or lost-record. See States. |
| 3 | sessions | Processes running in the sandbox besides its anchor. One shell running one command counts as 2. |
| 4 | age | Time since hort up built it. |
| 5 | idle | active while anything runs inside; otherwise time since the latest of creation, last attach and last announced completion. |
| 6 | branch | The sandbox's branch, or - without git. |
| 7 | dirty | dirty if the worktree has uncommitted changes, clean if not. Always - for a clone-mode sandbox, whose /workdir is a clone rather than a worktree of your repository. |
A - means hort could not tell: there is no record to read (a lost-record row), the project has no git (branch and dirty), the sandbox is in clone mode (dirty), the worktree is gone, or hort could not read the sandbox's process list (sessions, and idle with it). hort shows - rather than guessing.
The listing does not say which git mode a sandbox was built in; showing it is planned.
A lost-record row is followed by an indented line with the command that collects it.
Durations are printed at full precision, down to nanoseconds.
Behaviour
ls never changes anything and never fails because of one sandbox: it compares hort's records, the processes the kernel is running and the worktrees on disk, and reports what it finds. It needs neither pasta nor a rootfs and does not read the configuration, so it works even when a configuration file is broken.
dirty is checked in each sandbox's own worktree, whatever directory you run ls from. A dirty sandbox is the one to look at first: it holds work nobody has committed.
Messages
ls prints nothing but the listing. On an empty machine it prints nothing at all. The general errors in Commands can still occur, for example if hort's state directory is unreadable.
hort down
Tear a sandbox down.
hort down [OPTIONS] <NAME>
Arguments:
<NAME> The sandbox to tear down
Options:
-f, --force Skip the confirmations for open sessions and unreturned work
-h, --help Print help
Examples
hort down fix-login # asks first if sessions are open, or if a clone holds work you never sent out
hort down fix-login -f # no questions
What it does
Always in this order, because deleting a directory that a running process still has mounted corrupts I/O:
- Stops the notification watcher and the host-side network helpers (pasta, the egress proxy, database forwarders).
- Removes the container, which ends every session and the anchor and releases the worktree mount.
- In a git project, removes the worktree directory, including uncommitted changes, and its registration in your repository. In clone mode that directory is the sandbox's clone, so every commit made inside it that was not pushed or fetched out goes with it, which is why
downasks first (see Unreturned work). That step also deletes the sandbox's pinned refrefs/hort/<name>/basefrom your repository; the pins of your other clone-mode sandboxes are left alone. - Removes hort's record of the sandbox.
It keeps the branch and its commits (deleting it from down is planned), your repository, and the project's dependency caches. In a project without git it never touches your folder: only the container and the record go.
down prints nothing when it succeeds.
Naming the sandbox is taken as your decision: down does not check for uncommitted work. Commit first. It asks two questions, and only two: about open sessions, and about commits that exist only inside a clone.
Open sessions
If processes are running in the sandbox besides its anchor (someone, or some agent, is inside), down asks on a terminal:
tear down sandbox 'fix-login' with open sessions? [y/N]
Only y or yes proceeds; anything else, including Enter, leaves the sandbox alone and exits 0. -f skips the question. Without a terminal and without -f, down refuses instead of guessing:
refusing to down without confirmation: stdin is not a TTY (pass --force to proceed)
Unreturned work
In clone mode the sandbox's /workdir is a repository of its own, so a commit made inside it lives nowhere else until it is pushed or fetched out. Before removing such a sandbox, down compares the clone's tip against your repository and asks on a terminal when your repository does not have it:
sandbox 'fix-login' holds commits the project repository does not have; tear it down anyway? [y/N]
Both reads happen on the host and neither needs the box running. Answering anything but y or yes leaves the sandbox alone and exits 0; -f skips the question; without a terminal and without -f, down refuses with the same message as above. If hort cannot read the clone at all, it asks anyway, because the alternative is deleting commits it could not see.
Worktree-mode sandboxes are never asked about: their commits go straight into your repository, so tearing the box down does not touch them.
Sandboxes in other states
orphaned: there is nothing running to stop;downremoves the worktree and the record.inconsistent(worktree deleted on the host):downstops the container and clears the stale worktree registration and the record.lost-record: hort has no record, but the container is running.downstops the helpers and the container and stops there, because the worktree's path was in the missing record. See Troubleshooting for what may be left on disk.
Messages
| Message | Meaning and what to do |
|---|---|
no sandbox named '<name>' (run 'hort ls' to see what exists) | Neither a record nor a running container has that name. |
refusing to down without confirmation: stdin is not a TTY (pass --force to proceed) | Sessions are open, or a clone holds commits your repository does not have, and there is nobody to ask. Add -f if you mean it. |
container runtime failed: ... / sandbox networking failed: ... | A step failed. hort stops at the failing step, so later steps (worktree, record) have not run; run hort down <name> again, and see Troubleshooting. |
git command failed: ... | Removing the worktree failed; git's message says why. |
In clone mode, down also deletes that sandbox's pinned ref refs/hort/<name>/base from your repository, so the commit it was holding for the clone becomes collectable by git gc again. Pins belonging to your other clone-mode sandboxes are untouched: each one keeps your repository's gc off the objects that clone is still borrowing.
hort prune
Clean up: sandboxes whose container is gone, broken records, caches of deleted projects, and, if you ask, sandboxes that have been idle a long time. It always shows what it will remove and asks first.
hort prune [OPTIONS]
Options:
-f, --force Skip the confirmation prompt and the dirty exclusion
--idle <IDLE> Also remove sandboxes idle at least this long
-h, --help Print help
Examples
hort prune # debris only
hort prune --idle 7d # debris, plus live sandboxes idle for a week or more
hort prune --idle 36h -f # no question, and dirty worktrees too
--idle takes a duration such as 90m, 24h or 7d.
prune is global: it considers every sandbox and cache on the machine, whatever project you run it from.
What it considers
Sandboxes:
orphanedandinconsistentsandboxes are always candidates.livesandboxes are candidates only with--idle, and only when idle at least that long. A sandbox with anything running inside isactive, never idle, soprunenever removes a sandbox someone is working in.lost-recordsandboxes are never touched byprune. Usehort down <name>.
Broken records: sandbox directories whose record cannot be read.
Caches: the dependency caches of projects.
What protects a candidate
Without -f, a candidate is removed only when hort can prove nothing is at risk:
- a sandbox whose worktree has uncommitted changes is skipped as
dirty; - a clone-mode sandbox holding commits your repository does not have is skipped as
work only in the box: its own word, because the work is committed and looking for uncommitted changes would find nothing; - a sandbox whose worktree state cannot be read is skipped as
unknown; - a cache whose project directory still exists is skipped as
project on disk; - a cache whose project hort cannot check is skipped as
project unreadable.
-f removes those too. What -f never overrides:
- a cache that a running sandbox is using is skipped as
sandbox running:hort downthat sandbox first; - if hort finds a running container it cannot attribute to a project (for example a
lost-recordsandbox), every cache is skipped assandbox unaccounted for, because that container might be using it; - with
--idle, a live sandbox whose idle time could not be read is skipped asidle unknown.
Confirmation
With something to remove and no -f, prune lists it and asks:
prune old-spike, /home/you/src/deleted-project? this removes their worktrees, metadata and caches [y/N]
Only y or yes proceeds. Without a terminal and without -f it refuses:
refusing to prune without confirmation: stdin is not a TTY (pass --force to proceed)
When there is nothing to remove it does not ask.
Output
One line per result:
removed old-spike
removed cache of /home/you/src/deleted-project
skipped experiment (dirty)
skipped /home/you/src/webapp (project on disk)
Every sandbox it removes is torn down in the same order as hort down: helpers, container, worktree, record. Caches are removed last. Finally, prune runs git worktree prune in the repository you ran it from, clearing registrations of worktrees that no longer exist there (correct cleanup when run from outside the sandbox's repository is planned). Run hort prune from inside a git repository: that last step needs one, and from anywhere else it ends with a git command failed: worktree prune: fatal: not a git repository ... error.
Messages
| Message | Meaning and what to do |
|---|---|
refusing to prune without confirmation: stdin is not a TTY (pass --force to proceed) | There is something to remove and nobody to ask. |
git command failed: ... | A worktree removal or the final git worktree prune failed; git's message says why. |
container runtime failed: ... / sandbox networking failed: ... / state directory error: ... | Removing one sandbox or cache failed. Run hort ls to see what is left and retry. |
hort config
Ask what this host can do and write the global configuration.
hort config [OPTIONS]
Options:
-f, --force Overwrite a configuration already on disk without asking
-h, --help Print help
When it runs
- By itself, once. The first time you run
hort uporhort attachon a terminal and there is no global configuration file yet, hort runs this dialogue first, then carries on with the command you typed. Without a terminal it does not: the command continues with no global configuration, andhort upthen stops atno rootfs configured .... - Whenever you run it, to generate the file again. If
~/.config/hort/config.jsonexists, it asks before overwriting, unless you pass-f.
hort ls, down, prune and doctor never start it.
The dialogue
A few inline questions, answered with the keyboard. What it offers depends on what it finds on your host. A run on a host with no rootfs yet, where ~/.claude exists:
do you have a prepared rootfs directory? [y/n] no
which of these should every sandbox mount read-only?:
> [ ] ~/.config/nvim
[ ] ~/.config/fish
[ ] ~/.gitconfig
add Claude Code to the agents list, mounting ~/.claude read-only? [y/n] no
raise a desktop notification when an agent announces that it finished? [y/n] yes
warning: no rootfs directory was given, so 'rootfs' is written commented out: hort cannot build a sandbox until it names a prepared directory
- rootfs. If you answer yes, it asks
path to the prepared rootfs directoryand checks it at once. A directory that could not build a sandbox is still written, with a warning carrying the same messagehort upwould print. - Dotfiles. A multi-select of
~/.config/nvim,~/.config/fish,~/.tmux.confand~/.gitconfig, showing only those that exist. Move with the arrow keys, toggle each item with Space (select as many as you like), confirm with Enter. A hint on the prompt itself is planned. (Prefer individual fish files over the whole directory; see the fish tip.) - Agents. Offered only when their credentials exist on the host: Claude Code when
~/.claudeexists, written asclaude --dangerously-skip-permissionswith~/.clauderead-only and its completion hook enabled. - Notifications. Whether to raise a desktop notification when an agent finishes.
What it writes
A commented JSONC file. Everything your host cannot do, or that you did not ask for, is written commented out with a note on how to enable it, so the file never promises something that will not work. The file written by the run above:
// hort global configuration, written as JSONC: comments and trailing commas are
// fine here. A project's own .hort.json overrides these, key by key.
{
// No prepared rootfs yet. hort runs a rootfs directory and never builds
// one, so make one first, then put its path here and uncomment:
// podman export $(podman create <image>) | tar -x -C <dir>
// debootstrap stable <dir> http://deb.debian.org/debian
// umoci unpack --image <image> <bundle>, then keep <bundle>/rootfs
// "rootfs": "~/.local/share/hort/rootfs/devbox",
// Host paths every sandbox mounts read-only, dotfiles and the like.
"mounts": {
"readOnly": [],
},
// Uncomment to give each sandbox a clone of its own, so an agent can commit
// and open a pull request from inside. The default, worktree, keeps git a
// host activity. A token you pass in so the agent can push lives inside the
// box with it: scope it to one repository and protect the remote's branches.
// The clone borrows your history instead of copying it and costs about as
// much disk as a worktree: 2.3 GB of history gave a .git of about 6 MB.
// "git": "clone",
// Outbound is open and unfiltered until this is uncommented. With a list, a
// sandbox reaches only these hosts, and only through tools that read
// HTTP_PROXY. A bare name matches exactly, and "*." covers subdomains
// but not the name itself.
// "egress": { "allow": ["api.example.com", "*.example.com"] },
// Agents you typically run here. A reminder for you and not a binding:
// a sandbox boots empty and you start the agent yourself.
"agents": [],
// Raised when an agent announces that it finished.
// Add a "message" here to change the text; <name> becomes the sandbox name.
"notifications": {
"sink": "desktop",
},
// A sandbox is capped by nothing until one of these is uncommented.
"resources": {
// Uncomment to cap the memory a sandbox may use.
// "memory": "4g",
// Capping the CPU needs the cpu controller, not delegated to this user.
// Add cpu to Delegate= in a systemd drop-in for user@.service first.
// "cpus": 2,
},
}
The commented rootfs is only an example path. Put your own rootfs directory there (for example the ~/.local/share/hort/devbox from Preparing a rootfs), and export each rootfs into a directory of its own.
You can edit the file by hand at any time; see Configuration.
Messages
| Message | Meaning and what to do |
|---|---|
hort config needs a terminal to ask you what to configure; no flag replaces it (run it from an interactive shell, or write ~/.config/hort/config.json by hand) | You ran hort config without a terminal. -f does not help: it only means "overwrite". |
<path> already exists; overwrite it? | The prompt when a configuration exists and -f was not given. Answering no leaves the file alone. |
warning: no rootfs directory was given, so 'rootfs' is written commented out: hort cannot build a sandbox until it names a prepared directory | Prepare a rootfs and set rootfs. |
warning: rootfs ... (any rootfs message from hort up) | The path you gave cannot build a sandbox yet. The file names it anyway; fix the directory. |
configuration write failed: could not create <dir>: ... / configuration write failed: could not write <file>: ... | The directory or the file could not be written. Check permissions of ~/.config. |
hort doctor
Report what this host can do, changing nothing.
hort doctor
Options:
-h, --help Print help
Output
$ hort doctor
host
user namespaces yes
pasta /usr/sbin/pasta
ip /usr/sbin/ip
cgroup memory delegated
cgroup pids delegated
cgroup cpu not delegated
a sandbox runs with no CPU ceiling. Add cpu to Delegate= in a systemd drop-in for user@.service.
cgroup cpuset not delegated
a sandbox cannot be pinned to a set of cores. Add cpuset to Delegate= in a systemd drop-in for user@.service.
landlock ABI 7
rootless overlayfs yes
notify-send /usr/sbin/notify-send
git yes
configuration
rootfs ready
Every row is read from the live host. Programs are reported with the path where they were found on your PATH. Under each missing capability, an indented line says what that costs and how to get it.
| Row | Needed for | When missing |
|---|---|---|
| user namespaces | everything | a sandbox is a user namespace, so nothing gets built here at all. Look at the user.max_user_namespaces sysctl, and at whatever security profile your distribution ships. |
| pasta | everything | no sandbox gets built: up refuses before it starts, because pasta is what bridges one to the network. It ships in the passt package. |
| ip | egress allowlists | only an egress allowlist needs it, to empty the sandbox's routing table; an open sandbox builds fine without it. It ships in iproute2. |
| cgroup memory | resources.memory | a sandbox runs with no memory ceiling. Add memory to Delegate= in a systemd drop-in for user@.service. |
| cgroup pids | nothing configurable | nothing caps how many processes a sandbox forks. Add pids to Delegate= in a systemd drop-in for user@.service. |
| cgroup cpu | resources.cpus | a sandbox runs with no CPU ceiling. Add cpu to Delegate= in a systemd drop-in for user@.service. |
| cgroup cpuset | nothing configurable | a sandbox cannot be pinned to a set of cores. Add cpuset to Delegate= in a systemd drop-in for user@.service. |
| landlock | one layer of egress allowlists | unavailable, then: an allowlisted sandbox loses the kernel restriction on which ports a session may dial; the routeless namespace, the proxy and the absent resolver still hold. ABI 4 or later carries that port half. |
| rootless overlayfs | everything | every sandbox root is an overlay, so a build gets as far as the mount and dies there. The kernel has to offer overlay to an unprivileged user namespace. |
| notify-send | desktop notifications | an agent announcing that it finished gets recorded and nothing reaches the screen. It ships in libnotify. |
| git | everything | no sandbox gets built, in a repository or in a marked folder alike: up refuses before it starts, because git is what tells those two apart and what prepares the worktree. Install it. |
How to fix each is in Installation.
The configuration row
doctor reads the configuration that hort up would read from the directory you run it in.
rootfs ready: the configured rootfs can carry a sandbox.rootfs <message>: the exact messagehort upwould stop with, for exampleno rootfs configured — ...orrootfs '<path>': /workdir is not writable by the mapped uid — make it world-writable.config file invalid config: <file>: <parser message>, followed byeverything above is still what this host can do; nothing here speaks for this project until that file parses.: a configuration file does not parse. The host rows are still printed.
Warnings from reading the configuration (for example about a devcontainer file) are printed on stderr after the report.
Exit status
0 when user namespaces, pasta and git are all present; 1 otherwise. The rootfs and the optional capabilities do not affect it, so a freshly set up machine with no rootfs yet still passes. Use it as a gate:
if hort doctor > /dev/null; then
hort up fix-login -d
fi
Configuration
hort reads its configuration from up to two files, merges them, and uses the result every time it builds a sandbox or opens a session. This page is the reference for every key. For worked examples of common setups, see Configuration recipes and tips.
Files and layers
| Layer | Location | Typical contents |
|---|---|---|
| Global | ~/.config/hort/config.json ($XDG_CONFIG_HOME/hort/config.json when that variable is set) | Your default rootfs, dotfiles, agents and their credentials, notifications. |
| Project | .hort.json in the project directory | What this project needs: caches, databases, an egress allowlist, a different rootfs. |
| Project, fallback | .devcontainer/devcontainer.json in the project directory | Read only when the project has no .hort.json. |
The project directory is the nearest directory, starting where you run hort and walking up, that holds .hort.json, .devcontainer/devcontainer.json or .git. The project file is read from your host checkout, so it does not need to be committed.
Missing files are fine: no global file and no project file both mean defaults (and no rootfs, which hort up refuses). A file that exists but cannot be parsed stops hort up and hort attach with a message naming it:
invalid config: /home/you/src/webapp/.hort.json: expected value at line 1 column 13
hort ls, hort down and hort prune do not read the configuration, so a broken file never stops you from listing or tearing down sandboxes. hort doctor reports it as a row.
When to change what: configuration is read on every up and every attach. A sandbox that is already running keeps the mounts, caches, resources and network it was built with; hort down and hort up it to apply changes to those. shell and agents[].auth.env apply to the next session you open.
Format
Both files are JSONC: JSON that also allows // and /* */ comments and trailing commas. All keys are camelCase.
Keys hort does not know are ignored silently, so a typo such as "readonly" for "readOnly" does nothing. If something you configured has no effect, check the spelling first.
A leading ~/ is expanded to your host home in rootfs, mounts.readOnly and agents[].auth.readOnly. Environment variables such as $HOME are not expanded anywhere.
How layers merge
The project layer wins over the global one:
| Key | Rule |
|---|---|
rootfs, shell, git | The project value replaces the global one. |
egress | The project value replaces the global one entirely. Allowlists are never combined, so a global true can never loosen a project allowlist. |
mounts.readOnly, cache.dirs | Both lists are combined, duplicates removed. |
agents | Both lists are combined. Entries with the same command are merged: their auth.readOnly and auth.env lists are combined, and the project's notify wins. |
network | Both lists are combined. An entry with the same host and port as a global one replaces it. |
notifications, resources | Merged field by field; the project wins where both set a field. |
A project file cannot remove an entry the global file adds to a list.
Complete example
A global file:
// ~/.config/hort/config.json
{
// The prepared rootfs every sandbox runs, unless a project says otherwise.
"rootfs": "~/.local/share/hort/devbox",
// Dotfiles every sandbox gets, read-only.
"mounts": {
"readOnly": ["~/.config/nvim", "~/.gitconfig", "~/.tmux.conf"],
},
"agents": [
{
"command": "claude --dangerously-skip-permissions",
"auth": { "readOnly": ["~/.claude"] },
"notify": { "stopHook": true },
},
{ "command": "aider", "auth": { "env": ["OPENAI_API_KEY"] } },
],
"notifications": {
"sink": "desktop",
"message": "hort sandbox '<name>' finished",
},
}
A project file:
// ~/src/webapp/.hort.json
{
// Dependency caches that survive down and up.
"cache": {
"dirs": ["node_modules", { "name": "pip", "target": "~/.cache/pip" }],
},
// The development database, reached at 127.0.0.1:5432 inside the sandbox.
"network": [{ "mode": "host", "host": "127.0.0.1", "port": 5432 }],
// Only these hosts, through the proxy.
"egress": {
"allow": ["api.anthropic.com", "registry.npmjs.org", "github.com", "*.githubusercontent.com"],
},
// A ceiling per sandbox.
"resources": { "memory": "4g", "cpus": 2 },
// Give each sandbox its own clone, so an agent can commit and open a pull
// request from inside. The default, "worktree", keeps git a host activity.
"git": "clone",
// The login shell of every session; must exist in the rootfs.
"shell": "/bin/bash",
}
Key reference
rootfs
string. Default: none. Merge: replaces.
The prepared rootfs directory every sandbox of this project runs. Use an absolute path or ~/.... Required for hort up. See Preparing a rootfs.
shell
string. Default: your host $SHELL if that same path exists inside the rootfs, otherwise /bin/sh. Merge: replaces.
The shell each session runs, as a login shell (-l) in /workdir. It is a path inside the rootfs, not expanded. hort up refuses a shell the rootfs does not have:
shell '/bin/bash' not found in rootfs '/home/you/.local/share/hort/devbox' — set "shell" to one the rootfs provides, or omit it
git
string. Default: "worktree". Merge: replaces.
How a sandbox of this project gets git into /workdir.
| Value | Meaning |
|---|---|
"worktree" | /workdir is a git worktree of your repository. git does not work inside the sandbox; you commit from the host. |
"clone" | /workdir is a clone of your repository, with a writable .git of its own, so an agent can commit, branch and open a pull request from inside. |
Before you turn "clone" on for a project, two things are worth knowing. Any credential you pass into the sandbox so the agent can push lives inside the box, with the agent, so scope it to one repository and protect the branches on the remote (security). And the clone borrows your history rather than copying it, so it costs about as much disk as a worktree: measured on a repository with 2.3 GB of history, the sandbox's own .git was about 6 MB and the new disk was the checkout alone. See git inside the sandbox for the whole picture, including what hort down asks before it removes a clone.
hort up --git <mode> overrides this for one build. In a project that is not a git repository the key has nothing to act on, so a configured "clone" warns and the sandbox is built with the project folder mounted:
warning: the configured 'clone' git mode needs a git repository and this project is not one, so the sandbox mounts the project folder itself
Clone mode puts a token inside the box if you want the agent to push, so read Git inside the sandbox before turning it on for a project.
mounts
object. Default: {}.
mounts.readOnly
array of strings. Default: []. Merge: combined.
Host paths (files or directories) mounted read-only into every sandbox, usually dotfiles. A path under your host home is placed at the same relative path under the sandbox home: ~/.config/nvim appears at /home/hort/.config/nvim. Any other path keeps its absolute path. A path that does not exist on the host is skipped with a warning:
warning: read-only mount '/home/you/.config/nvim' is not on this host, so the sandbox starts without it
Mounting a whole directory read-only means programs cannot write inside it. Some shells and editors write state into their config directory; see the fish tip.
agents
array of objects. Default: []. Merge: combined, entries with the same command merged.
The agents you run in this project and what they need. This list is not a launcher: a sandbox starts empty and you run the agent yourself. What an entry does is carry that agent's credentials and completion hook into every sandbox.
agents[].command
string, required. The command you run, for example claude --dangerously-skip-permissions. It identifies the entry for merging and is recorded in completion events. hort never runs it.
agents[].auth.readOnly
array of strings. Default: []. Host paths holding the agent's credentials, mounted read-only exactly like mounts.readOnly (same placement under /home/hort, same warning when absent). A missing credential never stops a build; the agent will ask you to log in.
agents[].auth.env
array of strings. Default: []. Names of host environment variables to copy into every session, for API keys. The value is read from the environment hort attach (or hort up) runs in, at the moment the session opens. A variable that is not set is skipped with a warning:
warning: environment variable 'OPENAI_API_KEY' is not set on this host, so the session starts without it
agents[].notify.stopHook
boolean. Default: false. For Claude Code: installs a Stop hook in the sandbox so every finished task is announced to hort, which raises a notification on the host. See Notifications. An agent without this key never notifies.
network
array of objects. Default: none. Merge: combined, keyed by host and port.
Databases and other TCP services the sandbox should reach. Every entry is reachable inside the sandbox at 127.0.0.1:<port>, under any egress posture. See Databases.
| Key | Type | Meaning |
|---|---|---|
mode | string, required | "host" (a service installed on the host) or "network" (a containerized one). Informational; both work the same way. |
host | string, required | The address the service answers on, as seen from your host. |
port | number, required | Its port, and the port the sandbox uses. |
egress
boolean or object. Default: open. Merge: replaces entirely.
| Value | Meaning |
|---|---|
absent or true | Open: the sandbox reaches whatever the host reaches, no proxy. |
{ "allow": ["host", "*.domain"] } | Allowlist: only these hosts, over HTTPS, through a proxy; plus declared network entries. |
false | An empty allowlist: no host at all; declared network entries still reachable. |
A bare entry matches that exact host; a *. entry matches subdomains only, never the apex. An entry that is not a plain host name (a URL, a port) makes hort up fail with invalid name. See Networking and egress.
notifications
object. Default: none. Merge: field by field.
Where and how hort raises a completion announced by an agent with notify.stopHook.
| Key | Type | Default | Meaning |
|---|---|---|---|
sink | string | "desktop" | Where to raise it. desktop (through notify-send) is the only sink this version has; any other value produces a warning and no notification. |
message | string | "hort sandbox '<name>' finished" | The notification text. <name> is replaced by the sandbox name. |
cache
object. Default: {}.
cache.dirs
array of strings or objects. Default: []. Merge: combined.
Writable directories that persist across sandboxes of the same project, for dependency caches such as node_modules or a pip cache.
- A string
"node_modules"is mounted at/workdir/node_modules. - An object
{ "name": "pip", "target": "~/.cache/pip" }is mounted attarget, a path inside the sandbox. A leading~/there means the sandbox home, so this lands at/home/hort/.cache/pip.
On the host, each lives at ~/.local/state/hort/cache/<project path, encoded>/<name>/, shared by every sandbox built from the same project and kept when a sandbox goes down. hort creates the directories. Only hort prune removes them, and never while a running sandbox uses them.
A cache mounted at /workdir/<name> leaves an empty directory of that name in the worktree on the host. git ignores empty directories, so it does not make the worktree dirty.
A cache may target a path inside a read-only mount; it becomes a writable directory there. The path must already exist inside the read-only source on the host, or hort up refuses:
cache 'fish' targets '/home/hort/.config/fish', which does not exist inside the read-only mount '/home/you/.config' — point the cache elsewhere, or create it on the host first
resources
object. Default: none (no ceiling). Merge: field by field.
A per-sandbox ceiling enforced with cgroup v2.
| Key | Type | Meaning |
|---|---|---|
memory | string | Maximum memory. A positive integer with an optional k, m, g or t suffix, optionally followed by b or ib, case-insensitive and base 1024: 4g, 4G, 4gb and 4GiB all mean 4 GiB. No suffix means bytes. |
cpus | number | CPU time as a number of cores (2 is two cores' worth, 0.5 half of one). It caps time; it never pins cores. |
A value that does not parse stops hort up:
invalid config: resources.memory 'lots' is not a positive size like "4g" (an integer with an optional k, m, g or t suffix, base 1024)
If your user does not have the needed cgroup controller delegated, that ceiling is dropped and the sandbox starts anyway:
warning: cgroup controller 'cpu' is not delegated to this user, so the cpu ceiling is not enforced; add 'cpu' to Delegate= in a systemd drop-in for user@.service
See Installation for enabling delegation.
.devcontainer/devcontainer.json
When a project has no .hort.json, hort reads its devcontainer.json (also JSONC), but takes very little from it:
- Each entry of
mountswritten in the string form and containingreadonly, for example"source=/home/you/.config/nvim,target=/root/.config/nvim,type=bind,readonly", adds itssourcetomounts.readOnly. Thetargetis ignored; the path is placed like any other read-only mount. Variables such as${localEnv:HOME}are not expanded. image,build,featuresandcustomizationsare ignored, each with a warning printed on everyup,attachanddoctor:
warning: devcontainer.json 'image' is ignored: hort runs a prepared rootfs and never builds images
- Everything else is ignored silently.
The rootfs still has to come from your global configuration. To configure anything else for such a project, add a .hort.json, which then replaces the devcontainer file entirely.
Environment variables
hort itself reads:
| Variable | Effect |
|---|---|
XDG_CONFIG_HOME | Global configuration under $XDG_CONFIG_HOME/hort/. |
XDG_STATE_HOME | Records, worktrees, overlays and caches under $XDG_STATE_HOME/hort/. |
XDG_RUNTIME_DIR | Container state, helper pid files and logs under $XDG_RUNTIME_DIR/hort/. |
SHELL | The session shell when shell is not configured and the rootfs has that path. |
Names in agents[].auth.env | Copied into sessions. |
Every session gets, in addition to your forwarded variables: HORT_SANDBOX, HORT_WORKTREE, HOME=/home/hort, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME (all under /home/hort), PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, and under an allowlist the proxy variables. Nothing else from your host environment reaches a session; in particular TERM, COLORTERM and LANG do not (see Colors inside the sandbox).
Security model
A security tool that oversells itself is worse than none. This page says exactly what hort protects, how, and where it stops.
In one sentence: hort contains destruction, and it mitigates exfiltration when you configure an allowlist, but it does not eliminate it. Run hort on repositories you trust, with development credentials only, never production.
What hort protects
Your filesystem
A sandbox runs in its own mount namespace, pivoted into its own root. The host filesystem does not exist inside: cd .. from /workdir reaches the sandbox's /, and there is no path that leads to your home directory, your SSH keys or other projects. This is enforced by the kernel's namespaces, not by wrappers or aliases.
What the sandbox can see and write:
| Surface | Inside | Writable | Survives hort down |
|---|---|---|---|
| The worktree, or the clone | /workdir | yes | the directory is deleted; in worktree mode committed work survives on the branch, and in clone mode only what left the sandbox, by a push or a fetch from the host |
| Declared caches | /workdir/<name> or their target | yes | yes, on the host under hort's state, shared by the project's sandboxes |
Everything else in / | the rootfs plus a per-sandbox layer | yes | no, the layer is discarded |
HOME (/home/hort) and /tmp | in memory | yes | no |
| Dotfiles and credentials you configure | under /home/hort | no, read-only | not applicable |
| The rootfs itself | the base of / | never modified | not applicable |
The whole root is writable, so tools that write to /usr or /etc work. The guarantee is not "only these paths are writable". It is: no write persists past down except in the worktree and the caches, no write reaches the host or another sandbox, and the base is never modified.
Your repository
In worktree mode, the default. The real .git directory stays on the host and is not mounted. The agent sees a worktree whose .git is a pointer file naming a host path that does not exist inside. That pointer is mounted read-only: the agent cannot rewrite or replace it, and the mount point itself cannot be removed or renamed from inside. It can delete every other file in /workdir; your history, your other branches and your main checkout are untouched. The most a rogue command can destroy is the uncommitted content of one worktree. That is also why git does not work inside a sandbox in this mode.
You commit the work from the host, in that same worktree, while the sandbox is up. The read-only pointer is what makes that safe: a git you run there reads the genuine pointer, not one the agent rewrote to name a repository it planted with its own hooks or filters. Such a planted pointer would run the agent's configuration as you, on the host, outside every layer hort has, the moment you ran an ordinary git command in the worktree. Committing advances the sandbox's own branch in your repository; the commits are yours, made on the host.
In clone mode
Clone mode lets the agent run git itself, and the guarantee about your repository is unchanged: it is never written from inside. /workdir is a clone with its own writable .git, and your repository reaches it in two ways only, both of them one-way:
- its object store is mounted read-only, so the clone reads all of your history and can rewrite none of it (a write there is refused by the kernel with
Read-only file system); - the remote pointing back at your repository,
hort-base, is fetch only, because a plain shared clone would otherwise be able to push new branches straight into it.
After an agent commits inside such a sandbox, your repository's .git is byte for byte what it was. The one thing hort writes there is a ref, refs/hort/<name>/base, which pins the commit the clone started from so your own git gc cannot prune objects the clone borrows. hort down deletes that sandbox's ref along with the sandbox, leaving the refs of your other clone-mode sandboxes alone.
What clone mode does change is where a credential lives. If you want the agent to push, you forward a token into a box you are not watching, so the token is what bounds the damage: scope it to one repository, give it only the permissions the job needs, and protect the branches on the remote.
Your machine
- No capabilities. Every process in the sandbox runs with an empty capability set.
sudoinside has nothing to grant. - A user namespace. Inside, a session is uid 0 of its own user namespace. On the host it is your own unprivileged user. Files written to
/workdirbelong to you on the host. - A syscall filter. Every process runs under the default seccomp profile container runtimes ship.
- Landlock adds filesystem and, under an allowlist, network restrictions where the kernel supports them.
- A resource ceiling, when you configure
resourcesand your user has the cgroup controllers delegated, bounds memory and CPU so a runaway loop cannot starve the host. - No root and no daemon. hort runs entirely as your user. There is no setuid helper and no privileged service to attack.
What hort does not protect
Exfiltration under open egress
By default a sandbox has open network access, because agents need to reach their model provider. A hostile repository, or an agent tricked by a prompt injection, can send anything it can read to any server. That includes services listening on your host's loopback interface, which are reachable at 127.0.0.1 from inside an open sandbox.
What you mount into the box
Read-only means the agent cannot modify your dotfiles and credentials. It can still read them, and under open egress it can send them anywhere. Mount only what the agent needs, and only development or personal session credentials. Environment variables you forward with auth.env are equally readable.
Abuse of an allowed host
An egress allowlist closes arbitrary destinations, but a host you allow can still carry data out. If github.com is on the list, a malicious push to a repository the agent controls is still egress.
The allowlist's dependence on SNI
The proxy confirms each connection's destination by reading the host name in the TLS handshake, without decrypting anything. If Encrypted Client Hello becomes common, that confirmation stops working; the allowlist of host names requested from the proxy still applies, but a client could name an allowed host and reach a different one sharing the same infrastructure.
Malicious repositories
hort is not armor against code designed to escape containers or exploit the kernel. Its layers raise the cost of that considerably; they are not a guarantee.
How the allowlist is enforced
When egress is an allowlist, four layers stack:
- A network namespace the sandbox does not own, with no routes. hort's own user namespace owns it, so the agent, root in a different user namespace, has no network privileges over it and cannot add a route. The only addresses reachable are the proxy and the declared databases, spliced onto the sandbox's loopback.
- Landlock (Linux 6.7+) restricts which ports a session may connect to, irreversibly.
- The proxy tunnels only
CONNECTrequests to allowed hosts, and only when the TLS handshake names the same host. It logs every decision. - No DNS. The sandbox has no resolver; the proxy resolves names on the host, so DNS cannot be used as a side channel.
Details, and how to read the proxy log, are in Networking and egress.
What an agent inside can and cannot end
Nothing inside a sandbox can end the sandbox. Its first process is an idle anchor, and the kernel discards signals sent to that process from inside its own namespace, so kill -9 1 does nothing. Only hort down, run on the host, tears a sandbox down.
What an agent can end is sessions. Every session runs as the same user in the same process namespace, so a process in one session can kill processes in another, including the shell you are typing in. An exit an agent runs in its own tool shell ends only that shell, but an agent that runs commands directly in the session's shell can end that session.
When that happens, nothing is lost: the sandbox, its files and every other session keep running. Open a new session with hort attach <name>.
The rules that follow
- Run hort on repositories you trust.
- Mount and forward development or personal credentials only. Never production.
- Point databases at development data only.
- Turn on an egress allowlist when you leave an agent working unattended on anything you are less than sure about.
- Commit work you want to keep before
hort down.
Troubleshooting
Find the message you see, or the symptom, below. For refusals of a specific command, each command page lists every message it can print. Start any investigation with:
hort doctor # what the host can do
hort ls # what hort thinks exists
and, for anything about networking or notifications, the sandbox's host-side log:
cat "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/hort/sandboxes/<name>/output.log"
Setting up
no rootfs configured — set "rootfs" to a prepared rootfs directory in .hort.json or ~/.config/hort/config.json
No configuration layer names a rootfs. Prepare one, then set rootfs in ~/.config/hort/config.json. On a terminal, hort config writes the file for you.
rootfs '<path>': /workdir is not writable by the mapped uid — make it world-writable
Run chmod 1777 <path>/workdir. Exporting an image into a directory as a normal user resets that mode.
rootfs '<path>' has no usable shell (expected /bin/sh) — the rootfs must provide one
The directory is not a root filesystem, or not the one you meant: check that <path>/bin/sh exists. A common cause is exporting into a subdirectory.
'<path>' is not a project — run hort from a git repository, or add a .hort.json there to sandbox the directory itself
You ran hort up outside any repository or marked folder. cd into your project. To sandbox a plain folder, create a .hort.json in it ({} is enough).
unprivileged user namespaces are disabled in this kernel — hort cannot create a sandbox
See Installation.
two databases are declared on port <port> (<host> and <other>), and a sandbox can reach only one of them — remove one from "network" in your configuration or give it another port
Inside a sandbox every declared database is 127.0.0.1:<port>, so two on one port collide. Remove one, or give it another port. Nothing was built or changed; a sandbox that is already running is unaffected.
git command failed: worktree add: fatal: invalid reference: HEAD
The repository has no commit yet, so there is nothing to branch from. Make a first commit.
Building and entering sandboxes
branch '<name>' already exists (a 'hort down' keeps a sandbox's branch) — run 'hort up <name> --branch <name>' to build the sandbox on it, or choose another name
A previous sandbox of this name was torn down and its branch kept. Run the command in the message to continue on that branch (on a terminal, hort offers it), or pick a new name. Delete the branch with git branch -d <name> if you no longer need it.
branch '<branch>' is already checked out in another worktree
git allows a branch in one worktree at a time. If it is checked out in your main checkout, switch that checkout to another branch first (git switch main). git worktree list shows where it is.
a sandbox named '<name>' already exists (run 'hort attach <name>' to join it, or 'hort down <name>' first)
A sandbox of that name is running. If you want a fresh one, commit what you need and hort down <name> first. If hort ls shows it as inconsistent or lost-record, hort down <name> is the only way forward.
another 'hort up <name>' is already in progress
Another hort up of the same name is running, possibly waiting at a prompt in another terminal. Finish or cancel it. A killed hort up releases the lock automatically.
sandbox '<name>' is not running (run 'hort up <name>' to start it, or 'hort prune' to clean up the stale record)
The sandbox is orphaned: its container is gone, usually because the machine rebooted. The worktree and its uncommitted changes are still on disk. hort up <name> rebuilds the container on the same worktree.
sandbox '<name>' is running but its container state is gone, so no session can join it (...)
Something deleted hort's runtime files under $XDG_RUNTIME_DIR/hort/containers/ while the sandbox ran. hort does not reconstruct them. Commit what you want to keep from the worktree named in the message, then run the two commands the message gives.
The changes I had in my checkout are not in the sandbox
A worktree is cut from HEAD. Uncommitted changes stay in your checkout. Commit them, then hort down and hort up again, or build the sandbox on a branch that has them with --branch.
container runtime failed: ...
Creating, joining or removing the container failed; the rest of the message comes from the runtime and names the step. Check, in order:
hort doctor: user namespaces, rootless overlayfs.- The rootfs: is it a complete root filesystem, and did something change it while sandboxes were using it? Never modify a rootfs a live sandbox uses; see Changing a rootfs.
hort ls: if the sandbox shows asorphanedafter a failed build,hort up <name>retries;hort down <name>gives up and cleans.
If a build fails after its container started, hort stops what it started and keeps the worktree and the record, so nothing is lost and a retry picks up where it left.
sandbox networking failed: ...
Starting or stopping pasta, the egress proxy or a database forwarder failed. The sandbox's output.log holds pasta's own report. Common details:
binding 127.0.0.1:<port> for <host>: Address already in use: a declared database on another address needs a forwarder on your host's127.0.0.1:<port>, and something already listens there. Free the port, or use the service that is already there by declaring"host": "127.0.0.1".
After fixing the cause, hort up <name> completes the sandbox.
Inside the sandbox
--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons
Claude Code sees uid 0. In a hort sandbox that is the root of an unprivileged user namespace with no capabilities, which is safe. Set IS_SANDBOX=1 in the rootfs; see Agents that refuse to run as root.
git says the directory is not a repository
Expected in the default worktree mode, by design, because the worktree's .git points at your host repository, which the box does not have. Run git on the host, in the worktree (~/.local/state/hort/sandboxes/<name>/worktree-<name>, also printed by echo $HORT_WORKTREE inside). See Git is a host activity.
If you want git to work inside instead, build the sandbox with hort up <name> --git clone, which gives it a clone of its own: see Git inside the sandbox.
git on the host says unable to normalize alternate object path
error: unable to normalize alternate object path: /run/hort/objects
fatal: bad object HEAD
You ran git on the host inside a clone-mode sandbox's directory. The clone borrows your history at an address that exists inside the sandbox and not on your machine, so git run from outside cannot resolve it. Nothing is damaged. Ask the sandbox instead:
hort run <name> -- git -C /workdir status
Fetching from that directory fails for the same reason (remote: aborting due to possible repository corruption on the remote side). To pull the agent's commits out without pushing them, have the sandbox write a bundle, which is made where the history resolves and lands on your host:
hort run <name> -- git -C /workdir bundle create /workdir/work.bundle <branch>
git fetch ~/.local/state/hort/sandboxes/<name>/worktree-<name>/work.bundle '<branch>:refs/hort/incoming'
error: Unable to open universal variable file '/home/hort/.config/fish/fish_variables': EROFS: Read-only file system
~/.config/fish is mounted read-only as a whole. Mount its parts instead; see the fish tip.
Programs show no colors
Sessions do not inherit TERM or COLORTERM. See Colors inside the sandbox.
A tool cannot reach the network (egress allowlist)
Look at the proxy's decisions in output.log:
refused <host> (not in the allowlist): add the host toegress.allowif you trust it, thenhort downandhort up.refused <url> (only CONNECT tunnels are proxied): the tool made a plain HTTP request. Usehttps://. For a local address, the tool is ignoringNO_PROXY.- no line for that host: the tool ignores the proxy variables and has no route. Configure its proxy explicitly, or use a proxy-aware tool.
DNS lookups fail by design under an allowlist; tools resolve through the proxy. See Networking and egress.
A tool cannot resolve names (open egress)
The sandbox's /etc/resolv.conf should say nameserver 198.51.100.53. If a program overwrote it, restore that line. If it is right, check that name resolution works on the host.
A database is unreachable
Inside the sandbox, use 127.0.0.1:<port>, never the host's own address. Under an allowlist, the database must be declared in network; declaring it later needs hort down and hort up. See Databases.
My shell session disappeared
A session ends when its shell exits or is killed, and an agent can end sessions (see Security model). The sandbox is still running: hort attach <name>.
warning: environment variable '<VAR>' is not set on this host, so the session starts without it
export the variable in the terminal you run hort attach from, then open a new session.
Notifications
No notification when the agent finishes
hort upprinted a warning aboutnotify-sendor the sink? Fix that, then rebuild the sandbox.- The Claude Code entry has
"notify": { "stopHook": true }, and the sandbox was built after you added it? Check that/etc/claude-code/managed-settings.d/hort-notify.jsonexists inside. - Append a test line from a session:
echo '{}' >> /run/hort/notify/events.jsonl. If nothing appears, readoutput.log: a line starting withhort: a completion of this sandbox was not raised:meansnotify-sendran and failed, typically because there is no desktop session (for example over SSH).
State that looks wrong
A sandbox is orphaned after a reboot
Normal. A reboot stops every sandbox's processes; hort's records and your worktrees remain. hort up <name> brings a sandbox back with its uncommitted work, or hort down <name> discards it.
A sandbox is inconsistent
Its worktree directory was deleted on the host while it ran. Anything uncommitted in it is gone. hort down <name> removes the container and the stale registration.
A lost-record sandbox
hort ls shows lost-record when a sandbox is running but hort's record of it is gone, for example because ~/.local/state/hort/sandboxes/<name> was deleted. attach and up cannot use it. Run the command ls prints under the row:
hort down <name>
It stops the container and its helpers. It cannot remove the worktree, because its path was in the lost record: if the directory ~/.local/state/hort/sandboxes/<name>/worktree-<name> still exists, commit anything you need from it on the host, then run git worktree remove --force <that path> in the repository it belonged to (or delete the directory and run git worktree prune). Adopting such a sandbox back into hort is not supported yet; it is on the Roadmap.
hort prune ends with git command failed: worktree prune: fatal: not a git repository ...
Run hort prune from inside a git repository; its last step needs one.
Roadmap
This page lists work that has been decided but is not available yet. It describes what you will be able to do, not when: there are no dates and no promised order, and any of it may change. Everything else in this book describes hort as it works today.
Cleaning up after clone mode
Clone mode works today. One piece of it is still missing:
hort lsdoes not show which git mode a sandbox was built in, and its dirty column reads-for a clone. Planned: the mode is visible in the listing.
Deleting the sandbox's branch on down and prune
Today hort down keeps the sandbox's branch and you delete it by hand with git branch -d. A planned --delete-branch flag (-b) on hort down and hort prune removes it too:
- a branch fully merged is deleted;
- a branch with unmerged commits is kept unless you confirm on a terminal (the default answer is no); without a terminal it is kept, and hort prints the command to delete it by hand.
Colors inside sessions
Today a session does not receive TERM or COLORTERM, so programs such as Claude Code start without colors, and you set them in your shell configuration (workaround). Planned: a session opened on a terminal receives your host's TERM and COLORTERM.
Friendlier hort config
Planned: the rootfs question suggests, as its default, a prepared rootfs directory hort finds on your machine, and the dotfile list says how to select more than one item.
Worktree cleanup from outside the repository
Planned: when hort down or hort prune runs from outside the sandbox's repository, the repository's record of the removed worktree is cleaned up correctly instead of being left stale. Today, running git worktree prune in that repository clears it.
Adopting a sandbox whose record was lost
Today a lost-record sandbox can only be stopped with hort down (details). Planned: adopting such a sandbox back under hort's management, when every piece of its record can be recovered from the system.
Working inside a hort sandbox
This page is written for a coding agent. Paste it into the agent's instructions (for example a project's CLAUDE.md or AGENTS.md), or point the agent at it.
You are running inside a hort sandbox: an isolated Linux container around a git worktree. It exists so you can work with full permissions without any risk to the user's machine. These facts hold for every hort sandbox:
Where you are
- Your working directory is
/workdir. It is the user's repository, on a branch created for this task, either as a worktree or as a clone (see Git below for how to tell and what changes). Everything you should change is in there. HORT_SANDBOXholds the sandbox name, which is also the branch name and the hostname.HORT_WORKTREEholds the host path of/workdir. It does not exist inside the sandbox; it is useful only when telling the user where to look.- Your home is
/home/hort. It lives in memory. - The user's real home directory and the rest of the host filesystem do not exist here. Some of the user's configuration files may be mounted read-only under
/home/hort.
What you can and cannot do
- You are uid 0, but only inside your own user namespace, with no capabilities.
sudogains nothing; you cannot mount filesystems, change the network or load kernel modules. - You can write anywhere in the filesystem:
/workdir, your home,/tmp, even/usrand/etc. Whether the system package manager works depends on the image; anything it installs is lost when the sandbox goes away. - Only two places persist after the sandbox is torn down: files in
/workdirthat the user commits, and the dependency cache directories the project declares (typically/workdir/node_modulesor directories under/home/hort/.cache). Everything else, including your home and anything installed outside those, is discarded. - Configuration files mounted read-only (dotfiles, credentials) cannot be modified; writes to them fail with "Read-only file system". Do not try to work around it.
Git
First, find out which mode you are in. Run git -C /workdir status. It either works or it does not, and that decides everything below. Do not assume; check.
If git does not work (the default mode)
- The worktree's
.gitfile points to the user's repository on the host, which does not exist here, sogit status,git diff,git commitand every other git command fail. This is intentional: it keeps the real repository's history and branches out of your reach. - Do not commit, and do not try to repair git (do not run
git init, do not delete or rewrite/workdir/.git, do not clone the repository again). Destroying.gitin/workdironly breaks the user's review of your work. - Write your changes as files. The user reviews them with
git diffon the host and commits them there. - When you finish, summarize what you changed and which files, since you cannot show a diff yourself.
- The GitHub CLI (
gh) may still work for API operations if the user forwarded a token: you can open a pull request, read issues or callgh apiagainst a branch that already exists on the remote. You cannot commit or push from here, because that needs git. Ifghreports it is not authenticated, tell the user; do not rungh auth login.
If git works (clone mode)
/workdiris a clone of the user's repository, made for this sandbox, already on a branch named after it. Commit there as you normally would.originis the user's own remote, and that is where your work goes. Push your branch and open a pull request with it.hort-basepoints back at the user's repository on this machine. It is fetch only and a push through it fails by design. Do not try to work around that./run/hort/objectsholds the history your clone borrows, mounted read-only. Never try to write there, and do not rungit gc,git repackor anything else meant to rewrite the object store you did not create.- If a push fails because you have no credentials, say so and stop. Do not run
gh auth login, do not invent a remote, and never write a token into the repository. - Rewriting history you did not create (a force push, a rebase of the base branch) reaches the user's remote. Do not do it unless the task explicitly asks for it.
Network
The project decides one of two modes.
Open (the default). You can reach the internet directly, and DNS works. If HTTP_PROXY is not set, you are in this mode.
Allowlist. If HTTPS_PROXY is set (to http://127.0.0.1:<port>), the sandbox can only reach hosts the user allowed, through that proxy.
- Use
https://URLs with tools that honourHTTPS_PROXY(curl, pip, npm and most language HTTP clients do). - Plain
http://requests through the proxy are refused. DNS lookups fail; the proxy resolves names. Direct connections to IP addresses fail. - A connection refused by the proxy (HTTP 403) means the host is not on the allowlist. Do not try to bypass it; tell the user which host you needed, so they can decide whether to add it.
Databases and local services
Databases the project declares are reachable at 127.0.0.1:<port>, for example PostgreSQL at 127.0.0.1:5432, in both network modes. NO_PROXY already covers 127.0.0.1 and localhost. Use only the development credentials the project provides. There is no production data here, and you must not look for any.
Processes and sessions
- The sandbox's first process (
sleep infinity, PID 1) keeps it alive. You cannot stop it, and you must not try. - The user may have several shells and other agents running in this sandbox at the same time, working on the same files. Do not kill processes you did not start. Other sessions' shells are processes you did not start.
- Exiting your own shell ends only your session. The sandbox keeps running until the user tears it down from the host.
If something fails
- "Read-only file system" on a file under
/home/hort: it is the user's mounted configuration; leave it alone. - "Network unreachable", DNS failures, or HTTP 403 from the proxy under an allowlist: report the host you needed.
- git errors in the default mode: expected; see above. In clone mode git works, so an error there is real and worth reporting.
- A tool the task needs is not installed and cannot be installed: say so; the user adds it to the sandbox image.