← Back to blog

A Safer Box for the Sketchy Stuff

Every gibil server is already an isolated VM — your own kernel, your own IP, burned when you're done. We just sealed the channel into it.

Your agent just npm install-ed a package it found in a README, ran an install.js hook with full network access, and piped the output back over SSH. If you ran that on your laptop, you'd think twice. On a gibil server, you didn't have to — the whole thing was running in a real VM that gets burned when the TTL expires.

That's the bet gibil has been making since v0.1: the right place to run code you don't fully trust is on a machine you don't keep. Untrusted dependencies, model-generated shell commands, scraping scripts, malware samples for a write-up — they all land inside a real Ubuntu server that's about to die anyway.

This week we tightened one of the load-bearing assumptions behind that bet. Host key pinning landed for every SSH connection gibil opens.

The isolation gibil already gave you

Every gibil create allocates a real cloud VM. Own kernel. Own systemd. Own root. Own public IP. A TTL counting down from the moment it boots. When the TTL expires — or when you run gibil destroy — the VM is gone, the disk is gone, the IP is recycled. The blast radius of anything you ran inside is the lifetime of that server.

That's a meaningfully different shape from a shared-kernel sandbox. If a package's postinstall writes to /etc/cron.d, sets up a systemd unit, opens an outbound socket — it's writing to a kernel you don't share with anyone else, and the whole kernel is about to be deleted. You don't have to trust the package. You have to trust that the box around it actually holds.

We've watched users lean on this property in the wild: agents auto-running pnpm install on PRs from strangers, security folks detonating a tarball to see what it does, dev-loop scripts that spawn a server, run something weird, and burn it before lunch. The numbers we see in our telemetry put a lot of weight on the same use cases — fresh VMs forged, used for minutes to hours, destroyed. Every one of those is a tiny act of trust in the perimeter.

The hole we left in the perimeter

A sandbox is only as strong as the channels into and out of it. And there was one channel we'd been treating sloppily: SSH.

Until last week, every SSH path inside gibil — the programmatic ssh2 client used by gibil run, the system ssh binary spawned by gibil ssh, the background tunnels behind gibil forward and gibil branch --port — explicitly disabled host key verification. The reason was historical and unglamorous: the VM has only existed for 90 seconds, nothing has its key yet, and we wanted the first connect to "just work." So we passed StrictHostKeyChecking=no and UserKnownHostsFile=/dev/null, told the client to accept whatever public key the other end presented, and moved on.

That's fine against a passive observer. It's not fine against anyone who can sit between your laptop and the cloud provider. A coffee-shop wifi attacker, a compromised home router, a hostile network on a conference floor — any one of them could have intercepted the first SSH connection, presented their own host key, and you would have happily handed them every command and every byte of stdout. Including the export ANTHROPIC_API_KEY=... you just typed.

The VM itself was still isolated. The channel to it wasn't. So the perimeter had a seam.

What changed: TOFU pinning, everywhere

The fix is the same pattern OpenSSH has used for decades, applied uniformly across every callsite gibil owns.

The first time gibil connects to a fresh server, it captures the server's ed25519 host public key and writes it to that instance's metadata file:

$ cat ~/.gibil/instances/my-app.json | jq .hostPublicKey
"AAAAC3NzaC1lZDI1NTE5AAAAILq8...XXXXX"

That's trust-on-first-use. We accept the first key we see, on the assumption that the VM is fresh and the network is probably honest right at this second. Everything after that gets compared against the pinned value. If the key the server presents tomorrow doesn't match, the connection dies:

SSH host key for 65.21.123.45 does not match the pinned fingerprint for "my-app".
  Pinned:    SHA256:abcd1234efgh5678...
  Presented: SHA256:wxyz9876qrst5432...

This usually means a man-in-the-middle attempt, or the VM was destroyed
and recreated under the same name.

If the rebuild was expected, run:
  gibil destroy my-app && gibil create --name my-app ...

Both fingerprints land in the error so you can actually compare them. The destroy-and-recreate hint is there because that's the legitimate reason most mismatches happen, and we'd rather say it once than have you stare at a JSON file under stress.

Two pieces worth calling out:

Every SSH callsite gets the same treatment. The programmatic ssh2 path that powers gibil run and the streaming log tail. The system ssh that backs gibil ssh for interactive sessions. The detached ssh -fN -L tunnels behind gibil forward and gibil branch --port. They all materialize a per-instance known_hosts file at ~/.gibil/known_hosts/<name>, and they all pass StrictHostKeyChecking=yes (not ask) so OpenSSH refuses a mismatch outright rather than prompting a tired user to accept it. gibil destroy sweeps the file up alongside the rest of the local state.

The ssh: field in --json output now points at gibil ssh <name> instead of the raw ssh -i ... -o StrictHostKeyChecking=no that earlier versions suggested. Any agent that followed the old hint was reconstructing an unverified connection by hand. Now it just calls gibil ssh and inherits the pinned key.

What this means in practice

Pinning the host key doesn't make the VM itself any more isolated — the kernel boundary was already there. What it does is close the last channel where someone outside the VM could have seen what was happening inside it.

That changes the kind of work you can comfortably hand off to a gibil server. Running an agent that's about to execute model-generated shell commands? The transcript stays between you and the VM. Detonating a suspicious package in a fresh box to watch what it does? The exfiltrated environment you're about to type isn't going anywhere except the VM that's already condemned. Letting a teammate's branch run on a per-PR server with gibil branch --port? The forwarded port isn't a man-in-the-middle's plaything any more.

The deal we've been pitching for a while — forge a real machine, run whatever, burn it when you're done — now has fewer asterisks attached.

What we still don't do

Worth being honest about the seams that remain. This shipped as M1 of a three-milestone epic, and there's real work left.

Secrets still travel through cloud-init on first boot. If you pass --env GITHUB_TOKEN=..., the value ends up in the cloud-init user-data and on disk for the VM's lifetime. M2 of the same epic moves secrets to tmpfs and pushes them over the (now-verified) SSH channel after boot — exactly the kind of thing that wasn't safe to do without M1.

Your local SSH keys are still unencrypted at rest. They sit in ~/.gibil/keys/<name>/ with 0600 permissions. Laptop theft is still laptop theft. M3 ships opt-in OS-keychain encryption.

TOFU is still TOFU. If you happen to be MITM'd on the very first connect, the attacker's key is what gets pinned. We don't pre-fetch the host key from a side channel (yet — the provider metadata API can give us one, and that's a candidate for a future patch). What pinning buys you is that the first connection is the only window of exposure, instead of every connection forever.

Upgrade

If you're already on v0.4.0, you don't need to do anything. The next time you run gibil create, the new server pins its key on the first connect. Existing instances without a pinned key get a one-line warning the first time you connect; running any gibil run <name> true against them migrates them silently.

If you're brand new:

npm install -g gibil
gibil init
gibil create --name sketchy --ttl 30m
gibil run sketchy "curl -fsSL https://some-script-from-the-internet.sh | bash"
gibil destroy sketchy

That last line is the point. The script ran on a real machine, with a verified channel in and out, and now the machine is gone.