Husk
GitHub

Security

Isolation by provider

What docker, podman, ssh, fly and local each actually guarantee — and why the local provider is guardrails and not a sandbox.

Five providers implement the same Computer interface. They do not offer the same protection. This page is what each one is worth against an adversary, not against an accident.

ProviderisolationKindBoundaryAn injected model can reach
dockerkernelnamespaces, cgroups, dropped capabilitiesthe container, and the network policy you set
podmankernelthe same, rootless when run unprivilegedthe container, and the network policy you set
flykernela Fly microVM, one per computerthe microVM
sshmachinea different physical boxeverything your user can reach on that box
localguardrailsprocess-level checks onlyeverything your user can reach on this machine

isolationKind exists because a plain boolean overclaims. An ssh box is genuinely isolated from the laptop you are sitting at, and genuinely not isolated from itself. husk doctor renders that as isolated from this machine rather than a green isolated.

docker and podman

Every container is created with the same hardening, and none of it is conditional on what the caller passed:

--cap-drop ALL
--security-opt no-new-privileges
--pids-limit 512
--read-only
--tmpfs /tmp:rw,exec,nosuid,size=512m
--tmpfs /run:rw,nosuid,size=16m
--tmpfs /work:rw,exec,nosuid,size=<diskMb or 2048>m
--cpus=<cpus or 2>
--memory=<memoryMb or 2048>m
--memory-swap=<memoryMb or 2048>m
-u 1000:1000
  • --memory-swap matches --memory because without it the limit only delays the OOM: the container swaps first.
  • --pids-limit 512 means a fork bomb inside the machine hits its own ceiling rather than the host's.
  • The root filesystem is read-only. Anything written outside /work and /tmp fails loudly instead of mutating an image layer that is about to vanish.
  • -u 1000:1000 is passed regardless of the image, so even a public fallback image runs unprivileged.

With persist: true, /work becomes a named volume husk-<id> instead of a tmpfs.

An injected model in this mode can wreck the container. It cannot reach your home directory, your SSH keys, your Docker socket, or your other containers.

The one hole you can open yourself

computer:
  mounts:
    - source: ./data
      target: /work/data
      readonly: true      # the default

A host mount is the one hole in a container. readonly defaults to true, and an agent that only needs to read a repository must not be handed the ability to rewrite it. Set readonly: false deliberately or not at all.

Rootless podman is reported, not assumed

podman info is asked whether it is rootless, and the answer is repeated back:

✓ podman   isolated
    podman is running as root -- containers get kernel isolation, but an escape lands on root
    fix: run husk as an unprivileged user to get rootless containers

Running as root is still kernel isolation. It is not the isolation this provider advertises, so it is said out loud.

fly

One Fly.io microVM per computer, created through the Machines API with restart: { policy: 'no' } and an init of mkdir -p <workdir> && exec sleep infinity. Hardware-level isolation, and the only provider that costs money — metered by the second while the machine runs.

husk doctor says so in the hint rather than after the fact:

fix: run `fly auth token` and export it as FLY_API_TOKEN (fly machines are metered, not free)

ssh

A Linux box you already own. Commands run as your user on that box, over a multiplexed SSH control master.

✓ ssh      isolated
    ssh · user@host (Linux aarch64)
    commands run as your user on that box -- isolated from this machine, not from itself

Workspaces live under $HOME/.husk-work/<id>/, with work and tmp subdirectories, both chmod 700. The jail is enforced on the far end — see below.

local

Not a sandbox. A guarded working directory, and the only provider that is always available.

This is not a caveat at the bottom of the page. It is the first thing husk doctor says, the first thing husk up warns about, and the first thing the MCP server tells the model:

husk up, on the local provider

A model that believes it is contained when it is not will take risks it otherwise would not, which is why the MCP server states it in its first tool result rather than leaving the model to guess.

What it does guarantee

ControlWhat it stops
Path jailEvery filesystem-API call resolves through realpath and is rejected if the target leaves the workspace, including through a symlink created inside it
Environment scrubEverything not on a small allow-list is dropped before the process starts
Command deny listA short list of unrecoverable commands is refused
Output capsA runaway process cannot exhaust memory through captured output
Process-tree killA timeout kills the whole process group, not just the shell
Mount namespace (WSL2)/work is bind-mounted per exec inside unshare -mr, so two computers cannot see each other's files

