32 KiB
Hanzo Bot — Architecture & Context
Overview
Multi-channel AI messaging gateway (TypeScript ESM). Routes messages between 50+ messaging platforms and AI models/agents. Composable plugin architecture with WebSocket + HTTP server core.
Key Architecture Layers
- CLI (
src/cli/) — Command registry, arg parsing,bot gateway run,bot agent,bot channels - Gateway (
src/gateway/) — WebSocket + HTTP server, auth, billing, marketplace, channels - Channels (
src/channels/,src/discord/,src/slack/,src/telegram/, etc.) — Platform adapters - Agents (
src/agents/) — ACP-based agent spawning, model selection, auth profiles - Extensions (
extensions/) — 50+ channel/feature plugins as workspace packages
Gateway Server (src/gateway/)
server.impl.ts— Main initialization and lifecycleserver-http.ts— HTTP handler chain: /health → /auth → hooks → tools → /v1/chat (chat bridge) → Slack → plugins (/v1/channels/*) → /v1/responses → /v1/marketplace → /v1/chat/completions → canvas → control-ui → 404- Every route is under
/v1;/api/*is a 404.v1-prefix.guard.test.tsfails on any first-party/apipath literal insrc/,extensions/orui/src/(third-party APIs are listed there by file). server-ws.ts— WebSocket connection mgmt for nodes/clientsserver-methods.ts— RPC method implementationsbilling/billing-gate.ts— Pre-request billing check (fail-closed in production)billing/usage-reporter.ts— Async usage reporting (batch 50, flush 5s, retry 3x)marketplace-http.ts— P2P inference marketplacebots-http.ts— org-scopedGET /v1/bots(list the caller org's runs) +POST /v1/bots/:runId/stop(own-key stop guard), authenticated at the pod boundarycoding-tasks-http.ts—POST /v1/coding-tasks: native coding-task runner (clone → agent → commit → push); sandbox isolation is fail-closed and OFF by default
Model System (src/agents/)
model-selection.ts— Provider/model parsing, alias resolution, allowlist matchingdefaults.ts—DEFAULT_PROVIDER = "hanzo",DEFAULT_MODEL = "claude-sonnet-4-6"- Tier-aware routing: free → claude-sonnet-4-6, paid → zen4-pro
- Auth profiles: multi-key round-robin with cooldown recovery
Configuration
- Zod schema in
src/config/zod-schema.ts - Primary:
~/.hanzo/bot/node.json(fallback:~/.bot/) - JSON5 parsing, hot-reload on file change
- Gateway config under
config.gateway.*, agents underconfig.agents.*
Plugin Model
type ChannelPlugin = {
id: string;
messaging: ChannelMessagingAdapter;
auth?: ChannelAuthAdapter;
// ... 10+ adapters
};
- Plugins in
extensions/*/with ownpackage.json - Runtime deps in
dependencies(noworkspace:*) - Loaded via
jitialias resolver
Testing
- Framework: Vitest (multiple configs: unit, gateway, e2e, live, extensions)
- Colocated
*.test.tsfiles - Coverage: 70% thresholds
- Workers: max 16, 2048MB heap each
pnpm test(vitest),pnpm test:coverage,pnpm test:live(real APIs)
Build
- pnpm + tsdown (bundle) + tsc (types) + oxfmt (format) + oxlint (lint)
- Node 22+, Bun supported for scripts/dev/tests
- Output:
dist/index.js, Docker:ghcr.io/hanzoai/bot:latest
Key Patterns
- Dual routing: inbound channels → agents, outbound agents → channels
- Billing gate: open/warn/fail-closed modes via
BILLING_GATE_MODEenv - Exec approval: Code execution gated behind interactive UI
- Config injection:
createDefaultDeps()pattern for CLI, plugin services for runtime - Channel adapters: Loose coupling via interface-based plugins
Billing Flow
- Pre-request:
checkBillingAllowance()→ Commerce API balance check (cached 60s) - Request: Route to LLM provider
- Post-request:
reportUsage()→ async queue → cloudPOST /v1/billing/usage - Commerce API:
COMMERCE_API_URL(cloud,http://cloud.hanzo.svc:8000in the cluster)
Git Remotes
origin=ssh://github.com/hanzoai/bot(a mirror; refs are carried onward)upstream= the upstream MIT project this forks (see NOTICE for provenance and attribution)
What is live
main does not build: tsdown fails with TS2527 and the catalog majors are untested. The
running gateway is an orphan lineage, tag v2026.7.22. Releases from it are tag-only:
v2026.7.23 is v2026.7.22 plus the /v1 route move (Serve every gateway route under /v1 and its guard test), built by the build door from the tag as
ghcr.io/hanzoai/bot:2026.7.23. Universe pins that digest. The chat bridge is POST /v1/chat.
How this ships
One way, and it runs on our own stack:
push -> github.com/hanzoai/bot push here
-> git.hanzo.ai/hanzoai/bot CANONICAL; push here too
.hanzo/workflows/ci.yml the checks
.hanzo/workflows/deploy.yml builds ghcr.io/hanzoai/bot
.hanzo/workflows/cloud.yml builds ghcr.io/hanzoai/bot-cloud
.hanzo/workflows/cicd.yml builds oci.hanzo.ai/hanzoai/sandbox
.hanzo/workflows/release.yml publishes npm @hanzo/bot
-> hanzoai/universe crs/bot-gateway.yaml names the tag that is live
-> hanzoai/operator reconciles the App
-> hanzoai/ingress serves bot.hanzo.ai, gw.hanzo.bot, market.hanzo.bot
git.hanzo.ai is canonical; GitHub is a mirror. Every build, check and release
is a workflow under .hanzo/workflows/, which the forge reads.
PUSH TO BOTH. git push origin main && git push forge main both succeed — the
forge accepts a write, so the mirror's own clock is not the only way a ref gets
there. It once answered Mirror Repository hanzoai/bot is read-only; it no
longer does. "Canonical" means the forge is what the fleet reads and where every
check runs, not that a commit has to land on GitHub first. Confirm both with
git ls-remote origin main and git ls-remote forge main and compare the shas.
There is no sync.yml and there never needs to be one; .github/workflows/ is
EMPTY. Nothing in this repo carries refs onward because the mirror is configured
on the forge, against the repository, where no file here can describe it. Confirm
a push arrived with git ls-remote forge main rather than by looking for a
workflow that was never written. .hanzo/workflows uses GitHub Actions syntax, so a workflow moves
between the two by changing directory and nothing else — runs-on included: the
git-runner advertises hanzo-build-linux-amd64 alongside ubuntu-latest,
self-hosted, linux and amd64.
.github/actions/ is untouched and still works: a local composite action path
like ./.github/actions/setup-node-env resolves from the repo root, not from the
workflow's own directory.
The image cannot be built without the admin SPA
Dockerfile asserts dist/control-ui/index.html exists (ARG REQUIRE_ADMIN_UI=1)
and fails the build when it does not. dist/control-ui is gitignored with
nothing tracked under it, so the SPA has to be assembled into the build context
first: deploy.yml clones the private hanzoai/admin workspace with GH_PAT,
builds apps/admin-bot with bun, and runs scripts/sync-admin-ui.sh.
This is why deploy.yml uses context: .. A remote git context cannot see
anything assembled into the workspace, so the assembly and the build have to
share one local context. The .hanzo deploy that existed before this had
neither the assembly step nor a local context, so it could not have produced an
image.
A build never deploys itself
deploy.yml publishes and stops. The tag that runs is spec.image.tag in
hanzoai/universe infra/k8s/operator/crs/bot-gateway.yaml, which is a reviewed
change; crs/kustomization.yaml lists CRs explicitly, so nothing is picked up by
a glob. A CI-side kubectl patch is reverted within minutes by cd.hanzo.ai's
selfHeal anyway.
Tags: a v* tag publishes :X.Y.Z and :latest; a push to main publishes
:sha-<sha7> and :latest; dev and test publish :sha-<sha7> only, so
:latest keeps meaning main. bot-gateway.yaml pins a calver tag such as
2026.7.22, which comes from the v* path.
Required forge secrets
| Secret | Used by | Without it |
|---|---|---|
GIT_TOKEN |
sync.yml (on GitHub) |
refs never reach the forge, so nothing runs at all |
GH_PAT |
deploy.yml |
the private admin SPA cannot be cloned; the build fails loud |
GHCR_USER / GHCR_TOKEN |
deploy.yml, cloud.yml |
the registry 403s the push |
NPM_TOKEN |
release.yml |
@hanzo/bot cannot be published |
Known gaps, written down rather than hidden
ghcr.io/hanzoai/bot-browserhas no producer.universecrs/bot-browser.yamlpins it atv0.1.0, but no workflow builds it — onlyscripts/sandbox-browser-setup.sh, by hand, locally.bot-cloudHAS a consumer, and it is not inuniverse.hanzoai/playgroundprovisions desktop-mode cloud agents fromghcr.io/hanzoai/bot-cloud:latest(internal/config/cloud.goBotCloudImage, used byinternal/cloud/provisioner.go). The App CRhanzo-playgroundoverrides..._OPERATIVE_IMAGEbut NOT the bot-cloud image, so production takes the default — a floating:latest, which is the real gap here. Looking for the consumer inuniverseand concluding there is none is what madedocker/cloud-entrypoint.shread like dead code; it is the entrypoint of every playground desktop agent.- The live
bottag shares NO history withmain.v2026.7.22— the calvercrs/bot-gateway.yamlpins — andorigin/mainhave an EMPTY merge base (34,825 commits one way, 34,660 the other; see thepre-ancestry-rebuildandpre-transplanttags). So "cut the next calver from main" is not a patch over what runs; it is a jump across a history rewrite, and a diff against the live tag says nothing. Establish what actually changed before shipping one. @hanzo/botis two versions behind. npm has2026.6.5,package.jsonsays2026.6.7; both were cut while the publisher did not exist.- The macOS checks do not run. They need a macOS runner on the forge; see the
header of
ci.yml.
Deleted rather than migrated
k8s-deploy-bot.yml— deployed by hand withkubectl set envand wrote ANTHROPIC/HANZO API keys as plaintext into a running pod viakubectl exec. Secrets live in KMS, and the App CR decides what runs.k8s-create-iam-app.yml,k8s-debug.yml,k8s-recreate-agent.yml— dispatch-only imperative one-shots, superseded by IAM and the universe CRs.formal-conformance.yml—continue-on-error: trueon every job plus a bareexit 0. It could not fail, which makes it worse than absent.auto-response.yml,labeler.yml— upstream's issue/PR triage bots.workflow-sanity.yml— hardcoded topathlib.Path(".github/workflows"), so after this move it would lint an empty directory and pass. Its useful half (auditing changed workflows with zizmor) lives inci.yml, now pointed at both directories.
The sandbox: a run is a computer
POST /v1/coding-tasks (src/gateway/coding-tasks-http.ts) takes four things
that matter, and everything else is derived from them:
| field | meaning |
|---|---|
prompt |
the task |
tool |
dev | claude | codex | python | node — default dev |
cloneUrl (+branch,credential) |
OPTIONAL. Present ⇒ a git grant rides with it. Absent ⇒ no clone, no push, and no credential exists in the run at all — a credential sent without one is a 400, not a silent drop. |
desktop |
OPTIONAL. Selects the xvfb image variant. |
Three things about that table are load-bearing.
tool is a data table, not a switch. TOOLS in coding-task.ts maps a name
to an argv, and every runner — host seam and docker — reads that one map. There
is no second place where "what does claude run" could answer differently. Each
agent entry passes its own "stop asking me" flag (--dangerously-skip-permissions
and friends), which is honest here and only here: the container is the
boundary, and a second in-process approval prompt inside a box that has already
dropped every capability just hangs a non-interactive run forever.
repo is optional because git is a capability of a run, not the shape of one.
runCodingTask is [clone] → run the tool → [commit, push]; the brackets are
the repo. Deep research and a bare python snippet want the same computer with
nothing checked out into it.
desktop is a TAG. Headless and xvfb are two tags of one image and the flag
picks the tag (imageFor). Nothing downstream branches on it. If the
orchestrator reasoned about it instead, there would be two code paths to keep in
step, and they would drift.
The image: one browser, two ways to run it
Dockerfile.sandbox → oci.hanzo.ai/hanzoai/sandbox, four tags on one chain
that forks once: exec → dev → {desktop, admin}.
admin is a tag that is NOT a class. The other three are words a caller puts
in a request; admin is one nobody can ask for. hanzoai/cloud substitutes it for
a SuperAdmin's dev sandbox (apps/sandbox/runtime.go imageFor(class, super), predicate principal.IsSuperAdmin) and leaves every other caller's
dev exactly as it was. Which bytes you get is a fact about the caller, so a
fourth class would be the one thing it must not be: a field every caller can set.
It adds zsh, kubectl and doctl to dev. It adds no credential, and that is
load-bearing — kubectl with no kubeconfig and doctl with no token are argument
parsers, so the image confers no authority and "who may pull it" is a bandwidth
question rather than an access one. Any future credential must reach the pod
from the identity that leased it, never a layer in a registry.
Layered on dev because a substitute has to be a superset of what it replaces:
exec stays the throwaway a tool call spends fifteen minutes in, and desktop
keeps the screen admin has no X server for. kubectl is pinned on the 1.35
line, not stable — DOKS runs 1.34 and kubectl is supported within one minor of
its apiserver, so today's stable 1.36 is out of skew with every cluster this box
exists to reach.
zsh's config is dotfiles, installed at build time by ellipsis.sh as uid 1000:
zeekay/zsh (zeesh), zeekay/vim, zeekay/files. ellipsis install zeekay/zsh
resolves to github.com/zeekay/dot-zsh over https — the dot- is
ELLIPSIS_PREFIX and https is ELLIPSIS_PROTO, both its defaults — and the
package's own pkg.link picks platform/linux/zshrc by os.platform(), which
is why the assert names that path rather than trusting the switch.
There is no /etc/zsh/zshrc any more, and there cannot be one. ~/.zshenv
sets no_global_rcs, so zsh skips every global rc file; a system-wide config
would be a file nothing reads. The same option stops zsh -l sourcing
/etc/profile back over the image's PATH. ~/.zshenv also sources
$CARGO_HOME/env, and CARGO_HOME moved to the home directory for a writable
cache while rustup wrote its env file beside the toolchain — hence the symlink.
ellipsis runs its hooks without set -e, so install exiting 0 proves nothing.
The build asserts one link per package plus ~/.zsh/cache/zcompdump, which only
exists once an interactive shell has read the rc.
The terminal picks it up on its own: cloud's shell() is a preference chain,
zsh → bash → sh, so an image without zsh costs a caller nothing.
The third-party agent CLIs install as uid 1000, into ~/.local. claude and
codex are npm install -g with the prefix set to the user's own ~/.local,
run under USER sandbox rather than root. Root-owned in /usr/local/lib/node_modules
they cannot rewrite themselves, so claude opened every shell with Auto-update failed: no write permission to npm prefix and stayed on the version in the layer.
ENV PATH puts /home/sandbox/.local/bin first — ahead of /usr/local/bin —
so the copy that can update itself is also the copy that runs, and claude install latest writes beside it. The build asserts command -v claude by PATH, not by
--version: a shadowed root copy answers --version just as happily.
The browser lives in exec, at the bottom. It used to be in desktop, which
meant the DEFAULT class for an agent run could not open a web page. The class
boundary is cut on RUNTIME cost — desktop earns its tag because Xvfb, openbox,
X0tigervnc and websockify are four processes running forever on every desktop
pod. A browser binary nobody launched costs nothing at runtime, and layers are
shared per node. So desktop does not add a browser; it adds a screen for
the browser that is already there.
A screen has to draw something. Xvfb plus openbox is a managed root window
with nothing on it, and a client that connects to it cannot tell it from a screen
that never came up. scripts/sandbox-desktop.sh therefore paints the root
(hsetroot, a dark gradient), loads the terminal's look
(xrdb -merge /etc/X11/Xresources/sandbox) and opens one xterm on the user's
own zsh — each as a BACKGROUND launch, because the foreground process at the
bottom of that script is the one the container waits on and a desktop started
there would end its own pod the first time somebody closed the window. Window
decorations come from openbox's own dark theme, set by editing the system
rc.xml: a user rc.xml replaces that file wholesale and openbox falls back to
compiled-in defaults for every element absent from it, which is a desktop with no
alt-tab. Installing xterm also registers the x-terminal-emulator alternative
openbox's root menu points at, so the right-mouse menu works too.
$HOME carries no dangling links, and that is load-bearing. ellipsis install zeekay/files links several ~/.config entries at a sibling package it
installs only on its author's machine. mkdir on a dangling symlink fails
EEXIST, so any tool that creates its own config directory on first use cannot
start there — hanzo auth login among them, which is how hanzoai/cloud
(apps/sandbox/cred.go) hands a fresh pod its owner's session. The image deletes
the links that do not resolve and asserts none are left.
The half that was missing was not the browser but the library. npx playwright install fetches the chromium build to PLAYWRIGHT_BROWSERS_PATH and leaves the
npm package in an npx cache that the next line deletes — a real chromium at
/opt/playwright that nothing in the image could drive (require("playwright")
threw MODULE_NOT_FOUND, measured on the built image). It is now npm install -g playwright + NODE_PATH=/usr/local/lib/node_modules, so require("playwright")
resolves from any cwd with the network turned all the way down — which is the
state a confined sandbox is supposed to run in.
Measured on the published amd64 images (compressed): exec 1.09 GB · dev
1.18 GB · desktop 1.69 GB. Moving the browser down adds ~0.4 GB to exec and
dev and leaves desktop where it was.
Chromium runs as uid 1000 with its own sandbox left on — no --no-sandbox
needed. What it does need comes from the container, not the image, and
createDockerRuntime sets both: --shm-size=1g (Docker's default 64 MiB
/dev/shm makes Chromium die mid-page-load as a renderer crash, which reads like a
flaky site) and --init (a browser forks a tree of zygote and renderer
processes; docker exec children are not under the image's tini).
The browser also has a NAME now. Playwright unpacks it to
/opt/playwright/chromium-1234/chrome-linux64/chrome — a path a library resolves
and a person never types — so an agent that shelled out to chromium got
"command not found" for a browser three directories away. One symlink,
/usr/local/bin/chromium, is the single fact; chromium-browser and
google-chrome link to it, and CHROME_BIN / CHROME_PATH /
PUPPETEER_EXECUTABLE_PATH name it for the three tools that ask in three
different spellings. Nothing repeats the versioned path, so a playwright bump
moves one target. A symlink is enough because chromium finds its resource bundle
from the realpath of /proc/self/exe; the build proves it with chromium --version.
Three products, three binaries
hanzo, dev and hanzo-mcp are native Rust binaries fetched from GitHub
release assets, not npm packages:
| binary | from | what it is |
|---|---|---|
hanzo |
hanzoai/cli v1.9.37 |
the cloud CLI — auth, billing, every product |
dev |
hanzoai/dev v0.6.91 |
the coding agent; dev exec … is what TOOLS spells |
hanzo-mcp |
hanzoai/mcp rust-v1.1.15 |
the MCP server, 13 HIP-0300 tools over JSON-RPC |
What this replaced was npm install -g @hanzo/dev, and it was wrong three ways
at once. It put a Node shim at /usr/local/bin/dev that spawns a native
binary nested in its own node_modules, so launching the agent needed a working
node. That package also claims the bin name hanzo, which is why the CLOUD CLI
was a symlink to the AGENT and no hanzo command existed at all. And the MCP
server has no package on that path, so it was simply absent — measured in a
live pod: hanzo and dev were the same dev.js, command -v hanzo-mcp was
empty. A build-time assert now refuses an image where hanzo and dev resolve
to one file again.
One fetch rule, which is the publisher's own (hanzoai/cli install.sh): every
Hanzo binary is a release asset named <BIN>-<os>-<arch>.tar.gz unpacking to a
single file named <BIN>. So the Dockerfile has one loop over (repo, tag, bin, sha256) and a fourth binary costs one line. Checksums are literals, read
from the publisher, not the .sha256 beside the asset — a digest fetched from
the same host proves the transport, not the bytes we agreed to ship, and a
release asset can be replaced under a tag that never moves.
hanzo is pinned at 1.9.37, not at latest, and that is a gap.
hanzoai/cli's newest release is v1.9.40 and its release matrix declares a
linux-amd64 target, but v1.9.40 published only darwin-amd64, darwin-arm64,
linux-arm64 and windows-amd64. There is no linux-amd64 asset to fetch and this
image is linux-amd64; 1.9.37 is the newest release that has one. When that
matrix publishes linux-amd64 again this is one version and one checksum.
The version is the image's own
docker/sandbox.version — one file, read twice. hanzo.yml's version: key
turns it into the published <version>-<class> tag through hanzoai/ci's
imgver, and Dockerfile.sandbox COPYs the same file to /etc/sandbox-version, so
a running pod can be asked what it is and cannot disagree with its own tag.
Before this, imgver fell back to the build context's manifest — the repo root's
package.json, which is bot's version, 2026.6.7, last bumped 2026-06-07.
So today's rebuild republished 2026.6.7-desktop from source two months newer:
2026.6.7-desktop and sha-d190451-amd64-desktop are one digest,
sha256:7a41f69f…. A tag that gets rewritten is not a pin, and one that LOOKS
pinned is worse than latest, which at least admits what it is — which is
precisely why hanzoai/cloud had to pin these images by digest. The series
starts at 1.0.0 because 2026.6.7 was never this artifact's number; the old
tags stay in the registry as archaeology.
Env names
SANDBOX_IMAGE, SANDBOX_NETWORK, SANDBOX_RUNTIME_CLASS. The old
HANZO_CODING_SANDBOX_* names are accepted for ONE release so a deploy cannot
half-land, then they are deleted — they are not permanent aliases, because two
live names for one knob is how nobody can say which value won. The prefix was
wrong by construction: deep research and bare exec want the same sandbox and
neither is coding. apps/bots/transport.go keeps BOT_GATEWAY_URL for actual
bot traffic; that name is right for what it does.
SANDBOX_IMAGE names the repo. SANDBOX_IMAGE_TAG optionally pins a
version. What imageFor composes from those is dictated by the producer, and
the producer's shape is not the obvious guess — see below.
Known gaps, measured 2026-08-06
- The desktop class serves nothing, and the missing piece was never in this
image.
command -v x11vncis empty and that is deliberate — the VNC server here is TigerVNC'sx0vncserver, fromtigervnc-scraping-server, chosen over x11vnc for the MSG_PEEK deadlock (seeDockerfile.sandbox). What kept port 5900 unserved washanzoai/cloudapps/sandbox/runtime.gosettingcommand: ["sleep","infinity"]on every pod: a k8scommandwith noargsreplaces the image's ENTRYPOINT and CMD, sosandbox-desktopnever ran and tini was never PID 1. Proven in a live desktop pod —/proc/1/cmdlinewassleep infinityandss -ltnwas empty; running the script by hand brought X up on:1with127.0.0.1:5900and127.0.0.1:6080LISTENing, nothing installed. The override is removed on cloud's side; the image now asserts Xvfb/openbox/x0vncserver/websockify/xdpyinfo at build time so the natural wrong diagnosis is answered by the build. - A bare
:<class>tag is a name no build can create. hanzoai/ci's image lane composes every tag fromtag-suffixand emits exactly three:sha-<short>-amd64-<class>,<class>-latest,<version>-<class>. The order flips — the floating tag is class-FIRST (${sfx:+$sfx-}latest), the pinned one class-LAST (${sfx:+-$sfx}) — and nothing in those 1837 lines emits<class>alone. SoimageForhere composes<class>-latestunpinned and<version>-<class>pinned, and a test asserts it never produces the bare form. - The moving tags in the registry are not our image.
:exec,:dev,:desktopand all three*-latestresolve to ONE digest —sha256:0557ac14…, a stocknode:22(entrypointdocker-entrypoint.sh, cmdnode, Debian 12, root). The real builds exist only assha-d190451-amd64-<class>and2026.6.7-<class>, three correct distinct digests. The build lane cannot have done this — it has no placeholder push, and itscranestep only mirrors refs already in its own tag list — so six moving tags were overwritten out of band. Meanwhile cloud'sapps/sandboxasks for the bare<repo>:<class>, so every sandbox pod in production is a bare node:22: no toolchain, nodev, no browser, running as root. A container that starts, reads EOF and exits 0 looks like a run that "worked". This is whycreateDockerRuntimenow names the command EXPLICITLY for both classes instead of relying on the image's CMD — a wrong image fails loudly instead of quietly. - hanzoai/ci's mirror step copies this image into the wrong org. It rewrites
hanzoai→hanzoon the way out, so arepo:that already namesoci.hanzo.ai/hanzoai/sandboxgets crane-copied tooci.hanzo.ai/hanzo/sandboxas well — a second repo nothing pulls.
Security: what a prompt-injected run can reach
Re-measured 2026-08-06 after the NetworkPolicy landed, from a labelled pod in
hanzo-sandboxes on hanzo-k8s. Every row has a control in the same shell, because
a probe that returns nothing proves nothing on its own — it looks identical
whether the target is blocked or the tool is missing.
| target | result |
|---|---|
arbitrary internet (example.com) — the CONTROL |
reachable, 200, 559 bytes |
DO droplet metadata 169.254.169.254 |
blocked — /metadata/v1/id and /metadata/v1/user-data both time out |
cloud.hanzo.svc, kms.hanzo.svc, kubelet :10250 |
blocked |
| pod service-account token | not mounted (automountServiceAccountToken: false) |
sandbox-isolation in hanzo-sandboxes selects hanzo.ai/sandbox-class (Exists)
and its egress ipBlock is 0.0.0.0/0 except 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, 169.254.0.0/16. That last entry is the metadata one, and
it is not RFC1918 — writing "the private ranges" from memory leaves it out, which
is exactly how it was left out everywhere else.
The hole was NOT the sandbox namespace, and the fix there did not close it.
The same probe from bot-gateway in namespace hanzo answered 200 with 5983
bytes of /metadata/v1/user-data — the node's cloud-init: a private key, a
bootstrap token, k8saas join material. Status and length only; the body was
never read. The negative control is what makes that finding solid: from the same
shell the node's own 10.124.0.38:10250 timed out, so the except-list WAS being
enforced and link-local was simply not in it. hanzo/policy is a podSelector: {}
blanket, and NetworkPolicy allows are ADDITIVE — hanzo/lsp already carried the
correct four-entry list and its pod reached the metadata service anyway, because
the blanket rule granted what the tight one withheld. Fixed in universe
charts/app/values/hanzo/policy.yaml.
A browser does not create any of this, but it is an egress channel by definition — and with indirect prompt injection the page being read becomes the instruction source, so "fetch this, then POST it there" needs no operator.
What confines a run, in reward/effort order:
- A NetworkPolicy — done, in both namespaces. This is the load-bearing control and it is declarative, which is the reason the Kubernetes path is the one production uses.
SANDBOX_NETWORK(docker path) — now REQUIRED; unset is a refusal. It used to be optional, and unset meant docker's default bridge, which routes through the host and therefore reaches169.254.169.254. Docker has no per-network egress ACL — a user-defined bridge reaches link-local exactly like the default one, and a container withoutNET_ADMINcannot blackhole the route itself — so confinement there has to come from the host'sDOCKER-USERchain, which is host state this process cannot see. WhatsandboxNetwork()can honestly check is that an operator NAMED a network, and that the name is nothostorcontainer:<id>; those are the absence of a network, not a choice of one.SANDBOX_RUNTIME_CLASS. ONE FIELD, a deployment value, never a fork in code.gvisoris live oncode-exec-pool. The benchmark on this fleet: Firecracker beat gVisor on both latency and memory (git status 82 ms vs 980 ms; start 294 ms vs 881 ms; ~57 MiB both), but firecracker-containerd has never cut a release — so that is a deployment choice, not something to encode.
There are two sandbox execution paths and only one of them runs in
production. Kubernetes (cloud apps/sandbox) is deployed, confined and
verified. The docker path has no docker endpoint on bot-gateway — no CLI, no
socket — so POST /v1/coding-tasks refuses there rather than running unconfined.
It exists for the single-binary local case, where docker is the only runtime
available. Point 2 is what keeps it from ever silently becoming a second
production path.