What it does not stop

The agent shares your kernel, your network and your user account. It can reach anything your user can reach that is not specifically blocked. Verifiably:

on the local provider, WSL2

The command deny list is a deny list, and deny lists are bypassable by anyone who is trying. It exists to stop rm -rf / typed by a confused model, not to stop an adversary who can read the list.

Inside a WSL exec the agent is uid 0 of a user namespace created by unshare -mr, so id -un prints root. Files it creates are owned by your real user outside the namespace, and the bind mount disappears when the command exits. It is the same trick rootless containers use, and it is not a container.

The path jail

The jail presents /work and /tmp to the guest and maps them onto real directories. It runs in two passes:

  1. Lexical. toHostPath normalises the guest path, rejects anything outside /work or /tmp, and re-checks containment after resolve() collapses ...
  2. Resolved. assertInJail calls realpath on the nearest existing ancestor and confirms the real target is still inside the workspace. This is the pass that catches a symlink created inside the workspace and pointing out of it.
error path is outside the machine's writable area: /etc/passwd
hint:  this computer exposes /work and /tmp; use a path under one of them
error path resolves outside the workspace through a symlink
hint:  husk refuses to follow links that leave the machine

On ssh the resolved pass cannot be a local realpath — that would be a round trip per operation — so it is compiled into the same remote shell command that does the work, using readlink -f, and exits with code 77 on a violation.

What the path jail does not cover

The command deny list

Applied by every provider, on every exec, before the process starts. It is short on purpose: an over-eager deny list gets switched off, and a switched-off deny list protects nobody.

The bar for inclusion is no legitimate agent task needs this, and running it by accident is unrecoverable.

RefusedReason reported
rm -rf /, rm /recursive delete of / · delete of /
mkfs, mkswap, fdisk, parted, sgdiskdisk formatting
dd … of=/dev/sd* (also nvme, hd, disk)raw write to a block device
shutdown, reboot, halt, poweroff, init 0host power control
systemctl <x> stop, disable, mask; the same via servicestopping host services
> or >> into /etc/passwd, shadow, sudoers, hostsoverwriting a system file
chmod 777 /world-writable /
anything piped into sh, bash, zsh, kshpiping a remote script into a shell
:(){ :|:& };:fork bomb
sudoprivilege escalation
nc/ncat/netcat with -ereverse shell

Rules are anchored to command position — the start of a line, or after ;, |, &, $(, then, do. Without that anchor, grep -r "sudo" . and echo "shutdown" >> notes.txt get refused, and a deny list that cries wolf is a deny list users switch off.

Adding to it, and overriding it

guardrails:
  denyCommands:
    - 'git\s+(push|commit|reset\s+--hard)'
  allowCommands:
    - '^sudo apt-get install'

allowCommands is checked first and wins over both your deny list and the built-in one. An explicit allow entry is you saying you know better about your own machine, and you do. An entry that is not valid regex falls back to a substring match rather than throwing.

Output caps

Two independent caps, both defaulting to 256 KiB.

  • Per exec. OutputBuffer keeps the first 60% and the last 40% of each stream and elides the middle. The middle of a runaway build log is never the interesting part; the command that started it and the error that ended it are.
  • Per tool result. limits.maxOutputBytes clamps a tool result before it re-enters the conversation, keeping the head and the tail.

Both mark the elision so neither the model nor you can mistake a truncated log for a complete one:

... [1048576 bytes elided by husk] ...

The clamp runs before redaction, so redaction always operates over bounded input.

Timeouts

limits.execTimeoutSec defaults to 120 seconds per exec, and limits.timeoutSec to 300 seconds for the whole run. On a timeout the entire process group is killed — process.kill(-pid, 'SIGKILL') on POSIX, taskkill /T /F on Windows — so a shell that spawned a build does not leave the build running.

Which one to use

Use local for your own code on your own machine. Use docker or podman the moment an agent will read anything you did not write.

Guardrails handle accidents and confusion. Only isolation handles prompt injection, because an injected model is not making a mistake — it is following instructions competently, toward someone else's goal.